1325 lines
43 KiB
Go
1325 lines
43 KiB
Go
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)
|
|
}
|
|
|
|
// 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
|
|
}
|