Skip to content

Draw/Match Management — API Contracts & Screen Flow

Owner: Morgan (Solution Architect) Status: Implementation blueprint Last updated: 2026-04-12


1. tRPC Router Structure

The existing root.ts mounts three routers: health, signage, tournament. This design adds four new routers and extends one existing router. All new routers use protectedProcedure unless noted otherwise.

Updated root.ts

export const appRouter = router({
  health: healthRouter,
  signage: signageRouter,      // existing — extended with draw + bracket queries
  tournament: tournamentRouter, // existing — no changes
  draw: drawRouter,            // NEW
  player: playerRouter,        // NEW
  registration: registrationRouter, // NEW
  match: matchRouter,          // NEW
});

1.1 draw Router

Manages draws within a tournament (ENTITY-003).

draw.list — query

input: z.object({
  tournamentId: z.string().uuid(),
})
output: Array<{
  id: string;
  name: string;
  type: "SINGLE_ELIMINATION" | "ROUND_ROBIN";
  drawFormatId: string;       // "F1".."F8"
  drawFormatName: string;     // joined from draw_formats
  isLoserDraw: boolean;
  registrationCount: number;  // count of registrations for this draw
  matchCount: number;         // count of matches for this draw
}>

DB: SELECT draws.*, draw_formats.name, COUNT(registrations), COUNT(matches) FROM draws LEFT JOIN ... WHERE draws.tournament_id = $1 AND draws.is_loser_draw = false GROUP BY draws.id.

draw.byId — query

input: z.object({
  id: z.string().uuid(),
})
output: {
  id: string;
  tournamentId: string;
  name: string;
  type: "SINGLE_ELIMINATION" | "ROUND_ROBIN";
  drawFormatId: string;
  drawFormat: DrawFormat;     // full draw_formats row
  isLoserDraw: boolean;
  registrations: Array<{
    id: string;
    playerId: string;
    playerFirstName: string;
    playerLastName: string;
    playerRankingCategory: string | null;
    isSeeded: boolean;
    qualifierWildcard: string | null;
  }>;
  matches: Array<Match>;     // all matches in this draw, ordered by roundNumber, slotIndex
}>

DB: three queries in parallel — draw row + format join, registrations with player join, matches. Assembles in the procedure.

draw.create — mutation

input: z.object({
  tournamentId: z.string().uuid(),
  name: z.string().min(1).max(200),
  type: z.enum(["SINGLE_ELIMINATION", "ROUND_ROBIN"]),
  drawFormatId: z.string().min(1).max(10),  // "F1".."F8"
})
output: Draw  // the inserted row

DB: INSERT INTO draws. Validate drawFormatId exists in draw_formats — the FK constraint handles it, translate the FK violation into a tRPC BAD_REQUEST.

draw.update — mutation

input: z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(200).optional(),
  type: z.enum(["SINGLE_ELIMINATION", "ROUND_ROBIN"]).optional(),
  drawFormatId: z.string().min(1).max(10).optional(),
})
output: Draw

Guard: if the draw already has matches, reject changes to type and drawFormatId. Changing the scoring format after matches exist would invalidate stored results. Name-only changes are always safe.

draw.delete — mutation

input: z.object({
  id: z.string().uuid(),
})
output: { success: true }

DB: DELETE FROM draws WHERE id = $1. CASCADE deletes registrations and sets match draw_id to null. Warn: this is destructive. The frontend must confirm.

draw.generateBracket — mutation

This is the core bracket/group generation procedure. It reads the draw's registrations, applies seeding logic, and creates match rows.

input: z.object({
  drawId: z.string().uuid(),
  // Ordered list of player IDs representing the seeded order.
  // Position 0 = seed 1 (top of bracket), position 1 = seed 2, etc.
  // Players not in this list are unseeded and placed randomly.
  seedOrder: z.array(z.string().uuid()),
})
output: {
  matchesCreated: number;
  // For RR: the group(s) generated. For SE: the bracket depth (rounds).
  structure: 
    | { type: "SINGLE_ELIMINATION"; rounds: number; matchIds: string[] }
    | { type: "ROUND_ROBIN"; playerCount: number; matchIds: string[] }
}

