199 lines
6.1 KiB
Go
199 lines
6.1 KiB
Go
package shared
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/game"
|
|
"galaxy/lobby/internal/domain/invite"
|
|
"galaxy/lobby/internal/ports"
|
|
"galaxy/lobby/internal/telemetry"
|
|
"galaxy/notificationintent"
|
|
)
|
|
|
|
// CloseEnrollmentDeps groups the collaborators that CloseEnrollment
|
|
// requires. The struct is reused by manualreadytostart and the
|
|
// enrollmentautomation worker so the enrollment-close pipeline lives in a
|
|
// single place.
|
|
type CloseEnrollmentDeps struct {
|
|
// Games mediates the authoritative game status transition.
|
|
Games ports.GameStore
|
|
|
|
// Invites is scanned for created records that must transition to
|
|
// expired alongside the close.
|
|
Invites ports.InviteStore
|
|
|
|
// Intents publishes lobby.invite.expired notifications, one per
|
|
// expired record. Failures are best-effort and do not roll back the
|
|
// already-committed status transitions.
|
|
Intents ports.IntentPublisher
|
|
|
|
// Logger receives best-effort warnings for invite-CAS conflicts and
|
|
// notification-publish failures. CloseEnrollment falls back to
|
|
// slog.Default when nil.
|
|
Logger *slog.Logger
|
|
|
|
// Telemetry records `lobby.game.transitions` once per close and
|
|
// `lobby.invite.outcomes` once per expired invite. Optional; nil
|
|
// disables metric emission.
|
|
Telemetry *telemetry.Runtime
|
|
}
|
|
|
|
// CloseEnrollment performs the enrollment_open → ready_to_start transition
|
|
// for gameID using trigger and at, then expires every still-created invite
|
|
// attached to the game, publishing one lobby.invite.expired intent per
|
|
// expired record. The post-transition game snapshot is returned.
|
|
//
|
|
// Failures of the game status transition (game.ErrConflict,
|
|
// game.ErrInvalidTransition, store technical errors) are returned to the
|
|
// caller so manualreadytostart can map them to HTTP responses and the
|
|
// worker can log and continue. Failures of individual invite CAS updates
|
|
// or intent publishes are logged and ignored — they are notification
|
|
// degradations, not authoritative state changes.
|
|
func CloseEnrollment(
|
|
ctx context.Context,
|
|
deps CloseEnrollmentDeps,
|
|
gameID common.GameID,
|
|
trigger game.Trigger,
|
|
at time.Time,
|
|
) (game.Game, error) {
|
|
if ctx == nil {
|
|
return game.Game{}, errors.New("close enrollment: nil context")
|
|
}
|
|
if deps.Games == nil {
|
|
return game.Game{}, errors.New("close enrollment: nil game store")
|
|
}
|
|
if deps.Invites == nil {
|
|
return game.Game{}, errors.New("close enrollment: nil invite store")
|
|
}
|
|
if deps.Intents == nil {
|
|
return game.Game{}, errors.New("close enrollment: nil intent publisher")
|
|
}
|
|
if err := gameID.Validate(); err != nil {
|
|
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
|
}
|
|
if !trigger.IsKnown() {
|
|
return game.Game{}, fmt.Errorf("close enrollment: trigger %q is unsupported", trigger)
|
|
}
|
|
if at.IsZero() {
|
|
return game.Game{}, errors.New("close enrollment: at must not be zero")
|
|
}
|
|
|
|
logger := deps.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
|
|
if err := deps.Games.UpdateStatus(ctx, ports.UpdateStatusInput{
|
|
GameID: gameID,
|
|
ExpectedFrom: game.StatusEnrollmentOpen,
|
|
To: game.StatusReadyToStart,
|
|
Trigger: trigger,
|
|
At: at,
|
|
}); err != nil {
|
|
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
|
}
|
|
|
|
deps.Telemetry.RecordGameTransition(ctx,
|
|
string(game.StatusEnrollmentOpen),
|
|
string(game.StatusReadyToStart),
|
|
string(trigger),
|
|
)
|
|
|
|
updated, err := deps.Games.Get(ctx, gameID)
|
|
if err != nil {
|
|
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
|
|
}
|
|
|
|
expireCreatedInvites(ctx, deps, logger, updated, at)
|
|
|
|
return updated, nil
|
|
}
|
|
|
|
// expireCreatedInvites scans the invite store for the game and pushes
|
|
// every still-created record to the expired terminal status, publishing a
|
|
// per-invite lobby.invite.expired intent on success. All errors are
|
|
// logged and swallowed: an expired-but-unnotified invite is acceptable
|
|
// degradation while the authoritative game status is already advanced.
|
|
func expireCreatedInvites(
|
|
ctx context.Context,
|
|
deps CloseEnrollmentDeps,
|
|
logger *slog.Logger,
|
|
gameRecord game.Game,
|
|
at time.Time,
|
|
) {
|
|
invites, err := deps.Invites.GetByGame(ctx, gameRecord.GameID)
|
|
if err != nil {
|
|
logger.WarnContext(ctx, "list invites on enrollment close",
|
|
"game_id", gameRecord.GameID.String(),
|
|
"err", err.Error(),
|
|
)
|
|
return
|
|
}
|
|
|
|
for _, record := range invites {
|
|
if record.Status != invite.StatusCreated {
|
|
continue
|
|
}
|
|
|
|
if err := deps.Invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
|
InviteID: record.InviteID,
|
|
ExpectedFrom: invite.StatusCreated,
|
|
To: invite.StatusExpired,
|
|
At: at,
|
|
}); err != nil {
|
|
logger.WarnContext(ctx, "expire invite on enrollment close",
|
|
"game_id", gameRecord.GameID.String(),
|
|
"invite_id", record.InviteID.String(),
|
|
"err", err.Error(),
|
|
)
|
|
continue
|
|
}
|
|
|
|
deps.Telemetry.RecordInviteOutcome(ctx, "expired")
|
|
|
|
intent, err := notificationintent.NewLobbyInviteExpiredIntent(
|
|
notificationintent.Metadata{
|
|
IdempotencyKey: "lobby.invite.expired:" + record.InviteID.String(),
|
|
OccurredAt: at,
|
|
},
|
|
gameRecord.OwnerUserID,
|
|
notificationintent.LobbyInviteExpiredPayload{
|
|
GameID: gameRecord.GameID.String(),
|
|
GameName: gameRecord.GameName,
|
|
InviteeUserID: record.InviteeUserID,
|
|
InviteeName: inviteeDisplayName(record),
|
|
},
|
|
)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "build invite expired intent",
|
|
"invite_id", record.InviteID.String(),
|
|
"err", err.Error(),
|
|
)
|
|
continue
|
|
}
|
|
if _, publishErr := deps.Intents.Publish(ctx, intent); publishErr != nil {
|
|
logger.WarnContext(ctx, "publish invite expired intent",
|
|
"invite_id", record.InviteID.String(),
|
|
"err", publishErr.Error(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// inviteeDisplayName returns the invitee display name carried by the
|
|
// expired-invite notification. The invitee never redeemed (RaceName is
|
|
// empty for created invites) so the user id stands in as the readable
|
|
// label, matching the createinvite fallback for absent inviter
|
|
// memberships.
|
|
func inviteeDisplayName(record invite.Invite) string {
|
|
if record.RaceName != "" {
|
|
return record.RaceName
|
|
}
|
|
return record.InviteeUserID
|
|
}
|