From 20a23c51b1816dea2a808b8db30e5a1f7c6f8ba5 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:15:07 -0500 Subject: [PATCH 01/13] fix(infra): decouple WS lifetime from request timeout, fix rate-limiter refill, trim CORS origins - ws/handler.go: subscribe with context.WithoutCancel so the global chimw.Timeout(60s) no longer forcibly closes every WebSocket after 60s; disconnect teardown still handled by cancelSub + read-pump done channel - middleware/rate_limit.go: credit only whole-interval refills and advance lastSeen by the consumed time, preserving sub-interval remainder so a steadily-polling client reaches the sustained rate instead of hard-locking - config/config.go: TrimSpace each CORS_ALLOWED_ORIGINS entry and drop empties so the WS origin allowlist matches CORS even with spaces after commas Co-Authored-By: Claude Opus 4.8 --- api/config/config.go | 14 +++++++++++--- api/middleware/rate_limit.go | 18 ++++++++++++------ api/ws/handler.go | 10 +++++++++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/api/config/config.go b/api/config/config.go index 5cf3280e..864e701a 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -44,9 +44,17 @@ func Load() (*Config, error) { cfg.Env = "development" } - origins := os.Getenv("CORS_ALLOWED_ORIGINS") - if origins != "" { - cfg.CORSAllowedOrigins = strings.Split(origins, ",") + // Normalize origins once at parse time so every consumer (the CORS + // middleware and the WebSocket origin allowlist) shares an identical, + // whitespace-free set. Trimming here makes the parity the ws.Handler doc + // comment claims actually hold even when the env var has spaces after + // commas (e.g. "https://a.com, https://b.com"). + if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins != "" { + for _, o := range strings.Split(origins, ",") { + if o = strings.TrimSpace(o); o != "" { + cfg.CORSAllowedOrigins = append(cfg.CORSAllowedOrigins, o) + } + } } return cfg, nil diff --git a/api/middleware/rate_limit.go b/api/middleware/rate_limit.go index 2351c51f..3d0d34b0 100644 --- a/api/middleware/rate_limit.go +++ b/api/middleware/rate_limit.go @@ -72,14 +72,20 @@ func (rl *RateLimiter) allow(key string) bool { return true } - // Refill tokens based on elapsed time + // Refill tokens based on whole intervals elapsed; preserve the remainder. + // Advancing lastSeen unconditionally to now() would discard the + // sub-interval fractional time, so a client polling more often than once + // per interval would never accrue a full interval and could never refill. elapsed := time.Since(v.lastSeen) - refill := int(elapsed/rl.interval) * rl.rate - v.tokens += refill - if v.tokens > rl.burst { - v.tokens = rl.burst + if intervals := int(elapsed / rl.interval); intervals > 0 { + v.tokens += intervals * rl.rate + if v.tokens > rl.burst { + v.tokens = rl.burst + } + // Advance lastSeen only by the whole intervals consumed, so the + // leftover fractional time still counts toward the next refill. + v.lastSeen = v.lastSeen.Add(time.Duration(intervals) * rl.interval) } - v.lastSeen = time.Now() if v.tokens <= 0 { return false diff --git a/api/ws/handler.go b/api/ws/handler.go index 761b906f..6350025f 100644 --- a/api/ws/handler.go +++ b/api/ws/handler.go @@ -1,6 +1,7 @@ package ws import ( + "context" "log/slog" "net/http" "strconv" @@ -152,7 +153,14 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request, cha h.logger.Info("websocket connected", "channel", channel, "remote", r.RemoteAddr) // Subscribe to the Redis channel. - msgChan, cancelSub, err := h.ps.Subscribe(r.Context(), channel) + // + // Use context.WithoutCancel to strip the HTTP request's deadline/cancellation + // (the global chimw.Timeout(60s) middleware applies to /ws too) while + // preserving request-scoped values. Without this, every long-lived WebSocket + // would be forcibly torn down after 60s when the request context's deadline + // fires. The subscription is still cancelled on real client disconnect via + // the deferred cancelSub() below plus the read-pump's done channel. + msgChan, cancelSub, err := h.ps.Subscribe(context.WithoutCancel(r.Context()), channel) if err != nil { h.logger.Error("failed to subscribe", "channel", channel, "error", err) conn.WriteMessage(websocket.CloseMessage, From 95cd5a3f304dec9db6aaba255760933588f169b7 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:15:20 -0500 Subject: [PATCH 02/13] fix(db): use partial unique indexes so soft-deleted rows free their slug/email/name Soft-deleted rows kept occupying table-level UNIQUE constraints (teams.slug, venues.slug, leagues.slug, tournaments.slug, seasons(league_id,slug), divisions(tournament_id,slug), pods(division_id,name), users.email) even though the app's existence checks all filter deleted_at IS NULL, causing 23505 unique_violation on recreate/re-register. New migration 00044 drops each blanket UNIQUE and recreates it as a partial unique index scoped to live rows (matching the courts 00006 pattern), with a full goose Down to restore the originals. Co-Authored-By: Claude Opus 4.8 --- .../00044_partial_unique_soft_delete.sql | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 api/db/migrations/00044_partial_unique_soft_delete.sql diff --git a/api/db/migrations/00044_partial_unique_soft_delete.sql b/api/db/migrations/00044_partial_unique_soft_delete.sql new file mode 100644 index 00000000..68839702 --- /dev/null +++ b/api/db/migrations/00044_partial_unique_soft_delete.sql @@ -0,0 +1,78 @@ +-- +goose Up +-- B6: Soft-delete vs. unique-constraint mismatch. +-- +-- Several tables enforce slug/email/name uniqueness with plain table-level +-- UNIQUE constraints that cover ALL rows, including soft-deleted ones +-- (deleted_at IS NOT NULL). The application's pre-insert existence checks +-- (CheckTeamSlugExists, SlugExistsDivision, SlugExistsSeason, +-- SlugExistsTournament, SlugExistsLeague, CheckVenueSlugExists, +-- GetUserByEmail, etc.) all filter `deleted_at IS NULL`, so once a row is +-- soft-deleted the app considers its slug/email/name free, but the still-live +-- UNIQUE constraint rejects the re-insert with a 23505 unique_violation. +-- +-- This migration realigns the DB constraints with the app's view by replacing +-- each blanket UNIQUE with a PARTIAL UNIQUE INDEX scoped to live rows +-- (WHERE deleted_at IS NULL), matching the pattern already used by courts +-- (00006). The auto-generated constraint names below follow Postgres' +-- default `__key` convention; IF EXISTS makes each drop safe. + +-- teams.slug (00003): TEXT NOT NULL UNIQUE -> partial unique index. +ALTER TABLE teams DROP CONSTRAINT IF EXISTS teams_slug_key; +CREATE UNIQUE INDEX teams_slug_uniq ON teams(slug) WHERE deleted_at IS NULL; + +-- venues.slug (00005): TEXT NOT NULL UNIQUE -> partial unique index. +ALTER TABLE venues DROP CONSTRAINT IF EXISTS venues_slug_key; +CREATE UNIQUE INDEX venues_slug_uniq ON venues(slug) WHERE deleted_at IS NULL; + +-- leagues.slug (00007): TEXT NOT NULL UNIQUE -> partial unique index. +ALTER TABLE leagues DROP CONSTRAINT IF EXISTS leagues_slug_key; +CREATE UNIQUE INDEX leagues_slug_uniq ON leagues(slug) WHERE deleted_at IS NULL; + +-- seasons (00008): UNIQUE (league_id, slug) -> partial unique index. +ALTER TABLE seasons DROP CONSTRAINT IF EXISTS seasons_league_id_slug_key; +CREATE UNIQUE INDEX seasons_league_slug_uniq ON seasons(league_id, slug) WHERE deleted_at IS NULL; + +-- tournaments.slug (00010): TEXT NOT NULL UNIQUE -> partial unique index. +ALTER TABLE tournaments DROP CONSTRAINT IF EXISTS tournaments_slug_key; +CREATE UNIQUE INDEX tournaments_slug_uniq ON tournaments(slug) WHERE deleted_at IS NULL; + +-- divisions (00011): UNIQUE (tournament_id, slug) -> partial unique index. +ALTER TABLE divisions DROP CONSTRAINT IF EXISTS divisions_tournament_id_slug_key; +CREATE UNIQUE INDEX divisions_tournament_slug_uniq ON divisions(tournament_id, slug) WHERE deleted_at IS NULL; + +-- pods (00012): UNIQUE (division_id, name) -> partial unique index. +ALTER TABLE pods DROP CONSTRAINT IF EXISTS pods_division_id_name_key; +CREATE UNIQUE INDEX pods_division_name_uniq ON pods(division_id, name) WHERE deleted_at IS NULL; + +-- users.email (00001): TEXT UNIQUE (email is nullable) -> partial unique +-- index scoped to live, non-null emails, matching GetUserByEmail's filter. +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; +CREATE UNIQUE INDEX users_email_uniq ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL; + +-- +goose Down +-- Reverse the changes: drop the partial unique indexes and restore the +-- original blanket UNIQUE constraints. + +DROP INDEX IF EXISTS users_email_uniq; +ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email); + +DROP INDEX IF EXISTS pods_division_name_uniq; +ALTER TABLE pods ADD CONSTRAINT pods_division_id_name_key UNIQUE (division_id, name); + +DROP INDEX IF EXISTS divisions_tournament_slug_uniq; +ALTER TABLE divisions ADD CONSTRAINT divisions_tournament_id_slug_key UNIQUE (tournament_id, slug); + +DROP INDEX IF EXISTS tournaments_slug_uniq; +ALTER TABLE tournaments ADD CONSTRAINT tournaments_slug_key UNIQUE (slug); + +DROP INDEX IF EXISTS seasons_league_slug_uniq; +ALTER TABLE seasons ADD CONSTRAINT seasons_league_id_slug_key UNIQUE (league_id, slug); + +DROP INDEX IF EXISTS leagues_slug_uniq; +ALTER TABLE leagues ADD CONSTRAINT leagues_slug_key UNIQUE (slug); + +DROP INDEX IF EXISTS venues_slug_uniq; +ALTER TABLE venues ADD CONSTRAINT venues_slug_key UNIQUE (slug); + +DROP INDEX IF EXISTS teams_slug_uniq; +ALTER TABLE teams ADD CONSTRAINT teams_slug_key UNIQUE (slug); From 821007f7b5294393636283f8b0c1f027f1553a44 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:16:30 -0500 Subject: [PATCH 03/13] fix(scoring-ui): disable keyboard scoring during game/match-over confirmation windows - RefMatchConsole: gate keyboard shortcut hook on !gameOverPrompt && !matchOverPrompt so keys don't mutate match state behind the Game/Match Over modal - ScorekeeperMatchConsole: gate keyboard hook on scoring/confirm in-flight pending state and mirror confirm-pending in the scoreboard pending prop to close the auto-confirm race Co-Authored-By: Claude Opus 4.8 --- web/src/features/referee/RefMatchConsole.tsx | 6 +++++- .../scorekeeper/ScorekeeperMatchConsole.tsx | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/web/src/features/referee/RefMatchConsole.tsx b/web/src/features/referee/RefMatchConsole.tsx index 0b18cc63..90ae1390 100644 --- a/web/src/features/referee/RefMatchConsole.tsx +++ b/web/src/features/referee/RefMatchConsole.tsx @@ -207,7 +207,11 @@ export function RefMatchConsole({ publicId }: RefMatchConsoleProps) { setMenuOpen(false) }, }, - prefs.keyboard && match?.status === 'in_progress' && !disabled, + prefs.keyboard && + match?.status === 'in_progress' && + !disabled && + !gameOverPrompt && + !matchOverPrompt, ) if (matchQuery.isLoading) { diff --git a/web/src/features/scorekeeper/ScorekeeperMatchConsole.tsx b/web/src/features/scorekeeper/ScorekeeperMatchConsole.tsx index 891e1b45..4b3689e7 100644 --- a/web/src/features/scorekeeper/ScorekeeperMatchConsole.tsx +++ b/web/src/features/scorekeeper/ScorekeeperMatchConsole.tsx @@ -152,7 +152,14 @@ export function ScorekeeperMatchConsole({ }, onUndo: handleUndo, }, - prefs.keyboard && match?.status === 'in_progress' && !disabled, + prefs.keyboard && + match?.status === 'in_progress' && + !disabled && + !scorePoint.isPending && + !sideOut.isPending && + !undo.isPending && + !confirmGame.isPending && + !confirmMatch.isPending, ) if (matchQuery.isLoading) { @@ -210,7 +217,11 @@ export function ScorekeeperMatchConsole({ mode="scorekeeper" disabled={disabled} pending={ - scorePoint.isPending || sideOut.isPending || undo.isPending + scorePoint.isPending || + sideOut.isPending || + undo.isPending || + confirmGame.isPending || + confirmMatch.isPending } onPoint={handlePoint} onSideOut={handleSideOut} From a98f1688ce7f2d7f29e964ffbc459ef03aea249f Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:17:14 -0500 Subject: [PATCH 04/13] fix(overlay-ui): per-run WS cancellation + predicate cache write - useOverlayWebSocket: replace shared closedByUnmountRef with a per-run cancelled flag captured in the effect closure, and guard each onclose against the run-local sockets map (ws identity check). Reset state/sockets/attempts/timers maps per run. Stops torn-down runs from rescheduling connect() to a stale court/match, leaking zombie sockets, and flapping the live aggregate state on courtID/matchPublicID changes. - applyMessage overlay_data: write by prefix predicate (setQueriesData) instead of a hardcoded (token=null, demo=false) key so token-gated and demo overlays get the in-place merge with no HTTP round-trip; drop the broad invalidate that defeated the zero-fetch design. Co-Authored-By: Claude Opus 4.8 --- .../features/overlay/useOverlayWebSocket.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/web/src/features/overlay/useOverlayWebSocket.ts b/web/src/features/overlay/useOverlayWebSocket.ts index 1df318d2..74440544 100644 --- a/web/src/features/overlay/useOverlayWebSocket.ts +++ b/web/src/features/overlay/useOverlayWebSocket.ts @@ -83,7 +83,6 @@ export function useOverlayWebSocket( const socketsRef = useRef>({}) const attemptsRef = useRef>({}) const timersRef = useRef>({}) - const closedByUnmountRef = useRef(false) const onMessageRef = useRef(onMessage) onMessageRef.current = onMessage @@ -92,7 +91,18 @@ export function useOverlayWebSocket( setState('disconnected') return } - closedByUnmountRef.current = false + // Per-run cancellation. A single shared ref would be reset to false by + // the next effect run before the previous run's sockets fire their + // (asynchronous) onclose, letting a torn-down run reschedule connect() + // for the stale court/match and pollute the new run's state. Capturing + // the flag in the effect closure ties it to this run only. + let cancelled = false + // Fresh per-run maps so a late onclose from a previous run can't mutate + // the new run's live socket/state/timer entries (same label keys). + stateRef.current = {} + socketsRef.current = {} + attemptsRef.current = {} + timersRef.current = {} const specs: OverlaySocketSpec[] = [ { label: 'overlay', url: `${wsBaseUrl()}/ws/overlay/${courtID}` }, @@ -153,8 +163,13 @@ export function useOverlayWebSocket( } ws.onclose = () => { + // Ignore late closes from a torn-down effect run. `cancelled` is + // captured per-run so a previous run's socket can't act here. + if (cancelled) return + // Guard against a stale onclose deleting the new run's live socket: + // only clear/reschedule if this ws is still the registered one. + if (socketsRef.current[spec.label] !== ws) return delete socketsRef.current[spec.label] - if (closedByUnmountRef.current) return stateRef.current[spec.label] = 'disconnected' computeAggregate() const current = attemptsRef.current[spec.label] ?? 0 @@ -171,7 +186,7 @@ export function useOverlayWebSocket( specs.forEach(connect) return () => { - closedByUnmountRef.current = true + cancelled = true Object.values(timersRef.current).forEach((t) => window.clearTimeout(t)) timersRef.current = {} Object.values(socketsRef.current).forEach((ws) => ws.close()) @@ -194,12 +209,17 @@ function applyMessage( ): void { switch (msg.type) { case 'overlay_data': { - qc.setQueryData( - ['overlay', 'data', courtID, null, false], - msg.data as OverlayData, + // useOverlayData keys by (courtID, token ?? null, !!demo), so a + // hardcoded null/false key misses token-gated and demo overlays. + // Write by prefix predicate so the merge lands on whatever variant + // the renderer is actually observing — no HTTP round-trip. The raw + // backend shape is stored; normalizeOverlayData still runs via the + // query's `select` on read, preserving current behavior. Only touch + // entries something is observing (prev !== undefined). + qc.setQueriesData( + { queryKey: ['overlay', 'data', courtID] }, + (prev) => (prev === undefined ? prev : (msg.data as OverlayData)), ) - // Also mirror into any token-scoped caches. - qc.invalidateQueries({ queryKey: ['overlay', 'data', courtID] }) break } case 'config_update': { From 1f71eff70848628efbd281baebda9b6dd1f62c44 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:18:15 -0500 Subject: [PATCH 05/13] fix(league/standings): forfeits, capacity, overrides, queue order, season check - standings: include forfeited matches in recompute (carry win/loss/points) - standings: honor admin override_points and is_withdrawn when ranking - standings: validate division belongs to the given season before recompute - registration: count approved+checked_in for division capacity, not just approved - court queue: order ListMatchesByCourt by court_queue_position so reorder takes effect Co-Authored-By: Claude Opus 4.8 --- api/db/generated/matches.sql.go | 2 +- api/db/generated/registrations.sql.go | 12 +++++ api/db/queries/matches.sql | 2 +- api/db/queries/registrations.sql | 4 ++ api/service/registration.go | 13 ++--- api/service/standings.go | 74 +++++++++++++++++++++++++-- 6 files changed, 95 insertions(+), 12 deletions(-) diff --git a/api/db/generated/matches.sql.go b/api/db/generated/matches.sql.go index f2daaf74..ba000836 100644 --- a/api/db/generated/matches.sql.go +++ b/api/db/generated/matches.sql.go @@ -1047,7 +1047,7 @@ func (q *Queries) ListLiveMatches(ctx context.Context, arg ListLiveMatchesParams const listMatchesByCourt = `-- name: ListMatchesByCourt :many SELECT id, public_id, tournament_id, division_id, pod_id, court_id, created_by_user_id, match_type, round, round_name, match_number, team1_id, team2_id, team1_seed, team2_seed, scoring_preset_id, games_per_set, sets_to_win, points_to_win, win_by, max_points, rally_scoring, timeouts_per_game, timeout_duration_sec, freeze_at, team1_score, team2_score, current_set, current_game, serving_team, server_number, set_scores, status, started_at, completed_at, winner_team_id, loser_team_id, win_reason, next_match_id, next_match_slot, loser_next_match_id, loser_next_match_slot, referee_user_id, notes, expires_at, scheduled_at, created_at, updated_at, match_series_id, court_queue_position FROM matches WHERE court_id = $1 -ORDER BY scheduled_at NULLS LAST, created_at +ORDER BY court_queue_position NULLS LAST, scheduled_at NULLS LAST, created_at LIMIT $2 OFFSET $3 ` diff --git a/api/db/generated/registrations.sql.go b/api/db/generated/registrations.sql.go index 149dcc12..a6720e79 100644 --- a/api/db/generated/registrations.sql.go +++ b/api/db/generated/registrations.sql.go @@ -31,6 +31,18 @@ func (q *Queries) BulkUpdateNoShow(ctx context.Context, arg BulkUpdateNoShowPara return err } +const countActiveRegistrationsByDivision = `-- name: CountActiveRegistrationsByDivision :one +SELECT COUNT(*) FROM registrations +WHERE division_id = $1 AND status IN ('approved', 'checked_in') +` + +func (q *Queries) CountActiveRegistrationsByDivision(ctx context.Context, divisionID int64) (int64, error) { + row := q.db.QueryRow(ctx, countActiveRegistrationsByDivision, divisionID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countRegistrationsByDivision = `-- name: CountRegistrationsByDivision :one SELECT COUNT(*) FROM registrations WHERE division_id = $1 ` diff --git a/api/db/queries/matches.sql b/api/db/queries/matches.sql index 1ef5ef86..1c9b695c 100644 --- a/api/db/queries/matches.sql +++ b/api/db/queries/matches.sql @@ -136,7 +136,7 @@ LIMIT $2 OFFSET $3; -- name: ListMatchesByCourt :many SELECT * FROM matches WHERE court_id = $1 -ORDER BY scheduled_at NULLS LAST, created_at +ORDER BY court_queue_position NULLS LAST, scheduled_at NULLS LAST, created_at LIMIT $2 OFFSET $3; -- name: ListMatchesByCourtActive :many diff --git a/api/db/queries/registrations.sql b/api/db/queries/registrations.sql index eb31edd4..0e9929aa 100644 --- a/api/db/queries/registrations.sql +++ b/api/db/queries/registrations.sql @@ -22,6 +22,10 @@ SELECT COUNT(*) FROM registrations WHERE division_id = $1; SELECT COUNT(*) FROM registrations WHERE division_id = $1 AND status = $2; +-- name: CountActiveRegistrationsByDivision :one +SELECT COUNT(*) FROM registrations +WHERE division_id = $1 AND status IN ('approved', 'checked_in'); + -- name: ListRegistrationsByDivisionAndStatus :many SELECT * FROM registrations WHERE division_id = $1 AND status = $2 diff --git a/api/service/registration.go b/api/service/registration.go index 6c2aa888..3733a806 100644 --- a/api/service/registration.go +++ b/api/service/registration.go @@ -100,16 +100,17 @@ func (s *RegistrationService) Register(ctx context.Context, params generated.Cre return RegistrationResponse{}, &ValidationError{Message: "division is not open for registration"} } - // Check capacity — if max_teams set, check approved count + // Check capacity — if max_teams set, count all slot-occupying registrations. + // Both 'approved' and 'checked_in' teams occupy a real field slot (matching + // bracket generation and standings, which treat approved+checked_in as the + // active set); counting only 'approved' would let check-ins free phantom + // slots and allow registrations to exceed max_teams. if division.MaxTeams.Valid && division.MaxTeams.Int32 > 0 { - approvedCount, err := s.queries.CountRegistrationsByDivisionAndStatus(ctx, generated.CountRegistrationsByDivisionAndStatusParams{ - DivisionID: params.DivisionID, - Status: "approved", - }) + activeCount, err := s.queries.CountActiveRegistrationsByDivision(ctx, params.DivisionID) if err != nil { return RegistrationResponse{}, fmt.Errorf("failed to count registrations: %w", err) } - if approvedCount >= int64(division.MaxTeams.Int32) { + if activeCount >= int64(division.MaxTeams.Int32) { // Division is full — waitlist params.Status = "waitlisted" } diff --git a/api/service/standings.go b/api/service/standings.go index 8aaee3d1..095806ba 100644 --- a/api/service/standings.go +++ b/api/service/standings.go @@ -113,6 +113,21 @@ type teamStats struct { PointsAgainst int32 MatchesPlayed int32 StandingPoints int32 + + // Admin adjustments carried over from the existing standings entry so that + // ranking honors them. OverridePoints, when set, replaces StandingPoints as + // the ranking key; IsWithdrawn teams are sorted to the bottom. + OverridePoints *int32 + IsWithdrawn bool +} + +// rankingPoints returns the points value used to order standings, honoring an +// admin override when present. +func (t teamStats) rankingPoints() int32 { + if t.OverridePoints != nil { + return *t.OverridePoints + } + return t.StandingPoints } // ---------- Recompute ---------- @@ -127,6 +142,22 @@ func (svc *StandingsService) RecomputeStandings(ctx context.Context, seasonID, d return nil, &NotFoundError{Message: "season not found"} } + // Verify the division actually belongs to this season via its tournament, + // mirroring the canonical derivation used by the public standings read path + // (division -> tournament -> tournament.season_id). Without this, a mismatched + // (season, division) pair would upsert incoherent standings_entries rows. + division, err := svc.queries.GetDivisionByID(ctx, divisionID) + if err != nil { + return nil, &NotFoundError{Message: "division not found"} + } + tournament, err := svc.queries.GetTournamentByID(ctx, division.TournamentID) + if err != nil { + return nil, &NotFoundError{Message: "tournament not found"} + } + if !tournament.SeasonID.Valid || tournament.SeasonID.Int64 != seasonID { + return nil, &ValidationError{Message: "division does not belong to this season"} + } + method := "placement_points" if season.StandingsMethod != nil && *season.StandingsMethod != "" { method = *season.StandingsMethod @@ -175,6 +206,24 @@ func (svc *StandingsService) RecomputeStandings(ctx context.Context, seasonID, d return []StandingsEntryResponse{}, nil } + // Load existing standings entries so admin adjustments (override_points, + // is_withdrawn) are honored when ranking. UpsertStandingsEntry preserves + // these columns in the DB, but ranking must read them or the recomputed + // rank would contradict the override/withdrawal. + existing, err := svc.queries.ListStandingsByDivision(ctx, generated.ListStandingsByDivisionParams{ + SeasonID: seasonID, + DivisionID: divisionID, + Limit: 1000, + Offset: 0, + }) + if err != nil { + return nil, fmt.Errorf("failed to list existing standings: %w", err) + } + existingByTeam := make(map[int64]generated.StandingsEntry, len(existing)) + for _, e := range existing { + existingByTeam[e.TeamID] = e + } + // 3. For each team, fetch completed matches and compute stats allStats := make([]teamStats, 0, len(teamSet)) @@ -190,7 +239,7 @@ func (svc *StandingsService) RecomputeStandings(ctx context.Context, seasonID, d stats := teamStats{TeamID: teamID} for _, m := range matches { - if m.Status != "completed" { + if m.Status != "completed" && m.Status != "forfeited" { continue } @@ -221,13 +270,30 @@ func (svc *StandingsService) RecomputeStandings(ctx context.Context, seasonID, d } } + // Carry over admin adjustments from the existing entry so ranking honors them. + if prev, ok := existingByTeam[teamID]; ok { + stats.IsWithdrawn = prev.IsWithdrawn + if prev.OverridePoints.Valid { + v := prev.OverridePoints.Int32 + stats.OverridePoints = &v + } + } + allStats = append(allStats, stats) } - // 4. Sort by standing_points DESC, then point_differential DESC, then wins DESC + // 4. Sort withdrawn teams to the bottom, then by effective ranking points + // (override when set, else standing_points) DESC, then point_differential + // DESC, then wins DESC. sort.Slice(allStats, func(i, j int) bool { - if allStats[i].StandingPoints != allStats[j].StandingPoints { - return allStats[i].StandingPoints > allStats[j].StandingPoints + if allStats[i].IsWithdrawn != allStats[j].IsWithdrawn { + // Active teams (not withdrawn) rank ahead of withdrawn teams. + return !allStats[i].IsWithdrawn + } + ptsI := allStats[i].rankingPoints() + ptsJ := allStats[j].rankingPoints() + if ptsI != ptsJ { + return ptsI > ptsJ } diffI := allStats[i].PointsFor - allStats[i].PointsAgainst diffJ := allStats[j].PointsFor - allStats[j].PointsAgainst From 0f00186c24220bd7f96899585081d73e3966fcdb Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:18:19 -0500 Subject: [PATCH 06/13] fix(auth): close Logto role-elevation gaps and revoke API keys for inactive users - MeJWT (/auth/me): inject OrgRoleResolver and add the Management-API fallback JWTSession uses, so genuine platform_admins no longer report role='player' and get locked out of the admin/elevated SPA. - OptionalJWT: add the same Path-2 resolver elevation so mixed-auth admin-only writes (standings recompute/override, tournament staff) authorize real platform_admins instead of 403ing them. - ValidateApiKey: reject keys owned by non-active or soft-deleted users so a suspended/banned account can't keep authenticating via a pre-existing API key. Co-Authored-By: Claude Opus 4.8 --- api/handler/auth.go | 49 ++++++++++++++++++++++++++++- api/main.go | 10 +++++- api/middleware/optional_jwt.go | 37 ++++++++++++++++++++-- api/middleware/optional_jwt_test.go | 4 +-- api/router/router.go | 2 +- api/service/api_key.go | 17 ++++++++++ api/testutil/testserver.go | 5 ++- 7 files changed, 116 insertions(+), 8 deletions(-) diff --git a/api/handler/auth.go b/api/handler/auth.go index 4d189e2d..924eb3e3 100644 --- a/api/handler/auth.go +++ b/api/handler/auth.go @@ -3,10 +3,12 @@ package handler import ( "errors" + "log/slog" "net/http" "time" "github.com/court-command/court-command/auth" + "github.com/court-command/court-command/middleware" "github.com/court-command/court-command/service" "github.com/court-command/court-command/session" ) @@ -15,13 +17,24 @@ import ( type AuthHandler struct { authService *service.AuthService secureCookie bool + // orgRoles resolves a user's org roles via the Logto Management API + // when the JWT lacks the organization_roles claim. Mirrors the + // resolver JWTSession uses so MeJWT can elevate platform_admins the + // same way protected handlers do. May be nil (tests / dev without + // Logto Mgmt creds): the resolver path is then skipped. + orgRoles middleware.OrgRoleResolver } // NewAuthHandler creates a new AuthHandler. -func NewAuthHandler(authService *service.AuthService, secureCookie bool) *AuthHandler { +// +// orgRoles may be nil; when nil, MeJWT relies solely on the JWT fast +// path for role elevation (the same posture JWTSession takes with a nil +// resolver). +func NewAuthHandler(authService *service.AuthService, secureCookie bool, orgRoles middleware.OrgRoleResolver) *AuthHandler { return &AuthHandler{ authService: authService, secureCookie: secureCookie, + orgRoles: orgRoles, } } @@ -181,6 +194,40 @@ func (h *AuthHandler) MeJWT(w http.ResponseWriter, r *http.Request) { user.Role = elevated } + // Management API fallback: in production Logto does NOT populate the + // organization_roles claim on API-resource access tokens, so the JWT + // fast path above never fires and user.Role stays at the local DB + // default ('player'). This mirrors JWTSession's Path 2 + // (api/middleware/jwt_session.go): when the token carries an + // organization_id and the user isn't already platform_admin, ask the + // Logto Management API for the user's org roles and elevate. Without + // this, /auth/me reports 'player' for a genuine platform_admin and the + // SPA hides the admin UI even though the backend RequirePlatformAdmin + // gate (which runs under JWTSession's resolver) would authorize them. + if h.orgRoles != nil && + user.Role != "platform_admin" && + claims.OrganizationID != "" { + roles, lookupErr := h.orgRoles.GetUserOrganizationRoles( + r.Context(), claims.OrganizationID, claims.Subject) + if lookupErr != nil { + // Don't fail the request -- the user may legitimately be a + // non-admin, and a Logto Mgmt API hiccup shouldn't break + // /auth/me. Log so ops can see degraded elevation behavior. + slog.WarnContext(r.Context(), + "logto org-role lookup failed in /auth/me; falling back to local DB role", + "user_id", claims.Subject, + "org_id", claims.OrganizationID, + "error", lookupErr) + } else { + for _, role := range roles { + if role == "platform_admin" { + user.Role = "platform_admin" + break + } + } + } + } + resp := &MeResponse{UserResponse: user} // Impersonation signal: the act claim carries the impersonating admin's diff --git a/api/main.go b/api/main.go index bb50d59b..6e32ae4d 100644 --- a/api/main.go +++ b/api/main.go @@ -99,7 +99,9 @@ func main() { // Phase 1+2 handlers secureCookie := !cfg.IsDevelopment() - authHandler := handler.NewAuthHandler(authService, secureCookie) + // authHandler is constructed later (after orgRoleResolver) so MeJWT + // can share the Management-API role resolver JWTSession uses; see the + // NewAuthHandler call below the orgRoleResolver block. healthHandler := handler.NewHealthHandler(pool, sessionStore.Client()) playerHandler := handler.NewPlayerHandler(playerService) teamHandler := handler.NewTeamHandler(teamService) @@ -288,6 +290,12 @@ func main() { logtoClient, sessionStore.Client(), middleware.OrgRolesCacheTTLFromEnv()) } + // AuthHandler takes the resolver so MeJWT (GET /api/v1/auth/me) elevates + // platform_admins via the Logto Management API the same way JWTSession + // does for the protected route groups. Constructed here, after the + // resolver, rather than with the other Phase 1+2 handlers above. + authHandler := handler.NewAuthHandler(authService, secureCookie, orgRoleResolver) + // Auto-bootstrap the Logto tenant on every boot. This calls the // same idempotent provisioning logic as the api/cmd/logto-seed CLI: // - registers the API resource + 12 scopes diff --git a/api/middleware/optional_jwt.go b/api/middleware/optional_jwt.go index 62f68227..ce783995 100644 --- a/api/middleware/optional_jwt.go +++ b/api/middleware/optional_jwt.go @@ -47,13 +47,17 @@ import ( // valid JWT if present. Pass-through on every failure path so anonymous // requests survive. // -// Args mirror JWTSession (validator + Logto client + queries + userSync) -// because the populate path is identical to the bridge. +// Args mirror JWTSession (validator + Logto client + queries + userSync +// + orgRoles) because the populate path is identical to the bridge. +// +// orgRoles can be nil -- in that case only the JWT fast path runs (the +// same posture JWTSession takes with a nil resolver). func OptionalJWT( validator *auth.Validator, client LogtoUserFetcher, queries JWTSessionQueries, userSync UserSyncer, + orgRoles OrgRoleResolver, ) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -141,9 +145,38 @@ func OptionalJWT( // the local users.role column which defaults to 'player' for // freshly-mirrored users; we override here when claims show an // elevated org role. + // + // Path 1: JWT fast path. Free if it works; no-op otherwise. if elevated := claims.ElevatedRole(); elevated != "" && elevated != data.Role { data.Role = elevated } + + // Path 2: Management API path -- mirror JWTSession exactly. + // Logto does NOT populate organization_roles on API-resource + // access tokens, so in production Path 1 never fires and + // data.Role stays at the local default ('player'). Without + // this, mixed-auth admin-only writes (e.g. /standings recompute + // /override, /tournaments/{id}/staff) wrongly 403 a genuine + // platform_admin whose session.Data comes solely from this + // global middleware. Permissive on resolver error: pass through + // with the local role rather than failing a possibly-anonymous + // request. + if orgRoles != nil && + data.Role != "platform_admin" && + claims.OrganizationID != "" { + roles, lookupErr := orgRoles.GetUserOrganizationRoles( + r.Context(), claims.OrganizationID, sub) + if lookupErr != nil { + slog.WarnContext(r.Context(), + "optional-jwt org-role lookup failed; falling back to local DB role", + "user_id", sub, + "org_id", claims.OrganizationID, + "err", lookupErr) + } else if containsRole(roles, "platform_admin") { + data.Role = "platform_admin" + } + } + ctx := session.SetSessionData(r.Context(), data) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/api/middleware/optional_jwt_test.go b/api/middleware/optional_jwt_test.go index dd6ba13d..36a383a0 100644 --- a/api/middleware/optional_jwt_test.go +++ b/api/middleware/optional_jwt_test.go @@ -34,7 +34,7 @@ func optionalJWTRunner( authHeader string, ) (sess *session.Data, reached bool, status int) { t.Helper() - mw := middleware.OptionalJWT(v, fetcher, queries, syncer) + mw := middleware.OptionalJWT(v, fetcher, queries, syncer, nil) h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true sess = session.SessionData(r.Context()) @@ -170,7 +170,7 @@ func TestOptionalJWT_PreExistingSession_PreservesIt(t *testing.T) { preData := &session.Data{UserID: 999, Role: "player", PublicID: "CC-99999"} mw := middleware.OptionalJWT(v, &fakeFetcher{}, - &fakeQueries{user: makeUser()}, &fakeSyncer{}) + &fakeQueries{user: makeUser()}, &fakeSyncer{}, nil) var observed *session.Data h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { observed = session.SessionData(r.Context()) diff --git a/api/router/router.go b/api/router/router.go index daa41dae..2e9281cc 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -219,7 +219,7 @@ func New(cfg *Config) chi.Router { // this, the SPA's JWT can never reach those handlers. if cfg.JWTValidator != nil && cfg.LogtoClient != nil && cfg.UserSyncService != nil && cfg.Queries != nil { r.Use(middleware.OptionalJWT( - cfg.JWTValidator, cfg.LogtoClient, cfg.Queries, cfg.UserSyncService)) + cfg.JWTValidator, cfg.LogtoClient, cfg.Queries, cfg.UserSyncService, cfg.OrgRoles)) } // API v1 routes diff --git a/api/service/api_key.go b/api/service/api_key.go index e853f1b6..3a24b4fe 100644 --- a/api/service/api_key.go +++ b/api/service/api_key.go @@ -159,6 +159,23 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, rawKey string) (*gen return nil, NewValidation("API key has expired") } + // Reject keys belonging to non-active or soft-deleted users. The key's + // own is_active flag is checked in the query (GetApiKeyByHash), but a + // suspend/ban only wipes the user's sessions -- it does NOT deactivate + // their API keys -- so without this check a banned/suspended user keeps + // a working authenticated identity through any key created before the + // ban. Mirrors the status gate the password Login path enforces. + user, err := s.queries.GetUserByID(ctx, apiKey.UserID) + if err != nil { + return nil, NewNotFound("invalid API key") + } + if user.DeletedAt.Valid { + return nil, NewNotFound("invalid API key") + } + if user.Status != "active" { + return nil, NewValidationf("account is %s", user.Status) + } + // Update last used (fire and forget) _ = s.queries.UpdateApiKeyLastUsed(ctx, apiKey.ID) diff --git a/api/testutil/testserver.go b/api/testutil/testserver.go index 61de55aa..c64a8c1c 100644 --- a/api/testutil/testserver.go +++ b/api/testutil/testserver.go @@ -111,7 +111,10 @@ func TestServer(t *testing.T, pool *pgxpool.Pool) *httptest.Server { courtQueueService := service.NewCourtQueueService(queries, pool, nil) // Phase 1+2 handlers - authHandler := handler.NewAuthHandler(authService, false) + // nil org-role resolver: the test server doesn't wire Logto Mgmt API + // creds, so MeJWT relies on the JWT fast path only (same posture as + // JWTSession with a nil resolver). + authHandler := handler.NewAuthHandler(authService, false, nil) healthHandler := handler.NewHealthHandler(pool, store.Client()) playerHandler := handler.NewPlayerHandler(playerService) teamHandler := handler.NewTeamHandler(teamService) From 295ad3489538dcca0241f2b2ea465e24a31e43be Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:19:04 -0500 Subject: [PATCH 07/13] fix(tournaments-ui): announcement cache invalidation, full-division bulk no-show, render-phase setState - useUpdateAnnouncement/useDeleteAnnouncement now invalidate ['announcements'] so league announcement feeds refresh after edit/delete - Bulk No-Show now marks every non-checked-in registration in the division (fetchAllRegistrations walks all pages) instead of only the current page - RegistrationsForDivision pushes data via useEffect, not a render-phase useMemo Co-Authored-By: Claude Opus 4.8 --- .../tournaments/DivisionRegistrations.tsx | 18 ++++++++-- .../tournaments/RegistrationTable.tsx | 4 +-- web/src/features/tournaments/hooks.ts | 36 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/web/src/features/tournaments/DivisionRegistrations.tsx b/web/src/features/tournaments/DivisionRegistrations.tsx index 5021b0fe..ebdd4b8b 100644 --- a/web/src/features/tournaments/DivisionRegistrations.tsx +++ b/web/src/features/tournaments/DivisionRegistrations.tsx @@ -3,6 +3,7 @@ import { useListRegistrations, useBulkNoShow, useCreateRegistration, + fetchAllRegistrations, type Registration, type Division, } from './hooks' @@ -44,6 +45,7 @@ export function DivisionRegistrations({ const debouncedSearch = useDebounce(search) const pagination = usePagination(20) const [bulkNoShowOpen, setBulkNoShowOpen] = useState(false) + const [bulkNoShowLoading, setBulkNoShowLoading] = useState(false) const [showAddForm, setShowAddForm] = useState(false) const { data, isLoading } = useListRegistrations( @@ -67,15 +69,27 @@ export function DivisionRegistrations({ : registrations async function handleBulkNoShow() { + setBulkNoShowLoading(true) try { - const ids = registrations + // The dialog promises to mark *all* non-checked-in registrations, so we + // must operate on the full division set, not just the current page. + // Fetch every registration (unfiltered) before computing the id list. + const allRegistrations = await fetchAllRegistrations(divisionId) + const ids = allRegistrations .filter((r) => r.status !== 'checked_in' && r.checked_in_at == null) .map((r) => r.id) + if (ids.length === 0) { + toast('success', 'No unchecked registrations to mark') + setBulkNoShowOpen(false) + return + } await bulkNoShowMutation.mutateAsync({ registration_ids: ids }) toast('success', 'Marked unchecked registrations as no-show') setBulkNoShowOpen(false) } catch (err) { toast('error', (err as Error).message) + } finally { + setBulkNoShowLoading(false) } } @@ -223,7 +237,7 @@ export function DivisionRegistrations({ message="Mark all registrations that haven't checked in as no-show? This can be reversed per registration afterwards." confirmText="Mark No-Show" variant="danger" - loading={bulkNoShowMutation.isPending} + loading={bulkNoShowLoading || bulkNoShowMutation.isPending} /> ) diff --git a/web/src/features/tournaments/RegistrationTable.tsx b/web/src/features/tournaments/RegistrationTable.tsx index 7edaefeb..1818bfed 100644 --- a/web/src/features/tournaments/RegistrationTable.tsx +++ b/web/src/features/tournaments/RegistrationTable.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useListRegistrations, type Division, type Registration } from './hooks' import { useDebounce } from '../../hooks/useDebounce' import { Table } from '../../components/Table' @@ -29,7 +29,7 @@ function RegistrationsForDivision({ const { data } = useListRegistrations(String(division.id), undefined, 200) // Push data up to parent when it changes - useMemo(() => { + useEffect(() => { if (data?.items) { onData(division.id, data.items) } diff --git a/web/src/features/tournaments/hooks.ts b/web/src/features/tournaments/hooks.ts index 627aa581..d412e6e3 100644 --- a/web/src/features/tournaments/hooks.ts +++ b/web/src/features/tournaments/hooks.ts @@ -370,6 +370,40 @@ export function useListRegistrations( }) } +/** + * Fetch every registration for a division across all pages. + * + * The list endpoint is paginated (server caps the page size at 100), so a + * single client query only ever holds one page. Bulk operations that must + * cover the whole division need the complete set, which this helper assembles + * by walking the pages until `total` is reached. + */ +export async function fetchAllRegistrations( + divisionId: string, + status?: string, +): Promise { + const pageSize = 100 + const all: Registration[] = [] + let offset = 0 + + // Loop guarded by `total`; pageSize caps each request at the server limit. + // eslint-disable-next-line no-constant-condition + while (true) { + const page = await apiGetPaginated( + `/api/v1/divisions/${divisionId}/registrations${buildQueryString({ + status, + limit: pageSize, + offset, + })}`, + ) + all.push(...page.items) + offset += page.items.length + if (page.items.length === 0 || offset >= page.total) break + } + + return all +} + export function useCreateRegistration(divisionId: string) { const queryClient = useQueryClient() return useMutation({ @@ -589,6 +623,7 @@ export function useUpdateAnnouncement(announcementId: string) { apiPatch(`/api/v1/announcements/${announcementId}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tournaments'] }) + queryClient.invalidateQueries({ queryKey: ['announcements'] }) }, }) } @@ -600,6 +635,7 @@ export function useDeleteAnnouncement(announcementId: string) { apiDelete(`/api/v1/announcements/${announcementId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tournaments'] }) + queryClient.invalidateQueries({ queryKey: ['announcements'] }) }, }) } From d6daaf5ef5aea8e8cb34b67135435c52c761c510 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:20:00 -0500 Subject: [PATCH 08/13] fix(components): Bearer-auth uploads, by-id venue lookup, MapView XSS, date-only TZ - ImageUpload: route /api/v1/uploads through apiPostForm (Bearer + X-Sport) instead of cookie auth - VenuePicker: fetch selected venue by id via GET /api/v1/venues/{id} - MapView: build InfoWindow content with DOM/textContent to prevent stored DOM XSS - formatDate: parse date-only strings as local to fix US off-by-one display Co-Authored-By: Claude Opus 4.8 --- web/src/components/ImageUpload.tsx | 14 ++------ web/src/components/MapView.tsx | 52 +++++++++++++++++++----------- web/src/components/VenuePicker.tsx | 11 ++----- web/src/lib/api.ts | 9 ++++++ web/src/lib/formatters.ts | 10 +++++- 5 files changed, 57 insertions(+), 39 deletions(-) diff --git a/web/src/components/ImageUpload.tsx b/web/src/components/ImageUpload.tsx index b2166da9..3c267610 100644 --- a/web/src/components/ImageUpload.tsx +++ b/web/src/components/ImageUpload.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useCallback } from 'react' import { cn } from '../lib/cn' +import { apiPostForm } from '../lib/api' import { Upload, X, Loader2 } from 'lucide-react' interface ImageUploadProps { @@ -39,17 +40,8 @@ export function ImageUpload({ try { const formData = new FormData() formData.append('file', file) - const response = await fetch('/api/v1/uploads', { - method: 'POST', - credentials: 'include', - body: formData, - }) - if (!response.ok) { - const body = await response.json().catch(() => ({})) - throw new Error(body.error?.message || 'Upload failed') - } - const body = await response.json() - onChange(body.data?.url || body.url || null) + const data = await apiPostForm<{ url: string }>('/api/v1/uploads', formData) + onChange(data?.url || null) } catch (err) { setError((err as Error).message) } finally { diff --git a/web/src/components/MapView.tsx b/web/src/components/MapView.tsx index 67573a3b..845d9697 100644 --- a/web/src/components/MapView.tsx +++ b/web/src/components/MapView.tsx @@ -94,27 +94,43 @@ export function MapView({ }) gMarker.addListener('click', () => { - const content = ` -
- ${m.label} - ${m.sublabel ? `
${m.sublabel}` : ''} - ${onMarkerClick ? `
View details →` : ''} -
- ` - infoWindowRef.current.setContent(content) - infoWindowRef.current.open(mapInstanceRef.current, gMarker) + // Build the InfoWindow content with DOM APIs and set untrusted + // values via textContent so label/sublabel can never be parsed as + // HTML (avoids stored DOM XSS via venue/org names). + const container = document.createElement('div') + container.style.fontFamily = 'system-ui, sans-serif' + container.style.padding = '4px 0' + + const strong = document.createElement('strong') + strong.style.fontSize = '14px' + strong.textContent = m.label + container.appendChild(strong) + + if (m.sublabel) { + container.appendChild(document.createElement('br')) + const span = document.createElement('span') + span.style.fontSize = '12px' + span.style.color = '#666' + span.textContent = m.sublabel + container.appendChild(span) + } if (onMarkerClick) { - setTimeout(() => { - const link = document.getElementById(`map-marker-${m.id}`) - if (link) { - link.addEventListener('click', (e) => { - e.preventDefault() - onMarkerClick(m) - }) - } - }, 50) + container.appendChild(document.createElement('br')) + const link = document.createElement('a') + link.href = '#' + link.style.fontSize = '12px' + link.style.color = '#0ea5e9' + link.textContent = 'View details →' + link.addEventListener('click', (e) => { + e.preventDefault() + onMarkerClick(m) + }) + container.appendChild(link) } + + infoWindowRef.current.setContent(container) + infoWindowRef.current.open(mapInstanceRef.current, gMarker) }) markersRef.current.push(gMarker) diff --git a/web/src/components/VenuePicker.tsx b/web/src/components/VenuePicker.tsx index 84c81301..cdc36fe2 100644 --- a/web/src/components/VenuePicker.tsx +++ b/web/src/components/VenuePicker.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' -import { apiGetPaginated, type PaginatedData } from '../lib/api' +import { apiGet, apiGetPaginated, type PaginatedData } from '../lib/api' import { buildQueryString } from '../lib/formatters' import { cn } from '../lib/cn' import { useDebounce } from '../hooks/useDebounce' @@ -36,14 +36,7 @@ export function VenuePicker({ value, onChange, className }: VenuePickerProps) { const { data: selectedVenue } = useQuery({ queryKey: ['venues', value], - queryFn: () => { - return apiGetPaginated( - `/api/v1/venues${buildQueryString({ limit: 1, offset: 0 })}`, - ).then((r) => { - const found = r.items.find((v) => v.id === value) - return found || { id: value!, name: 'Unknown Venue', city: null, state_province: null } - }) - }, + queryFn: () => apiGet(`/api/v1/venues/${value}`), enabled: !!value && !open, }) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1920980c..9b7c4410 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -90,6 +90,15 @@ export async function apiPost(path: string, body?: unknown): Promise { return handleResponse(response) } +export async function apiPostForm(path: string, form: FormData): Promise { + // Do NOT set Content-Type so the browser adds the multipart boundary. + const headers = await buildHeaders() + const response = await fetch(`${API_BASE}${path}`, { + method: 'POST', headers, body: form, + }) + return handleResponse(response) +} + export async function apiPatch(path: string, body: unknown): Promise { const headers = await buildHeaders({ 'Content-Type': 'application/json' }) const response = await fetch(`${API_BASE}${path}`, { diff --git a/web/src/lib/formatters.ts b/web/src/lib/formatters.ts index 760523eb..f3940b99 100644 --- a/web/src/lib/formatters.ts +++ b/web/src/lib/formatters.ts @@ -1,7 +1,15 @@ export function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return '\u2014' try { - return new Date(dateStr).toLocaleDateString('en-US', { + // Date-only strings ('2026-06-30') are parsed as UTC midnight per the + // ECMAScript spec; formatting in the viewer's local (negative-UTC) zone + // would render the prior day. Parse such values as local so the displayed + // calendar day matches the stored DATE regardless of timezone. + const m = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/) + const date = m + ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + : new Date(dateStr) + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', From eecd15cd9085bc438d6959669f75e938da59f82d Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:20:18 -0500 Subject: [PATCH 09/13] fix(navigation): route dashboard/search/division links through sport-scoped routes with correct ids - UserDetail: impersonation onSuccess navigates to /$sportSlug/dashboard (or / fallback) instead of non-existent /dashboard - RecentResults/UpcomingMatches: use typed /$sport/matches/$publicId with sport param from useSport() - MyTeams: link to /$sport/teams/$teamId using numeric team.id (not slug) - PublicDivisionDetail: bracket + matches-tab links use /$sport/matches/$publicId with 'pickleball' fallback - SearchModal: team/org results link by numeric id (backend resolves by id, not slug) - TournamentDirectory: reset pagination to page 1 on status filter change Co-Authored-By: Claude Opus 4.8 --- web/src/features/admin/UserDetail.tsx | 7 +++++-- web/src/features/dashboard/MyTeams.tsx | 7 ++++++- web/src/features/dashboard/RecentResults.tsx | 7 ++++++- web/src/features/dashboard/UpcomingMatches.tsx | 7 ++++++- web/src/features/public/PublicDivisionDetail.tsx | 13 +++++++++---- web/src/features/public/TournamentDirectory.tsx | 5 ++++- web/src/features/search/SearchModal.tsx | 4 ++-- 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/web/src/features/admin/UserDetail.tsx b/web/src/features/admin/UserDetail.tsx index 3c2b5b20..bc27e758 100644 --- a/web/src/features/admin/UserDetail.tsx +++ b/web/src/features/admin/UserDetail.tsx @@ -92,8 +92,11 @@ export function UserDetail({ userId }: UserDetailProps) { if (!user) return startImpersonation.mutate(user.id, { onSuccess: () => { - // Full reload to reset all cached state and enter impersonation mode - window.location.href = '/dashboard' + // Full reload to reset all cached state and enter impersonation mode. + // Navigate to the sport-scoped dashboard; if no sport is resolved, + // fall back to '/' so the SPA re-resolves sport context (matching + // ImpersonationBanner.handleStop). + window.location.href = sportSlug ? `/${sportSlug}/dashboard` : '/' }, onError: (err) => { toast('error', err.message || 'Failed to start impersonation.') diff --git a/web/src/features/dashboard/MyTeams.tsx b/web/src/features/dashboard/MyTeams.tsx index 8a11b4da..c0f1b529 100644 --- a/web/src/features/dashboard/MyTeams.tsx +++ b/web/src/features/dashboard/MyTeams.tsx @@ -3,6 +3,7 @@ import { Users } from 'lucide-react' import { Card } from '../../components/Card' import { Badge } from '../../components/Badge' import { EmptyState } from '../../components/EmptyState' +import { useSport } from '../../auth/SportContext' import type { DashboardTeam } from './hooks' interface Props { @@ -35,6 +36,9 @@ function TeamAvatar({ team }: { team: DashboardTeam }) { } export function MyTeams({ data }: Props) { + const { sport } = useSport() + const sportSlug = sport?.slug ?? '' + if (data.length === 0) { return ( ( diff --git a/web/src/features/dashboard/RecentResults.tsx b/web/src/features/dashboard/RecentResults.tsx index c64c361d..8e23a499 100644 --- a/web/src/features/dashboard/RecentResults.tsx +++ b/web/src/features/dashboard/RecentResults.tsx @@ -3,6 +3,7 @@ import { Trophy } from 'lucide-react' import { Card } from '../../components/Card' import { EmptyState } from '../../components/EmptyState' import { formatDate } from '../../lib/formatters' +import { useSport } from '../../auth/SportContext' import type { DashboardMatch } from './hooks' interface Props { @@ -10,6 +11,9 @@ interface Props { } export function RecentResults({ data }: Props) { + const { sport } = useSport() + const sportSlug = sport?.slug ?? '' + if (data.length === 0) { return ( ( diff --git a/web/src/features/dashboard/UpcomingMatches.tsx b/web/src/features/dashboard/UpcomingMatches.tsx index 454fba15..46cf0a17 100644 --- a/web/src/features/dashboard/UpcomingMatches.tsx +++ b/web/src/features/dashboard/UpcomingMatches.tsx @@ -4,6 +4,7 @@ import { Card } from '../../components/Card' import { EmptyState } from '../../components/EmptyState' import { StatusBadge } from '../../components/StatusBadge' import { formatDateTime } from '../../lib/formatters' +import { useSport } from '../../auth/SportContext' import type { DashboardMatch } from './hooks' interface Props { @@ -11,6 +12,9 @@ interface Props { } export function UpcomingMatches({ data }: Props) { + const { sport } = useSport() + const sportSlug = sport?.slug ?? '' + if (data.length === 0) { return ( ( diff --git a/web/src/features/public/PublicDivisionDetail.tsx b/web/src/features/public/PublicDivisionDetail.tsx index 20e6f3a0..99d8f5c9 100644 --- a/web/src/features/public/PublicDivisionDetail.tsx +++ b/web/src/features/public/PublicDivisionDetail.tsx @@ -25,6 +25,7 @@ import { TabLayout } from '../../components/TabLayout' import { EmptyState } from '../../components/EmptyState' import { Badge } from '../../components/Badge' import { usePageTitle } from '../../hooks/usePageTitle' +import { useSport } from '../../auth/SportContext' import { cn } from '../../lib/cn' interface PublicDivisionDetailProps { @@ -384,6 +385,8 @@ function BracketMatchCard({ match: LiveMatch isLastRound: boolean }) { + const { sport } = useSport() + const sportSlug = sport?.slug ?? 'pickleball' const isComplete = match.status === 'completed' const team1Won = isComplete && match.team_1_score > match.team_2_score const team2Won = isComplete && match.team_2_score > match.team_1_score @@ -452,8 +455,8 @@ function BracketMatchCard({ if (match.public_id) { return ( } + to="/$sport/matches/$publicId" + params={{ sport: sportSlug, publicId: match.public_id }} > {inner} @@ -681,6 +684,8 @@ function MatchesTab({ } function MatchRow({ match }: { match: LiveMatch }) { + const { sport } = useSport() + const sportSlug = sport?.slug ?? 'pickleball' const isLive = match.status === 'in_progress' const isDone = ['completed', 'forfeited', 'cancelled'].includes(match.status) @@ -747,8 +752,8 @@ function MatchRow({ match }: { match: LiveMatch }) { if (match.public_id) { return ( } + to="/$sport/matches/$publicId" + params={{ sport: sportSlug, publicId: match.public_id }} className="block" > {card} diff --git a/web/src/features/public/TournamentDirectory.tsx b/web/src/features/public/TournamentDirectory.tsx index 5e91dbf2..204a0f3f 100644 --- a/web/src/features/public/TournamentDirectory.tsx +++ b/web/src/features/public/TournamentDirectory.tsx @@ -62,7 +62,10 @@ export function TournamentDirectory() { onQueryChange={setQuery} statusOptions={TOURNAMENT_STATUS_OPTIONS} selectedStatus={status} - onStatusChange={setStatus} + onStatusChange={(v) => { + setStatus(v) + pagination.setPage(1) + }} /> {isLoading ? ( diff --git a/web/src/features/search/SearchModal.tsx b/web/src/features/search/SearchModal.tsx index aed8803c..b5d98b85 100644 --- a/web/src/features/search/SearchModal.tsx +++ b/web/src/features/search/SearchModal.tsx @@ -50,7 +50,7 @@ function mapTeams(items: SearchTeamResult[], sportSlug: string): SearchResultIte id: t.id, label: t.name, subtitle: t.short_name !== t.name ? t.short_name : undefined, - link: `/${sportSlug}/teams/${t.slug}`, + link: `/${sportSlug}/teams/${t.id}`, })) } @@ -60,7 +60,7 @@ function mapOrganizations(items: SearchOrganizationResult[], sportSlug: string): id: o.id, label: o.name, subtitle: [o.city, o.state_province].filter(Boolean).join(', ') || undefined, - link: `/${sportSlug}/organizations/${o.slug}`, + link: `/${sportSlug}/organizations/${o.id}`, })) } From 604672726189175e08482bbe96f745ff88aeec24 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:25:24 -0500 Subject: [PATCH 10/13] fix(handlers/security): close upload XSS, authz gaps, IDORs, and public pagination - upload: derive stored file extension from validated content-type, never the client-supplied filename (prevents stored XSS via .html/.svg) - scoring presets: gate Create/Update/Deactivate/Activate behind platform_admin or tournament_director - registrations: stop leaking admin_notes on public read paths; scope all single-registration writes to the URL division and gate them by role - overlay: add CanManageCourt court-ownership check on all config writes and GetConfig; verify source-profile ownership in SetSourceProfile - public tournaments: filter to public statuses in SQL with a matching count so offset/limit and total are computed over the same set Co-Authored-By: Claude Opus 4.8 --- api/db/generated/tournaments.sql.go | 79 ++++++++++++++++++ api/db/queries/tournaments.sql | 12 +++ api/handler/overlay.go | 118 ++++++++++++++------------- api/handler/public.go | 56 +++++++------ api/handler/registration.go | 112 +++++++++++++++++++++++--- api/handler/scoring_preset.go | 29 +++++++ api/service/overlay.go | 41 ++++++++++ api/service/registration.go | 119 +++++++++++++++++++++------- api/service/upload.go | 11 ++- 9 files changed, 458 insertions(+), 119 deletions(-) diff --git a/api/db/generated/tournaments.sql.go b/api/db/generated/tournaments.sql.go index 53f0aca0..e1005722 100644 --- a/api/db/generated/tournaments.sql.go +++ b/api/db/generated/tournaments.sql.go @@ -12,6 +12,18 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countPublicTournaments = `-- name: CountPublicTournaments :one +SELECT COUNT(*) FROM tournaments +WHERE deleted_at IS NULL AND status = ANY($1::text[]) +` + +func (q *Queries) CountPublicTournaments(ctx context.Context, dollar_1 []string) (int64, error) { + row := q.db.QueryRow(ctx, countPublicTournaments, dollar_1) + var count int64 + err := row.Scan(&count) + return count, err +} + const countSearchTournaments = `-- name: CountSearchTournaments :one SELECT COUNT(*) FROM tournaments WHERE deleted_at IS NULL @@ -387,6 +399,73 @@ func (q *Queries) GetTournamentsByIDs(ctx context.Context, dollar_1 []int64) ([] return items, nil } +const listPublicTournaments = `-- name: ListPublicTournaments :many +SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments +WHERE deleted_at IS NULL AND status = ANY($1::text[]) +ORDER BY start_date DESC +LIMIT $2 OFFSET $3 +` + +type ListPublicTournamentsParams struct { + Column1 []string `json:"column_1"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +// Public directory listing: filters to publicly-visible statuses in SQL so +// LIMIT/OFFSET and the matching count are computed over the same filtered set. +func (q *Queries) ListPublicTournaments(ctx context.Context, arg ListPublicTournamentsParams) ([]Tournament, error) { + rows, err := q.db.Query(ctx, listPublicTournaments, arg.Column1, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tournament{} + for rows.Next() { + var i Tournament + if err := rows.Scan( + &i.ID, + &i.PublicID, + &i.Name, + &i.Slug, + &i.Status, + &i.StartDate, + &i.EndDate, + &i.VenueID, + &i.LeagueID, + &i.SeasonID, + &i.Description, + &i.LogoUrl, + &i.BannerUrl, + &i.ContactEmail, + &i.ContactPhone, + &i.WebsiteUrl, + &i.RegistrationOpenAt, + &i.RegistrationCloseAt, + &i.MaxParticipants, + &i.RulesDocumentUrl, + &i.CancellationReason, + &i.SocialLinks, + &i.Notes, + &i.SponsorInfo, + &i.ShowRegistrations, + &i.CreatedByUserID, + &i.TdUserID, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.SportID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTournaments = `-- name: ListTournaments :many SELECT id, public_id, name, slug, status, start_date, end_date, venue_id, league_id, season_id, description, logo_url, banner_url, contact_email, contact_phone, website_url, registration_open_at, registration_close_at, max_participants, rules_document_url, cancellation_reason, social_links, notes, sponsor_info, show_registrations, created_by_user_id, td_user_id, created_at, updated_at, deleted_at, sport_id FROM tournaments WHERE deleted_at IS NULL diff --git a/api/db/queries/tournaments.sql b/api/db/queries/tournaments.sql index 41c9b1d1..31bb21c9 100644 --- a/api/db/queries/tournaments.sql +++ b/api/db/queries/tournaments.sql @@ -122,6 +122,18 @@ LIMIT $2 OFFSET $3; SELECT COUNT(*) FROM tournaments WHERE status = $1 AND deleted_at IS NULL; +-- name: ListPublicTournaments :many +-- Public directory listing: filters to publicly-visible statuses in SQL so +-- LIMIT/OFFSET and the matching count are computed over the same filtered set. +SELECT * FROM tournaments +WHERE deleted_at IS NULL AND status = ANY($1::text[]) +ORDER BY start_date DESC +LIMIT $2 OFFSET $3; + +-- name: CountPublicTournaments :one +SELECT COUNT(*) FROM tournaments +WHERE deleted_at IS NULL AND status = ANY($1::text[]); + -- name: SearchTournamentsByStatus :many SELECT * FROM tournaments WHERE deleted_at IS NULL diff --git a/api/handler/overlay.go b/api/handler/overlay.go index 9bdc81f1..1e3da70e 100644 --- a/api/handler/overlay.go +++ b/api/handler/overlay.go @@ -49,6 +49,36 @@ func (h *OverlayHandler) requireSession(w http.ResponseWriter, r *http.Request) return sess } +// requireCourtManage authenticates the request and verifies the caller may +// manage the target court's overlay configuration. On success it returns the +// resolved courtID and true. On any failure it writes the appropriate error +// response (401/400/403/404) and returns false, so callers can simply +// `if courtID, ok := h.requireCourtManage(w, r); !ok { return }`. +func (h *OverlayHandler) requireCourtManage(w http.ResponseWriter, r *http.Request) (int64, bool) { + sess := h.requireSession(w, r) + if sess == nil { + return 0, false + } + + courtID, err := h.parseCourtID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + return 0, false + } + + ok, err := h.overlayService.CanManageCourt(r.Context(), courtID, sess.UserID, sess.Role) + if err != nil { + HandleServiceError(w, err) + return 0, false + } + if !ok { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "You do not have permission to manage this court's overlay") + return 0, false + } + + return courtID, true +} + // ResolveCourtSlug handles GET /api/v1/overlay/court/{courtID}/resolve // Public endpoint — resolves a court slug (or numeric ID) to its canonical // court_id and slug. Used by the frontend to map URL slugs to numeric IDs @@ -105,13 +135,8 @@ func (h *OverlayHandler) GetOverlayData(w http.ResponseWriter, r *http.Request) // GetConfig handles GET /api/v1/overlay/court/{courtID}/config func (h *OverlayHandler) GetConfig(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -126,13 +151,8 @@ func (h *OverlayHandler) GetConfig(w http.ResponseWriter, r *http.Request) { // UpdateTheme handles PUT /api/v1/overlay/court/{courtID}/config/theme func (h *OverlayHandler) UpdateTheme(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -161,13 +181,8 @@ func (h *OverlayHandler) UpdateTheme(w http.ResponseWriter, r *http.Request) { // UpdateElements handles PUT /api/v1/overlay/court/{courtID}/config/elements func (h *OverlayHandler) UpdateElements(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -190,13 +205,8 @@ func (h *OverlayHandler) UpdateElements(w http.ResponseWriter, r *http.Request) // GenerateToken handles POST /api/v1/overlay/court/{courtID}/config/token/generate func (h *OverlayHandler) GenerateToken(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -211,13 +221,8 @@ func (h *OverlayHandler) GenerateToken(w http.ResponseWriter, r *http.Request) { // RevokeToken handles DELETE /api/v1/overlay/court/{courtID}/config/token func (h *OverlayHandler) RevokeToken(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -232,13 +237,13 @@ func (h *OverlayHandler) RevokeToken(w http.ResponseWriter, r *http.Request) { // SetSourceProfile handles PUT /api/v1/overlay/court/{courtID}/config/source-profile func (h *OverlayHandler) SetSourceProfile(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { + sess := h.requireSession(w, r) + if sess == nil { return } - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -250,6 +255,23 @@ func (h *OverlayHandler) SetSourceProfile(w http.ResponseWriter, r *http.Request return } + // When linking a profile (not clearing), verify the caller owns the + // referenced source profile (or is a platform admin) — mirroring the + // ownership gate the dedicated source-profile handlers enforce. Without + // this, any court manager could wire their court to a profile they do + // not own. + if req.SourceProfileID != nil { + profile, err := h.sourceProfileService.GetByID(r.Context(), *req.SourceProfileID) + if err != nil { + WriteError(w, http.StatusNotFound, "NOT_FOUND", "Source profile not found") + return + } + if profile.CreatedByUserID != sess.UserID && sess.Role != "platform_admin" { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Access denied") + return + } + } + config, err := h.overlayService.SetSourceProfile(r.Context(), courtID, req.SourceProfileID) if err != nil { WriteError(w, http.StatusInternalServerError, "UPDATE_FAILED", err.Error()) @@ -263,13 +285,8 @@ func (h *OverlayHandler) SetSourceProfile(w http.ResponseWriter, r *http.Request // Allows Broadcast Operators to override any canonical overlay field per-court // without modifying the underlying tournament/team/match data. Authenticated. func (h *OverlayHandler) UpdateDataOverrides(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } @@ -293,13 +310,8 @@ func (h *OverlayHandler) UpdateDataOverrides(w http.ResponseWriter, r *http.Requ // ClearDataOverrides handles DELETE /api/v1/overlay/court/{courtID}/config/data-overrides // Resets all per-court data overrides to empty. Authenticated. func (h *OverlayHandler) ClearDataOverrides(w http.ResponseWriter, r *http.Request) { - if sess := h.requireSession(w, r); sess == nil { - return - } - - courtID, err := h.parseCourtID(r) - if err != nil { - WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid court ID") + courtID, ok := h.requireCourtManage(w, r) + if !ok { return } diff --git a/api/handler/public.go b/api/handler/public.go index 2ea38e57..a7e7fe6d 100644 --- a/api/handler/public.go +++ b/api/handler/public.go @@ -193,15 +193,27 @@ func parseLimitOffset(r *http.Request, defaultLimit, maxLimit int32) (int32, int return int32(limit), int32(offset) } -// publicTournamentStatuses are the only statuses visible on the public directory. -var publicTournamentStatuses = map[string]bool{ - "published": true, - "registration_open": true, - "registration_closed": true, - "in_progress": true, - "completed": true, +// publicTournamentStatusList is the canonical ordered list of statuses visible +// on the public directory. It is the single source of truth; the lookup map +// below is derived from it so the SQL filter and the validation map can never +// drift apart. +var publicTournamentStatusList = []string{ + "published", + "registration_open", + "registration_closed", + "in_progress", + "completed", } +// publicTournamentStatuses are the only statuses visible on the public directory. +var publicTournamentStatuses = func() map[string]bool { + m := make(map[string]bool, len(publicTournamentStatusList)) + for _, s := range publicTournamentStatusList { + m[s] = true + } + return m +}() + // ListTournaments handles GET /api/v1/public/tournaments // Filterable by status query param (only publicly visible statuses allowed). func (h *PublicHandler) ListTournaments(w http.ResponseWriter, r *http.Request) { @@ -215,6 +227,7 @@ func (h *PublicHandler) ListTournaments(w http.ResponseWriter, r *http.Request) } var tournaments []generated.Tournament + var total int64 var err error if status != "" { @@ -223,25 +236,21 @@ func (h *PublicHandler) ListTournaments(w http.ResponseWriter, r *http.Request) Limit: limit, Offset: offset, }) + if err == nil { + total, err = h.queries.CountTournamentsByStatus(r.Context(), status) + } } else { - // No filter: fetch all tournaments and keep only publicly visible ones. - // Use a generous limit to compensate for post-filter reduction. - all, fetchErr := h.queries.ListTournaments(r.Context(), generated.ListTournamentsParams{ - Limit: limit + 50, - Offset: offset, + // No status filter: filter to publicly-visible statuses in SQL so + // LIMIT/OFFSET and the total are computed over the same set. This + // keeps page boundaries aligned (no skipped/duplicated rows) and the + // reported total matching the visible public-only items. + tournaments, err = h.queries.ListPublicTournaments(r.Context(), generated.ListPublicTournamentsParams{ + Column1: publicTournamentStatusList, + Limit: limit, + Offset: offset, }) - err = fetchErr if err == nil { - tournaments = make([]generated.Tournament, 0, len(all)) - for _, t := range all { - if publicTournamentStatuses[t.Status] { - tournaments = append(tournaments, t) - } - } - // Respect the original limit after filtering - if int32(len(tournaments)) > limit { - tournaments = tournaments[:limit] - } + total, err = h.queries.CountPublicTournaments(r.Context(), publicTournamentStatusList) } } @@ -254,7 +263,6 @@ func (h *PublicHandler) ListTournaments(w http.ResponseWriter, r *http.Request) tournaments = []generated.Tournament{} } - total, _ := h.queries.CountTournaments(r.Context()) Paginated(w, toPublicTournaments(tournaments), total, int(limit), int(offset)) } diff --git a/api/handler/registration.go b/api/handler/registration.go index c1a05b77..46e1dcd0 100644 --- a/api/handler/registration.go +++ b/api/handler/registration.go @@ -52,6 +52,35 @@ func parseDivisionID(r *http.Request) (int64, error) { return strconv.ParseInt(chi.URLParam(r, "divisionID"), 10, 64) } +// isRegistrationStaff reports whether the current request is made by a +// privileged staff member who may see staff-only registration fields +// (admin_notes). The registration read routes are public, so unauthenticated +// callers and plain players never receive admin_notes; only platform admins and +// tournament directors do. +func isRegistrationStaff(r *http.Request) bool { + sess := session.SessionData(r.Context()) + if sess == nil { + return false + } + switch sess.Role { + case "platform_admin", "tournament_director": + return true + default: + return false + } +} + +// canManageRegistrations reports whether the given role may mutate +// registrations (status, seed, placement, check-in, withdraw, admin notes). +func canManageRegistrations(role string) bool { + switch role { + case "platform_admin", "tournament_director": + return true + default: + return false + } +} + // Register creates a new registration. func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { sess := session.SessionData(r.Context()) @@ -113,7 +142,7 @@ func (h *RegistrationHandler) GetRegistration(w http.ResponseWriter, r *http.Req return } - reg, err := h.regSvc.GetByID(r.Context(), regID) + reg, err := h.regSvc.GetByID(r.Context(), regID, isRegistrationStaff(r)) if err != nil { HandleServiceError(w, err) return @@ -131,10 +160,11 @@ func (h *RegistrationHandler) ListRegistrations(w http.ResponseWriter, r *http.R } limit, offset := parsePagination(r) + includeStaff := isRegistrationStaff(r) // Check for optional status filter if status := r.URL.Query().Get("status"); status != "" { - regs, total, err := h.regSvc.ListByDivisionAndStatus(r.Context(), divisionID, status, limit, offset) + regs, total, err := h.regSvc.ListByDivisionAndStatus(r.Context(), divisionID, status, limit, offset, includeStaff) if err != nil { HandleServiceError(w, err) return @@ -143,7 +173,7 @@ func (h *RegistrationHandler) ListRegistrations(w http.ResponseWriter, r *http.R return } - regs, total, err := h.regSvc.ListByDivision(r.Context(), divisionID, limit, offset) + regs, total, err := h.regSvc.ListByDivision(r.Context(), divisionID, limit, offset, includeStaff) if err != nil { HandleServiceError(w, err) return @@ -159,6 +189,16 @@ func (h *RegistrationHandler) UpdateStatus(w http.ResponseWriter, r *http.Reques WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -180,7 +220,7 @@ func (h *RegistrationHandler) UpdateStatus(w http.ResponseWriter, r *http.Reques return } - reg, err := h.regSvc.UpdateStatus(r.Context(), regID, body.Status) + reg, err := h.regSvc.UpdateStatus(r.Context(), divisionID, regID, body.Status) if err != nil { HandleServiceError(w, err) return @@ -196,6 +236,16 @@ func (h *RegistrationHandler) UpdateSeed(w http.ResponseWriter, r *http.Request) WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -214,7 +264,7 @@ func (h *RegistrationHandler) UpdateSeed(w http.ResponseWriter, r *http.Request) seed := pgtype.Int4{Int32: body.Seed, Valid: true} - reg, err := h.regSvc.UpdateSeed(r.Context(), regID, seed) + reg, err := h.regSvc.UpdateSeed(r.Context(), divisionID, regID, seed) if err != nil { HandleServiceError(w, err) return @@ -230,6 +280,16 @@ func (h *RegistrationHandler) UpdatePlacement(w http.ResponseWriter, r *http.Req WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -248,7 +308,7 @@ func (h *RegistrationHandler) UpdatePlacement(w http.ResponseWriter, r *http.Req placement := pgtype.Int4{Int32: body.Placement, Valid: true} - reg, err := h.regSvc.UpdatePlacement(r.Context(), regID, placement) + reg, err := h.regSvc.UpdatePlacement(r.Context(), divisionID, regID, placement) if err != nil { HandleServiceError(w, err) return @@ -264,6 +324,16 @@ func (h *RegistrationHandler) CheckIn(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -271,7 +341,7 @@ func (h *RegistrationHandler) CheckIn(w http.ResponseWriter, r *http.Request) { return } - reg, err := h.regSvc.CheckIn(r.Context(), regID) + reg, err := h.regSvc.CheckIn(r.Context(), divisionID, regID) if err != nil { HandleServiceError(w, err) return @@ -287,6 +357,16 @@ func (h *RegistrationHandler) WithdrawMidTournament(w http.ResponseWriter, r *ht WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -294,7 +374,7 @@ func (h *RegistrationHandler) WithdrawMidTournament(w http.ResponseWriter, r *ht return } - reg, err := h.regSvc.WithdrawMidTournament(r.Context(), regID) + reg, err := h.regSvc.WithdrawMidTournament(r.Context(), divisionID, regID) if err != nil { HandleServiceError(w, err) return @@ -312,6 +392,10 @@ func (h *RegistrationHandler) BulkNoShow(w http.ResponseWriter, r *http.Request) WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } divisionID, err := parseDivisionID(r) if err != nil { @@ -359,6 +443,16 @@ func (h *RegistrationHandler) UpdateAdminNotes(w http.ResponseWriter, r *http.Re WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageRegistrations(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage registrations") + return + } + + divisionID, err := parseDivisionID(r) + if err != nil { + WriteError(w, http.StatusBadRequest, "INVALID_ID", "Invalid division ID") + return + } regID, err := strconv.ParseInt(chi.URLParam(r, "registrationID"), 10, 64) if err != nil { @@ -374,7 +468,7 @@ func (h *RegistrationHandler) UpdateAdminNotes(w http.ResponseWriter, r *http.Re return } - reg, err := h.regSvc.UpdateAdminNotes(r.Context(), regID, body.AdminNotes) + reg, err := h.regSvc.UpdateAdminNotes(r.Context(), divisionID, regID, body.AdminNotes) if err != nil { HandleServiceError(w, err) return diff --git a/api/handler/scoring_preset.go b/api/handler/scoring_preset.go index cc6252c5..1c349201 100644 --- a/api/handler/scoring_preset.go +++ b/api/handler/scoring_preset.go @@ -22,6 +22,19 @@ func NewScoringPresetHandler(service *service.ScoringPresetService) *ScoringPres return &ScoringPresetHandler{service: service} } +// canManageScoringPresets reports whether the given role may create or modify +// scoring presets. Presets are a global, cross-tenant pool, so writes are +// restricted to privileged operational roles; plain players (and any other +// authenticated role) get read-only access via List/Get. +func canManageScoringPresets(role string) bool { + switch role { + case "platform_admin", "tournament_director": + return true + default: + return false + } +} + // Routes returns a chi.Router with all scoring preset routes mounted. func (h *ScoringPresetHandler) Routes() chi.Router { r := chi.NewRouter() @@ -62,6 +75,10 @@ func (h *ScoringPresetHandler) Create(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageScoringPresets(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage scoring presets") + return + } var body struct { Name string `json:"name"` @@ -160,6 +177,10 @@ func (h *ScoringPresetHandler) Update(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageScoringPresets(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage scoring presets") + return + } id, err := strconv.ParseInt(chi.URLParam(r, "presetID"), 10, 64) if err != nil { @@ -236,6 +257,10 @@ func (h *ScoringPresetHandler) Deactivate(w http.ResponseWriter, r *http.Request WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageScoringPresets(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage scoring presets") + return + } id, err := strconv.ParseInt(chi.URLParam(r, "presetID"), 10, 64) if err != nil { @@ -258,6 +283,10 @@ func (h *ScoringPresetHandler) Activate(w http.ResponseWriter, r *http.Request) WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated") return } + if !canManageScoringPresets(sess.Role) { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "Only platform admins or tournament directors can manage scoring presets") + return + } id, err := strconv.ParseInt(chi.URLParam(r, "presetID"), 10, 64) if err != nil { diff --git a/api/service/overlay.go b/api/service/overlay.go index 557fea59..5865cda3 100644 --- a/api/service/overlay.go +++ b/api/service/overlay.go @@ -304,6 +304,47 @@ func (s *OverlayService) GetCourtSlug(ctx context.Context, courtID int64) (strin return slug, nil } +// CanManageCourt reports whether the given user may manage the overlay +// configuration for a court. Returns true for platform admins, the court's +// creator, or a manager of the court's owning venue. Mirrors +// VenueService.CanManageVenue so the same ownership boundary that gates venue +// and court mutations also gates overlay control-panel writes. Returns +// NotFoundError when the court does not exist. +func (s *OverlayService) CanManageCourt(ctx context.Context, courtID, userID int64, userRole string) (bool, error) { + if userRole == "platform_admin" { + return true, nil + } + + court, err := s.queries.GetCourtByID(ctx, courtID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, &NotFoundError{Message: "court not found"} + } + return false, fmt.Errorf("get court for authorization: %w", err) + } + + // The court creator may always manage it. + if court.CreatedByUserID.Valid && court.CreatedByUserID.Int64 == userID { + return true, nil + } + + // Otherwise, the caller must be a manager of the court's owning venue. + if court.VenueID.Valid { + isMgr, err := s.queries.IsVenueManager(ctx, generated.IsVenueManagerParams{ + VenueID: court.VenueID.Int64, + UserID: userID, + }) + if err != nil { + return false, fmt.Errorf("check venue manager for court authorization: %w", err) + } + if isMgr { + return true, nil + } + } + + return false, nil +} + func generateSecureToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { diff --git a/api/service/registration.go b/api/service/registration.go index 6c2aa888..3b8389c7 100644 --- a/api/service/registration.go +++ b/api/service/registration.go @@ -41,17 +41,24 @@ type RegistrationResponse struct { CheckedInAt *string `json:"checked_in_at,omitempty"` } -func toRegistrationResponse(r generated.Registration) RegistrationResponse { +// toRegistrationResponse maps a registration row to its API representation. +// When includeStaff is false (unauthenticated / non-staff readers), staff-only +// fields (admin_notes) are omitted so internal tournament-director commentary is +// never leaked on the public read paths. +func toRegistrationResponse(r generated.Registration, includeStaff bool) RegistrationResponse { resp := RegistrationResponse{ ID: r.ID, DivisionID: r.DivisionID, RegisteredByUserID: r.RegisteredByUserID, Status: r.Status, RegistrationNotes: r.RegistrationNotes, - AdminNotes: r.AdminNotes, RegisteredAt: r.RegisteredAt.Format(time.RFC3339), } + if includeStaff { + resp.AdminNotes = r.AdminNotes + } + if r.TeamID.Valid { resp.TeamID = &r.TeamID.Int64 } @@ -129,11 +136,13 @@ func (s *RegistrationService) Register(ctx context.Context, params generated.Cre return RegistrationResponse{}, fmt.Errorf("failed to create registration: %w", err) } - return toRegistrationResponse(reg), nil + return toRegistrationResponse(reg, true), nil } -// GetByID retrieves a registration by ID. -func (s *RegistrationService) GetByID(ctx context.Context, id int64) (RegistrationResponse, error) { +// GetByID retrieves a registration by ID. includeStaff controls whether +// staff-only fields (admin_notes) are returned; pass false for unauthenticated +// or non-staff callers. +func (s *RegistrationService) GetByID(ctx context.Context, id int64, includeStaff bool) (RegistrationResponse, error) { reg, err := s.queries.GetRegistrationByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -141,11 +150,12 @@ func (s *RegistrationService) GetByID(ctx context.Context, id int64) (Registrati } return RegistrationResponse{}, fmt.Errorf("get registration by id: %w", err) } - return toRegistrationResponse(reg), nil + return toRegistrationResponse(reg, includeStaff), nil } -// ListByDivision returns registrations for a division. -func (s *RegistrationService) ListByDivision(ctx context.Context, divisionID int64, limit, offset int32) ([]RegistrationResponse, int64, error) { +// ListByDivision returns registrations for a division. includeStaff controls +// whether staff-only fields (admin_notes) are returned. +func (s *RegistrationService) ListByDivision(ctx context.Context, divisionID int64, limit, offset int32, includeStaff bool) ([]RegistrationResponse, int64, error) { regs, err := s.queries.ListRegistrationsByDivision(ctx, generated.ListRegistrationsByDivisionParams{ DivisionID: divisionID, Limit: limit, @@ -162,14 +172,15 @@ func (s *RegistrationService) ListByDivision(ctx context.Context, divisionID int result := make([]RegistrationResponse, len(regs)) for i, r := range regs { - result[i] = toRegistrationResponse(r) + result[i] = toRegistrationResponse(r, includeStaff) } return result, count, nil } // ListByDivisionAndStatus returns registrations filtered by division and status. -func (s *RegistrationService) ListByDivisionAndStatus(ctx context.Context, divisionID int64, status string, limit, offset int32) ([]RegistrationResponse, int64, error) { +// includeStaff controls whether staff-only fields (admin_notes) are returned. +func (s *RegistrationService) ListByDivisionAndStatus(ctx context.Context, divisionID int64, status string, limit, offset int32, includeStaff bool) ([]RegistrationResponse, int64, error) { regs, err := s.queries.ListRegistrationsByDivisionAndStatus(ctx, generated.ListRegistrationsByDivisionAndStatusParams{ DivisionID: divisionID, Status: status, @@ -190,14 +201,16 @@ func (s *RegistrationService) ListByDivisionAndStatus(ctx context.Context, divis result := make([]RegistrationResponse, len(regs)) for i, r := range regs { - result[i] = toRegistrationResponse(r) + result[i] = toRegistrationResponse(r, includeStaff) } return result, count, nil } // UpdateStatus updates a registration's status, with auto-promote from waitlist on withdrawal/rejection. -func (s *RegistrationService) UpdateStatus(ctx context.Context, id int64, newStatus string) (RegistrationResponse, error) { +// The registration must belong to divisionID; otherwise a not-found error is +// returned so callers cannot mutate registrations outside the URL's division. +func (s *RegistrationService) UpdateStatus(ctx context.Context, divisionID, id int64, newStatus string) (RegistrationResponse, error) { reg, err := s.queries.GetRegistrationByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -205,6 +218,9 @@ func (s *RegistrationService) UpdateStatus(ctx context.Context, id int64, newSta } return RegistrationResponse{}, fmt.Errorf("get registration for status update: %w", err) } + if reg.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } updated, err := s.queries.UpdateRegistrationStatus(ctx, generated.UpdateRegistrationStatusParams{ ID: id, @@ -228,11 +244,23 @@ func (s *RegistrationService) UpdateStatus(ctx context.Context, id int64, newSta } } - return toRegistrationResponse(updated), nil + return toRegistrationResponse(updated, true), nil } -// UpdateSeed updates a registration's seed. -func (s *RegistrationService) UpdateSeed(ctx context.Context, id int64, seed pgtype.Int4) (RegistrationResponse, error) { +// UpdateSeed updates a registration's seed. The registration must belong to +// divisionID; otherwise a not-found error is returned. +func (s *RegistrationService) UpdateSeed(ctx context.Context, divisionID, id int64, seed pgtype.Int4) (RegistrationResponse, error) { + existing, err := s.queries.GetRegistrationByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + return RegistrationResponse{}, fmt.Errorf("get registration for seed update: %w", err) + } + if existing.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + reg, err := s.queries.UpdateRegistrationSeed(ctx, generated.UpdateRegistrationSeedParams{ ID: id, Seed: seed, @@ -243,11 +271,23 @@ func (s *RegistrationService) UpdateSeed(ctx context.Context, id int64, seed pgt } return RegistrationResponse{}, fmt.Errorf("update registration seed: %w", err) } - return toRegistrationResponse(reg), nil + return toRegistrationResponse(reg, true), nil } -// UpdatePlacement updates a registration's final placement. -func (s *RegistrationService) UpdatePlacement(ctx context.Context, id int64, placement pgtype.Int4) (RegistrationResponse, error) { +// UpdatePlacement updates a registration's final placement. The registration +// must belong to divisionID; otherwise a not-found error is returned. +func (s *RegistrationService) UpdatePlacement(ctx context.Context, divisionID, id int64, placement pgtype.Int4) (RegistrationResponse, error) { + existing, err := s.queries.GetRegistrationByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + return RegistrationResponse{}, fmt.Errorf("get registration for placement update: %w", err) + } + if existing.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + reg, err := s.queries.UpdateRegistrationPlacement(ctx, generated.UpdateRegistrationPlacementParams{ ID: id, FinalPlacement: placement, @@ -258,7 +298,7 @@ func (s *RegistrationService) UpdatePlacement(ctx context.Context, id int64, pla } return RegistrationResponse{}, fmt.Errorf("update registration placement: %w", err) } - return toRegistrationResponse(reg), nil + return toRegistrationResponse(reg, true), nil } // BulkNoShow marks the given registration IDs as no_show, scoped to the @@ -276,6 +316,7 @@ func (s *RegistrationService) BulkNoShow(ctx context.Context, divisionID int64, } // ListSeekingPartner returns registrations that are seeking a partner. +// This is a public read path, so staff-only fields are always omitted. func (s *RegistrationService) ListSeekingPartner(ctx context.Context, divisionID int64) ([]RegistrationResponse, error) { regs, err := s.queries.ListSeekingPartner(ctx, divisionID) if err != nil { @@ -284,14 +325,15 @@ func (s *RegistrationService) ListSeekingPartner(ctx context.Context, divisionID result := make([]RegistrationResponse, len(regs)) for i, r := range regs { - result[i] = toRegistrationResponse(r) + result[i] = toRegistrationResponse(r, false) } return result, nil } -// CheckIn marks a registration as checked in. -func (s *RegistrationService) CheckIn(ctx context.Context, id int64) (RegistrationResponse, error) { +// CheckIn marks a registration as checked in. The registration must belong to +// divisionID; otherwise a not-found error is returned. +func (s *RegistrationService) CheckIn(ctx context.Context, divisionID, id int64) (RegistrationResponse, error) { reg, err := s.queries.GetRegistrationByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -299,6 +341,9 @@ func (s *RegistrationService) CheckIn(ctx context.Context, id int64) (Registrati } return RegistrationResponse{}, fmt.Errorf("get registration for check-in: %w", err) } + if reg.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } if reg.Status != "approved" { return RegistrationResponse{}, &ValidationError{Message: "only approved registrations can check in"} @@ -312,11 +357,12 @@ func (s *RegistrationService) CheckIn(ctx context.Context, id int64) (Registrati return RegistrationResponse{}, fmt.Errorf("failed to check in: %w", err) } - return toRegistrationResponse(updated), nil + return toRegistrationResponse(updated, true), nil } -// WithdrawMidTournament withdraws a registration mid-tournament. -func (s *RegistrationService) WithdrawMidTournament(ctx context.Context, id int64) (RegistrationResponse, error) { +// WithdrawMidTournament withdraws a registration mid-tournament. The +// registration must belong to divisionID; otherwise a not-found error is returned. +func (s *RegistrationService) WithdrawMidTournament(ctx context.Context, divisionID, id int64) (RegistrationResponse, error) { reg, err := s.queries.GetRegistrationByID(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -324,6 +370,9 @@ func (s *RegistrationService) WithdrawMidTournament(ctx context.Context, id int6 } return RegistrationResponse{}, fmt.Errorf("get registration for withdrawal: %w", err) } + if reg.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } if reg.Status != "checked_in" && reg.Status != "approved" { return RegistrationResponse{}, &ValidationError{Message: "only approved or checked-in registrations can withdraw mid-tournament"} @@ -337,11 +386,23 @@ func (s *RegistrationService) WithdrawMidTournament(ctx context.Context, id int6 return RegistrationResponse{}, fmt.Errorf("failed to withdraw: %w", err) } - return toRegistrationResponse(updated), nil + return toRegistrationResponse(updated, true), nil } -// UpdateAdminNotes updates the admin notes on a registration. -func (s *RegistrationService) UpdateAdminNotes(ctx context.Context, id int64, notes *string) (RegistrationResponse, error) { +// UpdateAdminNotes updates the admin notes on a registration. The registration +// must belong to divisionID; otherwise a not-found error is returned. +func (s *RegistrationService) UpdateAdminNotes(ctx context.Context, divisionID, id int64, notes *string) (RegistrationResponse, error) { + existing, err := s.queries.GetRegistrationByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + return RegistrationResponse{}, fmt.Errorf("get registration for admin notes update: %w", err) + } + if existing.DivisionID != divisionID { + return RegistrationResponse{}, &NotFoundError{Message: "registration not found"} + } + updated, err := s.queries.UpdateRegistrationAdminNotes(ctx, generated.UpdateRegistrationAdminNotesParams{ ID: id, AdminNotes: notes, @@ -353,5 +414,5 @@ func (s *RegistrationService) UpdateAdminNotes(ctx context.Context, id int64, no return RegistrationResponse{}, fmt.Errorf("update registration admin notes: %w", err) } - return toRegistrationResponse(updated), nil + return toRegistrationResponse(updated, true), nil } diff --git a/api/service/upload.go b/api/service/upload.go index 216bf5ac..899a8a85 100644 --- a/api/service/upload.go +++ b/api/service/upload.go @@ -93,10 +93,13 @@ func (s *UploadService) SaveFile(ctx context.Context, userID int64, file io.Read if _, err := rand.Read(randBytes); err != nil { return nil, fmt.Errorf("generating filename: %w", err) } - ext := filepath.Ext(originalName) - if ext == "" { - ext = extensionFromContentType(contentType) - } + // Always derive the on-disk extension from the validated/sniffed + // contentType, never from the client-controlled original filename. + // contentType is already gated by AllowedContentTypes above, so this + // can only ever yield a safe allowlisted extension (.jpg/.png/.gif/ + // .webp/.pdf) and never .html/.svg/.xhtml. The original filename is + // retained only in the DB original_name column for display. + ext := extensionFromContentType(contentType) filename := hex.EncodeToString(randBytes) + ext // Ensure upload directory exists From a96ad0aa65f23d81e9126e0faea1fc9e6d1676b4 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Tue, 30 Jun 2026 10:25:30 -0500 Subject: [PATCH 11/13] fix(scoring/bracket): correct LB wiring, set-aware match-over, undo, lost-update race, series + event validation - bracket: rebuild losers-bracket internal wiring round-by-round (no more flat-index collisions/orphaned matches); wire WB-final loser to grand finals slot 2 for the 2-team (single-WB-round) case. - engine: count SET wins (segmented by GamesPerSet/GamesToWin) for match-over so SetsToWin>1 no longer ends after the first set; fire IsEndChange exactly once per deciding-game midpoint (no double switch at 6-6). - match service: run the engine against the row-locked match inside applyEngineResult (closes the lost-update race) and snapshot the PRE-action state in the event row so Undo restores the prior score correctly. - RecordEvent: validate event_type against AllEventTypes and reject score-mutating types (routed through the engine) instead of re-implementing divergent inline scoring. - match series: RecordMatchResult now validates the child match belongs to the series and is completed, derives the winner from the child match, and recomputes wins idempotently from completed child matches. Co-Authored-By: Claude Opus 4.8 --- api/bracket/generator.go | 58 +++-- api/bracket/generator_test.go | 102 +++++++++ api/engine/config.go | 12 +- api/engine/config_test.go | 17 ++ api/engine/scoring.go | 48 +++- api/engine/scoring_test.go | 32 +++ api/service/events.go | 36 +++ api/service/match.go | 339 +++++++++-------------------- api/service/match_contract_test.go | 9 +- api/service/match_series.go | 56 ++++- 10 files changed, 438 insertions(+), 271 deletions(-) diff --git a/api/bracket/generator.go b/api/bracket/generator.go index 6bbaf3ad..611c31d2 100644 --- a/api/bracket/generator.go +++ b/api/bracket/generator.go @@ -204,6 +204,9 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) { loserMatchesInFirstRound := losersInRound / 2 var prevLoserMatches []int + // Track the index (into loserMatches) of each match, grouped by losers + // round, so we can wire the internal progression round-by-round below. + var loserRoundIdx [][]int for lRound := 1; lRound <= numLoserRounds; lRound++ { var matchesThisRound int @@ -221,6 +224,7 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) { } var currentLoserMatches []int + var currentRoundIdx []int for i := 0; i < matchesThisRound; i++ { matchNum++ lm := BracketMatch{ @@ -229,31 +233,44 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) { RoundName: fmt.Sprintf("Losers Round %d", lRound), } - // Wire losers bracket matches to next losers bracket match - if lRound < numLoserRounds { - // Will be calculated after all matches are created - } - + currentRoundIdx = append(currentRoundIdx, len(loserMatches)) currentLoserMatches = append(currentLoserMatches, matchNum) loserMatches = append(loserMatches, lm) } prevLoserMatches = currentLoserMatches + loserRoundIdx = append(loserRoundIdx, currentRoundIdx) } - // Wire losers bracket internal progression - allLoserMatches := loserMatches - for i := range allLoserMatches { - if i < len(allLoserMatches)-1 { - // Simple sequential wiring for losers bracket - nextIdx := (i / 2) + len(winnerMatches) + len(allLoserMatches[:i+1]) - if nextIdx < len(winnerMatches)+len(allLoserMatches) { - nextMatchNum := allLoserMatches[nextIdx-len(winnerMatches)].MatchNumber - allLoserMatches[i].NextMatchNumber = nextMatchNum + // Wire losers bracket internal progression round-by-round. Each match in + // losers round r feeds its winner into a match in losers round r+1; the + // last losers round's match is the LB final and is wired to grand finals + // separately below. + // + // Slot convention (chosen so an LB survivor never collides with a winners + // bracket dropout landing in the same destination): + // - When the next round halves the field (minor round: survivors meet + // each other), match i maps to match i/2 with slot from parity. + // - When the next round keeps the same match count (major round: + // survivor meets an incoming winners bracket dropout), match i maps to + // match i and the survivor always takes slot 1, reserving slot 2 for + // the dropout. + for r := 0; r < len(loserRoundIdx)-1; r++ { + curr := loserRoundIdx[r] + next := loserRoundIdx[r+1] + halving := len(next) < len(curr) + for i, idx := range curr { + if halving { + destIdx := next[i/2] + loserMatches[idx].NextMatchNumber = loserMatches[destIdx].MatchNumber if i%2 == 0 { - allLoserMatches[i].NextMatchSlot = 1 + loserMatches[idx].NextMatchSlot = 1 } else { - allLoserMatches[i].NextMatchSlot = 2 + loserMatches[idx].NextMatchSlot = 2 } + } else { + destIdx := next[i] + loserMatches[idx].NextMatchNumber = loserMatches[destIdx].MatchNumber + loserMatches[idx].NextMatchSlot = 1 } } } @@ -286,11 +303,18 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) { wbFinals.NextMatchNumber = matchNum wbFinals.NextMatchSlot = 1 - // Wire LB finals winner to grand finals slot 2 + // Wire grand finals slot 2: normally fed by the LB final winner. With only a + // single winners-bracket round (e.g. exactly 2 teams) there are no losers + // bracket matches, so the WB final's loser is the "losers bracket" and must + // feed grand finals slot 2 directly. Without this, slot 2 can never be + // filled and the event cannot complete. if len(loserMatches) > 0 { lbFinals := &loserMatches[len(loserMatches)-1] lbFinals.NextMatchNumber = matchNum lbFinals.NextMatchSlot = 2 + } else { + wbFinals.LoserNextMatchNumber = matchNum + wbFinals.LoserNextMatchSlot = 2 } // Combine all matches diff --git a/api/bracket/generator_test.go b/api/bracket/generator_test.go index 88ceb8e4..bfadc63e 100644 --- a/api/bracket/generator_test.go +++ b/api/bracket/generator_test.go @@ -1,6 +1,7 @@ package bracket import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -147,6 +148,107 @@ func TestGenerateDoubleElimination_TooFewEntries(t *testing.T) { assert.Error(t, err) } +// TestGenerateDoubleElimination_LosersBracketWiring asserts that the internal +// losers-bracket progression is structurally valid for several bracket sizes: +// every non-final LB match points its winner forward into a later, existing LB +// match in a later round, exactly one LB match feeds grand finals, and no +// destination slot is targeted by more than one source. +func TestGenerateDoubleElimination_LosersBracketWiring(t *testing.T) { + for _, n := range []int{4, 8, 16} { + t.Run(roundsLabel(n), func(t *testing.T) { + matches, err := GenerateDoubleElimination(makeEntries(n)) + require.NoError(t, err) + + byNum := make(map[int]BracketMatch, len(matches)) + for _, m := range matches { + byNum[m.MatchNumber] = m + } + + grandFinals := matches[len(matches)-1] + require.Equal(t, "Grand Finals", grandFinals.RoundName) + + // Collect losers-bracket matches in declared order. + var lbMatches []BracketMatch + for _, m := range matches { + if len(m.RoundName) >= 6 && m.RoundName[:6] == "Losers" { + lbMatches = append(lbMatches, m) + } + } + require.NotEmpty(t, lbMatches, "expected losers bracket matches for n=%d", n) + + // Track (destMatch, slot) targets to detect collisions across both + // the LB-internal winner wiring and the WB-dropout loser wiring. + type target struct{ matchNum, slot int } + seen := make(map[target]int) + record := func(dst, slot int) { + seen[target{dst, slot}]++ + } + + lbFeedingGrandFinals := 0 + for _, m := range lbMatches { + if m.NextMatchNumber == grandFinals.MatchNumber { + lbFeedingGrandFinals++ + record(m.NextMatchNumber, m.NextMatchSlot) + continue + } + // Non-final LB match: must point at a later, existing match in a + // strictly later round. + require.NotZero(t, m.NextMatchNumber, + "LB match %d (%s) has no NextMatchNumber (orphaned)", m.MatchNumber, m.RoundName) + dst, ok := byNum[m.NextMatchNumber] + require.True(t, ok, "LB match %d points at non-existent match %d", m.MatchNumber, m.NextMatchNumber) + assert.Greater(t, dst.MatchNumber, m.MatchNumber, + "LB match %d should feed a later match, got %d", m.MatchNumber, dst.MatchNumber) + assert.Greater(t, dst.Round, m.Round, + "LB match %d should feed a later round", m.MatchNumber) + record(m.NextMatchNumber, m.NextMatchSlot) + } + + assert.Equal(t, 1, lbFeedingGrandFinals, "exactly one LB match should feed grand finals") + + // Include winners-bracket dropout targets so survivor/dropout + // collisions in shared LB matches would be caught. + for _, m := range matches { + if m.LoserNextMatchNumber != 0 { + record(m.LoserNextMatchNumber, m.LoserNextMatchSlot) + } + } + + for tgt, count := range seen { + assert.LessOrEqual(t, count, 1, + "destination match %d slot %d targeted by %d sources (collision)", tgt.matchNum, tgt.slot, count) + } + }) + } +} + +// TestGenerateDoubleElimination_TwoTeams verifies the 2-team edge case: with no +// losers bracket, the winners-final loser must feed grand finals slot 2 so the +// final can be filled and the event can complete. +func TestGenerateDoubleElimination_TwoTeams(t *testing.T) { + matches, err := GenerateDoubleElimination(makeEntries(2)) + require.NoError(t, err) + + grandFinals := matches[len(matches)-1] + require.Equal(t, "Grand Finals", grandFinals.RoundName) + + // Some match must feed grand finals slot 2. + slot2Fed := false + for _, m := range matches { + if m.NextMatchNumber == grandFinals.MatchNumber && m.NextMatchSlot == 2 { + slot2Fed = true + } + if m.LoserNextMatchNumber == grandFinals.MatchNumber && m.LoserNextMatchSlot == 2 { + slot2Fed = true + } + } + assert.True(t, slot2Fed, "grand finals slot 2 must have a feeder for a 2-team double elimination") +} + +func roundsLabel(n int) string { + return fmt.Sprintf("n=%d", n) +} + func TestGenerateRoundRobin_4Teams(t *testing.T) { entries := makeEntries(4) diff --git a/api/engine/config.go b/api/engine/config.go index 55272be0..45e91466 100644 --- a/api/engine/config.go +++ b/api/engine/config.go @@ -95,11 +95,11 @@ func (c ScoringConfig) IsEndChange(team1Score, team2Score, currentGameNum int32, } midpoint := (c.PointsToWin + 1) / 2 - leading := team1Score - if team2Score > leading { - leading = team2Score - } - // Trigger exactly once when the leading score first reaches the midpoint. - return leading == midpoint && (team1Score == midpoint || team2Score == midpoint) + // Fire exactly once: only when one team newly reaches the midpoint while the + // other is still strictly below it. Because scores increment by exactly one + // point at a time, the scoring team always lands precisely on the midpoint, + // so this fires at 6-5 (or 5-6) but NOT again at 6-6, 7-6, etc. + return (team1Score == midpoint && team2Score < midpoint) || + (team2Score == midpoint && team1Score < midpoint) } diff --git a/api/engine/config_test.go b/api/engine/config_test.go index 8859948d..8298ff1a 100644 --- a/api/engine/config_test.go +++ b/api/engine/config_test.go @@ -100,3 +100,20 @@ func TestIsEndChange_SingleGame(t *testing.T) { assert.True(t, cfg.IsEndChange(6, 3, 1, 0)) assert.False(t, cfg.IsEndChange(5, 3, 1, 0)) } + +func TestIsEndChange_DoesNotFireTwiceInDeuce(t *testing.T) { + cfg := ScoringConfig{PointsToWin: 11, WinBy: 2, GamesPerSet: 1, SetsToWin: 1} + // Deciding game is game 1. Midpoint = 6. + + // First team to reach the midpoint triggers the end change exactly once. + assert.True(t, cfg.IsEndChange(6, 5, 1, 0), "should fire when team1 first reaches midpoint") + assert.True(t, cfg.IsEndChange(5, 6, 1, 0), "should fire when team2 first reaches midpoint") + + // Once the trailing team also reaches the midpoint, the end change must NOT + // fire a second time (the previous implementation re-fired at 6-6). + assert.False(t, cfg.IsEndChange(6, 6, 1, 0), "must NOT fire again at 6-6") + + // Past the midpoint, no fire. + assert.False(t, cfg.IsEndChange(7, 6, 1, 0), "past the midpoint: no fire") + assert.False(t, cfg.IsEndChange(7, 5, 1, 0), "leader past midpoint: no fire") +} diff --git a/api/engine/scoring.go b/api/engine/scoring.go index 5a059791..33eb9015 100644 --- a/api/engine/scoring.go +++ b/api/engine/scoring.go @@ -122,11 +122,18 @@ func (e *ScoringEngine) Point(state MatchState, scoringTeam int32) EngineResult Winner: winner, }) - // Check match over. - team1Wins, team2Wins := countWins(state.CompletedGames) - gamesToWin := e.config.GamesToWin() + // Check match over. The match is decided by SET wins, not raw game + // wins: CompletedGames accumulates every game of the entire match, so we + // segment it into sets (each won by the first team to GamesToWin() games) + // and only finish when a team reaches SetsToWin sets. With SetsToWin==1 + // this reduces to "first team to GamesToWin() games wins the match". + team1Sets, team2Sets := e.countSetWins(state.CompletedGames) + setsToWin := e.config.SetsToWin + if setsToWin < 1 { + setsToWin = 1 + } - if team1Wins >= gamesToWin || team2Wins >= gamesToWin { + if team1Sets >= setsToWin || team2Sets >= setsToWin { result.MatchOverDetected = true } @@ -293,3 +300,36 @@ func countWins(games []GameResult) (int32, int32) { } return t1, t2 } + +// countSetWins segments the match's completed games into sets and tallies set +// wins per team. games is the full, ordered list of completed games for the +// whole match (CompletedGames is never reset per set). Within each set the +// first team to GamesToWin() game wins takes the set; the in-set tally then +// resets for the next set. This makes match completion respect SetsToWin +// rather than counting raw game wins globally. +func (e *ScoringEngine) countSetWins(games []GameResult) (int32, int32) { + gamesToWin := e.config.GamesToWin() + if gamesToWin < 1 { + gamesToWin = 1 + } + + var setWins1, setWins2 int32 + var gameWins1, gameWins2 int32 + for _, g := range games { + switch g.Winner { + case 1: + gameWins1++ + case 2: + gameWins2++ + } + + if gameWins1 >= gamesToWin { + setWins1++ + gameWins1, gameWins2 = 0, 0 + } else if gameWins2 >= gamesToWin { + setWins2++ + gameWins1, gameWins2 = 0, 0 + } + } + return setWins1, setWins2 +} diff --git a/api/engine/scoring_test.go b/api/engine/scoring_test.go index 26c7327a..eb23295d 100644 --- a/api/engine/scoring_test.go +++ b/api/engine/scoring_test.go @@ -204,6 +204,38 @@ func TestMatchNotOver_BestOf3_OneWinEach(t *testing.T) { assert.False(t, result.MatchOverDetected, "1-1 in games, need game 3") } +func TestMatchOver_SetsToWin_RequiresMultipleSets(t *testing.T) { + // Best-of-3 SETS where each set is a single game (GamesPerSet=1 -> 1 game per set). + // A team must win 2 sets (i.e. 2 games) to win the match; winning the first + // set/game must NOT end the match. + cfg, _ := ParseScoringConfig(SideOutScoring, 11, 2, 0, 1, 2, 0) + eng := NewScoringEngine(cfg) + + // Win the first set (game 1). + state := defaultState() + state.TeamOneScore = 10 + state.TeamTwoScore = 5 + + result := eng.Point(state, 1) // 11-5, team 1 wins game 1 + require.False(t, result.IsError) + assert.True(t, result.GameOverDetected) + assert.False(t, result.MatchOverDetected, "winning only the first of two required sets must not end the match") + + // Win the second set (game 2) -> 2 sets -> match over. + state2 := defaultState() + state2.CompletedGames = []GameResult{ + {GameNum: 1, TeamOneScore: 11, TeamTwoScore: 5, Winner: 1}, + } + state2.CurrentGameNum = 2 + state2.TeamOneScore = 10 + state2.TeamTwoScore = 8 + + result2 := eng.Point(state2, 1) // 11-8, team 1 wins game 2 -> 2 sets + require.False(t, result2.IsError) + assert.True(t, result2.GameOverDetected) + assert.True(t, result2.MatchOverDetected, "winning both required sets must end the match") +} + // --- End change detection --- func TestEndChangeDetected(t *testing.T) { diff --git a/api/service/events.go b/api/service/events.go index 573ed90a..25792b06 100644 --- a/api/service/events.go +++ b/api/service/events.go @@ -88,3 +88,39 @@ var AllEventTypes = []string{ EventTypeFault, EventTypeLineCall, } + +// scoreMutatingEventTypes are the event types that change a match's +// score/serving state. These MUST be applied through the scoring engine +// (ScorePoint, SideOut, RemovePoint, etc.) so the engine remains the single +// source of truth for scoring; they are rejected on the generic RecordEvent +// annotation endpoint. +var scoreMutatingEventTypes = map[string]bool{ + EventTypePointTeam1: true, + EventTypePointTeam2: true, + EventTypePointRemoved: true, + EventTypeSideOut: true, + EventTypeUndo: true, + EventTypeConfirmGameOver: true, + EventTypeConfirmMatchOver: true, +} + +// validEventTypes is the set of every valid event_type value, derived from +// AllEventTypes for O(1) membership checks. +var validEventTypes = func() map[string]bool { + m := make(map[string]bool, len(AllEventTypes)) + for _, t := range AllEventTypes { + m[t] = true + } + return m +}() + +// IsValidEventType reports whether t is a recognized event_type. +func IsValidEventType(t string) bool { + return validEventTypes[t] +} + +// IsScoreMutatingEventType reports whether t changes scoring/serving state and +// therefore must be routed through the scoring engine rather than RecordEvent. +func IsScoreMutatingEventType(t string) bool { + return scoreMutatingEventTypes[t] +} diff --git a/api/service/match.go b/api/service/match.go index db8dff60..17c94ea7 100644 --- a/api/service/match.go +++ b/api/service/match.go @@ -1026,9 +1026,29 @@ func (s *MatchService) StartMatch(ctx context.Context, matchID int64, userID int }, nil } -// RecordEvent records a scoring event and updates the match score. -// Uses a transaction to ensure atomicity. +// RecordEvent records a non-score-mutating timeline annotation (e.g. let, fault, +// line_call, timeout). Uses a transaction to ensure atomicity. +// +// Score-mutating event types (points, side-outs, undo, game/match confirmation) +// must NOT be recorded here: they have to flow through the scoring engine +// (ScorePoint, SideOut, RemovePoint, ConfirmGameOver, ...) so the engine stays +// the single source of truth. Previously this method re-implemented scoring +// inline with logic that diverged from the engine (e.g. flipping the serving +// team on every side-out instead of only on the second server's loss, and no +// game-over detection), corrupting live match state. Such types are now +// rejected, and unknown event types are rejected as well. func (s *MatchService) RecordEvent(ctx context.Context, matchID int64, eventType string, payload json.RawMessage, userID int64) (MatchEventResponse, error) { + if !IsValidEventType(eventType) { + return MatchEventResponse{}, &ValidationError{ + Message: fmt.Sprintf("unknown event_type %q", eventType), + } + } + if IsScoreMutatingEventType(eventType) { + return MatchEventResponse{}, &ValidationError{ + Message: fmt.Sprintf("event_type %q changes the score and must be submitted through the scoring action endpoints (e.g. /point, /sideout, /remove-point, /undo, /confirm-game, /confirm-match), not the events endpoint", eventType), + } + } + tx, err := s.pool.Begin(ctx) if err != nil { return MatchEventResponse{}, fmt.Errorf("failed to begin transaction: %w", err) @@ -1060,7 +1080,8 @@ func (s *MatchService) RecordEvent(ctx context.Context, matchID int64, eventType payload = json.RawMessage("{}") } - // Snapshot BEFORE this event + // Snapshot the current state with the annotation. Score/serving state is + // left unchanged — this endpoint records annotations only. event, err := qtx.CreateMatchEvent(ctx, generated.CreateMatchEventParams{ MatchID: matchID, SequenceID: nextSeq, @@ -1079,51 +1100,6 @@ func (s *MatchService) RecordEvent(ctx context.Context, matchID int64, eventType return MatchEventResponse{}, fmt.Errorf("failed to create event: %w", err) } - // Apply the event to update match score - newT1 := match.Team1Score - newT2 := match.Team2Score - newServingTeam := match.ServingTeam - newServerNumber := match.ServerNumber - - switch eventType { - case "point_team1": - newT1++ - if match.RallyScoring { - newServingTeam = pgtype.Int4{Int32: 1, Valid: true} - } - case "point_team2": - newT2++ - if match.RallyScoring { - newServingTeam = pgtype.Int4{Int32: 2, Valid: true} - } - case "side_out": - if newServingTeam.Valid && newServingTeam.Int32 == 1 { - newServingTeam = pgtype.Int4{Int32: 2, Valid: true} - } else { - newServingTeam = pgtype.Int4{Int32: 1, Valid: true} - } - // Toggle server number for doubles - if newServerNumber.Valid && newServerNumber.Int32 == 1 { - newServerNumber = pgtype.Int4{Int32: 2, Valid: true} - } else if newServerNumber.Valid { - newServerNumber = pgtype.Int4{Int32: 1, Valid: true} - } - } - - _, err = qtx.UpdateMatchScoring(ctx, generated.UpdateMatchScoringParams{ - ID: matchID, - Team1Score: newT1, - Team2Score: newT2, - CurrentSet: match.CurrentSet, - CurrentGame: match.CurrentGame, - ServingTeam: newServingTeam, - ServerNumber: newServerNumber, - SetScores: match.SetScores, - }) - if err != nil { - return MatchEventResponse{}, fmt.Errorf("failed to update match scoring: %w", err) - } - if err := tx.Commit(ctx); err != nil { return MatchEventResponse{}, fmt.Errorf("failed to commit event: %w", err) } @@ -1491,23 +1467,35 @@ func matchToScoringConfig(m generated.Match) engine.ScoringConfig { return cfg } -// applyEngineResult writes the engine result back to the database in a transaction. -// It updates the match scoring state and records an event with the current snapshot. +// applyEngineResult computes and persists a scoring action in a single +// transaction. +// +// The engine computation is supplied as `compute` and is run AGAINST THE +// ROW-LOCKED match (GetMatchForUpdate) inside the transaction, not against an +// earlier unlocked read. This closes the lost-update race: the value written +// always derives from the row read under FOR UPDATE, so two concurrent or +// rapid-repeat scoring requests can no longer both compute N+1 from the same +// stale N and clobber each other. // -// Returns the raw generated.Match, the MatchEvent, and the enriched -// MatchResponse. Enrichment runs exactly once (per CR-4). Callers MUST use -// the returned MatchResponse instead of calling enrichedMatchResponse again. +// The event row stores the PRE-action snapshot (the locked match's state before +// `compute` is applied), matching RecordEvent's convention. This is what Undo +// relies on when it restores from the latest event. +// +// Returns the raw generated.Match, the engine instance (config-bound, useful for +// ScoreCall), the EngineResult, the MatchEvent, and the enriched MatchResponse. +// Enrichment runs exactly once (per CR-4). Callers MUST use the returned +// MatchResponse instead of calling enrichedMatchResponse again. func (s *MatchService) applyEngineResult( ctx context.Context, matchID int64, - result engine.EngineResult, + compute func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult, eventType string, payload json.RawMessage, userID int64, -) (generated.Match, generated.MatchEvent, MatchResponse, error) { +) (generated.Match, *engine.ScoringEngine, engine.EngineResult, generated.MatchEvent, MatchResponse, error) { tx, err := s.pool.Begin(ctx) if err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to begin transaction: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) @@ -1516,9 +1504,17 @@ func (s *MatchService) applyEngineResult( match, err := qtx.GetMatchForUpdate(ctx, matchID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, &NotFoundError{Message: "match not found"} + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, &NotFoundError{Message: "match not found"} } - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("get match for engine result: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("get match for engine result: %w", err) + } + + // Run the engine against the row-locked match so the persisted value derives + // from the locked read (lost-update protection). + eng := engine.NewScoringEngine(matchToScoringConfig(match)) + result := compute(eng, matchToEngineState(match)) + if result.IsError { + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, &ValidationError{Message: result.ErrorMessage} } // Encode completed games as set_scores JSON. @@ -1554,7 +1550,7 @@ func (s *MatchService) applyEngineResult( SetScores: setScores, }) if err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to update match scoring: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to update match scoring: %w", err) } // If status changed, update it. @@ -1564,71 +1560,60 @@ func (s *MatchService) applyEngineResult( Status: newStatus, }) if err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to update match status: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to update match status: %w", err) } } // Record event. nextSeq, err := qtx.GetNextSequenceID(ctx, matchID) if err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to get next sequence: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to get next sequence: %w", err) } if payload == nil { payload = json.RawMessage("{}") } + // Snapshot the state BEFORE this action (the row read under lock), so the + // snapshot semantics match RecordEvent and Undo can correctly restore the + // prior state from the latest event. event, err := qtx.CreateMatchEvent(ctx, generated.CreateMatchEventParams{ MatchID: matchID, SequenceID: nextSeq, EventType: eventType, - Team1Score: result.State.TeamOneScore, - Team2Score: result.State.TeamTwoScore, + Team1Score: match.Team1Score, + Team2Score: match.Team2Score, CurrentSet: match.CurrentSet, - CurrentGame: result.State.CurrentGameNum, - ServingTeam: pgtype.Int4{Int32: result.State.ServingTeam, Valid: true}, - ServerNumber: pgtype.Int4{Int32: result.State.ServerNumber, Valid: true}, - SetScores: setScores, + CurrentGame: match.CurrentGame, + ServingTeam: match.ServingTeam, + ServerNumber: match.ServerNumber, + SetScores: match.SetScores, Payload: payload, CreatedByUserID: pgtype.Int8{Int64: userID, Valid: true}, }) if err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to create event: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to create event: %w", err) } if err := tx.Commit(ctx); err != nil { - return generated.Match{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to commit: %w", err) + return generated.Match{}, nil, engine.EngineResult{}, generated.MatchEvent{}, MatchResponse{}, fmt.Errorf("failed to commit: %w", err) } // Enrich ONCE (CR-4). broadcast + HTTP response share this value. resp := s.enrichedMatchResponse(ctx, updated) s.broadcastMatchUpdate(ctx, updated, resp) - return updated, event, resp, nil + return updated, eng, result, event, resp, nil } // ScorePoint awards a point to the given team via the scoring engine. func (s *MatchService) ScorePoint(ctx context.Context, matchID int64, team int32, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for score point: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.Point(state, team) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - payload, _ := json.Marshal(map[string]interface{}{"team": team}) eventType := fmt.Sprintf("point_team%d", team) - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, eventType, payload, userID) + _, eng, result, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.Point(state, team) + }, eventType, payload, userID) if err != nil { return ScoringActionResult{}, err } @@ -1645,24 +1630,10 @@ func (s *MatchService) ScorePoint(ctx context.Context, matchID int64, team int32 // SideOut handles a side-out (loss of serve). func (s *MatchService) SideOut(ctx context.Context, matchID int64, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for side out: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.SideOut(state) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeSideOut, nil, userID) + _, eng, result, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.SideOut(state) + }, EventTypeSideOut, nil, userID) if err != nil { return ScoringActionResult{}, err } @@ -1676,26 +1647,12 @@ func (s *MatchService) SideOut(ctx context.Context, matchID int64, userID int64) // RemovePoint removes the last point scored for a team. func (s *MatchService) RemovePoint(ctx context.Context, matchID int64, team int32, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for remove point: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - // Use current serving state as previous (simplified undo — full undo via Undo endpoint). - result := eng.RemovePoint(state, team, state.ServingTeam, state.ServerNumber) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - payload, _ := json.Marshal(map[string]interface{}{"team": team}) - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypePointRemoved, payload, userID) + _, eng, result, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + // Use current serving state as previous (simplified undo — full undo via Undo endpoint). + return eng.RemovePoint(state, team, state.ServingTeam, state.ServerNumber) + }, EventTypePointRemoved, payload, userID) if err != nil { return ScoringActionResult{}, err } @@ -1709,24 +1666,10 @@ func (s *MatchService) RemovePoint(ctx context.Context, matchID int64, team int3 // ConfirmGameOver transitions to the next game after a game win is detected. func (s *MatchService) ConfirmGameOver(ctx context.Context, matchID int64, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for confirm game over: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.ConfirmGameOver(state) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeConfirmGameOver, nil, userID) + _, eng, result, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.ConfirmGameOver(state) + }, EventTypeConfirmGameOver, nil, userID) if err != nil { return ScoringActionResult{}, err } @@ -1740,24 +1683,10 @@ func (s *MatchService) ConfirmGameOver(ctx context.Context, matchID int64, userI // ConfirmMatchOver finalizes the match as completed. func (s *MatchService) ConfirmMatchOver(ctx context.Context, matchID int64, winnerTeamID, loserTeamID int64, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for confirm match over: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.ConfirmMatchOver(state) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - - updated, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeConfirmMatchOver, nil, userID) + updated, _, _, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.ConfirmMatchOver(state) + }, EventTypeConfirmMatchOver, nil, userID) if err != nil { return ScoringActionResult{}, err } @@ -1827,25 +1756,11 @@ func (s *MatchService) ConfirmMatchOver(ctx context.Context, matchID int64, winn // CallTimeout records a timeout event. func (s *MatchService) CallTimeout(ctx context.Context, matchID int64, team int32, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for timeout: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.Timeout(state, team) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - payload, _ := json.Marshal(map[string]interface{}{"team": team}) - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeTimeout, payload, userID) + _, eng, result, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.Timeout(state, team) + }, EventTypeTimeout, payload, userID) if err != nil { return ScoringActionResult{}, err } @@ -1859,24 +1774,10 @@ func (s *MatchService) CallTimeout(ctx context.Context, matchID int64, team int3 // PauseMatch pauses the match. func (s *MatchService) PauseMatch(ctx context.Context, matchID int64, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for pause: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.Pause(state) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeMatchPaused, nil, userID) + _, _, _, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.Pause(state) + }, EventTypeMatchPaused, nil, userID) if err != nil { return ScoringActionResult{}, err } @@ -1889,24 +1790,10 @@ func (s *MatchService) PauseMatch(ctx context.Context, matchID int64, userID int // ResumeMatch resumes a paused match. func (s *MatchService) ResumeMatch(ctx context.Context, matchID int64, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for resume: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.Resume(state) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeMatchResumed, nil, userID) + _, _, _, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.Resume(state) + }, EventTypeMatchResumed, nil, userID) if err != nil { return ScoringActionResult{}, err } @@ -1921,29 +1808,15 @@ func (s *MatchService) ResumeMatch(ctx context.Context, matchID int64, userID in // optional free text that's recorded in the forfeit_declared event payload // for audit purposes; an empty string is allowed. func (s *MatchService) DeclareForfeit(ctx context.Context, matchID int64, forfeitingTeam int32, winnerTeamID, loserTeamID int64, reason string, userID int64) (ScoringActionResult, error) { - match, err := s.queries.GetMatch(ctx, matchID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ScoringActionResult{}, &NotFoundError{Message: "match not found"} - } - return ScoringActionResult{}, fmt.Errorf("get match for forfeit: %w", err) - } - - cfg := matchToScoringConfig(match) - eng := engine.NewScoringEngine(cfg) - state := matchToEngineState(match) - - result := eng.Forfeit(state, forfeitingTeam) - if result.IsError { - return ScoringActionResult{}, &ValidationError{Message: result.ErrorMessage} - } - payloadMap := map[string]interface{}{"forfeiting_team": forfeitingTeam} if reason != "" { payloadMap["reason"] = reason } payload, _ := json.Marshal(payloadMap) - _, event, resp, err := s.applyEngineResult(ctx, matchID, result, EventTypeForfeitDeclared, payload, userID) + _, _, _, event, resp, err := s.applyEngineResult(ctx, matchID, + func(eng *engine.ScoringEngine, state engine.MatchState) engine.EngineResult { + return eng.Forfeit(state, forfeitingTeam) + }, EventTypeForfeitDeclared, payload, userID) if err != nil { return ScoringActionResult{}, err } diff --git a/api/service/match_contract_test.go b/api/service/match_contract_test.go index 8d4ffa52..a1f88a69 100644 --- a/api/service/match_contract_test.go +++ b/api/service/match_contract_test.go @@ -321,8 +321,11 @@ func keysOf(m map[string]json.RawMessage) []string { // --------------------------------------------------------------------------- // TestApplyEngineResult_ReturnsPreEnrichedResponse asserts that the private -// scoring helper returns FOUR values, the third being a MatchResponse. This -// is the mechanical guarantee that every mutation enriches exactly once. +// scoring helper returns a MatchResponse. This is the mechanical guarantee +// that every mutation enriches exactly once. The helper also returns the +// engine instance and EngineResult (it now runs the engine against the +// row-locked match to avoid lost updates), so callers can read scoring flags +// and build the score call. // // Reflection cannot see unexported methods on MatchService without an // instance, so we verify by re-parsing the AST and confirming the return @@ -356,7 +359,7 @@ func TestApplyEngineResult_ReturnsPreEnrichedResponse(t *testing.T) { names = append(names, exprString(field.Type)) } } - want := []string{"generated.Match", "generated.MatchEvent", "MatchResponse", "error"} + want := []string{"generated.Match", "*engine.ScoringEngine", "engine.EngineResult", "generated.MatchEvent", "MatchResponse", "error"} if !reflect.DeepEqual(names, want) { t.Errorf("applyEngineResult return types = %v, want %v", names, want) } diff --git a/api/service/match_series.go b/api/service/match_series.go index c3b2ed68..28ae6060 100644 --- a/api/service/match_series.go +++ b/api/service/match_series.go @@ -3,9 +3,11 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -413,18 +415,56 @@ func (s *MatchSeriesService) RecordMatchResult(ctx context.Context, seriesID int return MatchSeriesResponse{}, &ValidationError{Message: "series must be in_progress to record results"} } - // Determine which team won and update wins - team1Wins := series.Team1Wins - team2Wins := series.Team2Wins + // Validate the child match: it must exist, belong to THIS series, and be + // completed. Without this, the matchID parameter was ignored entirely, + // letting a caller record a result for a foreign or never-played match. + childMatch, err := qtx.GetMatchForUpdate(ctx, matchID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return MatchSeriesResponse{}, &ValidationError{Message: "match not found"} + } + return MatchSeriesResponse{}, fmt.Errorf("get child match for series result: %w", err) + } + if !childMatch.MatchSeriesID.Valid || childMatch.MatchSeriesID.Int64 != seriesID { + return MatchSeriesResponse{}, &ValidationError{Message: "match does not belong to this series"} + } + if childMatch.Status != "completed" { + return MatchSeriesResponse{}, &ValidationError{Message: "child match must be completed to record its result"} + } - if series.Team1ID.Valid && winnerTeamID == series.Team1ID.Int64 { - team1Wins++ - } else if series.Team2ID.Valid && winnerTeamID == series.Team2ID.Int64 { - team2Wins++ - } else { + // Derive the winner from the child match's own recorded winner rather than + // trusting the client-supplied winnerTeamID. The winner must still be one of + // the series teams. + if !childMatch.WinnerTeamID.Valid { + return MatchSeriesResponse{}, &ValidationError{Message: "child match has no recorded winner"} + } + derivedWinner := childMatch.WinnerTeamID.Int64 + if (!series.Team1ID.Valid || derivedWinner != series.Team1ID.Int64) && + (!series.Team2ID.Valid || derivedWinner != series.Team2ID.Int64) { return MatchSeriesResponse{}, &ValidationError{Message: "winner must be one of the series teams"} } + // Reconcile win counts by counting completed, series-linked child matches + // per winning team. This is idempotent: replaying the same result (or a + // previously counted match) cannot inflate a team's wins, because we recount + // from the authoritative set of completed matches each time. + childMatches, err := qtx.ListMatchesBySeriesID(ctx, pgtype.Int8{Int64: seriesID, Valid: true}) + if err != nil { + return MatchSeriesResponse{}, fmt.Errorf("list series child matches: %w", err) + } + var team1Wins, team2Wins int32 + for _, cm := range childMatches { + if cm.Status != "completed" || !cm.WinnerTeamID.Valid { + continue + } + switch { + case series.Team1ID.Valid && cm.WinnerTeamID.Int64 == series.Team1ID.Int64: + team1Wins++ + case series.Team2ID.Valid && cm.WinnerTeamID.Int64 == series.Team2ID.Int64: + team2Wins++ + } + } + _, err = qtx.UpdateMatchSeriesScore(ctx, generated.UpdateMatchSeriesScoreParams{ ID: seriesID, Team1Wins: team1Wins, From 1d4659b3a747f92e53cadfdb917209b7d6c1e9c3 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Wed, 1 Jul 2026 09:10:30 -0500 Subject: [PATCH 12/13] fix(scoring): per-run cancellation in useMatchWebSocket to kill reconnect race --- web/src/features/scoring/useMatchWebSocket.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/features/scoring/useMatchWebSocket.ts b/web/src/features/scoring/useMatchWebSocket.ts index b0b5fc6b..cdcbf369 100644 --- a/web/src/features/scoring/useMatchWebSocket.ts +++ b/web/src/features/scoring/useMatchWebSocket.ts @@ -36,11 +36,20 @@ export function useMatchWebSocket(publicId: string | undefined): { const attemptsRef = useRef(0) const wsRef = useRef(null) const reconnectTimerRef = useRef(null) - const closedByUnmountRef = useRef(false) useEffect(() => { if (!publicId) return - closedByUnmountRef.current = false + // Per-run cancellation. A single shared ref would be reset to false by + // the next effect run before the previous run's socket fires its + // (asynchronous) onclose, letting a torn-down run reschedule connect() + // for the stale publicId and pollute the new run's state. Capturing the + // flag in the effect closure ties it to this run only. + let cancelled = false + // Fresh per-run state so a late onclose from a previous run can't mutate + // the new run's live socket/timer/attempt entries. + attemptsRef.current = 0 + wsRef.current = null + reconnectTimerRef.current = null const connect = () => { const url = buildWsUrl(publicId) @@ -76,8 +85,13 @@ export function useMatchWebSocket(publicId: string | undefined): { } ws.onclose = () => { + // Ignore late closes from a torn-down effect run. `cancelled` is + // captured per-run so a previous run's socket can't act here. + if (cancelled) return + // Guard against a stale onclose clobbering the new run's live socket: + // only clear/reschedule if this ws is still the registered one. + if (wsRef.current !== ws) return wsRef.current = null - if (closedByUnmountRef.current) return setState('disconnected') const idx = Math.min(attemptsRef.current, BACKOFF_STEPS_MS.length - 1) const delay = BACKOFF_STEPS_MS[idx] @@ -90,7 +104,7 @@ export function useMatchWebSocket(publicId: string | undefined): { connect() return () => { - closedByUnmountRef.current = true + cancelled = true if (reconnectTimerRef.current) { window.clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null From 4c516ad1b2e565154d84a471638f786833aef993 Mon Sep 17 00:00:00 2001 From: Daniel Velez Date: Wed, 1 Jul 2026 09:13:51 -0500 Subject: [PATCH 13/13] fix(authz): gate registration mutations + court update/delete ownership --- api/handler/court.go | 26 +++++++++++++++++++++++++ api/handler/registration.go | 14 ++++++-------- api/router/router.go | 24 +++++++++++++++++++++-- api/service/venue.go | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/api/handler/court.go b/api/handler/court.go index 0bd9f769..54010c59 100644 --- a/api/handler/court.go +++ b/api/handler/court.go @@ -293,6 +293,19 @@ func (h *CourtHandler) UpdateCourt(w http.ResponseWriter, r *http.Request) { return } + // Court-ownership guard — mirrors the overlay config write handlers so a + // caller cannot modify a court outside their org/tournament. Platform + // admins, the court creator, and managers of the court's owning venue pass. + ok, err := h.venueService.CanManageCourt(r.Context(), courtID, sess.UserID, sess.Role) + if err != nil { + handleServiceError(w, err) + return + } + if !ok { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "You do not have permission to manage this court") + return + } + var body struct { Name *string `json:"name"` SurfaceType *string `json:"surface_type"` @@ -361,6 +374,19 @@ func (h *CourtHandler) DeleteCourt(w http.ResponseWriter, r *http.Request) { return } + // Court-ownership guard — mirrors the overlay config write handlers so a + // caller cannot delete a court outside their org/tournament. Platform + // admins, the court creator, and managers of the court's owning venue pass. + ok, err := h.venueService.CanManageCourt(r.Context(), courtID, sess.UserID, sess.Role) + if err != nil { + handleServiceError(w, err) + return + } + if !ok { + WriteError(w, http.StatusForbidden, "FORBIDDEN", "You do not have permission to manage this court") + return + } + if err := h.venueService.DeleteCourt(r.Context(), courtID); err != nil { handleServiceError(w, err) return diff --git a/api/handler/registration.go b/api/handler/registration.go index 46e1dcd0..1cbffe15 100644 --- a/api/handler/registration.go +++ b/api/handler/registration.go @@ -24,17 +24,15 @@ func NewRegistrationHandler(svc *service.RegistrationService) *RegistrationHandl return &RegistrationHandler{regSvc: svc} } -// Routes returns a chi.Router with all registration routes mounted. -// Expects to be mounted under /divisions/{divisionID}/registrations. +// Routes returns a chi.Router with the mutating registration routes. Expects +// to be mounted under /divisions/{divisionID}/registrations behind the +// authenticated + sport-isolation middleware chain (see router.go); each +// handler additionally enforces role checks. The public read routes +// (ListRegistrations, ListSeekingPartner, GetRegistration) are registered +// directly on the parent node in router.go so they stay unauthenticated. func (h *RegistrationHandler) Routes() chi.Router { r := chi.NewRouter() - // Public routes - r.Get("/", h.ListRegistrations) - r.Get("/seeking-partner", h.ListSeekingPartner) - r.Get("/{registrationID}", h.GetRegistration) - - // Authenticated routes r.Post("/", h.Register) r.Patch("/{registrationID}/status", h.UpdateStatus) r.Patch("/{registrationID}/seed", h.UpdateSeed) diff --git a/api/router/router.go b/api/router/router.go index 2e9281cc..a31e1344 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -384,9 +384,29 @@ func New(cfg *Config) chi.Router { r.Mount("/", cfg.AnnouncementHandler.FlatAnnouncementRoutes()) }) - // Division sub-resources (registrations and pods — auth checked by handlers) + // Division sub-resources (registrations and pods). + // + // Registration reads are intentionally public; the mutating routes + // (status, seed, placement, check-in, withdraw, admin-notes, create, + // bulk-no-show) must sit behind the same auth + sport-isolation chain + // as other authed write groups so RequireSportMatchesJWT enforces + // org/sport isolation (the service-layer division scoping alone does + // not run the isolation middleware). Chi allows only one Mount per + // path, so the public reads are mounted on this node and the authed + // mutations use a Group — mirroring the /matches split above. r.Route("/divisions/{divisionID}/registrations", func(r chi.Router) { - r.Mount("/", cfg.RegistrationHandler.Routes()) + // Public reads (no auth) — registered directly like /matches so + // only the authed subtree uses Mount (chi allows one Mount per + // path). + r.Get("/", cfg.RegistrationHandler.ListRegistrations) + r.Get("/seeking-partner", cfg.RegistrationHandler.ListSeekingPartner) + r.Get("/{registrationID}", cfg.RegistrationHandler.GetRegistration) + + // Authenticated mutations (auth + sport isolation). + r.Group(func(r chi.Router) { + useAuth(r, cfg) + r.Mount("/", cfg.RegistrationHandler.Routes()) + }) }) r.Route("/divisions/{divisionID}/pods", func(r chi.Router) { r.Mount("/", cfg.PodHandler.Routes()) diff --git a/api/service/venue.go b/api/service/venue.go index 58d0d507..07b217a6 100644 --- a/api/service/venue.go +++ b/api/service/venue.go @@ -772,6 +772,44 @@ func (s *VenueService) CanManageVenue(ctx context.Context, venueID int64, userID return isMgr, nil } +// CanManageCourt reports whether the given user may manage (edit/delete) a +// court. Returns true for platform admins, the court's creator, or a manager +// of the court's owning venue — the same ownership boundary +// OverlayService.CanManageCourt enforces for overlay-config writes, so a +// caller cannot mutate a court outside their org/tournament. Returns +// NotFoundError when the court does not exist. +func (s *VenueService) CanManageCourt(ctx context.Context, courtID int64, userID int64, userRole string) (bool, error) { + if userRole == "platform_admin" { + return true, nil + } + + court, err := s.queries.GetCourtByID(ctx, courtID) + if err != nil { + return false, &NotFoundError{Message: "court not found"} + } + + // The court creator may always manage it. + if court.CreatedByUserID.Valid && court.CreatedByUserID.Int64 == userID { + return true, nil + } + + // Otherwise, the caller must be a manager of the court's owning venue. + if court.VenueID.Valid { + isMgr, err := s.queries.IsVenueManager(ctx, generated.IsVenueManagerParams{ + VenueID: court.VenueID.Int64, + UserID: userID, + }) + if err != nil { + return false, err + } + if isMgr { + return true, nil + } + } + + return false, nil +} + // CanAdminVenue checks if a user has admin-level access to a venue // (can add/remove managers). Returns true for venue admins, creator, or platform admin. func (s *VenueService) CanAdminVenue(ctx context.Context, venueID int64, userID int64, userRole string) (bool, error) {