Guard: if the draw already has matches, reject. The organizer must delete existing matches first (via draw.clearBracket).

Server-side logic — Single Elimination:

  1. Read confirmed registrations for this draw.
  2. Determine bracket size: next power of 2 >= player count. Excess slots become byes.
  3. Place seeds using standard tennis seeding (seed 1 top, seed 2 bottom, seeds 3-4 in quarter positions, etc.).
  4. Fill remaining slots randomly.
  5. Create match rows with:
  6. round_number: 1 (first round) through N (final).
  7. slot_index: position within the round (0-indexed).
  8. next_match_id: FK to the match in the next round. Round N match at slot S feeds into round N+1 match at slot floor(S/2).
  9. winner_slot: UPPER if slot is even, LOWER if slot is odd.
  10. player1_id / player2_id: populated for first-round matches; null for later rounds.
  11. round_label: auto-generated from round depth ("R64", "R32", "R16", "QF", "SF", "Final").
  12. Process byes: if a first-round match has only one player, auto-advance that player to the next round immediately (set result to WALKOVER with the present player as winner, advance via computeAdvancement).

Server-side logic — Round Robin:

  1. Read confirmed registrations.
  2. Generate all pairings: N players produce N*(N-1)/2 matches.
  3. Create match rows with:
  4. round_number: round in the round-robin schedule (use circle method for balanced scheduling).
  5. slot_index: match position within the round.
  6. player1_id / player2_id: both populated.
  7. next_match_id / winner_slot: null (RR has no bracket advancement).
  8. round_label: "Round 1", "Round 2", etc.

All match inserts happen in a single transaction.

draw.clearBracket — mutation

input: z.object({
  drawId: z.string().uuid(),
})
output: { deletedCount: number }

DB: DELETE FROM matches WHERE draw_id = $1. This is the "reset" action if the organizer wants to re-generate after adjusting registrations or seeding.

Guard: if any match in the draw has status COMPLETED, reject unless a force: true flag is passed. Prevents accidental loss of entered results.

draw.standings — query

Round-robin standings computation. Returns live standings for a draw.

input: z.object({
  drawId: z.string().uuid(),
})
output: StandingsResult  // from @courtsidedesk/scoring

Server-side logic:

  1. Load the draw (must be ROUND_ROBIN, else throw BAD_REQUEST).
  2. Load all matches for the draw that have status = COMPLETED and a non-null result.
  3. Load the draw format from draw_formats.
  4. Parse each match's result JSONB column via parseMatchResult().
  5. Build PoolMatch[] array with player IDs, parsed results, and format.
  6. Call computeStandings(matches, playerIds) from @courtsidedesk/scoring.
  7. Return the StandingsResult.

1.2 player Router

Manages the persistent player registry (ENTITY-005).

player.search — query

input: z.object({
  query: z.string().min(1).max(100),
  limit: z.number().int().min(1).max(50).default(20),
})
output: Array<{
  id: string;
  firstName: string;
  lastName: string;
  licenseNumber: string | null;
  rankingCategory: string | null;
  clubName: string | null;
}>

DB: SELECT ... FROM players WHERE last_name ILIKE $1 OR first_name ILIKE $1 OR license_number = $1 LIMIT $2. The ILIKE pattern is query% (prefix match), not %query% (too slow without trigram index, not needed at this scale).

player.create — mutation

input: z.object({
  firstName: z.string().min(1).max(100),
  lastName: z.string().min(1).max(100),
  licenseNumber: z.string().max(20).optional(),
  email: z.string().email().optional(),
  phoneMobile: z.string().max(30).optional(),
  rankingCategory: z.string().max(10).optional(),
  rankingValue: z.number().optional(),
  clubName: z.string().max(200).optional(),
})
output: Player

DB: INSERT INTO players. If licenseNumber is provided and already exists, translate the unique violation into tRPC CONFLICT with a message suggesting the organizer search for the existing player instead.

player.update — mutation

