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 }