// Package listmyinvites implements the `lobby.my_invites.list` message // type. It returns invites addressed to the acting user with status // `created`, joined with the host game's `game_name` and the inviter's // display name as defined in `lobby/README.md` §«My open invitations»: // the inviter's race name when the inviter holds an active membership // in the host game, and the inviter's `user_id` otherwise. // // The service is exclusively self-service: admin actors are rejected // with `shared.ErrForbidden`. package listmyinvites import ( "context" "errors" "fmt" "log/slog" "sort" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the list-my-invites use case. type Service struct { games ports.GameStore invites ports.InviteStore memberships ports.MembershipStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games is loaded per invite to enrich the response with the host // game's name and to access its OwnerUserID. Games ports.GameStore // Invites supplies the per-invitee index. Invites ports.InviteStore // Memberships is consulted per invite's host game to derive the // inviter's race name. It may be replaced with a denormalized field // later without changing this contract. Memberships ports.MembershipStore // Logger records structured diagnostics. Defaults to slog.Default // when nil. Logger *slog.Logger } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new list my invites service: nil game store") case deps.Invites == nil: return nil, errors.New("new list my invites service: nil invite store") case deps.Memberships == nil: return nil, errors.New("new list my invites service: nil membership store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, invites: deps.Invites, memberships: deps.Memberships, logger: logger.With("service", "lobby.listmyinvites"), }, nil } // Input stores the arguments required to compute the acting user's // open-invites view. type Input struct { // Actor identifies the caller. Must be ActorKindUser. Actor shared.Actor // Page stores the pagination request. Page shared.Page } // Item enriches one open invite with the host game's name and the // derived inviter display name, mirroring the OpenAPI `MyInviteItem` // schema. type Item struct { Invite invite.Invite GameName string InviterName string } // Output stores the page returned by Handle. type Output struct { // Items stores the enriched invite records included in the current // page. Items []Item // NextPageToken stores the opaque continuation token; empty when // the current page is the last one. NextPageToken string } // Handle authorizes the actor as a user, fetches their invites, // filters to `created`, joins with the game store and the inviter's // active membership, sorts, and returns the requested page. func (service *Service) Handle(ctx context.Context, input Input) (Output, error) { if service == nil { return Output{}, errors.New("list my invites: nil service") } if ctx == nil { return Output{}, errors.New("list my invites: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list my invites: actor: %w", err) } if !input.Actor.IsUser() { return Output{}, fmt.Errorf( "%w: only authenticated user actors may list their invites", shared.ErrForbidden, ) } records, err := service.invites.GetByUser(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my invites: %w", err) } candidates := make([]Item, 0, len(records)) for _, record := range records { if record.Status != invite.StatusCreated { continue } gameName, ok := service.lookupGameName(ctx, record.GameID, record.InviteID) if !ok { continue } inviterName, err := service.deriveInviterName(ctx, record) if err != nil { return Output{}, fmt.Errorf("list my invites: %w", err) } candidates = append(candidates, Item{ Invite: record, GameName: gameName, InviterName: inviterName, }) } sortItems(candidates) start, end, nextOffset, hasMore := shared.Window(len(candidates), input.Page) out := Output{Items: append([]Item(nil), candidates[start:end]...)} if hasMore { out.NextPageToken = shared.EncodeToken(nextOffset) } return out, nil } // lookupGameName returns the host game's name. A missing game record // is logged at warn and the invite is dropped from the response — the // invite cannot meaningfully render without the parent game. func (service *Service) lookupGameName( ctx context.Context, gameID common.GameID, inviteID common.InviteID, ) (string, bool) { record, err := service.games.Get(ctx, gameID) if err == nil { return record.GameName, true } if errors.Is(err, game.ErrNotFound) { service.logger.WarnContext(ctx, "invite references missing game", "invite_id", inviteID.String(), "game_id", gameID.String(), ) return "", false } service.logger.ErrorContext(ctx, "load invite game", "invite_id", inviteID.String(), "game_id", gameID.String(), "err", err.Error(), ) return "", false } // deriveInviterName returns the inviter's race name when an active // membership exists in the host game; otherwise it returns the // inviter's user_id verbatim, matching README §«Invite Lifecycle». func (service *Service) deriveInviterName(ctx context.Context, record invite.Invite) (string, error) { memberships, err := service.memberships.GetByGame(ctx, record.GameID) if err != nil { return "", err } for _, mb := range memberships { if mb.UserID == record.InviterUserID && mb.Status == membership.StatusActive { return mb.RaceName, nil } } return record.InviterUserID, nil } // sortItems orders invites by CreatedAt descending; InviteID breaks // ties for stable output across adapters. func sortItems(items []Item) { sort.Slice(items, func(i, j int) bool { ci, cj := items[i].Invite.CreatedAt, items[j].Invite.CreatedAt if !ci.Equal(cj) { return ci.After(cj) } return items[i].Invite.InviteID < items[j].Invite.InviteID }) }