input: z.object({
  id: z.string().uuid(),
  firstName: z.string().min(1).max(100).optional(),
  lastName: z.string().min(1).max(100).optional(),
  licenseNumber: z.string().max(20).optional(),
  email: z.string().email().nullable().optional(),
  phoneMobile: z.string().max(30).nullable().optional(),
  phoneHome: z.string().max(30).nullable().optional(),
  phoneWork: z.string().max(30).nullable().optional(),
  rankingCategory: z.string().max(10).nullable().optional(),
  rankingValue: z.number().nullable().optional(),
  clubName: z.string().max(200).nullable().optional(),
})
output: Player

player.listForTournament — query

Contact sheet backing query (SCR-003).

input: z.object({
  tournamentId: z.string().uuid(),
})
output: Array<{
  id: string;
  firstName: string;
  lastName: string;
  phoneHome: string | null;
  phoneWork: string | null;
  phoneMobile: string | null;
  email: string | null;
  draws: string[];  // names of draws the player is registered in
}>

DB: SELECT players.*, array_agg(draws.name) FROM players JOIN registrations ON ... JOIN draws ON ... WHERE registrations.tournament_id = $1 GROUP BY players.id ORDER BY players.last_name ASC.


1.3 registration Router

Manages player-to-draw registrations (ENTITY-006).

registration.add — mutation

input: z.object({
  playerId: z.string().uuid(),
  tournamentId: z.string().uuid(),
  drawId: z.string().uuid(),
  isSeeded: z.boolean().default(false),
  qualifierWildcard: z.enum(["Q", "WC", "LL"]).nullable().default(null),
})
output: Registration

DB: INSERT INTO registrations. Unique constraint on (player_id, draw_id) — translate violation to CONFLICT.

registration.remove — mutation

input: z.object({
  id: z.string().uuid(),
})
output: { success: true }

Guard: if the draw already has generated matches, warn but allow. The match rows reference player IDs directly, not registration IDs, so removing a registration does not corrupt the bracket. But the organizer should be aware.

registration.bulkAdd — mutation

For adding multiple players to a draw at once (e.g., after searching/selecting).

input: z.object({
  tournamentId: z.string().uuid(),
  drawId: z.string().uuid(),
  playerIds: z.array(z.string().uuid()).min(1).max(128),
})
output: { added: number; skipped: number }  // skipped = already registered

DB: INSERT INTO registrations ... ON CONFLICT (player_id, draw_id) DO NOTHING. Count inserted vs skipped.

registration.updateSeeding — mutation

Bulk update seeding order for a draw.

input: z.object({
  drawId: z.string().uuid(),
  seedings: z.array(z.object({
    registrationId: z.string().uuid(),
    isSeeded: z.boolean(),
    qualifierWildcard: z.enum(["Q", "WC", "LL"]).nullable(),
  })),
})
output: { updated: number }

1.4 match Router

The core match lifecycle router. Handles result entry, court assignment, and bracket advancement.

match.listByDraw — query

input: z.object({
  drawId: z.string().uuid(),
})
output: Array<Match & {
  player1: { id: string; firstName: string; lastName: string; rankingCategory: string | null } | null;
  player2: { id: string; firstName: string; lastName: string; rankingCategory: string | null } | null;
}>

DB: SELECT matches.*, p1.*, p2.* FROM matches LEFT JOIN players p1 ON ... LEFT JOIN players p2 ON ... WHERE draw_id = $1 ORDER BY round_number ASC, slot_index ASC.

match.listByTournament — query

Returns all matches for SCR-005 (Court Assignment screen).

input: z.object({
  tournamentId: z.string().uuid(),
  // Optional filters:
  status: z.enum(["PLANNED", "ON_COURT", "COMPLETED"]).optional(),
  courtId: z.string().uuid().nullable().optional(),  // null = unassigned
})
output: Array<Match & {
  player1: PlayerSummary | null;
  player2: PlayerSummary | null;
  drawName: string | null;
}>

match.assignCourt — mutation

Drag-to-court action from SCR-005.

input: z.object({
  matchId: z.string().uuid(),
  courtId: z.string().uuid().nullable(),  // null = unassign (drag back to panel)
  plannedStartTime: z.string().datetime().nullable().optional(),
})
output: Match

