package postgres import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "mixmaker/backend/internal/application" "mixmaker/backend/internal/domain" ) type Store struct{ pool *pgxpool.Pool } func Open(ctx context.Context, url string) (*Store, error) { pool, err := pgxpool.New(ctx, url) if err != nil { return nil, err } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, err } return &Store{pool: pool}, nil } func (s *Store) Close() { s.pool.Close() } func (s *Store) Ready(ctx context.Context) error { return s.pool.Ping(ctx) } func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account, admin bool) (domain.Account, domain.Player, error) { tx, err := s.pool.Begin(ctx) if err != nil { return domain.Account{}, domain.Player{}, err } defer tx.Rollback(ctx) role := domain.RolePlayer if admin { role = domain.RoleAdmin } err = tx.QueryRow(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE SET username=excluded.username,avatar_url=excluded.avatar_url, role=CASE WHEN accounts.role IN ('admin','moderator') THEN accounts.role ELSE excluded.role END RETURNING id,discord_id,username,avatar_url,role,created_at`, account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt). Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt) if err != nil { return domain.Account{}, domain.Player{}, err } p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt} var preferredRoles, preferredPlayers, avoidedPlayers []byte err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`, p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt). Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt) if err != nil { return domain.Account{}, domain.Player{}, err } if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil { return domain.Account{}, domain.Player{}, err } if err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil { return domain.Account{}, domain.Player{}, err } if err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs); err != nil { return domain.Account{}, domain.Player{}, err } return account, p, tx.Commit(ctx) } func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) { rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`) if err != nil { return nil, err } defer rows.Close() accounts := make([]domain.Account, 0) for rows.Next() { var account domain.Account if err := rows.Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt); err != nil { return nil, err } accounts = append(accounts, account) } return accounts, rows.Err() } func (s *Store) UpdateAccountRole(ctx context.Context, accountID string, role domain.GlobalRole) (domain.Account, error) { var account domain.Account err := s.pool.QueryRow(ctx, `UPDATE accounts SET role=$2 WHERE id=$1 AND role<>'admin' RETURNING id,discord_id,username,avatar_url,role,created_at`, accountID, role). Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt) return account, mapError(err) } func (s *Store) CreateSession(ctx context.Context, token, accountID string, expires time.Time) error { _, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires) return err } func (s *Store) DeleteSession(ctx context.Context, token string) error { _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash=$1`, HashToken(token)) return err } func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Account, domain.Player, error) { var a domain.Account var p domain.Player var preferredRoles, preferredPlayers, avoidedPlayers []byte err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at, p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)). Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt, &p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt) if err == nil { err = json.Unmarshal(preferredRoles, &p.PreferredRoles) } if err == nil { err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs) } if err == nil { err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs) } return a, p, mapError(err) } func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Player, error) { preferredRoles, _ := json.Marshal(p.PreferredRoles) preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs) avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs) err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5, preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9 WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`, p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt). Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt) return p, mapError(err) } func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) { rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating, preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`) if err != nil { return nil, err } defer rows.Close() out := make([]domain.Player, 0) for rows.Next() { var p domain.Player var preferredRoles, preferredPlayers, avoidedPlayers []byte if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, err } if err := json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil { return nil, err } if err := json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil { return nil, err } if err := json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain.RSVP) (domain.Player, domain.RSVP, error) { tx, err := s.pool.Begin(ctx) if err != nil { return domain.Player{}, domain.RSVP{}, err } defer tx.Rollback(ctx) var preferredRoles, preferredPlayers, avoidedPlayers []byte err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at) VALUES($1,NULL,$2,$3,$4,$5,$6,$7) RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`, p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt). Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt) if err == nil { err = json.Unmarshal(preferredRoles, &p.PreferredRoles) } if err == nil { err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs) } if err == nil { err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs) } if err == nil { err = tx.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING event_id,player_id,status,actor_account_id,source,updated_at`, r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt). Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt) } if err == nil { err = tx.Commit(ctx) } if err != nil { return domain.Player{}, domain.RSVP{}, mapError(err) } return p, r, nil } func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) { err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id`, e.ID, e.Name, e.Description, e.StartsAt, e.EndsAt, e.RegistrationDeadline, e.CreatedBy, e.CreatedAt, e.UpdatedAt, e.State, e.Version, e.RulesetID). Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.State, &e.Version, &e.RulesetID, &e.ActiveSeriesID, &e.TournamentID) return e, err } func (s *Store) GetEvent(ctx context.Context, id string) (domain.Event, error) { var event domain.Event err := s.pool.QueryRow(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id FROM events WHERE id=$1`, id). Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt, &event.State, &event.Version, &event.RulesetID, &event.ActiveSeriesID, &event.TournamentID) return event, mapError(err) } func (s *Store) UpdateEvent(ctx context.Context, event domain.Event) (domain.Event, error) { err := s.pool.QueryRow(ctx, `UPDATE events SET name=$2,description=$3,starts_at=$4,ends_at=$5,registration_deadline=$6,updated_at=$7 WHERE id=$1 RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id`, event.ID, event.Name, event.Description, event.StartsAt, event.EndsAt, event.RegistrationDeadline, event.UpdatedAt). Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt, &event.State, &event.Version, &event.RulesetID, &event.ActiveSeriesID, &event.TournamentID) return event, mapError(err) } func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event, error) { rows, err := s.pool.Query(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id FROM events WHERE ends_at >= $1 ORDER BY starts_at`, from) if err != nil { return nil, err } defer rows.Close() out := make([]domain.Event, 0) for rows.Next() { var e domain.Event if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.State, &e.Version, &e.RulesetID, &e.ActiveSeriesID, &e.TournamentID); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func (s *Store) SaveEventWorkflow(ctx context.Context, event domain.Event, expectedVersion int) (domain.Event, error) { tag, err := s.pool.Exec(ctx, `UPDATE events SET state=$2,version=$3,ruleset_id=$4,active_series_id=$5,tournament_id=$6,updated_at=$7 WHERE id=$1 AND version=$8`, event.ID, event.State, event.Version, event.RulesetID, event.ActiveSeriesID, event.TournamentID, event.UpdatedAt, expectedVersion) if err == nil && tag.RowsAffected() == 0 { return domain.Event{}, domain.ErrConflict } if err != nil { return domain.Event{}, err } return s.GetEvent(ctx, event.ID) } func (s *Store) DeleteEvent(ctx context.Context, eventID string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) rows, err := tx.Query(ctx, `SELECT p.id FROM players p JOIN rsvps r ON r.player_id=p.id WHERE r.event_id=$1 AND p.account_id IS NULL`, eventID) if err != nil { return err } guestIDs := make([]string, 0) for rows.Next() { var id string if err = rows.Scan(&id); err != nil { rows.Close() return err } guestIDs = append(guestIDs, id) } rows.Close() if err = rows.Err(); err != nil { return err } if _, err = tx.Exec(ctx, `DELETE FROM audit_log WHERE subject_id=$1 OR subject_id IN (SELECT id FROM teams WHERE event_id=$1) OR subject_id IN (SELECT id FROM series WHERE body->>'eventId'=$1) OR subject_id IN (SELECT id FROM tournaments WHERE event_id=$1)`, eventID); err != nil { return err } if _, err = tx.Exec(ctx, `DELETE FROM series WHERE body->>'eventId'=$1`, eventID); err != nil { return err } tag, err := tx.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrNotFound } for _, guestID := range guestIDs { if _, err = tx.Exec(ctx, `DELETE FROM players WHERE id=$1 AND account_id IS NULL AND NOT EXISTS (SELECT 1 FROM rsvps WHERE player_id=$1)`, guestID); err != nil { return err } } return tx.Commit(ctx) } func (s *Store) SaveRoster(ctx context.Context, roster domain.RosterDraft, expectedVersion int) (domain.RosterDraft, error) { body, _ := json.Marshal(roster) var err error if expectedVersion < 0 { _, err = s.pool.Exec(ctx, `INSERT INTO event_rosters(event_id,body,version) VALUES($1,$2,$3) ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,updated_at=now()`, roster.EventID, body, roster.Version) } else { tag, execErr := s.pool.Exec(ctx, `UPDATE event_rosters SET body=$2,version=$3,updated_at=now() WHERE event_id=$1 AND version=$4`, roster.EventID, body, roster.Version, expectedVersion) err = execErr if execErr == nil && tag.RowsAffected() == 0 { err = domain.ErrConflict } } return roster, err } func (s *Store) GetRoster(ctx context.Context, eventID string) (domain.RosterDraft, error) { var roster domain.RosterDraft var body []byte err := s.pool.QueryRow(ctx, `SELECT body FROM event_rosters WHERE event_id=$1`, eventID).Scan(&body) if err == nil { err = json.Unmarshal(body, &roster) } return roster, mapError(err) } func (s *Store) ResetRoster(ctx context.Context, eventID string) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err = tx.Exec(ctx, `DELETE FROM event_rosters WHERE event_id=$1`, eventID); err != nil { return err } if _, err = tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil { return err } return tx.Commit(ctx) } func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVersion int, series []domain.Series, tournament *domain.Tournament) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for _, item := range series { body, _ := json.Marshal(item) if _, err = tx.Exec(ctx, `INSERT INTO series(id,tournament_id,body,version) VALUES($1,$2,$3,$4)`, item.ID, item.TournamentID, body, item.Version); err != nil { return mapError(err) } } if tournament != nil { body, _ := json.Marshal(tournament) if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)`, tournament.ID, tournament.EventID, body); err != nil { return mapError(err) } } tag, err := tx.Exec(ctx, `UPDATE events SET state=$2,version=$3,active_series_id=$4,tournament_id=$5,updated_at=$6 WHERE id=$1 AND version=$7`, event.ID, event.State, event.Version, event.ActiveSeriesID, event.TournamentID, event.UpdatedAt, expectedVersion) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrConflict } return tx.Commit(ctx) } func (s *Store) UpsertRSVP(ctx context.Context, r domain.RSVP) (domain.RSVP, error) { err := s.pool.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_id,player_id) DO UPDATE SET status=excluded.status,actor_account_id=excluded.actor_account_id,source=excluded.source,updated_at=excluded.updated_at RETURNING event_id,player_id,status,actor_account_id,source,updated_at`, r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt). Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt) return r, err } func (s *Store) ListRSVPs(ctx context.Context, eventID string) ([]domain.RSVP, error) { rows, err := s.pool.Query(ctx, `SELECT event_id,player_id,status,actor_account_id,source,updated_at FROM rsvps WHERE event_id=$1 ORDER BY player_id`, eventID) if err != nil { return nil, err } defer rows.Close() out := make([]domain.RSVP, 0) for rows.Next() { var r domain.RSVP if err := rows.Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } func (s *Store) DeleteRSVP(ctx context.Context, eventID, playerID string) error { tag, err := s.pool.Exec(ctx, `DELETE FROM rsvps WHERE event_id=$1 AND player_id=$2`, eventID, playerID) if err == nil && tag.RowsAffected() == 0 { return domain.ErrNotFound } return err } func (s *Store) SaveTeams(ctx context.Context, eventID string, teams []domain.Team) error { tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil { return err } for _, t := range teams { body, _ := json.Marshal(t.Slots) if _, err := tx.Exec(ctx, `INSERT INTO teams(id,event_id,name,captain_player_id,slots) VALUES($1,$2,$3,NULLIF($4,''),$5)`, t.ID, eventID, t.Name, t.CaptainPlayerID, body); err != nil { return err } } return tx.Commit(ctx) } func (s *Store) ListTeams(ctx context.Context, eventID string) ([]domain.Team, error) { rows, err := s.pool.Query(ctx, `SELECT id,event_id,name,COALESCE(captain_player_id,''),slots FROM teams WHERE event_id=$1 ORDER BY id`, eventID) if err != nil { return nil, err } defer rows.Close() out := make([]domain.Team, 0) for rows.Next() { var t domain.Team var body []byte if err := rows.Scan(&t.ID, &t.EventID, &t.Name, &t.CaptainPlayerID, &body); err != nil { return nil, err } if err := json.Unmarshal(body, &t.Slots); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func (s *Store) GetTeam(ctx context.Context, teamID string) (domain.Team, error) { var team domain.Team var body []byte err := s.pool.QueryRow(ctx, `SELECT id,event_id,name,COALESCE(captain_player_id,''),slots FROM teams WHERE id=$1`, teamID). Scan(&team.ID, &team.EventID, &team.Name, &team.CaptainPlayerID, &body) if err == nil { err = json.Unmarshal(body, &team.Slots) } return team, mapError(err) } func (s *Store) RenameTeam(ctx context.Context, teamID, name string) (domain.Team, error) { tx, err := s.pool.Begin(ctx) if err != nil { return domain.Team{}, err } defer tx.Rollback(ctx) var eventID string if err = tx.QueryRow(ctx, `UPDATE teams SET name=$2 WHERE id=$1 RETURNING event_id`, teamID, name).Scan(&eventID); err != nil { return domain.Team{}, mapError(err) } var rosterBody []byte err = tx.QueryRow(ctx, `SELECT body FROM event_rosters WHERE event_id=$1`, eventID).Scan(&rosterBody) if err == nil { var roster domain.RosterDraft if err = json.Unmarshal(rosterBody, &roster); err != nil { return domain.Team{}, err } for index := range roster.Teams { if roster.Teams[index].ID == teamID { roster.Teams[index].Name = name } } rosterBody, _ = json.Marshal(roster) if _, err = tx.Exec(ctx, `UPDATE event_rosters SET body=$2,updated_at=now() WHERE event_id=$1`, eventID, rosterBody); err != nil { return domain.Team{}, err } } else if !errors.Is(err, pgx.ErrNoRows) { return domain.Team{}, err } if err = tx.Commit(ctx); err != nil { return domain.Team{}, err } return s.GetTeam(ctx, teamID) } func (s *Store) AssignCaptain(ctx context.Context, teamID, playerID string) (domain.Team, error) { var eventID string if err := s.pool.QueryRow(ctx, `SELECT event_id FROM teams WHERE id=$1 AND slots @> $2::jsonb`, teamID, fmt.Sprintf(`[{"playerId":%q}]`, playerID)).Scan(&eventID); err != nil { return domain.Team{}, mapError(err) } if _, err := s.pool.Exec(ctx, `UPDATE teams SET captain_player_id=$2 WHERE id=$1`, teamID, playerID); err != nil { return domain.Team{}, err } teams, err := s.ListTeams(ctx, eventID) for _, t := range teams { if t.ID == teamID { return t, err } } return domain.Team{}, domain.ErrNotFound } func (s *Store) SaveRuleset(ctx context.Context, r domain.Ruleset) (domain.Ruleset, error) { body, _ := json.Marshal(r) _, err := s.pool.Exec(ctx, `INSERT INTO rulesets(id,name,body) VALUES($1,$2,$3) ON CONFLICT(id) DO UPDATE SET name=excluded.name,body=excluded.body`, r.ID, r.Name, body) return r, err } func (s *Store) ListRulesets(ctx context.Context) ([]domain.Ruleset, error) { rows, err := s.pool.Query(ctx, `SELECT body FROM rulesets ORDER BY name`) if err != nil { return nil, err } defer rows.Close() rulesets := make([]domain.Ruleset, 0) for rows.Next() { var body []byte var ruleset domain.Ruleset if err := rows.Scan(&body); err != nil { return nil, err } if err := json.Unmarshal(body, &ruleset); err != nil { return nil, err } rulesets = append(rulesets, ruleset) } return rulesets, rows.Err() } func (s *Store) GetRuleset(ctx context.Context, id string) (domain.Ruleset, error) { var body []byte var r domain.Ruleset err := s.pool.QueryRow(ctx, `SELECT body FROM rulesets WHERE id=$1`, id).Scan(&body) if err == nil { err = json.Unmarshal(body, &r) } return r, mapError(err) } func (s *Store) SaveSeries(ctx context.Context, series domain.Series) (domain.Series, error) { body, _ := json.Marshal(series) tag, err := s.pool.Exec(ctx, `INSERT INTO series(id,tournament_id,body,version) VALUES($1,$2,$3,$4) ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version WHERE series.version=excluded.version-1`, series.ID, series.TournamentID, body, series.Version) if err == nil && tag.RowsAffected() == 0 { err = domain.ErrConflict } return series, err } func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error) { var body []byte var out domain.Series err := s.pool.QueryRow(ctx, `SELECT body FROM series WHERE id=$1`, id).Scan(&body) if err == nil { err = json.Unmarshal(body, &out) } return out, mapError(err) } func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) { body, _ := json.Marshal(t) _, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3) ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body) return t, err } func (s *Store) GetTournament(ctx context.Context, id string) (domain.Tournament, error) { var body []byte var out domain.Tournament err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE id=$1`, id).Scan(&body) if err == nil { err = json.Unmarshal(body, &out) } return out, mapError(err) } func (s *Store) GetTournamentByEvent(ctx context.Context, eventID string) (domain.Tournament, error) { var body []byte var out domain.Tournament err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE event_id=$1 ORDER BY id DESC LIMIT 1`, eventID).Scan(&body) if err == nil { err = json.Unmarshal(body, &out) } return out, mapError(err) } func (s *Store) SaveDraft(ctx context.Context, id, kind string, value any, expectedVersion int) error { body, _ := json.Marshal(value) if expectedVersion < 0 { _, err := s.pool.Exec(ctx, `INSERT INTO draft_states(id,kind,body,version) VALUES($1,$2,$3,0)`, id, kind, body) return err } tag, err := s.pool.Exec(ctx, `UPDATE draft_states SET body=$3,version=version+1,updated_at=now() WHERE id=$1 AND kind=$2 AND version=$4`, id, kind, body, expectedVersion) if err == nil && tag.RowsAffected() == 0 { return domain.ErrConflict } return err } func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, int, error) { var kind string var version int var body []byte err := s.pool.QueryRow(ctx, `SELECT kind,version,body FROM draft_states WHERE id=$1`, id).Scan(&kind, &version, &body) if err == nil { err = json.Unmarshal(body, target) } return kind, version, mapError(err) } func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error { body, _ := json.Marshal(payload) _, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body) return err } func Migrate(ctx context.Context, poolURL, directory string) error { pool, err := pgxpool.New(ctx, poolURL) if err != nil { return err } defer pool.Close() if _, err = pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { return err } files, err := filepath.Glob(filepath.Join(directory, "*.sql")) if err != nil { return err } sort.Strings(files) for _, file := range files { version := filepath.Base(file) var exists bool if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, version).Scan(&exists); err != nil { return err } if exists { continue } body, err := os.ReadFile(file) if err != nil { return err } up := strings.Split(string(body), "-- +mixmaker Down")[0] tx, err := pool.Begin(ctx) if err != nil { return err } if _, err = tx.Exec(ctx, up); err == nil { _, err = tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version) } if err == nil { err = tx.Commit(ctx) } else { _ = tx.Rollback(ctx) } if err != nil { return fmt.Errorf("apply %s: %w", version, err) } } return nil } func mapError(err error) error { if errors.Is(err, pgx.ErrNoRows) { return domain.ErrNotFound } return err }