Files
mixmaker/backend/internal/adapter/postgres/store.go
lemintare c3624376b0
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
2026-07-19 11:34:11 +03:00

811 lines
31 KiB
Go

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
}
if _, err = tx.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation,role_snapshot)
SELECT $1,'teardown',$2,COALESCE(jsonb_agg(discord_role_id) FILTER (WHERE discord_role_id IS NOT NULL),'[]'::jsonb)
FROM discord_managed_roles WHERE scope='event' AND event_id=$1`,
eventID, time.Now().UTC().UnixNano()); 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) SaveBracketDraft(ctx context.Context, draft domain.BracketDraft, expectedVersion int) (domain.BracketDraft, error) {
body, _ := json.Marshal(draft)
if expectedVersion < 0 {
_, err := s.pool.Exec(ctx, `INSERT INTO event_bracket_drafts(event_id,body,version,confirmed) VALUES($1,$2,$3,$4)
ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,confirmed=excluded.confirmed,updated_at=now()`,
draft.EventID, body, draft.Version, draft.Confirmed)
return draft, err
}
tag, err := s.pool.Exec(ctx, `UPDATE event_bracket_drafts SET body=$2,version=$3,confirmed=$4,updated_at=now()
WHERE event_id=$1 AND version=$5`, draft.EventID, body, draft.Version, draft.Confirmed, expectedVersion)
if err == nil && tag.RowsAffected() == 0 {
err = domain.ErrConflict
}
return draft, err
}
func (s *Store) GetBracketDraft(ctx context.Context, eventID string) (domain.BracketDraft, error) {
var body []byte
var version int
var confirmed bool
var draft domain.BracketDraft
err := s.pool.QueryRow(ctx, `SELECT body,version,confirmed FROM event_bracket_drafts WHERE event_id=$1`, eventID).Scan(&body, &version, &confirmed)
if err == nil {
err = json.Unmarshal(body, &draft)
draft.Version, draft.Confirmed = version, confirmed
}
return draft, mapError(err)
}
func (s *Store) DeleteBracketDraft(ctx context.Context, eventID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM event_bracket_drafts WHERE event_id=$1`, eventID)
return err
}
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,version) VALUES($1,$2,$3,$4)`,
tournament.ID, tournament.EventID, body, tournament.Version); 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)
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
WHERE excluded.version=0 OR tournaments.version=excluded.version-1`, t.ID, t.EventID, body, t.Version)
if err == nil && tag.RowsAffected() == 0 {
err = domain.ErrConflict
}
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)
}
if err == nil {
err = s.hydrateTournament(ctx, &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)
}
if err == nil {
err = s.hydrateTournament(ctx, &out)
}
return out, mapError(err)
}
func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tournament) error {
rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE tournament_id=$1`, tournament.ID)
if err != nil {
return err
}
defer rows.Close()
live := make(map[string]domain.Series)
for rows.Next() {
var body []byte
var series domain.Series
if err := rows.Scan(&body); err != nil {
return err
}
if err := json.Unmarshal(body, &series); err != nil {
return err
}
live[series.ID] = series
}
if err := rows.Err(); err != nil {
return err
}
if len(tournament.Matches) > 0 {
for index := range tournament.Matches {
match := &tournament.Matches[index]
if series, ok := live[match.SeriesID]; ok {
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
match.Series = &series
} else if series, ok := live[match.ID]; ok {
match.SeriesID = series.ID
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
match.Series = &series
}
}
if err := tournament.Resolve(); err != nil {
return err
}
tournament.BuildRounds()
return nil
}
for round := range tournament.Rounds {
for match := range tournament.Rounds[round] {
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
tournament.Rounds[round][match] = series
}
}
}
return nil
}
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
}