DB: UPDATE matches SET court_id = $2, planned_start_time = $3, status = CASE WHEN $2 IS NULL THEN 'PLANNED' ELSE status END WHERE id = $1.

When courtId is null, also clear planned_start_time and reset status to PLANNED (unless COMPLETED).

Realtime: This write triggers Supabase Realtime on the matches table, which SCR-001 subscribes to.

match.setOnCourt — mutation

Marks a match as actively being played.

input: z.object({
  matchId: z.string().uuid(),
})
output: Match

DB: UPDATE matches SET status = 'ON_COURT', actual_start_time = COALESCE(actual_start_time, now()) WHERE id = $1 AND status = 'PLANNED'. Reject if status is not PLANNED.

match.submitResult — mutation

This is the most complex mutation in the system. It validates the result, saves it, determines the winner, and advances the bracket — all in one transaction.

input: z.object({
  matchId: z.string().uuid(),
  result: matchResultSchema,  // imported from @courtsidedesk/scoring
})
output: {
  match: Match;
  advancement: AdvancementIntent;  // from @courtsidedesk/scoring
  // For RR draws, recomputed standings after this result:
  standings: StandingsResult | null;
}

Server-side logic (single Postgres transaction):

  1. Load match row with FOR UPDATE row lock.
  2. Guard: match must not already be COMPLETED. If it is, throw CONFLICT ("result already entered — use match.editResult to correct").
  3. Load draw to get draw_format_id and type.
  4. Load DrawFormat from draw_formats table.
  5. Validate result via validateMatchResult(drawFormat, result) from @courtsidedesk/scoring. If validation fails, throw BAD_REQUEST with the structured ValidationIssue[] array in the error data (the frontend renders these as field-level errors on the result entry form).
  6. Update match row:
  7. result = the validated MatchResult as JSONB.
  8. winner = result.winner (PLAYER1 or PLAYER2).
  9. status = COMPLETED.
  10. Bracket advancement (Single Elimination only):
  11. Call computeAdvancement({ result, player1Id, player2Id, nextMatchId, winnerSlot }).
  12. If intent.kind === "advance": update the next match row, setting player1_id (if winnerSlot === "UPPER") or player2_id (if winnerSlot === "LOWER") to intent.winnerPlayerId.
  13. Standings recomputation (Round Robin only):
  14. Load all completed matches in the draw.
  15. Parse all results via parseMatchResult().
  16. Call computeStandings().
  17. Return standings in the response (the frontend updates the standings table immediately).
  18. Commit transaction.

Realtime: The match update triggers Supabase Realtime. SCR-001 picks up the status change; SCR-002 picks up the result and (for SE) the advancement.

match.editResult — mutation

Corrects a previously submitted result. Same validation pipeline as submitResult, but also handles un-advancing the bracket if the winner changes.

input: z.object({
  matchId: z.string().uuid(),
  result: matchResultSchema,
})
output: {
  match: Match;
  advancement: AdvancementIntent;
  standings: StandingsResult | null;
}

Server-side logic:

  1. Load match — must be COMPLETED.
  2. Check if winner changed compared to the stored result.
  3. If winner changed AND the match has next_match_id:
  4. Check if the next match already has a result. If so, throw CONFLICT — cannot change a winner whose advancement match is already completed. The organizer must edit results in reverse bracket order.
  5. Clear the advanced player from the next match (player1_id or player2_id depending on winner_slot).
  6. Recompute advancement with the new winner.
  7. Otherwise, same flow as submitResult steps 5-9.

match.clearResult — mutation

Removes a result from a match, resetting it to PLANNED or ON_COURT.

input: z.object({
  matchId: z.string().uuid(),
})
output: Match

Guard: if the match fed a next-round match that already has a result, reject. Same reverse-order constraint as editResult.

DB: UPDATE matches SET result = NULL, winner = NULL, status = 'PLANNED' WHERE id = $1. If bracket advancement occurred, also clear the winner's slot on the next match.

match.reorderOnCourt — mutation

