// Package createinvite implements the `lobby.invite.create` message type. It // validates the request against the private-game enrollment rules, persists // the new created invite via InviteStore, and publishes the // `lobby.invite.created` notification intent to the invitee. package createinvite import ( "context" "errors" "fmt" "log/slog" "strings" "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 creation use case. type Service struct { games ports.GameStore invites ports.InviteStore memberships ports.MembershipStore 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 enrollment validation. Games ports.GameStore // Invites persists the new created invite record and is scanned for // existing active invites that block re-invite. Invites ports.InviteStore // Memberships supplies the active-roster count for the capacity guard // and is scanned for an existing active membership of the invitee. Memberships ports.MembershipStore // Intents publishes the lobby.invite.created intent. Intents ports.IntentPublisher // IDs mints the new opaque invite identifier. IDs ports.IDGenerator // Clock supplies the wall-clock used for CreatedAt and the // notification's OccurredAt. Defaults to time.Now when nil. Clock func() time.Time // Logger records structured service-level events. Defaults to // slog.Default when nil. Logger *slog.Logger // Telemetry records the `lobby.invite.outcomes` counter on each // successful create. 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 create invite service: nil game store") case deps.Invites == nil: return nil, errors.New("new create invite service: nil invite store") case deps.Memberships == nil: return nil, errors.New("new create invite service: nil membership store") case deps.Intents == nil: return nil, errors.New("new create invite service: nil intent publisher") case deps.IDs == nil: return nil, errors.New("new create 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, intents: deps.Intents, ids: deps.IDs, clock: clock, logger: logger.With("service", "lobby.createinvite"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to create one invite. type Input struct { // Actor identifies the caller. Must be the private-game owner. Actor shared.Actor // GameID identifies the target private game. GameID common.GameID // InviteeUserID stores the platform user id of the invited user. InviteeUserID string } // Handle authorizes the actor, validates the game state, refuses duplicate // active invites and active memberships, persists the new created invite, // and publishes the lobby.invite.created intent on success. func (service *Service) Handle(ctx context.Context, input Input) (invite.Invite, error) { if service == nil { return invite.Invite{}, errors.New("create invite: nil service") } if ctx == nil { return invite.Invite{}, errors.New("create invite: nil context") } if err := input.Actor.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("create invite: actor: %w", err) } if !input.Actor.IsUser() { return invite.Invite{}, fmt.Errorf( "%w: only private-game owners may create invites", shared.ErrForbidden, ) } if err := input.GameID.Validate(); err != nil { return invite.Invite{}, fmt.Errorf("create invite: %w", err) } inviteeUserID := strings.TrimSpace(input.InviteeUserID) gameRecord, err := service.games.Get(ctx, input.GameID) if err != nil { return invite.Invite{}, fmt.Errorf("create invite: %w", err) } if gameRecord.GameType != game.GameTypePrivate { return invite.Invite{}, fmt.Errorf( "create invite: game %q is not private: %w", gameRecord.GameID, game.ErrConflict, ) } if gameRecord.Status != game.StatusEnrollmentOpen { return invite.Invite{}, fmt.Errorf( "create invite: game %q is not in enrollment_open: %w", gameRecord.GameID, game.ErrConflict, ) } if gameRecord.OwnerUserID != input.Actor.UserID { return invite.Invite{}, fmt.Errorf( "%w: actor is not the owner of game %q", shared.ErrForbidden, gameRecord.GameID, ) } if err := service.checkRosterCapacity(ctx, gameRecord); err != nil { return invite.Invite{}, err } if err := service.checkNoActiveInvite(ctx, gameRecord.GameID, inviteeUserID); err != nil { return invite.Invite{}, err } if err := service.checkNoActiveMembership(ctx, gameRecord.GameID, inviteeUserID); err != nil { return invite.Invite{}, err } inviteID, err := service.ids.NewInviteID() if err != nil { return invite.Invite{}, fmt.Errorf("create invite: %w", err) } now := service.clock().UTC() record, err := invite.New(invite.NewInviteInput{ InviteID: inviteID, GameID: gameRecord.GameID, InviterUserID: input.Actor.UserID, InviteeUserID: inviteeUserID, Now: now, ExpiresAt: gameRecord.EnrollmentEndsAt, }) if err != nil { return invite.Invite{}, fmt.Errorf("create invite: %w", err) } if err := service.invites.Save(ctx, record); err != nil { return invite.Invite{}, fmt.Errorf("create invite: %w", err) } inviterName := service.resolveInviterName(ctx, gameRecord.GameID, input.Actor.UserID) intent, err := notificationintent.NewLobbyInviteCreatedIntent( notificationintent.Metadata{ IdempotencyKey: "lobby.invite.created:" + record.InviteID.String(), OccurredAt: now, }, record.InviteeUserID, notificationintent.LobbyInviteCreatedPayload{ GameID: gameRecord.GameID.String(), GameName: gameRecord.GameName, InviterUserID: record.InviterUserID, InviterName: inviterName, }, ) if err != nil { service.logger.ErrorContext(ctx, "build invite created intent", "invite_id", record.InviteID.String(), "err", err.Error(), ) } else if _, publishErr := service.intents.Publish(ctx, intent); publishErr != nil { service.logger.WarnContext(ctx, "publish invite created intent", "invite_id", record.InviteID.String(), "err", publishErr.Error(), ) } service.telemetry.RecordInviteOutcome(ctx, "created") logArgs := []any{ "game_id", record.GameID.String(), "game_status", string(gameRecord.Status), "invite_id", record.InviteID.String(), "inviter_user_id", record.InviterUserID, "invitee_user_id", record.InviteeUserID, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "invite created", logArgs...) return record, nil } // checkRosterCapacity counts active memberships for gameRecord and returns // game.ErrConflict when the roster has reached the gap-window upper bound. func (service *Service) checkRosterCapacity(ctx context.Context, gameRecord game.Game) error { cap := gameRecord.MaxPlayers + gameRecord.StartGapPlayers count, err := shared.CountActiveMemberships(ctx, service.memberships, gameRecord.GameID) if err != nil { return fmt.Errorf("create invite: %w", err) } if count >= cap { return fmt.Errorf( "create invite: roster full (%d active >= %d cap): %w", count, cap, game.ErrConflict, ) } return nil } // checkNoActiveInvite enforces the README invariant that the invitee has no // existing active invite for the same game. The Redis InviteStore does not // maintain a (game, invitee) uniqueness index, so the service performs the // scan; see the design notes. func (service *Service) checkNoActiveInvite(ctx context.Context, gameID common.GameID, inviteeUserID string) error { existing, err := service.invites.GetByGame(ctx, gameID) if err != nil { return fmt.Errorf("create invite: %w", err) } for _, record := range existing { if record.Status == invite.StatusCreated && record.InviteeUserID == inviteeUserID { return fmt.Errorf( "create invite: invitee %q already has an active invite to game %q: %w", inviteeUserID, gameID, invite.ErrConflict, ) } } return nil } // checkNoActiveMembership enforces the README invariant that the invitee is // not already an active member of the game. func (service *Service) checkNoActiveMembership(ctx context.Context, gameID common.GameID, inviteeUserID string) error { existing, err := service.memberships.GetByGame(ctx, gameID) if err != nil { return fmt.Errorf("create invite: %w", err) } for _, record := range existing { if record.Status == membership.StatusActive && record.UserID == inviteeUserID { return fmt.Errorf( "create invite: invitee %q already has an active membership in game %q: %w", inviteeUserID, gameID, membership.ErrConflict, ) } } return nil } // resolveInviterName returns the owner's confirmed in-game race name when the // owner already has an active membership in the game. Otherwise it falls // back to the owner's user id, matching README §Invite Lifecycle. func (service *Service) resolveInviterName(ctx context.Context, gameID common.GameID, ownerUserID string) string { records, err := service.memberships.GetByGame(ctx, gameID) if err != nil { service.logger.WarnContext(ctx, "resolve inviter name: load memberships", "game_id", gameID.String(), "err", err.Error(), ) return ownerUserID } for _, record := range records { if record.UserID == ownerUserID && record.Status == membership.StatusActive { return record.RaceName } } return ownerUserID }