package lobby import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "galaxy/backend/internal/postgres/jet/backend/model" "galaxy/backend/internal/postgres/jet/backend/table" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" ) // Constraint names mirror the names declared in // `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as // constants keeps error classification robust against typos. const ( constraintMembershipsGameUserUnique = "memberships_game_user_unique" constraintApplicationsActiveUnique = "applications_active_per_user_game_uidx" constraintInvitesCodeUnique = "invites_code_uidx" constraintRaceNamesPK = "race_names_pkey" constraintRaceNamesRegisteredUnique = "race_names_registered_uidx" ) // Store is the Postgres-backed query surface for the lobby package. All // queries are built through go-jet against the generated table bindings // under `backend/internal/postgres/jet/backend/table`. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // gameColumns is the canonical projection for game reads. func gameColumns() postgres.ColumnList { g := table.Games return postgres.ColumnList{ g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description, g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers, g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion, g.RuntimeSnapshot, g.CreatedAt, g.UpdatedAt, g.StartedAt, g.FinishedAt, } } // applicationColumns is the canonical projection for application reads. func applicationColumns() postgres.ColumnList { a := table.Applications return postgres.ColumnList{ a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status, a.CreatedAt, a.DecidedAt, } } // inviteColumns is the canonical projection for invite reads. func inviteColumns() postgres.ColumnList { i := table.Invites return postgres.ColumnList{ i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code, i.Status, i.RaceName, i.CreatedAt, i.ExpiresAt, i.DecidedAt, } } // membershipColumns is the canonical projection for membership reads. func membershipColumns() postgres.ColumnList { m := table.Memberships return postgres.ColumnList{ m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status, m.JoinedAt, m.RemovedAt, } } // raceNameColumns is the canonical projection for race-name reads. func raceNameColumns() postgres.ColumnList { r := table.RaceNames return postgres.ColumnList{ r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID, r.ReservedAt, r.ExpiresAt, r.RegisteredAt, } } // gameInsert is the parameter struct for InsertGame. type gameInsert struct { GameID uuid.UUID OwnerUserID *uuid.UUID Visibility string GameName string Description string MinPlayers int32 MaxPlayers int32 StartGapHours int32 StartGapPlayers int32 EnrollmentEndsAt time.Time TurnSchedule string TargetEngineVersion string } // InsertGame persists a brand-new draft game record together with an // empty runtime snapshot. func (s *Store) InsertGame(ctx context.Context, in gameInsert) (GameRecord, error) { emptySnapshot, err := json.Marshal(RuntimeSnapshot{}) if err != nil { return GameRecord{}, fmt.Errorf("lobby store: marshal empty snapshot: %w", err) } g := table.Games stmt := g.INSERT( g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description, g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers, g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion, g.RuntimeSnapshot, ).VALUES( in.GameID, ownerArg(in.OwnerUserID), in.Visibility, GameStatusDraft, in.GameName, in.Description, in.MinPlayers, in.MaxPlayers, in.StartGapHours, in.StartGapPlayers, in.EnrollmentEndsAt, in.TurnSchedule, in.TargetEngineVersion, string(emptySnapshot), ).RETURNING(gameColumns()) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return GameRecord{}, fmt.Errorf("lobby store: insert game: %w", err) } return modelToGameRecord(row) } // LoadGame returns the game record for gameID. Returns ErrNotFound when // no row matches. func (s *Store) LoadGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) { g := table.Games stmt := postgres.SELECT(gameColumns()). FROM(g). WHERE(g.GameID.EQ(postgres.UUID(gameID))). LIMIT(1) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return GameRecord{}, ErrNotFound } return GameRecord{}, fmt.Errorf("lobby store: load game %s: %w", gameID, err) } return modelToGameRecord(row) } // ListPublicGames returns the requested page of public games together // with the total count for pagination. func (s *Store) ListPublicGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) { g := table.Games totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). FROM(g). WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic))) var totalDest struct { Count int64 `alias:"count"` } if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil { return nil, 0, fmt.Errorf("lobby store: count public games: %w", err) } offset := (page - 1) * pageSize listStmt := postgres.SELECT(gameColumns()). FROM(g). WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic))). ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()). LIMIT(int64(pageSize)).OFFSET(int64(offset)) var rows []model.Games if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, 0, fmt.Errorf("lobby store: list public games: %w", err) } games, err := modelsToGameRecords(rows) if err != nil { return nil, 0, err } return games, int(totalDest.Count), nil } // ListAllGames returns every game row, used by Cache.Warm at startup. func (s *Store) ListAllGames(ctx context.Context) ([]GameRecord, error) { stmt := postgres.SELECT(gameColumns()).FROM(table.Games) var rows []model.Games if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list all games: %w", err) } return modelsToGameRecords(rows) } // ListAdminGames returns the requested page of every game (admin view) // together with the total count. func (s *Store) ListAdminGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) { g := table.Games totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(g) var totalDest struct { Count int64 `alias:"count"` } if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil { return nil, 0, fmt.Errorf("lobby store: count games: %w", err) } offset := (page - 1) * pageSize listStmt := postgres.SELECT(gameColumns()). FROM(g). ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()). LIMIT(int64(pageSize)).OFFSET(int64(offset)) var rows []model.Games if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, 0, fmt.Errorf("lobby store: list admin games: %w", err) } games, err := modelsToGameRecords(rows) if err != nil { return nil, 0, err } return games, int(totalDest.Count), nil } // ListMyGames returns every game where userID has an active membership, // ordered by created_at DESC. func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) { g := table.Games m := table.Memberships stmt := postgres.SELECT(gameColumns()). FROM(g.INNER_JOIN(m, m.GameID.EQ(g.GameID))). WHERE( m.UserID.EQ(postgres.UUID(userID)). AND(m.Status.EQ(postgres.String(MembershipStatusActive))), ). ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()) var rows []model.Games if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list my games: %w", err) } return modelsToGameRecords(rows) } // DeleteGame removes the row at gameID. Cascades through every // referencing table (memberships / applications / invites / // runtime_records / player_mappings — all declared with ON DELETE // CASCADE in `00001_init.sql`). Idempotent: returns nil when no row // matches. Used by the dev-sandbox bootstrap to scrub terminal // games on every backend boot so the developer's lobby never piles // up cancelled tiles. func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error { g := table.Games stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("lobby store: delete game %s: %w", gameID, err) } return nil } // gameUpdate is the parameter struct for UpdateGame. Nil pointers leave // the corresponding column alone. type gameUpdate struct { GameName *string Description *string EnrollmentEndsAt *time.Time TurnSchedule *string TargetEngineVersion *string MinPlayers *int32 MaxPlayers *int32 StartGapHours *int32 StartGapPlayers *int32 } func (u gameUpdate) empty() bool { return u.GameName == nil && u.Description == nil && u.EnrollmentEndsAt == nil && u.TurnSchedule == nil && u.TargetEngineVersion == nil && u.MinPlayers == nil && u.MaxPlayers == nil && u.StartGapHours == nil && u.StartGapPlayers == nil } // UpdateGame patches the supplied columns and bumps updated_at. Returns // ErrNotFound when no row matches. func (s *Store) UpdateGame(ctx context.Context, gameID uuid.UUID, patch gameUpdate, now time.Time) (GameRecord, error) { if patch.empty() { return s.LoadGame(ctx, gameID) } g := table.Games rest := []any{} if patch.GameName != nil { rest = append(rest, g.GameName.SET(postgres.String(*patch.GameName))) } if patch.Description != nil { rest = append(rest, g.Description.SET(postgres.String(*patch.Description))) } if patch.EnrollmentEndsAt != nil { rest = append(rest, g.EnrollmentEndsAt.SET(postgres.TimestampzT(*patch.EnrollmentEndsAt))) } if patch.TurnSchedule != nil { rest = append(rest, g.TurnSchedule.SET(postgres.String(*patch.TurnSchedule))) } if patch.TargetEngineVersion != nil { rest = append(rest, g.TargetEngineVersion.SET(postgres.String(*patch.TargetEngineVersion))) } if patch.MinPlayers != nil { rest = append(rest, g.MinPlayers.SET(postgres.Int(int64(*patch.MinPlayers)))) } if patch.MaxPlayers != nil { rest = append(rest, g.MaxPlayers.SET(postgres.Int(int64(*patch.MaxPlayers)))) } if patch.StartGapHours != nil { rest = append(rest, g.StartGapHours.SET(postgres.Int(int64(*patch.StartGapHours)))) } if patch.StartGapPlayers != nil { rest = append(rest, g.StartGapPlayers.SET(postgres.Int(int64(*patch.StartGapPlayers)))) } stmt := g.UPDATE(). SET(g.UpdatedAt.SET(postgres.TimestampzT(now)), rest...). WHERE(g.GameID.EQ(postgres.UUID(gameID))). RETURNING(gameColumns()) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return GameRecord{}, ErrNotFound } return GameRecord{}, fmt.Errorf("lobby store: update game %s: %w", gameID, err) } return modelToGameRecord(row) } // statusUpdate carries the parameters for UpdateGameStatus. SetStarted // /ClearStarted/SetFinished are mutually-exclusive flags driving the // timestamp columns. type statusUpdate struct { NewStatus string UpdatedAt time.Time SetStarted bool StartedAt time.Time SetFinished bool FinishedAt time.Time ClearStarted bool } // UpdateGameStatus transitions status and (optionally) updates the // started_at / finished_at columns. Returns the refreshed row. func (s *Store) UpdateGameStatus(ctx context.Context, gameID uuid.UUID, in statusUpdate) (GameRecord, error) { g := table.Games rest := []any{} switch { case in.SetStarted: rest = append(rest, g.StartedAt.SET(postgres.TimestampzT(in.StartedAt))) case in.ClearStarted: rest = append(rest, g.StartedAt.SET(postgres.TimestampzExp(postgres.NULL))) } if in.SetFinished { rest = append(rest, g.FinishedAt.SET(postgres.TimestampzT(in.FinishedAt))) } stmt := g.UPDATE(). SET( g.Status.SET(postgres.String(in.NewStatus)), append([]any{g.UpdatedAt.SET(postgres.TimestampzT(in.UpdatedAt))}, rest...)..., ). WHERE(g.GameID.EQ(postgres.UUID(gameID))). RETURNING(gameColumns()) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return GameRecord{}, ErrNotFound } return GameRecord{}, fmt.Errorf("lobby store: update game status %s: %w", gameID, err) } return modelToGameRecord(row) } // UpdateGameRuntimeSnapshot replaces the JSON-encoded runtime snapshot // for gameID. Used by `OnRuntimeSnapshot` and the per-event hooks. func (s *Store) UpdateGameRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snapshot RuntimeSnapshot, now time.Time) (GameRecord, error) { encoded, err := json.Marshal(snapshot) if err != nil { return GameRecord{}, fmt.Errorf("lobby store: marshal snapshot: %w", err) } g := table.Games stmt := g.UPDATE(). SET( g.RuntimeSnapshot.SET(postgres.StringExp(postgres.CAST(postgres.String(string(encoded))).AS("jsonb"))), g.UpdatedAt.SET(postgres.TimestampzT(now)), ). WHERE(g.GameID.EQ(postgres.UUID(gameID))). RETURNING(gameColumns()) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return GameRecord{}, ErrNotFound } return GameRecord{}, fmt.Errorf("lobby store: update runtime snapshot %s: %w", gameID, err) } return modelToGameRecord(row) } // CountActiveMemberships returns the number of memberships in `active` // status for gameID. Drives `approved_count >= min_players` checks. func (s *Store) CountActiveMemberships(ctx context.Context, gameID uuid.UUID) (int, error) { m := table.Memberships stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). FROM(m). WHERE( m.GameID.EQ(postgres.UUID(gameID)). AND(m.Status.EQ(postgres.String(MembershipStatusActive))), ) var dest struct { Count int64 `alias:"count"` } if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return 0, fmt.Errorf("lobby store: count active memberships %s: %w", gameID, err) } return int(dest.Count), nil } // applicationInsert carries the parameters for InsertApplication. type applicationInsert struct { ApplicationID uuid.UUID GameID uuid.UUID ApplicantUserID uuid.UUID RaceName string } // InsertApplication creates a fresh `pending` application. Returns // ErrConflict on the partial UNIQUE violation against the per-user // per-game active constraint. func (s *Store) InsertApplication(ctx context.Context, in applicationInsert) (Application, error) { a := table.Applications stmt := a.INSERT( a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status, ).VALUES( in.ApplicationID, in.GameID, in.ApplicantUserID, in.RaceName, ApplicationStatusPending, ).RETURNING(applicationColumns()) var row model.Applications if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, constraintApplicationsActiveUnique) { return Application{}, fmt.Errorf("%w: application already exists for this user", ErrConflict) } return Application{}, fmt.Errorf("lobby store: insert application: %w", err) } return modelToApplication(row), nil } // LoadApplication returns the application for applicationID. func (s *Store) LoadApplication(ctx context.Context, applicationID uuid.UUID) (Application, error) { a := table.Applications stmt := postgres.SELECT(applicationColumns()). FROM(a). WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))). LIMIT(1) var row model.Applications if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Application{}, ErrNotFound } return Application{}, fmt.Errorf("lobby store: load application %s: %w", applicationID, err) } return modelToApplication(row), nil } // UpdateApplicationStatus patches status and decided_at; returns the // refreshed row. func (s *Store) UpdateApplicationStatus(ctx context.Context, applicationID uuid.UUID, status string, decidedAt time.Time) (Application, error) { a := table.Applications stmt := a.UPDATE(). SET( a.Status.SET(postgres.String(status)), a.DecidedAt.SET(postgres.TimestampzT(decidedAt)), ). WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))). RETURNING(applicationColumns()) var row model.Applications if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Application{}, ErrNotFound } return Application{}, fmt.Errorf("lobby store: update application status %s: %w", applicationID, err) } return modelToApplication(row), nil } // ListApplicationsForGame returns every application for gameID ordered // by created_at ASC. func (s *Store) ListApplicationsForGame(ctx context.Context, gameID uuid.UUID) ([]Application, error) { a := table.Applications stmt := postgres.SELECT(applicationColumns()). FROM(a). WHERE(a.GameID.EQ(postgres.UUID(gameID))). ORDER_BY(a.CreatedAt.ASC()) var rows []model.Applications if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list applications for game %s: %w", gameID, err) } out := make([]Application, 0, len(rows)) for _, row := range rows { out = append(out, modelToApplication(row)) } return out, nil } // ListMyApplications returns every application owned by userID. func (s *Store) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) { a := table.Applications stmt := postgres.SELECT(applicationColumns()). FROM(a). WHERE(a.ApplicantUserID.EQ(postgres.UUID(userID))). ORDER_BY(a.CreatedAt.DESC()) var rows []model.Applications if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list my applications: %w", err) } out := make([]Application, 0, len(rows)) for _, row := range rows { out = append(out, modelToApplication(row)) } return out, nil } // inviteInsert carries the parameters for InsertInvite. type inviteInsert struct { InviteID uuid.UUID GameID uuid.UUID InviterUserID uuid.UUID InvitedUserID *uuid.UUID Code string RaceName string ExpiresAt time.Time } // InsertInvite creates a fresh `pending` invite. func (s *Store) InsertInvite(ctx context.Context, in inviteInsert) (Invite, error) { i := table.Invites stmt := i.INSERT( i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code, i.Status, i.RaceName, i.ExpiresAt, ).VALUES( in.InviteID, in.GameID, in.InviterUserID, invitedArg(in.InvitedUserID), codeArg(in.Code), InviteStatusPending, in.RaceName, in.ExpiresAt, ).RETURNING(inviteColumns()) var row model.Invites if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, constraintInvitesCodeUnique) { return Invite{}, fmt.Errorf("%w: invite code collision", ErrConflict) } return Invite{}, fmt.Errorf("lobby store: insert invite: %w", err) } return modelToInvite(row), nil } // LoadInvite returns the invite for inviteID. func (s *Store) LoadInvite(ctx context.Context, inviteID uuid.UUID) (Invite, error) { i := table.Invites stmt := postgres.SELECT(inviteColumns()). FROM(i). WHERE(i.InviteID.EQ(postgres.UUID(inviteID))). LIMIT(1) var row model.Invites if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Invite{}, ErrNotFound } return Invite{}, fmt.Errorf("lobby store: load invite %s: %w", inviteID, err) } return modelToInvite(row), nil } // UpdateInviteStatus patches status and decided_at. func (s *Store) UpdateInviteStatus(ctx context.Context, inviteID uuid.UUID, status string, decidedAt time.Time) (Invite, error) { i := table.Invites stmt := i.UPDATE(). SET( i.Status.SET(postgres.String(status)), i.DecidedAt.SET(postgres.TimestampzT(decidedAt)), ). WHERE(i.InviteID.EQ(postgres.UUID(inviteID))). RETURNING(inviteColumns()) var row model.Invites if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Invite{}, ErrNotFound } return Invite{}, fmt.Errorf("lobby store: update invite status %s: %w", inviteID, err) } return modelToInvite(row), nil } // ListInvitesForGame returns every invite for gameID ordered by // created_at ASC. func (s *Store) ListInvitesForGame(ctx context.Context, gameID uuid.UUID) ([]Invite, error) { i := table.Invites stmt := postgres.SELECT(inviteColumns()). FROM(i). WHERE(i.GameID.EQ(postgres.UUID(gameID))). ORDER_BY(i.CreatedAt.ASC()) var rows []model.Invites if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list invites for game %s: %w", gameID, err) } out := make([]Invite, 0, len(rows)) for _, row := range rows { out = append(out, modelToInvite(row)) } return out, nil } // ListMyInvites returns every invite for which userID is the recipient. func (s *Store) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) { i := table.Invites stmt := postgres.SELECT(inviteColumns()). FROM(i). WHERE(i.InvitedUserID.EQ(postgres.UUID(userID))). ORDER_BY(i.CreatedAt.DESC()) var rows []model.Invites if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list my invites: %w", err) } out := make([]Invite, 0, len(rows)) for _, row := range rows { out = append(out, modelToInvite(row)) } return out, nil } // membershipInsert carries the parameters for InsertMembership. type membershipInsert struct { MembershipID uuid.UUID GameID uuid.UUID UserID uuid.UUID RaceName string CanonicalKey CanonicalKey } // InsertMembership creates an `active` membership row. Returns // ErrConflict on the per-game UNIQUE collision (user already a member). func (s *Store) InsertMembership(ctx context.Context, in membershipInsert) (Membership, error) { m := table.Memberships stmt := m.INSERT( m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status, ).VALUES( in.MembershipID, in.GameID, in.UserID, in.RaceName, string(in.CanonicalKey), MembershipStatusActive, ).RETURNING(membershipColumns()) var row model.Memberships if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, constraintMembershipsGameUserUnique) { return Membership{}, fmt.Errorf("%w: user already a member of this game", ErrConflict) } return Membership{}, fmt.Errorf("lobby store: insert membership: %w", err) } return modelToMembership(row), nil } // LoadMembership returns the membership for membershipID. func (s *Store) LoadMembership(ctx context.Context, membershipID uuid.UUID) (Membership, error) { m := table.Memberships stmt := postgres.SELECT(membershipColumns()). FROM(m). WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))). LIMIT(1) var row model.Memberships if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Membership{}, ErrNotFound } return Membership{}, fmt.Errorf("lobby store: load membership %s: %w", membershipID, err) } return modelToMembership(row), nil } // UpdateMembershipStatus patches status (and removed_at when removing or // blocking). func (s *Store) UpdateMembershipStatus(ctx context.Context, membershipID uuid.UUID, status string, removedAt time.Time) (Membership, error) { m := table.Memberships var removedExpr postgres.TimestampzExpression if status != MembershipStatusActive { removedExpr = postgres.TimestampzT(removedAt) } else { removedExpr = postgres.TimestampzExp(postgres.NULL) } stmt := m.UPDATE(). SET( m.Status.SET(postgres.String(status)), m.RemovedAt.SET(removedExpr), ). WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))). RETURNING(membershipColumns()) var row model.Memberships if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Membership{}, ErrNotFound } return Membership{}, fmt.Errorf("lobby store: update membership status %s: %w", membershipID, err) } return modelToMembership(row), nil } // ListMembershipsForGame returns every membership row for gameID // ordered by joined_at ASC. func (s *Store) ListMembershipsForGame(ctx context.Context, gameID uuid.UUID) ([]Membership, error) { m := table.Memberships stmt := postgres.SELECT(membershipColumns()). FROM(m). WHERE(m.GameID.EQ(postgres.UUID(gameID))). ORDER_BY(m.JoinedAt.ASC()) var rows []model.Memberships if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list memberships for game %s: %w", gameID, err) } out := make([]Membership, 0, len(rows)) for _, row := range rows { out = append(out, modelToMembership(row)) } return out, nil } // ListAllMemberships returns every membership row, used by Cache.Warm. func (s *Store) ListAllMemberships(ctx context.Context) ([]Membership, error) { stmt := postgres.SELECT(membershipColumns()).FROM(table.Memberships) var rows []model.Memberships if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list all memberships: %w", err) } out := make([]Membership, 0, len(rows)) for _, row := range rows { out = append(out, modelToMembership(row)) } return out, nil } // raceNameInsert carries the parameters for InsertRaceName. type raceNameInsert struct { Name string Canonical CanonicalKey Status string OwnerUserID uuid.UUID GameID uuid.UUID SourceGameID *uuid.UUID ReservedAt *time.Time ExpiresAt *time.Time RegisteredAt *time.Time } // InsertRaceName creates a fresh row in `race_names`. Returns // ErrConflict on either UNIQUE violation (registered uniqueness or // composite PK). func (s *Store) InsertRaceName(ctx context.Context, in raceNameInsert) (RaceNameEntry, error) { r := table.RaceNames stmt := r.INSERT( r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID, r.ReservedAt, r.ExpiresAt, r.RegisteredAt, ).VALUES( in.Name, string(in.Canonical), in.Status, in.OwnerUserID, in.GameID, sourceGameArg(in.SourceGameID), timePtrArg(in.ReservedAt), timePtrArg(in.ExpiresAt), timePtrArg(in.RegisteredAt), ).RETURNING(raceNameColumns()) var row model.RaceNames if err := stmt.QueryContext(ctx, s.db, &row); err != nil { switch { case isUniqueViolation(err, constraintRaceNamesPK): return RaceNameEntry{}, fmt.Errorf("%w: race name already bound to this game", ErrRaceNameTaken) case isUniqueViolation(err, constraintRaceNamesRegisteredUnique): return RaceNameEntry{}, fmt.Errorf("%w: race name is already registered", ErrRaceNameTaken) } return RaceNameEntry{}, fmt.Errorf("lobby store: insert race_name: %w", err) } return modelToRaceName(row), nil } // FindRaceNameByCanonical returns every row matching canonical. func (s *Store) FindRaceNameByCanonical(ctx context.Context, canonical CanonicalKey) ([]RaceNameEntry, error) { r := table.RaceNames stmt := postgres.SELECT(raceNameColumns()). FROM(r). WHERE(r.Canonical.EQ(postgres.String(string(canonical)))) var rows []model.RaceNames if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: find race name by canonical: %w", err) } out := make([]RaceNameEntry, 0, len(rows)) for _, row := range rows { out = append(out, modelToRaceName(row)) } return out, nil } // FindRaceNameByCanonicalAndGame returns the row matching canonical // inside game (or the registered sentinel game). func (s *Store) FindRaceNameByCanonicalAndGame(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) (RaceNameEntry, error) { r := table.RaceNames stmt := postgres.SELECT(raceNameColumns()). FROM(r). WHERE( r.Canonical.EQ(postgres.String(string(canonical))). AND(r.GameID.EQ(postgres.UUID(gameID))), ). LIMIT(1) var row model.RaceNames if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return RaceNameEntry{}, ErrNotFound } return RaceNameEntry{}, fmt.Errorf("lobby store: find race name: %w", err) } return modelToRaceName(row), nil } // ListRaceNamesForUser returns every race-name row owned by userID // across all statuses. func (s *Store) ListRaceNamesForUser(ctx context.Context, userID uuid.UUID) ([]RaceNameEntry, error) { r := table.RaceNames stmt := postgres.SELECT(raceNameColumns()). FROM(r). WHERE(r.OwnerUserID.EQ(postgres.UUID(userID))). ORDER_BY(r.Status.ASC(), r.Canonical.ASC()) var rows []model.RaceNames if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list race names for user %s: %w", userID, err) } out := make([]RaceNameEntry, 0, len(rows)) for _, row := range rows { out = append(out, modelToRaceName(row)) } return out, nil } // ListAllRaceNames returns every race-name row, used by Cache.Warm. func (s *Store) ListAllRaceNames(ctx context.Context) ([]RaceNameEntry, error) { stmt := postgres.SELECT(raceNameColumns()).FROM(table.RaceNames) var rows []model.RaceNames if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list all race names: %w", err) } out := make([]RaceNameEntry, 0, len(rows)) for _, row := range rows { out = append(out, modelToRaceName(row)) } return out, nil } // CountRegisteredRaceNamesByUser returns the number of registered rows // owned by userID. Drives the entitlement quota check at register-time. func (s *Store) CountRegisteredRaceNamesByUser(ctx context.Context, userID uuid.UUID) (int, error) { r := table.RaceNames stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). FROM(r). WHERE( r.OwnerUserID.EQ(postgres.UUID(userID)). AND(r.Status.EQ(postgres.String(RaceNameStatusRegistered))), ) var dest struct { Count int64 `alias:"count"` } if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return 0, fmt.Errorf("lobby store: count registered race names: %w", err) } return int(dest.Count), nil } // DeleteRaceName removes the row at (canonical, gameID). func (s *Store) DeleteRaceName(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) error { r := table.RaceNames stmt := r.DELETE(). WHERE( r.Canonical.EQ(postgres.String(string(canonical))). AND(r.GameID.EQ(postgres.UUID(gameID))), ) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("lobby store: delete race name: %w", err) } return nil } // PromotePendingToRegistered promotes a pending row to registered in one // transaction: deletes the (canonical, originGameID) reservation/pending // row and inserts the registered row keyed by the sentinel game_id. func (s *Store) PromotePendingToRegistered(ctx context.Context, canonical CanonicalKey, ownerUserID, originGameID uuid.UUID, name string, now time.Time) (RaceNameEntry, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return RaceNameEntry{}, fmt.Errorf("lobby store: begin promote tx: %w", err) } defer func() { _ = tx.Rollback() }() r := table.RaceNames deleteStmt := r.DELETE(). WHERE( r.Canonical.EQ(postgres.String(string(canonical))). AND(r.GameID.EQ(postgres.UUID(originGameID))). AND(r.OwnerUserID.EQ(postgres.UUID(ownerUserID))). AND(r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration))), ) res, err := deleteStmt.ExecContext(ctx, tx) if err != nil { return RaceNameEntry{}, fmt.Errorf("lobby store: delete pending: %w", err) } if affected, _ := res.RowsAffected(); affected == 0 { return RaceNameEntry{}, ErrNotFound } insertStmt := r.INSERT( r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID, r.RegisteredAt, ).VALUES( name, string(canonical), RaceNameStatusRegistered, ownerUserID, raceNameRegisteredGameSentinel, originGameID, now, ).RETURNING(raceNameColumns()) var row model.RaceNames if err := insertStmt.QueryContext(ctx, tx, &row); err != nil { if isUniqueViolation(err, constraintRaceNamesRegisteredUnique) { return RaceNameEntry{}, fmt.Errorf("%w: race name already registered", ErrRaceNameTaken) } return RaceNameEntry{}, fmt.Errorf("lobby store: insert registered: %w", err) } if err := tx.Commit(); err != nil { return RaceNameEntry{}, fmt.Errorf("lobby store: commit promote tx: %w", err) } return modelToRaceName(row), nil } // ListPendingRegistrationsExpired returns every pending_registration // row with expires_at <= now. The sweeper consumes the result. func (s *Store) ListPendingRegistrationsExpired(ctx context.Context, now time.Time) ([]RaceNameEntry, error) { r := table.RaceNames stmt := postgres.SELECT(raceNameColumns()). FROM(r). WHERE( r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration)). AND(r.ExpiresAt.IS_NOT_NULL()). AND(r.ExpiresAt.LT_EQ(postgres.TimestampzT(now))), ) var rows []model.RaceNames if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list expired pending: %w", err) } out := make([]RaceNameEntry, 0, len(rows)) for _, row := range rows { out = append(out, modelToRaceName(row)) } return out, nil } // ListEnrollmentExpiredGames returns every game in `enrollment_open` // status whose enrollment_ends_at has passed `now`. The sweeper uses // the result to drive the auto-close transition. func (s *Store) ListEnrollmentExpiredGames(ctx context.Context, now time.Time) ([]GameRecord, error) { g := table.Games stmt := postgres.SELECT(gameColumns()). FROM(g). WHERE( g.Status.EQ(postgres.String(GameStatusEnrollmentOpen)). AND(g.EnrollmentEndsAt.LT_EQ(postgres.TimestampzT(now))), ) var rows []model.Games if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("lobby store: list expired enrollment games: %w", err) } return modelsToGameRecords(rows) } // CascadeUserSnapshot returns every state needed by `OnUserBlocked` / // `OnUserDeleted` in a single read so the cascade transaction does not // need additional round-trips. type CascadeUserSnapshot struct { OwnedGameIDs []uuid.UUID ActiveMembershipIDs []uuid.UUID PendingApplications []uuid.UUID IncomingInvites []uuid.UUID OutgoingInvites []uuid.UUID RaceNameKeys []raceNameRef } type raceNameRef struct { Canonical CanonicalKey GameID uuid.UUID } // LoadCascadeSnapshot reads the per-user state for the cascade flow. func (s *Store) LoadCascadeSnapshot(ctx context.Context, userID uuid.UUID) (CascadeUserSnapshot, error) { var snap CascadeUserSnapshot gamesStmt := postgres.SELECT(table.Games.GameID). FROM(table.Games). WHERE(table.Games.OwnerUserID.EQ(postgres.UUID(userID))) if err := loadIDColumn(ctx, s.db, gamesStmt, &snap.OwnedGameIDs); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: owned games: %w", err) } memStmt := postgres.SELECT(table.Memberships.MembershipID). FROM(table.Memberships). WHERE( table.Memberships.UserID.EQ(postgres.UUID(userID)). AND(table.Memberships.Status.EQ(postgres.String(MembershipStatusActive))), ) if err := loadIDColumn(ctx, s.db, memStmt, &snap.ActiveMembershipIDs); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: memberships: %w", err) } appStmt := postgres.SELECT(table.Applications.ApplicationID). FROM(table.Applications). WHERE( table.Applications.ApplicantUserID.EQ(postgres.UUID(userID)). AND(table.Applications.Status.EQ(postgres.String(ApplicationStatusPending))), ) if err := loadIDColumn(ctx, s.db, appStmt, &snap.PendingApplications); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: applications: %w", err) } inStmt := postgres.SELECT(table.Invites.InviteID). FROM(table.Invites). WHERE( table.Invites.InvitedUserID.EQ(postgres.UUID(userID)). AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))), ) if err := loadIDColumn(ctx, s.db, inStmt, &snap.IncomingInvites); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: incoming invites: %w", err) } outStmt := postgres.SELECT(table.Invites.InviteID). FROM(table.Invites). WHERE( table.Invites.InviterUserID.EQ(postgres.UUID(userID)). AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))), ) if err := loadIDColumn(ctx, s.db, outStmt, &snap.OutgoingInvites); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: outgoing invites: %w", err) } rnStmt := postgres.SELECT(table.RaceNames.Canonical, table.RaceNames.GameID). FROM(table.RaceNames). WHERE(table.RaceNames.OwnerUserID.EQ(postgres.UUID(userID))) var rnRows []model.RaceNames if err := rnStmt.QueryContext(ctx, s.db, &rnRows); err != nil { return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: race names: %w", err) } for _, row := range rnRows { snap.RaceNameKeys = append(snap.RaceNameKeys, raceNameRef{ Canonical: CanonicalKey(row.Canonical), GameID: row.GameID, }) } return snap, nil } // CascadeUser applies the cascade writes captured in snapshot inside a // single transaction. func (s *Store) CascadeUser(ctx context.Context, userID uuid.UUID, snap CascadeUserSnapshot, membershipStatus string, now time.Time) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("cascade user: begin tx: %w", err) } defer func() { _ = tx.Rollback() }() if len(snap.ActiveMembershipIDs) > 0 { m := table.Memberships stmt := m.UPDATE(). SET( m.Status.SET(postgres.String(membershipStatus)), m.RemovedAt.SET(postgres.TimestampzT(now)), ). WHERE( m.UserID.EQ(postgres.UUID(userID)). AND(m.Status.EQ(postgres.String(MembershipStatusActive))), ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: update memberships: %w", err) } } if len(snap.PendingApplications) > 0 { a := table.Applications stmt := a.UPDATE(). SET( a.Status.SET(postgres.String(ApplicationStatusRejected)), a.DecidedAt.SET(postgres.TimestampzT(now)), ). WHERE( a.ApplicantUserID.EQ(postgres.UUID(userID)). AND(a.Status.EQ(postgres.String(ApplicationStatusPending))), ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: reject applications: %w", err) } } if len(snap.IncomingInvites) > 0 { i := table.Invites stmt := i.UPDATE(). SET( i.Status.SET(postgres.String(InviteStatusDeclined)), i.DecidedAt.SET(postgres.TimestampzT(now)), ). WHERE( i.InvitedUserID.EQ(postgres.UUID(userID)). AND(i.Status.EQ(postgres.String(InviteStatusPending))), ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: decline incoming invites: %w", err) } } if len(snap.OutgoingInvites) > 0 { i := table.Invites stmt := i.UPDATE(). SET( i.Status.SET(postgres.String(InviteStatusRevoked)), i.DecidedAt.SET(postgres.TimestampzT(now)), ). WHERE( i.InviterUserID.EQ(postgres.UUID(userID)). AND(i.Status.EQ(postgres.String(InviteStatusPending))), ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: revoke outgoing invites: %w", err) } } if len(snap.RaceNameKeys) > 0 { r := table.RaceNames stmt := r.DELETE(). WHERE(r.OwnerUserID.EQ(postgres.UUID(userID))) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: delete race names: %w", err) } } if len(snap.OwnedGameIDs) > 0 { g := table.Games stmt := g.UPDATE(). SET( g.Status.SET(postgres.String(GameStatusCancelled)), g.UpdatedAt.SET(postgres.TimestampzT(now)), ). WHERE( g.OwnerUserID.EQ(postgres.UUID(userID)). AND(g.Status.IN( postgres.String(GameStatusDraft), postgres.String(GameStatusEnrollmentOpen), postgres.String(GameStatusReadyToStart), postgres.String(GameStatusStartFailed), )), ) if _, err := stmt.ExecContext(ctx, tx); err != nil { return fmt.Errorf("cascade user: cancel owned games: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("cascade user: commit tx: %w", err) } return nil } // loadIDColumn runs stmt and accumulates the single uuid.UUID column it // returns into out. The destination model is the JSON-tagged shim shared // across the cascade snapshot loaders. func loadIDColumn(ctx context.Context, db qrm.DB, stmt postgres.SelectStatement, out *[]uuid.UUID) error { var rows []idRow if err := stmt.QueryContext(ctx, db, &rows); err != nil { return err } for _, row := range rows { *out = append(*out, row.ID) } return nil } // idRow is the single-column scan destination for loadIDColumn. The // alias tag matches the un-prefixed alias produced when SELECT only // asks for one identifier-typed column. type idRow struct { ID uuid.UUID `alias:"-"` } // ===================================================================== // Argument helpers (INSERT VALUES bindings) // ===================================================================== func ownerArg(p *uuid.UUID) any { if p == nil { return nil } return *p } func invitedArg(p *uuid.UUID) any { if p == nil { return nil } return *p } func sourceGameArg(p *uuid.UUID) any { if p == nil { return nil } return *p } func codeArg(s string) any { if s == "" { return nil } return s } func timePtrArg(t *time.Time) any { if t == nil { return nil } return *t } // ===================================================================== // Model → domain converters // ===================================================================== func modelToGameRecord(row model.Games) (GameRecord, error) { game := GameRecord{ GameID: row.GameID, Visibility: row.Visibility, Status: row.Status, GameName: row.GameName, Description: row.Description, MinPlayers: row.MinPlayers, MaxPlayers: row.MaxPlayers, StartGapHours: row.StartGapHours, StartGapPlayers: row.StartGapPlayers, EnrollmentEndsAt: row.EnrollmentEndsAt, TurnSchedule: row.TurnSchedule, TargetEngineVersion: row.TargetEngineVersion, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if row.OwnerUserID != nil { owner := *row.OwnerUserID game.OwnerUserID = &owner } if row.StartedAt != nil { t := *row.StartedAt game.StartedAt = &t } if row.FinishedAt != nil { t := *row.FinishedAt game.FinishedAt = &t } if row.RuntimeSnapshot != "" { var snap RuntimeSnapshot if err := json.Unmarshal([]byte(row.RuntimeSnapshot), &snap); err != nil { return GameRecord{}, fmt.Errorf("scan game: snapshot: %w", err) } game.RuntimeSnapshot = snap } return game, nil } func modelsToGameRecords(rows []model.Games) ([]GameRecord, error) { out := make([]GameRecord, 0, len(rows)) for _, row := range rows { game, err := modelToGameRecord(row) if err != nil { return nil, err } out = append(out, game) } return out, nil } func modelToApplication(row model.Applications) Application { app := Application{ ApplicationID: row.ApplicationID, GameID: row.GameID, ApplicantUserID: row.ApplicantUserID, RaceName: row.RaceName, Status: row.Status, CreatedAt: row.CreatedAt, } if row.DecidedAt != nil { t := *row.DecidedAt app.DecidedAt = &t } return app } func modelToInvite(row model.Invites) Invite { invite := Invite{ InviteID: row.InviteID, GameID: row.GameID, InviterUserID: row.InviterUserID, Status: row.Status, RaceName: row.RaceName, CreatedAt: row.CreatedAt, ExpiresAt: row.ExpiresAt, } if row.InvitedUserID != nil { invited := *row.InvitedUserID invite.InvitedUserID = &invited } if row.Code != nil { invite.Code = *row.Code } if row.DecidedAt != nil { t := *row.DecidedAt invite.DecidedAt = &t } return invite } func modelToMembership(row model.Memberships) Membership { m := Membership{ MembershipID: row.MembershipID, GameID: row.GameID, UserID: row.UserID, RaceName: row.RaceName, CanonicalKey: row.CanonicalKey, Status: row.Status, JoinedAt: row.JoinedAt, } if row.RemovedAt != nil { t := *row.RemovedAt m.RemovedAt = &t } return m } func modelToRaceName(row model.RaceNames) RaceNameEntry { entry := RaceNameEntry{ Name: row.Name, Canonical: CanonicalKey(row.Canonical), Status: row.Status, OwnerUserID: row.OwnerUserID, GameID: row.GameID, } if row.SourceGameID != nil { src := *row.SourceGameID entry.SourceGameID = &src } if row.ReservedAt != nil { t := *row.ReservedAt entry.ReservedAt = &t } if row.ExpiresAt != nil { t := *row.ExpiresAt entry.ExpiresAt = &t } if row.RegisteredAt != nil { t := *row.RegisteredAt entry.RegisteredAt = &t } return entry }