Updates planned start times for multiple matches on a court (after drag reorder).

input: z.object({
  courtId: z.string().uuid(),
  matchOrder: z.array(z.object({
    matchId: z.string().uuid(),
    plannedStartTime: z.string().datetime().nullable(),
  })),
})
output: { updated: number }

DB: batch UPDATE in a single transaction.


1.5 signage Router (extended)

Add queries for the public draw view (SCR-002).

signage.draw — query (NEW, publicProcedure)

input: z.object({
  drawId: z.string().uuid(),
})
output: {
  draw: { id: string; name: string; type: string; drawFormatId: string };
  matches: Array<{
    id: string;
    roundNumber: number | null;
    slotIndex: number | null;
    roundLabel: string | null;
    status: string;
    player1: PlayerSummary | null;
    player2: PlayerSummary | null;
    result: MatchResult | null;  // parsed from JSONB
    winner: string | null;
    nextMatchId: string | null;
    winnerSlot: string | null;
  }>;
  // For RR only:
  standings: StandingsResult | null;
}

signage.schedule (EXTENDED)

Extend the existing stub to return courts with matches.

output: {
  tournament: { id; slug; name; startDate; endDate };
  courts: Array<{
    id: string;
    name: string;
    sortOrder: number;
    matches: Array<{
      id: string;
      status: string;
      plannedStartTime: string | null;
      actualStartTime: string | null;
      player1: PlayerSummary | null;
      player2: PlayerSummary | null;
      drawName: string | null;
      roundLabel: string | null;
      result: MatchResult | null;
      winner: string | null;
    }>;
  }>;
  // Unassigned matches (court_id IS NULL):
  unassigned: Array<MatchSummary>;
}

2. Screen Flow

The organizer flow after creating a tournament proceeds through these screens. Each screen corresponds to a route in the Next.js App Router under /organizer/[tournamentId]/....

Screen Map

/organizer
  /[tournamentId]
    /                     -> Tournament Overview (dashboard)
    /draws                -> Draw List
    /draws/new            -> Create Draw form
    /draws/[drawId]       -> Draw Detail (registrations + bracket/group)
    /draws/[drawId]/seed  -> Seeding & Bracket Generation
    /draws/[drawId]/results -> Result Entry (focused view)
    /schedule             -> Court Assignment (SCR-005)
    /contacts             -> Contact Sheet (SCR-003)

2.1 Tournament Overview — /organizer/[tournamentId]

Purpose: Landing page after selecting a tournament. Shows summary cards.

Content: - Tournament name, dates. - Card grid: one card per draw showing name, format, registration count, match progress (X/Y completed). - Quick-action buttons: "Manage Draws", "Court Schedule", "Contact Sheet".

Data: tournament.byId + draw.list({ tournamentId }).


2.2 Draw List — /organizer/[tournamentId]/draws

Purpose: CRUD for draws within the tournament.

Content: - Table of draws: Name, Type (SE/RR), Format (F1-F8 label), Registrations count, Matches count, Status indicator. - "Create Draw" button -> navigates to /draws/new. - Row click -> navigates to /draws/[drawId].

Data: draw.list({ tournamentId }).


2.3 Create Draw — /organizer/[tournamentId]/draws/new

Purpose: Form to create a new draw.

Fields: 1. Name — free text input. Example placeholder: "WS 30+ R7/R9". 2. Type — radio or select: Single Elimination / Round Robin. 3. Scoring Format — select dropdown populated from the draw_formats catalogue (F1-F8). Each option shows the name, e.g., "F2 — Standard, Tiebreak All Sets". The dropdown groups by bestOf for clarity.

Submit: Calls draw.create. On success, navigates to /draws/[drawId].

Data: Draw format list is static (8 rows, seeded) — can be fetched once and cached, or hardcoded from @courtsidedesk/scoring DRAW_FORMATS.


2.4 Draw Detail — /organizer/[tournamentId]/draws/[drawId]

Purpose: The main management screen for a single draw. This is the screen the organizer spends the most time on.

Layout — Two Tabs:

