// Package listmyapplications implements the `lobby.my_applications.list` // message type. It returns the acting user's submitted applications // joined with the corresponding game's `game_name` and `game_type`, // matching `lobby/README.md` §«My pending applications». // // The service is exclusively self-service: admin actors are rejected // with `shared.ErrForbidden`. Cross-user reads are not exposed by this // route in v1. package listmyapplications import ( "context" "errors" "fmt" "log/slog" "sort" "galaxy/lobby/internal/domain/application" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the list-my-applications use case. type Service struct { games ports.GameStore applications ports.ApplicationStore logger *slog.Logger } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games is loaded per submitted application to enrich the response // with game name and type for client display. Games ports.GameStore // Applications supplies the per-applicant index. Applications ports.ApplicationStore // 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 applications service: nil game store") case deps.Applications == nil: return nil, errors.New("new list my applications service: nil application store") } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, applications: deps.Applications, logger: logger.With("service", "lobby.listmyapplications"), }, nil } // Input stores the arguments required to compute the acting user's // submitted-applications 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 submitted application with the host game's name // and type, mirroring the OpenAPI `MyApplicationItem` schema. type Item struct { Application application.Application GameName string GameType game.GameType } // Output stores the page returned by Handle. type Output struct { // Items stores the enriched application 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 applications, // filters to `submitted`, joins with the game store, 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 applications: nil service") } if ctx == nil { return Output{}, errors.New("list my applications: nil context") } if err := input.Actor.Validate(); err != nil { return Output{}, fmt.Errorf("list my applications: actor: %w", err) } if !input.Actor.IsUser() { return Output{}, fmt.Errorf( "%w: only authenticated user actors may list their applications", shared.ErrForbidden, ) } records, err := service.applications.GetByUser(ctx, input.Actor.UserID) if err != nil { return Output{}, fmt.Errorf("list my applications: %w", err) } candidates := make([]Item, 0, len(records)) for _, record := range records { if record.Status != application.StatusSubmitted { continue } gameName, gameType, ok := service.lookupGame(ctx, record.GameID, record.ApplicationID) if !ok { continue } candidates = append(candidates, Item{ Application: record, GameName: gameName, GameType: gameType, }) } 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 } // lookupGame returns the host game's name and type. A missing game // record is logged at warn and the application is silently dropped from // the list — surfacing a 500 to the user for a dangling reference would // be a worse experience and the operator alert is more useful. func (service *Service) lookupGame( ctx context.Context, gameID common.GameID, applicationID common.ApplicationID, ) (name string, gameType game.GameType, ok bool) { record, err := service.games.Get(ctx, gameID) if err == nil { return record.GameName, record.GameType, true } if errors.Is(err, game.ErrNotFound) { service.logger.WarnContext(ctx, "application references missing game", "application_id", applicationID.String(), "game_id", gameID.String(), ) return "", "", false } service.logger.ErrorContext(ctx, "load application game", "application_id", applicationID.String(), "game_id", gameID.String(), "err", err.Error(), ) return "", "", false } // sortItems orders applications by CreatedAt descending so the most // recent appears first; ApplicationID breaks ties for stable output. func sortItems(items []Item) { sort.Slice(items, func(i, j int) bool { ci, cj := items[i].Application.CreatedAt, items[j].Application.CreatedAt if !ci.Equal(cj) { return ci.After(cj) } return items[i].Application.ApplicationID < items[j].Application.ApplicationID }) }