// Package redeeminvite implements the `lobby.invite.redeem` message type. It // validates the invitee against the private-game enrollment rules, reserves // the chosen race name, transitions the invite from created to redeemed, // creates the matching active membership, opens the gap window when the new // approved count equals max_players, and publishes the // `lobby.invite.redeemed` notification intent to the private-game owner. package redeeminvite 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/domain/membership" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" "galaxy/notificationintent" ) // Service executes the private-game invite redemption use case. type Service struct { games ports.GameStore invites ports.InviteStore memberships ports.MembershipStore directory ports.RaceNameDirectory users ports.UserService gapStore ports.GapActivationStore intents ports.IntentPublisher ids ports.IDGenerator clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games loads the target game record for status verification and the // owner notification recipient. Games ports.GameStore // Invites loads the target invite and applies the // created → redeemed transition. Invites ports.InviteStore // Memberships persists the new active membership and supplies the // approved-count for the gap-window decision and capacity guard. Memberships ports.MembershipStore // Directory canonicalizes the race name and reserves it for the // invitee. Directory ports.RaceNameDirectory // Users resolves the synchronous eligibility snapshot for both the // inviter and the invitee. uses it to short-circuit // redemption when either party has been permanently blocked or // soft-deleted between invite creation and the redeem attempt. Users ports.UserService // GapStore records the gap-window activation timestamp when the new // approved count equals max_players. GapStore ports.GapActivationStore // Intents publishes the lobby.invite.redeemed intent to the owner. Intents ports.IntentPublisher // IDs mints the new opaque membership identifier. IDs ports.IDGenerator // Clock supplies the wall-clock used for DecidedAt, JoinedAt, // gap-activation time, and the notification's OccurredAt. Clock func() time.Time // Logger records structured service-level events. Logger *slog.Logger // Telemetry records the `lobby.invite.outcomes` and // `lobby.membership.changes` counters. Optional; nil disables // metric emission. Telemetry *telemetry.Runtime } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new redeem invite service: nil game store") case deps.Invites == nil: return nil, errors.New("new redeem invite service: nil invite store") case deps.Memberships == nil: return nil, errors.New("new redeem invite service: nil membership store") case deps.Directory == nil: return nil, errors.New("new redeem invite service: nil race name directory") case deps.Users == nil: return nil, errors.New("new redeem invite service: nil user service") case deps.GapStore == nil: return nil, errors.New("new redeem invite service: nil gap activation store") case deps.Intents == nil: return nil, errors.New("new redeem invite service: nil intent publisher") case deps.IDs == nil: return nil, errors.New("new redeem invite service: nil id generator") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, invites: deps.Invites, memberships: deps.Memberships, directory: deps.Directory, users: deps.Users, gapStore: deps.GapStore, intents: deps.Intents, ids: deps.IDs, clock: clock, logger: logger.With("service", "lobby.redeeminvite"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to redeem one invite. type Input struct { // Actor identifies the caller. Must be the invitee. Actor shared.Actor // GameID identifies the game referenced by the request path; it must // match the loaded invite's GameID. GameID common.GameID // InviteID identifies the target invite. InviteID common.InviteID // RaceName stores the desired in-game name in original casing. RaceName string } // Handle authorizes the invitee, validates the invite + game state, reserves // the race name, transitions the invite, persists the active membership, // opens the gap window if appropriate, and publishes the // lobby.invite.redeemed intent on success. func (service *Service) Handle(ctx context.Context, input Input) (membership.Membership, error) { if service == nil { return membership.Membership{}, errors.New("redeem invite: nil service") } if ctx == nil { return membership.Membership{}, errors.New("redeem invite: nil context") } if err := input.Actor.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: actor: %w", err) } if !input.Actor.IsUser() { return membership.Membership{}, fmt.Errorf( "%w: only the invited user may redeem an invite", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if err := input.InviteID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } inv, err := service.invites.Get(ctx, input.InviteID) if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if inv.GameID != input.GameID { // Defensive: opaque ids must not leak across games. return membership.Membership{}, fmt.Errorf( "redeem invite: invite %q does not belong to game %q: %w", inv.InviteID, input.GameID, invite.ErrNotFound, ) } if inv.InviteeUserID != input.Actor.UserID { return membership.Membership{}, fmt.Errorf( "%w: invite is addressed to a different user", shared.ErrForbidden, ) } if inv.Status != invite.StatusCreated { return membership.Membership{}, fmt.Errorf( "redeem invite: status %q is not %q: %w", inv.Status, invite.StatusCreated, invite.ErrConflict, ) } gameRecord, err := service.games.Get(ctx, input.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if gameRecord.GameType != game.GameTypePrivate { return membership.Membership{}, fmt.Errorf( "redeem invite: game %q is not private: %w", gameRecord.GameID, game.ErrConflict, ) } if gameRecord.Status != game.StatusEnrollmentOpen { return membership.Membership{}, fmt.Errorf( "redeem invite: game %q is not in enrollment_open: %w", gameRecord.GameID, game.ErrConflict, ) } // cascade guard: a permanent_block or DeleteUser on the // inviter or the invitee makes the invite untusable. Return // subject_not_found so the caller sees the same outcome as if the // invite never existed, even when the user-lifecycle cascade has // not yet flipped the invite to revoked. if err := service.requireActiveParty(ctx, inv.InviterUserID, "inviter"); err != nil { return membership.Membership{}, err } if err := service.requireActiveParty(ctx, input.Actor.UserID, "invitee"); err != nil { return membership.Membership{}, err } cap := gameRecord.MaxPlayers + gameRecord.StartGapPlayers currentActive, err := shared.CountActiveMemberships(ctx, service.memberships, gameRecord.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if currentActive >= cap { return membership.Membership{}, fmt.Errorf( "redeem invite: roster full (%d active >= %d cap): %w", currentActive, cap, game.ErrConflict, ) } canonical, err := service.directory.Canonicalize(input.RaceName) if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if err := service.directory.Reserve(ctx, gameRecord.GameID.String(), input.Actor.UserID, input.RaceName); err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } now := service.clock().UTC() updateErr := service.invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{ InviteID: inv.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusRedeemed, At: now, RaceName: input.RaceName, }) if updateErr != nil { // Concurrent mutation lost the CAS race. Release the // reservation we just made so it does not orphan; the user // may retry redeem later under the same canonical key. if releaseErr := service.directory.ReleaseReservation(ctx, gameRecord.GameID.String(), input.Actor.UserID, input.RaceName); releaseErr != nil { service.logger.WarnContext(ctx, "release reservation after redeem cas failure", "invite_id", inv.InviteID.String(), "err", releaseErr.Error(), ) } return membership.Membership{}, fmt.Errorf("redeem invite: %w", updateErr) } membershipID, err := service.ids.NewMembershipID() if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } memberRecord, err := membership.New(membership.NewMembershipInput{ MembershipID: membershipID, GameID: gameRecord.GameID, UserID: input.Actor.UserID, RaceName: input.RaceName, CanonicalKey: canonical, Now: now, }) if err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } if err := service.memberships.Save(ctx, memberRecord); err != nil { return membership.Membership{}, fmt.Errorf("redeem invite: %w", err) } // Gap-window trigger: count after Save now includes the new // membership, so if it equals max_players we just crossed the // threshold. The worker reads gap_activated_at to time // out the gap; auto-transition to ready_to_start is its job. if currentActive+1 == gameRecord.MaxPlayers { if err := service.gapStore.MarkActivated(ctx, gameRecord.GameID, now); err != nil { service.logger.WarnContext(ctx, "mark gap activation", "game_id", gameRecord.GameID.String(), "err", err.Error(), ) } } intent, err := notificationintent.NewLobbyInviteRedeemedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.invite.redeemed:" + inv.InviteID.String(), OccurredAt: now, }, gameRecord.OwnerUserID, notificationintent.LobbyInviteRedeemedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, InviteeUserID: memberRecord.UserID, InviteeName: memberRecord.RaceName, }, ) if err != nil { service.logger.ErrorContext(ctx, "build invite redeemed intent", "invite_id", inv.InviteID.String(), "err", err.Error(), ) } else if _, publishErr := service.intents.Publish(ctx, intent); publishErr != nil { service.logger.WarnContext(ctx, "publish invite redeemed intent", "invite_id", inv.InviteID.String(), "err", publishErr.Error(), ) } service.telemetry.RecordInviteOutcome(ctx, "redeemed") service.telemetry.RecordMembershipChange(ctx, "activated") logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "invite_id", inv.InviteID.String(), "membership_id", memberRecord.MembershipID.String(), "user_id", memberRecord.UserID, "race_name", memberRecord.RaceName, "canonical_key", string(memberRecord.CanonicalKey), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "invite redeemed", logArgs...) return memberRecord, nil } // requireActiveParty fetches the eligibility snapshot for userID and // returns shared.ErrSubjectNotFound when the user no longer exists or // has been permanently blocked. Transport failures wrap // shared.ErrServiceUnavailable so the caller can map to HTTP 503. func (service *Service) requireActiveParty(ctx context.Context, userID, role string) error { eligibility, err := service.users.GetEligibility(ctx, userID) if err != nil { return fmt.Errorf("redeem invite: %w of %s: %w", shared.ErrServiceUnavailable, role, err) } if !eligibility.Exists || eligibility.PermanentBlocked { return fmt.Errorf("redeem invite: %s no longer active: %w", role, shared.ErrSubjectNotFound) } return nil }