Tab A: Registrations - Table of registered players: Name, Ranking, Seeded (checkbox), Q/WC/LL badge, Actions (remove). - "Add Player" button opens a search dialog: - Type-ahead search (player.search) across existing players. - "Create New Player" inline option if no match found. - Select one or more players -> calls registration.add or registration.bulkAdd. - Inline seeding toggle: click to mark a player as seeded. - "Edit Seeding Order" button -> navigates to /draws/[drawId]/seed.

Tab B: Bracket / Group - If no matches generated: Shows empty state with a "Generate Bracket" (SE) or "Generate Schedule" (RR) button. This button navigates to the seeding screen first if any registrations are marked as seeded. - If matches exist (SE): Renders the elimination bracket tree. Each match node shows: - Player names (or "TBD" for unfilled slots). - Score if completed. - Status badge (Planned / On Court / Completed). - Click on a match -> opens the result entry dialog (see 2.7). - If matches exist (RR): Shows: - Left: result grid (player-vs-player matrix). - Right: standings table with POS, PLAYER, MP, MW, ML, SW, SL, GW, GL columns. - Click on a grid cell -> opens the result entry dialog for that match. - If standings have status: "manual-draw-required", show a warning banner indicating which players are tied and need a Losentscheid.

Data: draw.byId({ id: drawId }) for initial load. Supabase Realtime subscription on matches filtered by draw_id for live updates when another organizer tab enters results.


2.5 Seeding & Bracket Generation — /organizer/[tournamentId]/draws/[drawId]/seed

Purpose: Order players by seed before generating the bracket.

Content: - Draggable list of all registered players. Pre-sorted: seeded players first (by ranking), then unseeded alphabetically. - Each row shows: drag handle, seed number (auto-numbered from position), player name, ranking. - Only players explicitly dragged into the "seeded" zone get seed numbers. Remaining players are placed randomly. - "Generate Bracket" / "Generate Schedule" button at the bottom.

Submit: Calls draw.generateBracket({ drawId, seedOrder }). On success, navigates back to /draws/[drawId] Tab B.

Guard: If the draw already has matches, show a confirmation dialog: "This will delete all existing matches and results. Continue?" If confirmed, call draw.clearBracket first, then draw.generateBracket.


2.6 Court Assignment — /organizer/[tournamentId]/schedule

This IS SCR-005 from the requirements. The most interactive screen.

Layout: - Left panel: Unassigned Matches — scrollable list. Each card shows: player names, draw name, round label, status. - Right area: Court columns — one column per court, ordered by sort_order. Each column has: - Header: court name. - Current match (visually prominent, top). - Upcoming matches sorted by planned_start_time ASC (nulls last). - Completed matches collapsed/hidden by default (toggle to show).

Interactions: - Drag match card from unassigned panel to a court column -> match.assignCourt({ matchId, courtId }). - Drag between court columns -> match.assignCourt({ matchId, courtId: newCourtId }). - Drag back to unassigned panel -> match.assignCourt({ matchId, courtId: null }). - Click time on a match card -> inline datetime picker -> match.assignCourt({ matchId, courtId, plannedStartTime }). - Click a match card -> opens result entry dialog (see 2.7). - "Mark On Court" button on a PLANNED match -> match.setOnCourt({ matchId }).

Data: match.listByTournament({ tournamentId }) for initial load. Supabase Realtime subscription on matches filtered by tournament_id.

Optimistic updates: @dnd-kit drag operations update the UI immediately. The tRPC mutation runs in the background. On error, revert the UI and show a toast.


2.7 Result Entry Dialog

This is a modal/sheet, not a separate route. It opens from: - Draw Detail bracket/grid click. - Court Assignment match card click.

Content: - Header: Player 1 vs Player 2, draw name, round label. - Outcome selector: Radio group — Completed / Walkover / Default / Retirement. - If Walkover or Default: - Winner selector only (Player 1 / Player 2 radio). - No set entry. - If Completed or Retirement: - Set entry rows. Each row has: - Player 1 games input (number, 0-99). - Player 2 games input (number, 0-99). - Tiebreak score (optional, appears when games trigger a tiebreak per the draw format rules). Two number inputs for tiebreak points. - Sets are added dynamically. Start with one set row. "Add Set" button (max = bestOf). For RETIREMENT, the last set's "incomplete" checkbox is auto-checked. - For F3/F8 match tiebreak: a dedicated "Match Tiebreak" section appears when the decider set is reached. Two number inputs for MTB points. - Winner is auto-derived from set scores for COMPLETED. For RETIREMENT, winner selector appears. - Validation: Client-side preview using validateMatchResult() from @courtsidedesk/scoring imported in the browser. Real validation happens server-side in the mutation. - Submit: Calls match.submitResult({ matchId, result }). The dialog closes on success and shows a toast. If the match is in an SE bracket, the toast confirms advancement ("Winner advances to SF").


2.8 Contact Sheet — /organizer/[tournamentId]/contacts

This IS SCR-003 from the requirements. Already specified in detail there.

Data: player.listForTournament({ tournamentId }).


3. Data Flow — Scoring Package Integration

3.1 Result Submission (the critical path)

[Frontend]                              [tRPC match.submitResult]              [packages/scoring]
    |                                          |                                     |
    | 1. Build MatchResult object              |                                     |
    |    (client-side form state)              |                                     |
    |                                          |                                     |
    | 2. Client-side preview validation -----> |                                     |
    |    validateMatchResult(format, result)    |                                     |
    |    (runs in browser, non-authoritative)  |                                     |
    |                                          |                                     |
    | 3. Submit via tRPC ===================>  |                                     |
    |                                          |                                     |
    |                                          | 4. Zod parse (structural) --------> |
    |                                          |    matchResultSchema.parse(input)    |
    |                                          |                                     |
    |                                          | 5. Load DrawFormat from DB           |
    |                                          |                                     |
    |                                          | 6. Semantic validation ------------> |
    |                                          |    validateMatchResult(fmt, result)  |
    |                                          |    <-- ValidationResult ------------ |
    |                                          |                                     |
    |                                          | 7. BEGIN TRANSACTION                 |
    |                                          |    SELECT match FOR UPDATE           |
    |                                          |    UPDATE match SET result, winner   |
    |                                          |                                     |
    |                                          | 8. computeAdvancement() -----------> |
    |                                          |    <-- AdvancementIntent ----------- |
    |                                          |                                     |
    |                                          | 9. (SE) UPDATE next_match           |
    |                                          |    SET player1_id or player2_id     |
    |                                          |                                     |
    |                                          | 10. (RR) computeStandings() -------> |
    |                                          |     <-- StandingsResult ------------ |
    |                                          |                                     |
    |                                          | 11. COMMIT                           |
    |                                          |                                     |
    | <====== Response: match + advancement + standings                              |
    |                                          |                                     |
    | 12. Update local state                   |                                     |
    | 13. Supabase Realtime fires to           |                                     |
    |     all other subscribers                |                                     |

3.2 Key Integration Points

Scoring function Where called Purpose
matchResultSchema.parse() tRPC input validation (automatic via Zod) Structural parse of the result payload
validateMatchResult(format, result) match.submitResult / match.editResult server-side, AND client-side for preview Semantic validation against DrawFormat rules
computeAdvancement(args) match.submitResult / match.editResult server-side only Determines which player advances and to which slot
computeStandings(matches, players) draw.standings query, match.submitResult mutation (RR only), signage.draw query RR standings with tiebreak cascade
parseMatchResult(jsonb) Every read path that touches matches.result Safe parse of JSONB column into typed MatchResult
DRAW_FORMATS / getKnownFormat() Frontend draw creation form, client-side validation preview Static format catalogue for dropdowns and client-side logic

3.3 Realtime Event Flow

match.submitResult (tRPC mutation)
    |
    v
UPDATE matches row (Postgres)
    |
    v
Supabase Realtime publishes change event
    |
    +---> SCR-001 (Schedule Screen) — updates match status, score display
    +---> SCR-002 (Draw/Bracket View) — updates bracket node, RR grid cell
    +---> SCR-005 (Court Assignment) — updates match card in court column
    +---> Other organizer tabs — sync state

The Supabase Realtime subscription filter is tournament_id = $currentTournamentId on the matches table. This is coarse enough to catch all relevant changes and fine enough to not receive noise from other tournaments.

For RR standings: the mutation response includes the recomputed StandingsResult. Other clients that receive the Realtime event for the match change must re-query draw.standings to get the updated standings. This is acceptable because standings computation is fast (sub-millisecond for groups of 8) and the query is a single procedure call. An alternative (broadcasting computed standings via Realtime) would require a Supabase Edge Function or custom channel, which adds complexity without meaningful benefit at this scale.


4. Open Questions

[DECIDED — PO Session 7] Bye handling in first-round brackets

Decision: Option A — auto-advance. Byes are resolved immediately during draw.generateBracket. First-round match created with one player, set to COMPLETED/WALKOVER, winner advanced to round 2.

[DECIDED — PO Session 7] Maximum group size for Round Robin

Decision: soft warn at 8, hard cap at 12. Accepted as recommended.

[DECIDED — PO Session 7, pending Rafael] Seeding placement algorithm

Decision: SwissTennis-specific (not generic ITF). Rafael to provide the exact SwissTennis seeding placement template. Implementation blocks on this for bracket generation.

[DECIDED — PO Session 7] Manual bracket slot placement

Decision: deferred to v2. The seeding algorithm + draw.generateBracket handles placement. No manual drag-to-slot in v1.

[DECIDED — PO Session 7] Result correction after advancement

Decision: block if downstream results exist, require reverse-order correction. Cascading deletion rejected. draw.clearBracket available as nuclear option.


5. Implementation Sequence

Recommended build order for Sam (backend) and Taylor (frontend):

Phase 1 — Player & Registration CRUD (1-2 days)

  • Sam: player router (search, create, update), registration router (add, remove, bulkAdd, updateSeeding).
  • Taylor: Add Player dialog with search, registration table on Draw Detail Tab A.

Phase 2 — Draw CRUD & Bracket Generation (2-3 days)

  • Sam: draw router (list, byId, create, update, delete, generateBracket, clearBracket). This is the hardest backend piece — the bracket generation algorithm with seeding placement and bye handling.
  • Taylor: Create Draw form, Draw List, Draw Detail Tab B (bracket view for SE, grid for RR). The bracket visualization is the hardest frontend piece.

Phase 3 — Match Result Entry (2-3 days)

  • Sam: match.submitResult, match.editResult, match.clearResult. Integrate @courtsidedesk/scoring validation and advancement.
  • Taylor: Result Entry Dialog with format-aware set inputs and client-side validation preview.

Phase 4 — Court Assignment / SCR-005 (2-3 days)

  • Sam: match.assignCourt, match.setOnCourt, match.reorderOnCourt, match.listByTournament.
  • Taylor: Court Assignment screen with @dnd-kit drag-and-drop.

Phase 5 — Signage Extension (1-2 days)

  • Sam: Extend signage.schedule and add signage.draw with full match/result payloads.
  • Taylor: Migrate SCR-001 and SCR-002 off fixtures to live data.

Phase 6 — Realtime (1-2 days)

  • Taylor: Supabase Realtime subscriptions on SCR-001, SCR-002, SCR-005.
  • Sam: Verify Realtime publications are enabled on the matches table (already configured in packages/db/src/policies/realtime.sql).

6. Zod Schema Reference

For Sam's convenience, the complete input schemas for the three most complex mutations:

draw.generateBracket input

import { z } from "zod";

export const generateBracketInput = z.object({
  drawId: z.string().uuid(),
  seedOrder: z.array(z.string().uuid()),
});

match.submitResult input

import { z } from "zod";
import { matchResultSchema } from "@courtsidedesk/scoring";

export const submitResultInput = z.object({
  matchId: z.string().uuid(),
  result: matchResultSchema,
});

match.assignCourt input

import { z } from "zod";

export const assignCourtInput = z.object({
  matchId: z.string().uuid(),
  courtId: z.string().uuid().nullable(),
  plannedStartTime: z.string().datetime().nullable().optional(),
});