From 1199955df2c6d8441c4364aac4b11c8e5d08cd45 Mon Sep 17 00:00:00 2001 From: "dustin.nieves" Date: Tue, 18 Aug 2026 23:41:32 -0400 Subject: [PATCH 1/2] feat(stats): add host match-report review flow (#252) --- .env.example | 11 +- DEVELOPMENT.md | 4 + README.md | 2 + .../admin/match-reports/[id]/extract/route.ts | 131 +---- src/app/api/admin/match-reports/route.ts | 9 + .../[id]/host-token/route.test.ts | 74 +++ .../match-reports/[id]/host-token/route.ts | 100 ++++ .../match-reports/[id]/extract/route.test.ts | 34 ++ .../api/match-reports/[id]/extract/route.ts | 64 +++ .../match-reports/[id]/revise/route.test.ts | 86 ++++ .../api/match-reports/[id]/revise/route.ts | 91 ++++ src/app/api/match-reports/[id]/route.test.ts | 106 ++++ src/app/api/match-reports/[id]/route.ts | 62 +++ .../match-reports/[id]/session/route.test.ts | 69 +++ .../api/match-reports/[id]/session/route.ts | 74 +++ .../match-reports/[id]/submit/route.test.ts | 69 +++ .../api/match-reports/[id]/submit/route.ts | 87 ++++ .../match-reports/[id]/upload/route.test.ts | 41 ++ .../api/match-reports/[id]/upload/route.ts | 71 +++ src/app/match-reports/[id]/review/page.tsx | 29 ++ src/components/admin/MatchReportCard.tsx | 4 +- src/components/admin/MatchReportClient.tsx | 2 +- src/components/admin/stat-inputs.tsx | 3 + .../HostMatchReportReviewClient.test.ts | 82 ++++ .../HostMatchReportReviewClient.tsx | 463 ++++++++++++++++++ src/lib/admin-ticket-match-report.test.ts | 21 + src/lib/admin-ticket-match-report.ts | 2 +- src/lib/admin-ticket-model.test.ts | 19 + src/lib/admin-ticket-model.ts | 20 +- src/lib/match-report-extraction.ts | 96 ++++ src/lib/match-report-host/auth.test.ts | 72 +++ src/lib/match-report-host/auth.ts | 106 ++++ src/lib/match-report-host/contracts.test.ts | 47 ++ src/lib/match-report-host/contracts.ts | 93 ++++ .../match-report-host/extract-service.test.ts | 58 +++ src/lib/match-report-host/extract-service.ts | 176 +++++++ src/lib/match-report-host/http.ts | 29 ++ src/lib/match-report-host/persistence.ts | 105 ++++ .../match-report-host/read-service.test.ts | 47 ++ src/lib/match-report-host/read-service.ts | 161 ++++++ .../match-report-host/upload-service.test.ts | 42 ++ src/lib/match-report-host/upload-service.ts | 100 ++++ src/types/match-report-host.ts | 54 ++ src/types/match-report.ts | 5 +- 44 files changed, 2886 insertions(+), 135 deletions(-) create mode 100644 src/app/api/internal/match-reports/[id]/host-token/route.test.ts create mode 100644 src/app/api/internal/match-reports/[id]/host-token/route.ts create mode 100644 src/app/api/match-reports/[id]/extract/route.test.ts create mode 100644 src/app/api/match-reports/[id]/extract/route.ts create mode 100644 src/app/api/match-reports/[id]/revise/route.test.ts create mode 100644 src/app/api/match-reports/[id]/revise/route.ts create mode 100644 src/app/api/match-reports/[id]/route.test.ts create mode 100644 src/app/api/match-reports/[id]/route.ts create mode 100644 src/app/api/match-reports/[id]/session/route.test.ts create mode 100644 src/app/api/match-reports/[id]/session/route.ts create mode 100644 src/app/api/match-reports/[id]/submit/route.test.ts create mode 100644 src/app/api/match-reports/[id]/submit/route.ts create mode 100644 src/app/api/match-reports/[id]/upload/route.test.ts create mode 100644 src/app/api/match-reports/[id]/upload/route.ts create mode 100644 src/app/match-reports/[id]/review/page.tsx create mode 100644 src/components/match-report/HostMatchReportReviewClient.test.ts create mode 100644 src/components/match-report/HostMatchReportReviewClient.tsx create mode 100644 src/lib/match-report-extraction.ts create mode 100644 src/lib/match-report-host/auth.test.ts create mode 100644 src/lib/match-report-host/auth.ts create mode 100644 src/lib/match-report-host/contracts.test.ts create mode 100644 src/lib/match-report-host/contracts.ts create mode 100644 src/lib/match-report-host/extract-service.test.ts create mode 100644 src/lib/match-report-host/extract-service.ts create mode 100644 src/lib/match-report-host/http.ts create mode 100644 src/lib/match-report-host/persistence.ts create mode 100644 src/lib/match-report-host/read-service.test.ts create mode 100644 src/lib/match-report-host/read-service.ts create mode 100644 src/lib/match-report-host/upload-service.test.ts create mode 100644 src/lib/match-report-host/upload-service.ts create mode 100644 src/types/match-report-host.ts diff --git a/.env.example b/.env.example index dcb08bc..721a1a8 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,19 @@ ADMIN_PASSWORD= # Set a separate long random string here to isolate captain session signing. CAPTAIN_SESSION_SECRET= +# Required host match-review cookie signing key. Keep it separate from the +# admin session key so private stat-correction sessions have their own boundary. +MATCH_REPORT_HOST_SESSION_SECRET= + # Shared secret for lab-salbot's server-to-server calls back into this site's -# admin API (currently: triggering a standings recalculation after a -# Discord-approved match result — audit F-01). Set the same random value here +# internal API (standings recalculation and one-time host review links). Set the +# same random value here # and as lab-salbot's SAL_SITE_INTERNAL_TOKEN. Not tied to admin identity; # unaffected by retiring ADMIN_PASSWORD (F-05/D-3). INTERNAL_SERVICE_TOKEN= +# Alias accepted by the match-report host-token endpoint. Use the same value +# as lab-salbot's SAL_SITE_INTERNAL_TOKEN (or keep INTERNAL_SERVICE_TOKEN set). +SAL_SITE_INTERNAL_TOKEN= # Discord OAuth (admin login) DISCORD_ADMIN_CLIENT_ID= diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 31bedac..62c32a9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,6 +33,8 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Admin panel ADMIN_SESSION_SECRET=a-long-random-string-min-32-chars +MATCH_REPORT_HOST_SESSION_SECRET=a-separate-long-random-string +SAL_SITE_INTERNAL_TOKEN=a-shared-random-bearer-secret ADMIN_PASSWORD=your-admin-password # Twitch (optional — /watch page works without this, shows offline state) @@ -44,6 +46,8 @@ TWITCH_CLIENT_SECRET= **Where to find these:** - `NEXT_PUBLIC_SUPABASE_URL` and keys: Supabase dashboard → Settings → API - `ADMIN_SESSION_SECRET`: generate with `openssl rand -hex 32` +- `MATCH_REPORT_HOST_SESSION_SECRET`: generate separately; signs only private host match-review sessions +- `SAL_SITE_INTERNAL_TOKEN`: use the same value in lab-salbot so it can mint one-time host review links - Twitch credentials: Twitch Developer Console → your application > Local development and E2E runs can use mock data without Supabase configured. diff --git a/README.md b/README.md index 1adacf0..d2b85dd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ npm run dev | `ADMIN_SESSION_SECRET` | Yes | Long random string used to sign admin session cookies | | `ADMIN_PASSWORD` | Optional | Password-based admin login fallback (used when Discord OAuth is not configured) | | `CAPTAIN_SESSION_SECRET` | Recommended | Separate signing key for captain session cookies; falls back to `ADMIN_SESSION_SECRET` | +| `MATCH_REPORT_HOST_SESSION_SECRET` | Required for match capture | Dedicated signing key for private host match-review cookies; no admin-key fallback | +| `SAL_SITE_INTERNAL_TOKEN` | Required for match capture | Shared bearer secret used by lab-salbot to mint host review links; `INTERNAL_SERVICE_TOKEN` is accepted as a compatibility alias | | `DISCORD_ADMIN_CLIENT_ID` | Optional | Discord OAuth app client ID for admin login | | `DISCORD_ADMIN_CLIENT_SECRET` | Optional | Discord OAuth app client secret | | `DISCORD_ADMIN_REDIRECT_URI` | Optional | Discord OAuth redirect URI | diff --git a/src/app/api/admin/match-reports/[id]/extract/route.ts b/src/app/api/admin/match-reports/[id]/extract/route.ts index b426ae4..b12f481 100644 --- a/src/app/api/admin/match-reports/[id]/extract/route.ts +++ b/src/app/api/admin/match-reports/[id]/extract/route.ts @@ -3,53 +3,8 @@ import { isAdminRequest } from "@/lib/admin-auth"; import { getSupabaseServerClient } from "@/lib/supabase-server"; import { toDatabaseJson } from "@/lib/database-json"; import { getAdminLeagueData, LeagueDataUnavailableError } from "@/lib/league-data"; -import type { ExtractedGame } from "@/types/match-report"; import { errorMessage } from "@/lib/error-monitor"; -import { callOpenRouterVision } from "@/lib/openrouter-vision"; - -const SMITE_ROLES = ["Solo", "Jungle", "Mid", "Carry", "Support"] as const; - -const EXTRACTION_PROMPT = ( - homeOrgName: string, - homeIgns: string[], - awayOrgName: string, - awayIgns: string[], -) => ` -You are analyzing a SMITE 2 end-of-match DETAILS tab screenshot. Extract the scoreboard data. - -Home team: ${homeOrgName} -Known home players: ${homeIgns.length > 0 ? homeIgns.join(", ") : "(unknown roster)"} - -Away team: ${awayOrgName} -Known away players: ${awayIgns.length > 0 ? awayIgns.join(", ") : "(unknown roster)"} - -Return ONLY valid JSON in this exact format, no other text: -{ - "winner": "home" | "away" | "unknown", - "players": [ - { - "ign": "string", - "side": "home" | "away", - "god": "string or null", - "role": "Solo" | "Jungle" | "Mid" | "Carry" | "Support" | null, - "kills": number, - "deaths": number, - "assists": number, - "damageDealt": number or null, - "damageMitigated": number or null - } - ] -} - -Instructions: -- Match each player to home or away using the known rosters above -- If a player is not in either roster, assign based on which column they appear in (left vs right) -- Extract kills, deaths, assists exactly as shown (integers) -- Extract damage numbers without commas (integers) -- "winner" is "home" if the home team won, "away" if away team won -- Look for VICTORY/DEFEAT text or trophy icons to determine winner -- Include all 10 players (5 per side) if visible -`.trim(); +import { extractMatchReportGames } from "@/lib/match-report-extraction"; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { if (!isAdminRequest(request)) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); @@ -87,83 +42,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const homePlayers = leagueData.players.filter((p) => p.orgId === match?.homeOrgId).map((p) => p.ign); const awayPlayers = leagueData.players.filter((p) => p.orgId === match?.awayOrgId).map((p) => p.ign); - const games: ExtractedGame[] = []; - - for (let i = 0; i < r.screenshot_urls.length; i++) { - const url = r.screenshot_urls[i]; - - // Fetch screenshot and convert to base64 data URL - let dataUrl: string; - try { - const imgRes = await fetch(url); - if (!imgRes.ok) throw new Error(`HTTP ${imgRes.status}`); - const buffer = await imgRes.arrayBuffer(); - const base64 = Buffer.from(buffer).toString("base64"); - const ct = imgRes.headers.get("content-type") ?? "image/jpeg"; - const mimeType = ct.includes("png") ? "image/png" : ct.includes("webp") ? "image/webp" : "image/jpeg"; - dataUrl = `data:${mimeType};base64,${base64}`; - } catch (err) { - console.error(`Failed to fetch screenshot ${i + 1}:`, err); - games.push({ gameNumber: i + 1, winningSide: "unknown", players: [] }); - continue; - } - - try { - const text = await callOpenRouterVision([ - { - role: "user", - content: [ - { type: "image_url", image_url: { url: dataUrl } }, - { - type: "text", - text: EXTRACTION_PROMPT( - homeOrg?.name ?? "Home Team", - homePlayers, - awayOrg?.name ?? "Away Team", - awayPlayers, - ), - }, - ], - }, - ], { maxTokens: 2048, title: "SAL Match Report" }); - - // Strip markdown code fences if present - const jsonText = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim(); - const parsed = JSON.parse(jsonText) as { - winner?: string; - players?: Array<{ - ign?: string; - side?: string; - god?: string | null; - role?: string | null; - kills?: number; - deaths?: number; - assists?: number; - damageDealt?: number | null; - damageMitigated?: number | null; - }>; - }; - - games.push({ - gameNumber: i + 1, - winningSide: parsed.winner === "home" ? "home" : parsed.winner === "away" ? "away" : "unknown", - players: (parsed.players ?? []).map((p) => ({ - ign: p.ign ?? "", - side: p.side === "away" ? "away" : "home", - god: p.god ?? undefined, - role: SMITE_ROLES.includes(p.role as (typeof SMITE_ROLES)[number]) ? (p.role as string) : undefined, - kills: Number(p.kills ?? 0), - deaths: Number(p.deaths ?? 0), - assists: Number(p.assists ?? 0), - damageDealt: p.damageDealt != null ? Number(p.damageDealt) : undefined, - damageMitigated: p.damageMitigated != null ? Number(p.damageMitigated) : undefined, - })), - }); - } catch (err) { - console.error(`AI extraction failed for game ${i + 1}:`, err); - games.push({ gameNumber: i + 1, winningSide: "unknown", players: [] }); - } - } + const games = await extractMatchReportGames({ + screenshotUrls: r.screenshot_urls, + homeOrgName: homeOrg?.name ?? "Home Team", + homeIgns: homePlayers, + awayOrgName: awayOrg?.name ?? "Away Team", + awayIgns: awayPlayers, + }); // Store extracted data and mark as review await supabase diff --git a/src/app/api/admin/match-reports/route.ts b/src/app/api/admin/match-reports/route.ts index 62b5eef..0ff8d74 100644 --- a/src/app/api/admin/match-reports/route.ts +++ b/src/app/api/admin/match-reports/route.ts @@ -36,6 +36,13 @@ export async function GET(request: NextRequest) { const matchMap = new Map(leagueData.matches.map((m) => [m.id, m])); const reports: MatchReportWithMatch[] = (data ?? []).map((row) => { + // Host-review columns land with db-v1.18.0. Keep the draft site branch + // type-safe against the currently released v1.17.0 generated contract; + // the contract pin follows only after the protected DB release exists. + const hostReviewRow = row as typeof row & { + revision?: number; + host_submitted_at?: string | null; + }; const match = matchMap.get(row.match_id as string); const homeOrg = orgMap.get(match?.homeOrgId ?? ""); const awayOrg = orgMap.get(match?.awayOrgId ?? ""); @@ -54,6 +61,8 @@ export async function GET(request: NextRequest) { createdAt: row.created_at as string, reviewedAt: row.reviewed_at as string | undefined, reviewedBy: row.reviewed_by as string | undefined, + revision: hostReviewRow.revision ?? 1, + hostSubmittedAt: hostReviewRow.host_submitted_at ?? undefined, homeOrgId: match?.homeOrgId ?? "", homeOrgName: homeOrg?.name ?? match?.homeOrgId ?? "", homeOrgTag: homeOrg?.tag ?? "", diff --git a/src/app/api/internal/match-reports/[id]/host-token/route.test.ts b/src/app/api/internal/match-reports/[id]/host-token/route.test.ts new file mode 100644 index 0000000..974ffce --- /dev/null +++ b/src/app/api/internal/match-reports/[id]/host-token/route.test.ts @@ -0,0 +1,74 @@ +import { createHash } from "crypto"; +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createIssueHostTokenHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const rawToken = "t".repeat(43); +const context = { params: Promise.resolve({ id: reportId }) }; + +describe("internal match-report host token issuance", () => { + it("stores only a token hash and returns a fragment review URL", async () => { + const issueToken = vi.fn().mockResolvedValue(undefined); + const handler = createIssueHostTokenHandler({ + canonicalSiteOrigin: "https://sal.example", + internalToken: "internal-secret", + generateToken: () => rawToken, + now: () => new Date("2026-08-18T12:00:00.000Z"), + issueToken, + }); + const request = new NextRequest( + `https://sal.example/api/internal/match-reports/${reportId}/host-token`, + { + method: "POST", + headers: { + authorization: "Bearer internal-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ host_discord_id: "1234567890" }), + }, + ); + + const response = await handler(request, context); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(issueToken).toHaveBeenCalledWith({ + matchReportId: reportId, + hostDiscordId: "1234567890", + tokenHash: createHash("sha256").update(rawToken).digest("hex"), + expiresAt: "2026-08-18T12:15:00.000Z", + }); + expect(body).toEqual({ + review_url: `https://sal.example/match-reports/${reportId}/review#access=${rawToken}`, + expires_at: "2026-08-18T12:15:00.000Z", + }); + expect(JSON.stringify(issueToken.mock.calls)).not.toContain(rawToken); + }); + + it("does not mint or persist a token without the internal bearer secret", async () => { + const issueToken = vi.fn(); + const generateToken = vi.fn(() => rawToken); + const handler = createIssueHostTokenHandler({ + canonicalSiteOrigin: "https://sal.example", + internalToken: "internal-secret", + generateToken, + now: () => new Date(), + issueToken, + }); + const request = new NextRequest( + `https://sal.example/api/internal/match-reports/${reportId}/host-token`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ host_discord_id: "1234567890" }), + }, + ); + + const response = await handler(request, context); + + expect(response.status).toBe(401); + expect(generateToken).not.toHaveBeenCalled(); + expect(issueToken).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/internal/match-reports/[id]/host-token/route.ts b/src/app/api/internal/match-reports/[id]/host-token/route.ts new file mode 100644 index 0000000..1592907 --- /dev/null +++ b/src/app/api/internal/match-reports/[id]/host-token/route.ts @@ -0,0 +1,100 @@ +import { createHash, randomBytes, timingSafeEqual } from "crypto"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; +import { hostReviewSessionIsConfigured } from "@/lib/match-report-host/auth"; + +export const dynamic = "force-dynamic"; + +const TOKEN_LIFETIME_MS = 15 * 60 * 1000; +const reportIdSchema = z.string().uuid(); +const requestSchema = z.object({ + host_discord_id: z.string().min(1).max(64).regex(/^\d+$/), +}).strict(); + +type RouteContext = { params: Promise<{ id: string }> }; + +export interface IssueHostTokenDependencies { + canonicalSiteOrigin: string | null; + internalToken: string | null; + generateToken: () => string; + now: () => Date; + issueToken: (input: { + matchReportId: string; + hostDiscordId: string; + tokenHash: string; + expiresAt: string; + }) => Promise; +} + +function bearerMatches(request: NextRequest, expected: string | null) { + if (!expected) return false; + const actual = request.headers.get("authorization"); + const expectedHeader = `Bearer ${expected}`; + if (!actual) return false; + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expectedHeader); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +export function createIssueHostTokenHandler(dependencies: IssueHostTokenDependencies) { + return async function POST(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) || + !bearerMatches(request, dependencies.internalToken) + ) { + return hostReviewJson({ error: "Unauthorized." }, 401); + } + + const { id } = await context.params; + if (!reportIdSchema.safeParse(id).success) { + return hostReviewJson({ error: "Invalid report id." }, 400); + } + const body = await request.json().catch(() => null); + const parsed = requestSchema.safeParse(body); + if (!parsed.success) return hostReviewJson({ error: "Invalid request." }, 400); + + const rawToken = dependencies.generateToken(); + const tokenHash = createHash("sha256").update(rawToken).digest("hex"); + const expiresAt = new Date(dependencies.now().getTime() + TOKEN_LIFETIME_MS).toISOString(); + try { + await dependencies.issueToken({ + matchReportId: id, + hostDiscordId: parsed.data.host_discord_id, + tokenHash, + expiresAt, + }); + } catch { + return hostReviewJson({ error: "Could not issue host review access." }, 503); + } + + const reviewUrl = new URL(`/match-reports/${id}/review`, dependencies.canonicalSiteOrigin); + reviewUrl.hash = `access=${rawToken}`; + return hostReviewJson( + { review_url: reviewUrl.toString(), expires_at: expiresAt }, + 201, + ); + }; +} + +const persistence = getMatchReportHostPersistence(); + +export const POST = createIssueHostTokenHandler({ + canonicalSiteOrigin: hostReviewSessionIsConfigured() ? getCanonicalSiteOrigin() : null, + internalToken: + process.env.SAL_SITE_INTERNAL_TOKEN?.trim() || + process.env.INTERNAL_SERVICE_TOKEN?.trim() || + null, + generateToken: () => randomBytes(32).toString("base64url"), + now: () => new Date(), + issueToken: async (input) => { + if (!persistence) throw new Error("Supabase not configured."); + return persistence.issueToken(input); + }, +}); diff --git a/src/app/api/match-reports/[id]/extract/route.test.ts b/src/app/api/match-reports/[id]/extract/route.test.ts new file mode 100644 index 0000000..f5211de --- /dev/null +++ b/src/app/api/match-reports/[id]/extract/route.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createHostReviewExtractHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const context = { params: Promise.resolve({ id: reportId }) }; + +describe("host match-report OCR extraction", () => { + it("extracts only through the report-bound host session", async () => { + const extract = vi.fn().mockResolvedValue({ report: { id: reportId } }); + const handler = createHostReviewExtractHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + extract, + }); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}/extract`, { + method: "POST", + headers: { origin: "https://sal.example" }, + }); + + const response = await handler(request, context); + + expect(response.status).toBe(200); + expect(extract).toHaveBeenCalledWith({ + matchReportId: reportId, + hostDiscordId: "1234567890", + }); + expect(await response.json()).toEqual({ ok: true, review: { report: { id: reportId } } }); + }); +}); diff --git a/src/app/api/match-reports/[id]/extract/route.ts b/src/app/api/match-reports/[id]/extract/route.ts new file mode 100644 index 0000000..2531563 --- /dev/null +++ b/src/app/api/match-reports/[id]/extract/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { getHostReviewSessionFromRequest, type HostReviewSession } from "@/lib/match-report-host/auth"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; +import type { HostMatchReportReview } from "@/types/match-report-host"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { params: Promise<{ id: string }> }; +export interface HostReviewExtractDependencies { + canonicalSiteOrigin: string | null; + getSession: (request: NextRequest, reportId: string) => HostReviewSession | null; + extract: (input: { + matchReportId: string; + hostDiscordId: string; + }) => Promise; +} + +export function createHostReviewExtractHandler(dependencies: HostReviewExtractDependencies) { + return async function POST(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) + ) return privateHostReviewNotFound(); + const { id } = await context.params; + if (!z.string().uuid().safeParse(id).success) return privateHostReviewNotFound(); + const session = dependencies.getSession(request, id); + if (!session) return privateHostReviewNotFound(); + try { + const review = await dependencies.extract({ + matchReportId: id, + hostDiscordId: session.hostDiscordId, + }); + return hostReviewJson({ ok: true as const, review }); + } catch (error) { + const code = databaseCode(error); + if (code === "P0002" || code === "PGRST116" || code === "42501") return privateHostReviewNotFound(); + if (code === "22023") return hostReviewJson({ error: "Upload a screenshot before extraction." }, 400); + if (code === "40001") return hostReviewJson({ error: "This report changed in another tab. Reload and try again." }, 409); + if (code === "55000") return hostReviewJson({ error: "Both season rosters need five players before stat correction can begin." }, 409); + return hostReviewJson({ error: "Stat extraction failed. Try again or contact an admin." }, 503); + } + }; +} + +function databaseCode(error: unknown) { + return typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; +} + +const persistence = getMatchReportHostPersistence(); +export const POST = createHostReviewExtractHandler({ + canonicalSiteOrigin: getCanonicalSiteOrigin(), + getSession: getHostReviewSessionFromRequest, + extract: async (input) => { + if (!persistence) throw new Error("Supabase not configured."); + return persistence.extractReview(input); + }, +}); diff --git a/src/app/api/match-reports/[id]/revise/route.test.ts b/src/app/api/match-reports/[id]/revise/route.test.ts new file mode 100644 index 0000000..5cf70d3 --- /dev/null +++ b/src/app/api/match-reports/[id]/revise/route.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createReviseHostReviewHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const context = { params: Promise.resolve({ id: reportId }) }; +const games = [{ + gameNumber: 1, + winningSide: "home" as const, + players: Array.from({ length: 10 }, (_, index) => ({ + ign: `Player${index + 1}`, + playerId: `22222222-2222-4222-8222-${String(index + 1).padStart(12, "0")}`, + side: index < 5 ? ("home" as const) : ("away" as const), + kills: 5, + deaths: 1, + assists: 8, + })), +}]; + +describe("host match-report revision", () => { + it("revises through the host-scoped optimistic-concurrency RPC", async () => { + const result = { + code: "revised" as const, + applied: true, + reportId, + revision: 3, + status: "review" as const, + games, + diagnostics: { + gameCount: 1, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [], + }, + }; + const reviseReview = vi.fn().mockResolvedValue(result); + const handler = createReviseHostReviewHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + reviseReview, + }); + const request = new NextRequest( + `https://sal.example/api/match-reports/${reportId}/revise`, + { + method: "POST", + headers: { "content-type": "application/json", origin: "https://sal.example" }, + body: JSON.stringify({ revision: 2, games }), + }, + ); + + const response = await handler(request, context); + + expect(response.status).toBe(200); + expect(reviseReview).toHaveBeenCalledWith({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expectedRevision: 2, + games, + }); + expect(await response.json()).toEqual({ ok: true, result }); + }); + + it("does not persist an incomplete game", async () => { + const reviseReview = vi.fn(); + const handler = createReviseHostReviewHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ matchReportId: reportId, hostDiscordId: "1234567890", expiresAt: Date.now() + 60_000 }), + reviseReview, + }); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}/revise`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://sal.example" }, + body: JSON.stringify({ revision: 2, games: [{ ...games[0], players: games[0]!.players.slice(0, 9) }] }), + }); + + const response = await handler(request, context); + + expect(response.status).toBe(400); + expect(reviseReview).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/match-reports/[id]/revise/route.ts b/src/app/api/match-reports/[id]/revise/route.ts new file mode 100644 index 0000000..d4fbfca --- /dev/null +++ b/src/app/api/match-reports/[id]/revise/route.ts @@ -0,0 +1,91 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { getHostReviewSessionFromRequest, type HostReviewSession } from "@/lib/match-report-host/auth"; +import { reviewedGamesSchema, reviseResultSchema } from "@/lib/match-report-host/contracts"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; + +export const dynamic = "force-dynamic"; + +const requestSchema = z.object({ + revision: z.number().int().min(1), + games: reviewedGamesSchema, +}).strict(); +type ReviseResult = z.infer; +type RouteContext = { params: Promise<{ id: string }> }; + +export interface ReviseHostReviewDependencies { + canonicalSiteOrigin: string | null; + getSession: (request: NextRequest, reportId: string) => HostReviewSession | null; + reviseReview: (input: { + matchReportId: string; + hostDiscordId: string; + expectedRevision: number; + games: z.infer; + }) => Promise; +} + +export function createReviseHostReviewHandler(dependencies: ReviseHostReviewDependencies) { + return async function POST(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) + ) return privateHostReviewNotFound(); + const { id } = await context.params; + if (!z.string().uuid().safeParse(id).success) return privateHostReviewNotFound(); + const session = dependencies.getSession(request, id); + if (!session) return privateHostReviewNotFound(); + const body = await request.json().catch(() => null); + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return hostReviewJson({ ok: false as const, error: "Check every game and player field." }, 400); + } + + try { + const result = await dependencies.reviseReview({ + matchReportId: id, + hostDiscordId: session.hostDiscordId, + expectedRevision: parsed.data.revision, + games: parsed.data.games, + }); + return hostReviewJson({ ok: true as const, result }); + } catch (error) { + const code = databaseCode(error); + if (code === "P0002" || code === "42501") return privateHostReviewNotFound(); + if (code === "40001") { + return hostReviewJson( + { ok: false as const, error: "This report changed in another tab. Reload before saving again." }, + 409, + ); + } + if (code === "22023" || code === "23505" || code === "23514") { + return hostReviewJson( + { ok: false as const, error: "Each game needs five players per side and one unique linked identity per player." }, + 409, + ); + } + return hostReviewJson({ ok: false as const, error: "The correction could not be saved." }, 503); + } + }; +} + +function databaseCode(error: unknown) { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +const persistence = getMatchReportHostPersistence(); +export const POST = createReviseHostReviewHandler({ + canonicalSiteOrigin: getCanonicalSiteOrigin(), + getSession: getHostReviewSessionFromRequest, + reviseReview: async (input) => { + if (!persistence) throw new Error("Supabase not configured."); + return persistence.reviseReview(input); + }, +}); diff --git a/src/app/api/match-reports/[id]/route.test.ts b/src/app/api/match-reports/[id]/route.test.ts new file mode 100644 index 0000000..b16de9d --- /dev/null +++ b/src/app/api/match-reports/[id]/route.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type { HostMatchReportReview } from "@/types/match-report-host"; +import { createHostReviewReadHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const context = { params: Promise.resolve({ id: reportId }) }; +const review: HostMatchReportReview = { + report: { + id: reportId, + revision: 1, + status: "review", + screenshotUrls: ["https://cdn.example/game-1.png"], + games: [], + diagnostics: { + gameCount: 0, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [], + }, + }, + match: { + id: "match-1", + seasonId: "season-1", + divisionId: "terra", + scheduledDate: "2026-08-18", + week: 1, + home: { id: "home", name: "Home", tag: "H", roster: [] }, + away: { id: "away", name: "Away", tag: "A", roster: [] }, + }, +}; + +describe("host match-report review read", () => { + it("returns only the report bound to the signed host session", async () => { + const readReview = vi.fn().mockResolvedValue(review); + const handler = createHostReviewReadHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + readReview, + }); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}`); + + const response = await handler(request, context); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, review }); + expect(readReview).toHaveBeenCalledWith({ + matchReportId: reportId, + hostDiscordId: "1234567890", + }); + }); + + it("returns the same private 404 without a scoped session or matching report", async () => { + const withoutSession = createHostReviewReadHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => null, + readReview: vi.fn(), + }); + const missingReport = createHostReviewReadHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ matchReportId: reportId, hostDiscordId: "1234567890", expiresAt: Date.now() + 60_000 }), + readReview: vi.fn().mockResolvedValue(null), + }); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}`); + + const first = await withoutSession(request, context); + const second = await missingReport(request, context); + + expect(first.status).toBe(404); + expect(second.status).toBe(404); + expect(await first.json()).toEqual(await second.json()); + expect(first.headers.get("cache-control")).toContain("no-store"); + expect(second.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("fails closed for a cancelled report even when an old scoped session remains valid", async () => { + const handler = createHostReviewReadHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + readReview: vi.fn().mockResolvedValue({ + ...review, + report: { ...review.report, status: "cancelled" }, + }), + }); + + const response = await handler( + new NextRequest(`https://sal.example/api/match-reports/${reportId}`), + context, + ); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + ok: false, + error: "This private match report could not be opened.", + }); + }); +}); diff --git a/src/app/api/match-reports/[id]/route.ts b/src/app/api/match-reports/[id]/route.ts new file mode 100644 index 0000000..9da59f6 --- /dev/null +++ b/src/app/api/match-reports/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { + getHostReviewSessionFromRequest, + type HostReviewSession, +} from "@/lib/match-report-host/auth"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; +import type { HostMatchReportReview } from "@/types/match-report-host"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { params: Promise<{ id: string }> }; +export interface HostReviewReadDependencies { + canonicalSiteOrigin: string | null; + getSession: (request: NextRequest, reportId: string) => HostReviewSession | null; + readReview: (input: { + matchReportId: string; + hostDiscordId: string; + }) => Promise; +} + +export function createHostReviewReadHandler(dependencies: HostReviewReadDependencies) { + return async function GET(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) + ) { + return privateHostReviewNotFound(); + } + const { id } = await context.params; + if (!z.string().uuid().safeParse(id).success) return privateHostReviewNotFound(); + const session = dependencies.getSession(request, id); + if (!session) return privateHostReviewNotFound(); + + try { + const review = await dependencies.readReview({ + matchReportId: id, + hostDiscordId: session.hostDiscordId, + }); + if (!review || review.report.status === "cancelled") return privateHostReviewNotFound(); + return hostReviewJson({ ok: true as const, review }); + } catch { + return hostReviewJson( + { ok: false as const, error: "Match report review is temporarily unavailable." }, + 503, + ); + } + }; +} + +const persistence = getMatchReportHostPersistence(); +export const GET = createHostReviewReadHandler({ + canonicalSiteOrigin: getCanonicalSiteOrigin(), + getSession: getHostReviewSessionFromRequest, + readReview: async (input) => persistence?.readReview(input) ?? null, +}); diff --git a/src/app/api/match-reports/[id]/session/route.test.ts b/src/app/api/match-reports/[id]/session/route.test.ts new file mode 100644 index 0000000..75e1499 --- /dev/null +++ b/src/app/api/match-reports/[id]/session/route.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHash } from "crypto"; +import { NextRequest } from "next/server"; +import { createHostReviewSessionHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const token = "a".repeat(43); +const context = { params: Promise.resolve({ id: reportId }) }; + +function requestFor(body: unknown, origin = "https://sal.example") { + return new NextRequest(`${origin}/api/match-reports/${reportId}/session`, { + method: "POST", + headers: { "content-type": "application/json", origin }, + body: JSON.stringify(body), + }); +} + +describe("host match-report session exchange", () => { + it("consumes the fragment token hash and sets a report-scoped session", async () => { + const consumeToken = vi.fn().mockResolvedValue({ + matchReportId: reportId, + hostDiscordId: "discord-host-1", + }); + const setSession = vi.fn(); + const handler = createHostReviewSessionHandler({ + canonicalSiteOrigin: "https://sal.example", + consumeToken, + setSession, + }); + + const response = await handler(requestFor({ token }), context); + + expect(response.status).toBe(200); + expect(consumeToken).toHaveBeenCalledWith( + createHash("sha256").update(token).digest("hex"), + ); + expect(setSession).toHaveBeenCalledWith(response, { + matchReportId: reportId, + hostDiscordId: "discord-host-1", + }); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("returns the same private 404 for invalid, replayed, or cross-origin access", async () => { + const consumeToken = vi.fn().mockResolvedValue(null); + const handler = createHostReviewSessionHandler({ + canonicalSiteOrigin: "https://sal.example", + consumeToken, + setSession: vi.fn(), + }); + + const invalid = await handler(requestFor({ token }), context); + const crossOrigin = await handler( + new NextRequest(`https://sal.example/api/match-reports/${reportId}/session`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://evil.example" }, + body: JSON.stringify({ token }), + }), + context, + ); + + expect(invalid.status).toBe(404); + expect(crossOrigin.status).toBe(404); + expect(await invalid.json()).toEqual(await crossOrigin.json()); + expect(invalid.headers.get("cache-control")).toContain("no-store"); + expect(crossOrigin.headers.get("referrer-policy")).toBe("no-referrer"); + }); +}); diff --git a/src/app/api/match-reports/[id]/session/route.ts b/src/app/api/match-reports/[id]/session/route.ts new file mode 100644 index 0000000..79cdddc --- /dev/null +++ b/src/app/api/match-reports/[id]/session/route.ts @@ -0,0 +1,74 @@ +import { createHash } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + setHostReviewSessionCookie, + hostReviewSessionIsConfigured, + type HostReviewSession, +} from "@/lib/match-report-host/auth"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { + getMatchReportHostPersistence, + type ConsumedHostReviewToken, +} from "@/lib/match-report-host/persistence"; + +export const dynamic = "force-dynamic"; + +const requestSchema = z.object({ + token: z.string().min(32).max(256).regex(/^[A-Za-z0-9_-]+$/), +}).strict(); + +const reportIdSchema = z.string().uuid(); +type RouteContext = { params: Promise<{ id: string }> }; + +export interface HostReviewSessionDependencies { + canonicalSiteOrigin: string | null; + consumeToken: (tokenHash: string) => Promise; + setSession: ( + response: NextResponse, + session: Omit, + ) => void; +} + +export function createHostReviewSessionHandler( + dependencies: HostReviewSessionDependencies, +) { + return async function POST(request: NextRequest, context: RouteContext) { + const canonicalOrigin = dependencies.canonicalSiteOrigin; + if (!canonicalOrigin || !requestUsesCanonicalOrigin(request, canonicalOrigin)) { + return privateHostReviewNotFound(); + } + const { id } = await context.params; + if (!reportIdSchema.safeParse(id).success) return privateHostReviewNotFound(); + + const body = await request.json().catch(() => null); + const parsed = requestSchema.safeParse(body); + if (!parsed.success) return privateHostReviewNotFound(); + + let consumed: ConsumedHostReviewToken | null; + try { + const tokenHash = createHash("sha256").update(parsed.data.token).digest("hex"); + consumed = await dependencies.consumeToken(tokenHash); + } catch { + return privateHostReviewNotFound(); + } + if (!consumed || consumed.matchReportId !== id) return privateHostReviewNotFound(); + + const response = hostReviewJson({ ok: true as const }); + dependencies.setSession(response, consumed); + return response; + }; +} + +const persistence = getMatchReportHostPersistence(); + +export const POST = createHostReviewSessionHandler({ + canonicalSiteOrigin: hostReviewSessionIsConfigured() ? getCanonicalSiteOrigin() : null, + consumeToken: async (tokenHash) => persistence?.consumeToken(tokenHash) ?? null, + setSession: setHostReviewSessionCookie, +}); diff --git a/src/app/api/match-reports/[id]/submit/route.test.ts b/src/app/api/match-reports/[id]/submit/route.test.ts new file mode 100644 index 0000000..73cc04b --- /dev/null +++ b/src/app/api/match-reports/[id]/submit/route.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createSubmitHostReviewHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const context = { params: Promise.resolve({ id: reportId }) }; + +describe("host match-report submission", () => { + it("submits the reviewed revision without granting final approval", async () => { + const result = { + code: "submitted" as const, + applied: true, + reportId, + pendingActionId: "pending-1", + matchId: "match-1", + revision: 4, + status: "host_review" as const, + hostSubmittedAt: "2026-08-18T12:00:00.000Z", + outboxIds: ["outbox-1"], + }; + const submitReview = vi.fn().mockResolvedValue(result); + const handler = createSubmitHostReviewHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + submitReview, + }); + const request = new NextRequest( + `https://sal.example/api/match-reports/${reportId}/submit`, + { + method: "POST", + headers: { "content-type": "application/json", origin: "https://sal.example" }, + body: JSON.stringify({ revision: 4 }), + }, + ); + + const response = await handler(request, context); + + expect(response.status).toBe(200); + expect(submitReview).toHaveBeenCalledWith({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expectedRevision: 4, + }); + expect(await response.json()).toEqual({ ok: true, result }); + }); + + it("keeps duplicate or unresolved identities out of admin review", async () => { + const submitReview = vi.fn().mockRejectedValue({ code: "23505" }); + const handler = createSubmitHostReviewHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ matchReportId: reportId, hostDiscordId: "1234567890", expiresAt: Date.now() + 60_000 }), + submitReview, + }); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}/submit`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://sal.example" }, + body: JSON.stringify({ revision: 4 }), + }); + + const response = await handler(request, context); + + expect(response.status).toBe(409); + expect((await response.json()).error).toContain("Resolve every unlinked"); + }); +}); diff --git a/src/app/api/match-reports/[id]/submit/route.ts b/src/app/api/match-reports/[id]/submit/route.ts new file mode 100644 index 0000000..374e91b --- /dev/null +++ b/src/app/api/match-reports/[id]/submit/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { getHostReviewSessionFromRequest, type HostReviewSession } from "@/lib/match-report-host/auth"; +import { submitResultSchema } from "@/lib/match-report-host/contracts"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; + +export const dynamic = "force-dynamic"; + +const requestSchema = z.object({ revision: z.number().int().min(1) }).strict(); +type SubmitResult = z.infer; +type RouteContext = { params: Promise<{ id: string }> }; + +export interface SubmitHostReviewDependencies { + canonicalSiteOrigin: string | null; + getSession: (request: NextRequest, reportId: string) => HostReviewSession | null; + submitReview: (input: { + matchReportId: string; + hostDiscordId: string; + expectedRevision: number; + }) => Promise; +} + +export function createSubmitHostReviewHandler(dependencies: SubmitHostReviewDependencies) { + return async function POST(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) + ) return privateHostReviewNotFound(); + const { id } = await context.params; + if (!z.string().uuid().safeParse(id).success) return privateHostReviewNotFound(); + const session = dependencies.getSession(request, id); + if (!session) return privateHostReviewNotFound(); + const body = await request.json().catch(() => null); + const parsed = requestSchema.safeParse(body); + if (!parsed.success) return hostReviewJson({ ok: false as const, error: "Invalid revision." }, 400); + + try { + const result = await dependencies.submitReview({ + matchReportId: id, + hostDiscordId: session.hostDiscordId, + expectedRevision: parsed.data.revision, + }); + return hostReviewJson({ ok: true as const, result }); + } catch (error) { + const code = databaseCode(error); + if (code === "P0002" || code === "42501") return privateHostReviewNotFound(); + if (code === "40001") { + return hostReviewJson( + { ok: false as const, error: "This report changed in another tab. Reload before submitting." }, + 409, + ); + } + if (code === "22023" || code === "23505" || code === "23514") { + return hostReviewJson( + { + ok: false as const, + error: "Resolve every unlinked, duplicate, or ambiguous player and complete both five-player teams.", + }, + 409, + ); + } + return hostReviewJson({ ok: false as const, error: "The report could not be submitted." }, 503); + } + }; +} + +function databaseCode(error: unknown) { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +const persistence = getMatchReportHostPersistence(); +export const POST = createSubmitHostReviewHandler({ + canonicalSiteOrigin: getCanonicalSiteOrigin(), + getSession: getHostReviewSessionFromRequest, + submitReview: async (input) => { + if (!persistence) throw new Error("Supabase not configured."); + return persistence.submitReview(input); + }, +}); diff --git a/src/app/api/match-reports/[id]/upload/route.test.ts b/src/app/api/match-reports/[id]/upload/route.test.ts new file mode 100644 index 0000000..cc02176 --- /dev/null +++ b/src/app/api/match-reports/[id]/upload/route.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createHostReviewUploadHandler } from "./route"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const context = { params: Promise.resolve({ id: reportId }) }; + +describe("host match-report screenshot upload", () => { + it("uploads only through the report-bound host session", async () => { + const upload = vi.fn().mockResolvedValue({ + urls: ["https://cdn.example/game-1.png"], + allUrls: ["https://cdn.example/game-1.png"], + revision: 2, + }); + const handler = createHostReviewUploadHandler({ + canonicalSiteOrigin: "https://sal.example", + getSession: () => ({ + matchReportId: reportId, + hostDiscordId: "1234567890", + expiresAt: Date.now() + 60_000, + }), + upload, + }); + const form = new FormData(); + form.append("screenshots", new File([new Uint8Array([1, 2, 3])], "score.png", { type: "image/png" })); + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}/upload`, { + method: "POST", + headers: { origin: "https://sal.example" }, + body: form, + }); + + const response = await handler(request, context); + + expect(response.status).toBe(200); + expect(upload).toHaveBeenCalledWith(expect.objectContaining({ + matchReportId: reportId, + hostDiscordId: "1234567890", + })); + expect((upload.mock.calls[0]?.[0].files as File[])[0]?.name).toBe("score.png"); + }); +}); diff --git a/src/app/api/match-reports/[id]/upload/route.ts b/src/app/api/match-reports/[id]/upload/route.ts new file mode 100644 index 0000000..d986935 --- /dev/null +++ b/src/app/api/match-reports/[id]/upload/route.ts @@ -0,0 +1,71 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { getHostReviewSessionFromRequest, type HostReviewSession } from "@/lib/match-report-host/auth"; +import { + getCanonicalSiteOrigin, + hostReviewJson, + privateHostReviewNotFound, + requestUsesCanonicalOrigin, +} from "@/lib/match-report-host/http"; +import { getMatchReportHostPersistence } from "@/lib/match-report-host/persistence"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { params: Promise<{ id: string }> }; +type UploadResult = { urls: string[]; allUrls: string[]; revision: number }; +export interface HostReviewUploadDependencies { + canonicalSiteOrigin: string | null; + getSession: (request: NextRequest, reportId: string) => HostReviewSession | null; + upload: (input: { + matchReportId: string; + hostDiscordId: string; + files: File[]; + }) => Promise; +} + +export function createHostReviewUploadHandler(dependencies: HostReviewUploadDependencies) { + return async function POST(request: NextRequest, context: RouteContext) { + if ( + !dependencies.canonicalSiteOrigin || + !requestUsesCanonicalOrigin(request, dependencies.canonicalSiteOrigin) + ) return privateHostReviewNotFound(); + const { id } = await context.params; + if (!z.string().uuid().safeParse(id).success) return privateHostReviewNotFound(); + const session = dependencies.getSession(request, id); + if (!session) return privateHostReviewNotFound(); + const form = await request.formData().catch(() => null); + if (!form) return hostReviewJson({ error: "Invalid screenshot upload." }, 400); + const files = form.getAll("screenshots").filter((value): value is File => value instanceof File); + + try { + return hostReviewJson(await dependencies.upload({ + matchReportId: id, + hostDiscordId: session.hostDiscordId, + files, + })); + } catch (error) { + const code = databaseCode(error); + if (code === "P0002" || code === "PGRST116" || code === "42501") return privateHostReviewNotFound(); + if (code === "22023") return hostReviewJson({ error: errorMessage(error, "Invalid screenshot upload.") }, 400); + if (code === "40001") return hostReviewJson({ error: "This report changed in another tab. Reload and try again." }, 409); + return hostReviewJson({ error: "Screenshot upload failed." }, 503); + } + }; +} + +function databaseCode(error: unknown) { + return typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; +} +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +const persistence = getMatchReportHostPersistence(); +export const POST = createHostReviewUploadHandler({ + canonicalSiteOrigin: getCanonicalSiteOrigin(), + getSession: getHostReviewSessionFromRequest, + upload: async (input) => { + if (!persistence) throw new Error("Supabase not configured."); + return persistence.uploadScreenshots(input); + }, +}); diff --git a/src/app/match-reports/[id]/review/page.tsx b/src/app/match-reports/[id]/review/page.tsx new file mode 100644 index 0000000..524270e --- /dev/null +++ b/src/app/match-reports/[id]/review/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { HostMatchReportReviewClient } from "@/components/match-report/HostMatchReportReviewClient"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const fetchCache = "force-no-store"; + +export const metadata: Metadata = { + title: "Private Match Stat Review - SAL", + description: "Correct OCR statistics for a hosted SAL match.", + robots: { index: false, follow: false }, + referrer: "no-referrer", +}; + +export default async function HostMatchReportReviewPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( +
+
+
+
+

Official match capture

+

Correct match statistics

+

Your private access token is removed from the address bar before any report data loads.

+
+
+
+ ); +} diff --git a/src/components/admin/MatchReportCard.tsx b/src/components/admin/MatchReportCard.tsx index 76b26aa..2a7ee11 100644 --- a/src/components/admin/MatchReportCard.tsx +++ b/src/components/admin/MatchReportCard.tsx @@ -11,7 +11,9 @@ const STATUS_BADGE: Record = { pending: "border-slate-500/40 bg-slate-500/10 text-slate-400", extracting: "border-amber-400/40 bg-amber-400/10 text-amber-300", review: "border-amber-400/40 bg-amber-400/10 text-amber-300", + host_review: "border-cyan-300/40 bg-cyan-300/10 text-cyan-200", done: "border-emerald-400/40 bg-emerald-400/10 text-emerald-300", + cancelled: "border-slate-500/40 bg-slate-500/10 text-slate-500", }; export function MatchReportCard({ @@ -47,7 +49,7 @@ export function MatchReportCard({ - {report.status === "extracting" ? "AI..." : report.status} + {report.status === "extracting" ? "AI..." : report.status === "host_review" ? "Admin review" : report.status} {report.status === "done" && report.homeScore !== undefined && report.awayScore !== undefined && ( diff --git a/src/components/admin/MatchReportClient.tsx b/src/components/admin/MatchReportClient.tsx index 6184851..c19ad62 100644 --- a/src/components/admin/MatchReportClient.tsx +++ b/src/components/admin/MatchReportClient.tsx @@ -173,7 +173,7 @@ export function MatchReportClient({ return; } - if (report.status === "review") { + if (report.status === "review" || report.status === "host_review") { const restoredGames = report.extractedData?.length ? toReviewGames(report.extractedData) : initBlankGamesValue(); diff --git a/src/components/admin/stat-inputs.tsx b/src/components/admin/stat-inputs.tsx index a3785f0..63c2acb 100644 --- a/src/components/admin/stat-inputs.tsx +++ b/src/components/admin/stat-inputs.tsx @@ -16,11 +16,13 @@ export function StatInput({ onChange, wide, label, + disabled, }: { value: number | undefined; onChange: (v: number) => void; wide?: boolean; label?: string; + disabled?: boolean; }) { return ( onChange(Math.max(0, Number(e.target.value) || 0))} className={cn( "rounded border border-white/10 bg-black/30 px-1 py-0.5 text-center text-xs font-semibold tabular-nums text-white focus:border-cyan-300/40 focus:outline-none", diff --git a/src/components/match-report/HostMatchReportReviewClient.test.ts b/src/components/match-report/HostMatchReportReviewClient.test.ts new file mode 100644 index 0000000..a70d394 --- /dev/null +++ b/src/components/match-report/HostMatchReportReviewClient.test.ts @@ -0,0 +1,82 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { + applyHostScreenshotUpload, + hasBlockingIdentityDiagnostics, + HostIdentityStatusBadge, + identityStatusesForGame, +} from "./HostMatchReportReviewClient"; +import type { HostMatchReportReview } from "@/types/match-report-host"; + +describe("host match-report publication guard", () => { + it("blocks submission whenever an identity is not uniquely linked", () => { + expect(hasBlockingIdentityDiagnostics({ + gameCount: 1, + duplicateIgns: [], + unlinkedIgns: ["UnknownIGN"], + ambiguousIgns: [], + games: [], + })).toBe(true); + expect(hasBlockingIdentityDiagnostics({ + gameCount: 1, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [], + })).toBe(false); + }); + + it("retains each successful sequential upload receipt before a later request can fail", () => { + const review = { + report: { + id: "11111111-1111-4111-8111-111111111111", + revision: 1, + status: "pending", + screenshotUrls: [], + games: [], + diagnostics: { gameCount: 0, duplicateIgns: [], unlinkedIgns: [], ambiguousIgns: [], games: [] }, + }, + match: { + id: "match-1", seasonId: "season-1", divisionId: "terra", scheduledDate: "2026-08-18", week: 1, + home: { id: "home", name: "Home", tag: "H", roster: [] }, + away: { id: "away", name: "Away", tag: "A", roster: [] }, + }, + } satisfies HostMatchReportReview; + + const afterFirst = applyHostScreenshotUpload(review, { + allUrls: ["https://cdn.example/game-1.jpg"], + revision: 2, + }); + + expect(afterFirst.report.screenshotUrls).toEqual(["https://cdn.example/game-1.jpg"]); + expect(afterFirst.report.revision).toBe(2); + }); + + it("maps and visibly labels duplicate and ambiguous identities on their player rows", () => { + const diagnostics = { + gameCount: 1, + duplicateIgns: ["MirrorIGN"], + unlinkedIgns: [], + ambiguousIgns: ["SharedIGN"], + games: [{ + gameNumber: 1, + players: [ + { index: 2, side: "home" as const, rawIgn: "MirrorIGN", playerId: "player-home-3", identityStatus: "duplicate" as const }, + { index: 7, side: "away" as const, rawIgn: "SharedIGN", playerId: null, identityStatus: "ambiguous" as const }, + ], + }], + }; + + const statuses = identityStatusesForGame(diagnostics, 1); + expect(statuses.get(2)).toBe("duplicate"); + expect(statuses.get(7)).toBe("ambiguous"); + + const markup = renderToStaticMarkup(createElement("div", null, + createElement(HostIdentityStatusBadge, { status: statuses.get(2)! }), + createElement(HostIdentityStatusBadge, { status: statuses.get(7)! }), + )); + expect(markup).toContain("Duplicate identity"); + expect(markup).toContain("Ambiguous identity"); + }); +}); diff --git a/src/components/match-report/HostMatchReportReviewClient.tsx b/src/components/match-report/HostMatchReportReviewClient.tsx new file mode 100644 index 0000000..07fbb2b --- /dev/null +++ b/src/components/match-report/HostMatchReportReviewClient.tsx @@ -0,0 +1,463 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ReviewScreenshotPane } from "@/components/admin/ReviewScreenshotPane"; +import { IgnInput, StatInput } from "@/components/admin/stat-inputs"; +import type { ExtractedGame, ExtractedPlayer } from "@/types/match-report"; +import type { + HostIdentityStatus, + HostMatchReportReview, + HostReviewDiagnostics, +} from "@/types/match-report-host"; + +interface EditablePlayer extends ExtractedPlayer { + playerId?: string; +} +interface EditableGame extends Omit { + players: EditablePlayer[]; +} + +type LoadState = + | { kind: "loading" } + | { kind: "not_found" } + | { kind: "unavailable"; message: string } + | { kind: "loaded"; review: HostMatchReportReview }; + +export function hasBlockingIdentityDiagnostics(diagnostics: HostReviewDiagnostics) { + return diagnostics.duplicateIgns.length > 0 || + diagnostics.unlinkedIgns.length > 0 || + diagnostics.ambiguousIgns.length > 0; +} + +export function identityStatusesForGame( + diagnostics: HostReviewDiagnostics, + gameNumber: number, +) { + return new Map( + diagnostics.games + .find((game) => game.gameNumber === gameNumber) + ?.players.map((player) => [player.index, player.identityStatus] as const) ?? [], + ); +} + +const IDENTITY_STATUS_LABEL: Record = { + linked: "Linked", + duplicate: "Duplicate identity", + unlinked: "Unlinked identity", + ambiguous: "Ambiguous identity", +}; + +export function HostIdentityStatusBadge({ status }: { status: HostIdentityStatus }) { + const valid = status === "linked"; + return ( + + {IDENTITY_STATUS_LABEL[status]} + + ); +} + +export function applyHostScreenshotUpload( + review: HostMatchReportReview, + result: { allUrls: string[]; revision: number }, +): HostMatchReportReview { + return { + ...review, + report: { + ...review.report, + screenshotUrls: result.allUrls, + revision: result.revision, + status: "pending", + }, + }; +} + +function requestOptions(init: RequestInit = {}): RequestInit { + return { cache: "no-store", referrerPolicy: "no-referrer", ...init }; +} + +export function HostMatchReportReviewClient({ reportId }: { reportId: string }) { + const [state, setState] = useState({ kind: "loading" }); + const [games, setGames] = useState([]); + const [activeGame, setActiveGame] = useState(0); + const [busy, setBusy] = useState(false); + const [dirty, setDirty] = useState(false); + const [message, setMessage] = useState(""); + const fileInput = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + const fragment = new URLSearchParams(window.location.hash.slice(1)); + const token = fragment.get("access"); + if (window.location.hash) { + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${window.location.search}`, + ); + } + void exchangeAndLoad(reportId, token, controller.signal).then((next) => { + if (controller.signal.aborted) return; + setState(next); + if (next.kind === "loaded") setGames(next.review.report.games as EditableGame[]); + }); + return () => controller.abort(); + }, [reportId]); + + const review = state.kind === "loaded" ? state.review : null; + const diagnostics = review?.report.diagnostics; + const incomplete = games.some((game) => + game.winningSide === "unknown" || + game.players.filter((player) => player.side === "home").length !== 5 || + game.players.filter((player) => player.side === "away").length !== 5 + ); + const submitted = review?.report.status === "host_review" || review?.report.status === "done"; + const canSubmit = Boolean( + review && !submitted && !dirty && games.length > 0 && !incomplete && + diagnostics && !hasBlockingIdentityDiagnostics(diagnostics), + ); + + if (state.kind === "loading") return ; + if (state.kind === "not_found") { + return ( + + The link may be expired or already used. Return to Discord and click Enter stats for a new link. + + ); + } + if (state.kind === "unavailable") return {state.message}; + if (!review) return null; + + const game = games[Math.min(activeGame, Math.max(games.length - 1, 0))]; + + function updatePlayer(index: number, patch: Partial) { + setGames((current) => current.map((entry, gameIndex) => gameIndex !== activeGame + ? entry + : { ...entry, players: entry.players.map((player, playerIndex) => playerIndex === index ? { ...player, ...patch } : player) })); + setDirty(true); + } + + function updateWinner(winningSide: "home" | "away") { + setGames((current) => current.map((entry, index) => index === activeGame ? { ...entry, winningSide } : entry)); + setDirty(true); + } + + async function uploadScreenshots() { + const files = Array.from(fileInput.current?.files ?? []); + if (files.length === 0) return; + if (!review) return; + if (review.report.screenshotUrls.length + files.length > 5) { + setMessage(`A report can contain at most 5 screenshots. ${review.report.screenshotUrls.length} already uploaded.`); + return; + } + setBusy(true); + setMessage(""); + let uploadedCount = 0; + try { + let result: { allUrls?: string[]; error?: string; revision?: number } = {}; + for (let index = 0; index < files.length; index++) { + setMessage(`Preparing screenshot ${index + 1} of ${files.length}…`); + const prepared = await prepareScreenshotForUpload(files[index]!); + const body = new FormData(); + body.append("screenshots", prepared, prepared.name); + const response = await fetch(`/api/match-reports/${reportId}/upload`, requestOptions({ method: "POST", body })); + result = await response.json() as { allUrls?: string[]; error?: string; revision?: number }; + if (!response.ok) throw new Error(result.error ?? `Screenshot ${index + 1} failed to upload.`); + if (!result.allUrls || result.revision === undefined) { + throw new Error(`Screenshot ${index + 1} returned an invalid upload receipt.`); + } + uploadedCount += 1; + setState((current) => current.kind === "loaded" + ? { kind: "loaded", review: applyHostScreenshotUpload(current.review, { + allUrls: result.allUrls!, + revision: result.revision!, + }) } + : current); + } + setMessage(`${files.length} screenshot${files.length === 1 ? "" : "s"} uploaded. Extract them to begin correction.`); + } catch (error) { + const detail = error instanceof Error ? error.message : "Upload failed."; + setMessage(uploadedCount > 0 + ? `${uploadedCount} screenshot${uploadedCount === 1 ? " was" : "s were"} uploaded and retained. ${detail}` + : detail); + } finally { + setBusy(false); + } + } + + async function extractScreenshots() { + setBusy(true); + setMessage(""); + try { + const response = await fetch(`/api/match-reports/${reportId}/extract`, requestOptions({ method: "POST" })); + const result = await response.json() as { games?: EditableGame[]; review?: HostMatchReportReview; error?: string }; + if (!response.ok) throw new Error(result.error ?? "Extraction failed."); + if (result.review) { + setState({ kind: "loaded", review: result.review }); + setGames(result.review.report.games as EditableGame[]); + } else if (result.games) { + setGames(result.games); + } + setActiveGame(0); + setDirty(false); + setMessage("Extraction complete. Check every player and stat before saving."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Extraction failed."); + } finally { + setBusy(false); + } + } + + async function saveCorrections() { + if (!review) return; + setBusy(true); + setMessage(""); + try { + const response = await fetch(`/api/match-reports/${reportId}/revise`, requestOptions({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ revision: review.report.revision, games }), + })); + const payload = await response.json() as { + ok?: boolean; + error?: string; + result?: { revision: number; status: "review"; games: EditableGame[]; diagnostics: HostReviewDiagnostics }; + }; + if (!response.ok || !payload.result) throw new Error(payload.error ?? "Save failed."); + setGames(payload.result.games); + setState((current) => current.kind === "loaded" ? { + kind: "loaded", + review: { + ...current.review, + report: { + ...current.review.report, + revision: payload.result!.revision, + status: payload.result!.status, + games: payload.result!.games, + diagnostics: payload.result!.diagnostics, + }, + }, + } : current); + setDirty(false); + setMessage(hasBlockingIdentityDiagnostics(payload.result.diagnostics) + ? "Saved. Resolve the highlighted identity issues before submitting." + : "Saved. Every identity is uniquely linked."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Save failed."); + } finally { + setBusy(false); + } + } + + async function submitForApproval() { + if (!review) return; + setBusy(true); + setMessage(""); + try { + const response = await fetch(`/api/match-reports/${reportId}/submit`, requestOptions({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ revision: review.report.revision }), + })); + const payload = await response.json() as { error?: string; result?: { revision: number; status: "host_review" } }; + if (!response.ok || !payload.result) throw new Error(payload.error ?? "Submit failed."); + setState((current) => current.kind === "loaded" ? { + kind: "loaded", + review: { ...current.review, report: { ...current.review.report, revision: payload.result!.revision, status: payload.result!.status } }, + } : current); + setMessage("Submitted for admin approval. Approved stats will appear publicly after an admin reviews them."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Submit failed."); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
+

Week {review.match.week}

+

{review.match.home.name} vs {review.match.away.name}

+

{review.match.scheduledDate} · {review.match.divisionId}

+
+ + {submitted ? "Awaiting admin approval" : review.report.status.replaceAll("_", " ")} + +
+

+ Upload each game scoreboard once, correct the OCR, then submit. An admin makes the final publication decision. +

+
+ + {message &&
{message}
} + + {!submitted && ( +
+

Scoreboard screenshots

+
+ + + +
+

Up to 5 images. Large files are compressed, then uploaded one at a time below the 4 MB request limit.

+
+ )} + + {games.length > 0 && ( +
+
+ +
+
+
+ {games.map((entry, index) => )} +
+ {game && ( + <> +
+ Winner: + {(["home", "away"] as const).map((side) => )} +
+ {diagnostics && hasBlockingIdentityDiagnostics(diagnostics) && ( + + )} + {(["home", "away"] as const).map((side) => ( + + ))} + + )} + {!submitted && ( +
+ + + {(dirty || incomplete || (diagnostics && hasBlockingIdentityDiagnostics(diagnostics))) &&

Save a complete 5v5 record with every identity uniquely linked before submitting.

} +
+ )} +
+
+ )} +
+ ); +} + +const HOST_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; + +async function prepareScreenshotForUpload(file: File): Promise { + if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) { + throw new Error("Screenshots must be PNG, JPEG, or WebP images."); + } + if (file.size <= HOST_UPLOAD_MAX_BYTES) return file; + + const objectUrl = URL.createObjectURL(file); + try { + const image = await new Promise((resolve, reject) => { + const candidate = new Image(); + candidate.onload = () => resolve(candidate); + candidate.onerror = () => reject(new Error(`Could not read ${file.name}.`)); + candidate.src = objectUrl; + }); + const scale = Math.min(1, 1600 / image.naturalWidth); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Image compression is unavailable in this browser."); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.82)); + if (!blob || blob.size > HOST_UPLOAD_MAX_BYTES) { + throw new Error(`${file.name} is still larger than 4 MB after compression.`); + } + return new File([blob], file.name.replace(/\.[^.]+$/, "") + ".jpg", { + type: "image/jpeg", + lastModified: file.lastModified, + }); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function PlayerTable({ side, team, players, identityStatuses, disabled, updatePlayer }: { + side: "home" | "away"; + team: HostMatchReportReview["match"]["home"]; + players: EditablePlayer[]; + identityStatuses: Map; + disabled: boolean; + updatePlayer: (index: number, patch: Partial) => void; +}) { + const rows = players.map((player, index) => ({ player, index })).filter(({ player }) => player.side === side); + return ( +
+

{team.name}

+ + + {rows.map(({ player, index }) => ( + + + + + + + + + + + ))} +
IGNGodRoleKDADamageMitigated
updatePlayer(index, { ign })} onPlayerMatch={(playerId) => updatePlayer(index, { playerId })} />
updatePlayer(index, { god: event.target.value })} aria-label={`${player.ign} god`} className="w-24 rounded border border-white/10 bg-black/30 px-1.5 py-0.5 text-xs text-white" /> updatePlayer(index, { role: event.target.value })} aria-label={`${player.ign} role`} className="w-20 rounded border border-white/10 bg-black/30 px-1.5 py-0.5 text-xs text-white" /> updatePlayer(index, { kills })} label={`${player.ign} kills`} /> updatePlayer(index, { deaths })} label={`${player.ign} deaths`} /> updatePlayer(index, { assists })} label={`${player.ign} assists`} /> updatePlayer(index, { damageDealt })} label={`${player.ign} damage`} /> updatePlayer(index, { damageMitigated })} label={`${player.ign} mitigated`} />
+
+ ); +} + +function IdentityIssueSummary({ diagnostics }: { diagnostics: HostReviewDiagnostics }) { + const issues = [ + diagnostics.duplicateIgns.length > 0 ? `Duplicate: ${diagnostics.duplicateIgns.join(", ")}` : null, + diagnostics.ambiguousIgns.length > 0 ? `Ambiguous: ${diagnostics.ambiguousIgns.join(", ")}` : null, + diagnostics.unlinkedIgns.length > 0 ? `Unlinked: ${diagnostics.unlinkedIgns.join(", ")}` : null, + ].filter((issue): issue is string => Boolean(issue)); + return ( +
+ Identity issues: {issues.join(" · ")} +
+ ); +} + +function ReviewState({ title, children }: { title: string; children?: React.ReactNode }) { + return

Private match review

{title}

{children &&

{children}

}
; +} + +async function exchangeAndLoad(reportId: string, token: string | null, signal: AbortSignal): Promise { + try { + if (token) { + const exchange = await fetch(`/api/match-reports/${reportId}/session`, requestOptions({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + signal, + })); + if (!exchange.ok) return exchange.status === 404 ? { kind: "not_found" } : { kind: "unavailable", message: "The private session could not be created." }; + } + const response = await fetch(`/api/match-reports/${reportId}`, requestOptions({ signal })); + const payload = await response.json() as { ok?: boolean; review?: HostMatchReportReview; error?: string }; + if (response.ok && payload.review) return { kind: "loaded", review: payload.review }; + if (response.status === 404) return { kind: "not_found" }; + return { kind: "unavailable", message: payload.error ?? "The review is temporarily unavailable." }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return { kind: "loading" }; + return { kind: "unavailable", message: "The review is temporarily unavailable." }; + } +} diff --git a/src/lib/admin-ticket-match-report.test.ts b/src/lib/admin-ticket-match-report.test.ts index adb0044..e4dc665 100644 --- a/src/lib/admin-ticket-match-report.test.ts +++ b/src/lib/admin-ticket-match-report.test.ts @@ -75,6 +75,27 @@ describe("buildMatchReportActionContext", () => { expect(serialized).not.toContain("reviewed_by"); }); + it("keeps a host-submitted report resolvable for final admin approval", () => { + const context = buildMatchReportActionContext( + { + id: "report-1", + match_id: "match-1", + status: "host_review", + screenshot_urls: [], + extracted_data: [extractedGame()], + }, + league, + ); + + expect(context.kind).toBe("resolvable"); + if (context.kind !== "resolvable") throw new Error("Expected host review to be resolvable"); + expect(context.games[0]?.players[0]).toMatchObject({ + playerIgn: "HomeIGN1", + playerId: "player-home-1", + }); + expect(JSON.stringify(context.games)).not.toContain('"ign"'); + }); + it("keeps a report read-only when the extracted stats are not safe to submit", () => { for (const extracted_data of [ null, diff --git a/src/lib/admin-ticket-match-report.ts b/src/lib/admin-ticket-match-report.ts index 9892e0b..7915e55 100644 --- a/src/lib/admin-ticket-match-report.ts +++ b/src/lib/admin-ticket-match-report.ts @@ -139,7 +139,7 @@ export function buildMatchReportActionContext( if ( typeof source.id !== "string" || typeof source.match_id !== "string" || - source.status !== "review" + !["review", "host_review"].includes(String(source.status)) ) { return READ_ONLY_CONTEXT; } diff --git a/src/lib/admin-ticket-model.test.ts b/src/lib/admin-ticket-model.test.ts index e2369d4..f5fff68 100644 --- a/src/lib/admin-ticket-model.test.ts +++ b/src/lib/admin-ticket-model.test.ts @@ -240,6 +240,19 @@ describe("normalizeRegistration", () => { }); describe("normalizeMatchReport", () => { + it("surfaces a host-submitted report as high-priority admin review work", () => { + const t = normalizeMatchReport(matchReportRow({ status: "host_review" })); + + expect(t.status).toBe("open"); + expect(t.priority).toBe("high"); + expect(t.summary).toContain("Host corrections submitted"); + expect(t.workflow).toEqual({ + kind: "site", + href: "/admin/match-report", + label: "Review and approve match stats", + }); + }); + it("normalizes a report awaiting admin review", () => { const t = normalizeMatchReport(matchReportRow()); expect(t.category).toBe("match_report"); @@ -268,6 +281,12 @@ describe("normalizeMatchReport", () => { expect(t.updatedAt).toBe("2026-07-06T09:00:00Z"); }); + it("keeps a cancelled report terminal and out of the resolvable queue", () => { + const t = normalizeMatchReport(matchReportRow({ status: "cancelled" })); + expect(t.status).toBe("cancelled"); + expect(t.priority).toBe("normal"); + }); + it("caps screenshot links at five and drops unsafe URLs", () => { const urls = [ "https://cdn.example.com/1.png", diff --git a/src/lib/admin-ticket-model.ts b/src/lib/admin-ticket-model.ts index a58ab06..2676814 100644 --- a/src/lib/admin-ticket-model.ts +++ b/src/lib/admin-ticket-model.ts @@ -221,7 +221,9 @@ const MATCH_REPORT_STATUS: Record = { pending: "open", extracting: "claimed", review: "open", + host_review: "open", done: "resolved", + cancelled: "cancelled", }; const BUG_REPORT_STATUS: Record = { @@ -450,8 +452,8 @@ export function normalizeMatchReport(row: MatchReportSourceRow): AdminTicket { category: "match_report", status, sourceStatus: row.status, - // A report sitting in "review" is waiting on an admin decision. - priority: row.status === "review" ? "high" : "normal", + // A report in either review state is waiting on an admin decision. + priority: row.status === "review" || row.status === "host_review" ? "high" : "normal", createdAt: row.created_at, updatedAt, slaDeadline: slaDeadlineFor("match_report", row.created_at, status), @@ -460,16 +462,22 @@ export function normalizeMatchReport(row: MatchReportSourceRow): AdminTicket { matchId: row.match_id, claimedBy: row.status === "extracting" ? "Automated extraction" : undefined, title: matchRef ? `Match report for match ${matchRef}` : "Match report", - summary: hasScore - ? `Reported score ${row.home_score} to ${row.away_score}${row.total_games ? ` over ${row.total_games} games` : ""}.` - : "Match screenshots submitted, awaiting extraction and review.", + summary: row.status === "host_review" + ? "Host corrections submitted, awaiting admin review and publication." + : hasScore + ? `Reported score ${row.home_score} to ${row.away_score}${row.total_games ? ` over ${row.total_games} games` : ""}.` + : "Match screenshots submitted, awaiting extraction and review.", privacy: "identity_restricted", links, timeline: timeline([ { at: row.created_at, label: "Report submitted" }, row.reviewed_at ? { at: row.reviewed_at, label: "Reviewed" } : null, ]), - workflow: { kind: "site", href: "/admin/match-report", label: "Handle in Match Report" }, + workflow: { + kind: "site", + href: "/admin/match-report", + label: row.status === "host_review" ? "Review and approve match stats" : "Handle in Match Report", + }, }; } diff --git a/src/lib/match-report-extraction.ts b/src/lib/match-report-extraction.ts new file mode 100644 index 0000000..245e0d4 --- /dev/null +++ b/src/lib/match-report-extraction.ts @@ -0,0 +1,96 @@ +import { callOpenRouterVision } from "@/lib/openrouter-vision"; +import type { ExtractedGame } from "@/types/match-report"; + +const SMITE_ROLES = ["Solo", "Jungle", "Mid", "Carry", "Support"] as const; + +function nonNegativeInteger(value: unknown) { + const number = Number(value ?? 0); + return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : 0; +} + +function extractionPrompt(input: { + homeOrgName: string; + homeIgns: string[]; + awayOrgName: string; + awayIgns: string[]; +}) { + return ` +You are analyzing a SMITE 2 end-of-match DETAILS tab screenshot. Extract the scoreboard data. + +Home team: ${input.homeOrgName} +Known home players: ${input.homeIgns.length > 0 ? input.homeIgns.join(", ") : "(unknown roster)"} + +Away team: ${input.awayOrgName} +Known away players: ${input.awayIgns.length > 0 ? input.awayIgns.join(", ") : "(unknown roster)"} + +Return ONLY valid JSON in this exact format, no other text: +{ + "winner": "home" | "away" | "unknown", + "players": [{ + "ign": "string", "side": "home" | "away", "god": "string or null", + "role": "Solo" | "Jungle" | "Mid" | "Carry" | "Support" | null, + "kills": number, "deaths": number, "assists": number, + "damageDealt": number or null, "damageMitigated": number or null + }] +} + +Match each player to home or away using the known rosters. If a player is not in either roster, +assign by scoreboard column. Extract all numbers exactly, determine the winner from the visible +VICTORY/DEFEAT text or icons, and include all ten visible players. +`.trim(); +} + +export async function extractMatchReportGames(input: { + screenshotUrls: string[]; + homeOrgName: string; + homeIgns: string[]; + awayOrgName: string; + awayIgns: string[]; +}): Promise { + const games: ExtractedGame[] = []; + for (let index = 0; index < input.screenshotUrls.length; index++) { + try { + const imageResponse = await fetch(input.screenshotUrls[index]!); + if (!imageResponse.ok) throw new Error(`HTTP ${imageResponse.status}`); + const contentType = imageResponse.headers.get("content-type") ?? "image/jpeg"; + const mimeType = contentType.includes("png") + ? "image/png" + : contentType.includes("webp") ? "image/webp" : "image/jpeg"; + const encoded = Buffer.from(await imageResponse.arrayBuffer()).toString("base64"); + const text = await callOpenRouterVision([{ + role: "user", + content: [ + { type: "image_url", image_url: { url: `data:${mimeType};base64,${encoded}` } }, + { type: "text", text: extractionPrompt(input) }, + ], + }], { maxTokens: 2048, title: "SAL Match Report" }); + const parsed = JSON.parse(text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim()) as { + winner?: string; + players?: Array<{ + ign?: string; side?: string; god?: string | null; role?: string | null; + kills?: number; deaths?: number; assists?: number; + damageDealt?: number | null; damageMitigated?: number | null; + }>; + }; + games.push({ + gameNumber: index + 1, + winningSide: parsed.winner === "home" ? "home" : parsed.winner === "away" ? "away" : "unknown", + players: (parsed.players ?? []).map((player) => ({ + ign: player.ign ?? "", + side: player.side === "away" ? "away" : "home", + god: player.god ?? undefined, + role: SMITE_ROLES.includes(player.role as (typeof SMITE_ROLES)[number]) ? player.role ?? undefined : undefined, + kills: nonNegativeInteger(player.kills), + deaths: nonNegativeInteger(player.deaths), + assists: nonNegativeInteger(player.assists), + damageDealt: player.damageDealt == null ? undefined : nonNegativeInteger(player.damageDealt), + damageMitigated: player.damageMitigated == null ? undefined : nonNegativeInteger(player.damageMitigated), + })), + }); + } catch (error) { + console.error(`Match-report OCR failed for game ${index + 1}:`, error); + games.push({ gameNumber: index + 1, winningSide: "unknown", players: [] }); + } + } + return games; +} diff --git a/src/lib/match-report-host/auth.test.ts b/src/lib/match-report-host/auth.test.ts new file mode 100644 index 0000000..8e8834f --- /dev/null +++ b/src/lib/match-report-host/auth.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { + getHostReviewSessionFromRequest, + setHostReviewSessionCookie, +} from "./auth"; + +const reportId = "11111111-1111-4111-8111-111111111111"; + +describe("match report host session", () => { + beforeEach(() => { + process.env.MATCH_REPORT_HOST_SESSION_SECRET = "test-secret-with-at-least-thirty-two-characters"; + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.MATCH_REPORT_HOST_SESSION_SECRET; + }); + + it("round-trips a signed cookie only for its scoped report", () => { + const response = NextResponse.json({ ok: true }); + setHostReviewSessionCookie(response, { + matchReportId: reportId, + hostDiscordId: "discord-host-1", + }); + const cookie = response.cookies.get("sal_match_report_host_session"); + expect(cookie?.path).toBe(`/api/match-reports/${reportId}`); + + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}`, { + headers: { cookie: `sal_match_report_host_session=${cookie?.value}` }, + }); + expect(getHostReviewSessionFromRequest(request, reportId)).toMatchObject({ + matchReportId: reportId, + hostDiscordId: "discord-host-1", + }); + expect( + getHostReviewSessionFromRequest( + request, + "22222222-2222-4222-8222-222222222222", + ), + ).toBeNull(); + }); + + it("rejects tampered and expired cookies", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-18T12:00:00.000Z")); + const response = NextResponse.json({ ok: true }); + setHostReviewSessionCookie(response, { + matchReportId: reportId, + hostDiscordId: "discord-host-1", + }); + const value = response.cookies.get("sal_match_report_host_session")!.value; + const requestFor = (cookieValue: string) => new NextRequest( + `https://sal.example/api/match-reports/${reportId}`, + { headers: { cookie: `sal_match_report_host_session=${cookieValue}` } }, + ); + + expect(getHostReviewSessionFromRequest(requestFor(`${value}x`), reportId)).toBeNull(); + vi.setSystemTime(new Date("2026-08-18T18:00:01.000Z")); + expect(getHostReviewSessionFromRequest(requestFor(value), reportId)).toBeNull(); + }); + + it("fails closed instead of throwing when the dedicated secret is missing", () => { + delete process.env.MATCH_REPORT_HOST_SESSION_SECRET; + const request = new NextRequest(`https://sal.example/api/match-reports/${reportId}`, { + headers: { cookie: "sal_match_report_host_session=untrusted.payload" }, + }); + + expect(() => getHostReviewSessionFromRequest(request, reportId)).not.toThrow(); + expect(getHostReviewSessionFromRequest(request, reportId)).toBeNull(); + }); +}); diff --git a/src/lib/match-report-host/auth.ts b/src/lib/match-report-host/auth.ts new file mode 100644 index 0000000..d9f55dd --- /dev/null +++ b/src/lib/match-report-host/auth.ts @@ -0,0 +1,106 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import type { NextRequest } from "next/server"; + +export const HOST_REVIEW_COOKIE_NAME = "sal_match_report_host_session"; +export const HOST_REVIEW_SESSION_MAX_AGE_SECONDS = 6 * 60 * 60; + +export interface HostReviewSession { + matchReportId: string; + hostDiscordId: string; + expiresAt: number; +} + +type CookieResponse = Response & { + cookies: { + set: ( + name: string, + value: string, + options: { + httpOnly: boolean; + sameSite: "strict"; + secure: boolean; + path: string; + maxAge: number; + }, + ) => void; + }; +}; + +function sessionSecret(): string { + const value = process.env.MATCH_REPORT_HOST_SESSION_SECRET; + if (!value || value.length < 32) { + throw new Error("MATCH_REPORT_HOST_SESSION_SECRET must be at least 32 characters."); + } + return value; +} + +export function hostReviewSessionIsConfigured() { + return (process.env.MATCH_REPORT_HOST_SESSION_SECRET?.length ?? 0) >= 32; +} + +function signPayload(payload: Omit & { expiresAt: number }) { + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signature = createHmac("sha256", sessionSecret()).update(encoded).digest("base64url"); + return `${encoded}.${signature}`; +} + +function verifyCookie(value: string): HostReviewSession | null { + const separator = value.lastIndexOf("."); + if (separator <= 0) return null; + const encoded = value.slice(0, separator); + const actual = value.slice(separator + 1); + const expected = createHmac("sha256", sessionSecret()).update(encoded).digest("base64url"); + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + if ( + actualBuffer.length !== expectedBuffer.length || + !timingSafeEqual(actualBuffer, expectedBuffer) + ) { + return null; + } + + try { + const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Partial; + if ( + typeof parsed.matchReportId !== "string" || + typeof parsed.hostDiscordId !== "string" || + typeof parsed.expiresAt !== "number" || + parsed.expiresAt <= Date.now() + ) { + return null; + } + return parsed as HostReviewSession; + } catch { + return null; + } +} + +export function setHostReviewSessionCookie( + response: CookieResponse, + session: Omit, +) { + const expiresAt = Date.now() + HOST_REVIEW_SESSION_MAX_AGE_SECONDS * 1000; + response.cookies.set( + HOST_REVIEW_COOKIE_NAME, + signPayload({ ...session, expiresAt }), + { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" && process.env.E2E_TEST_MODE !== "1", + path: `/api/match-reports/${session.matchReportId}`, + maxAge: HOST_REVIEW_SESSION_MAX_AGE_SECONDS, + }, + ); +} + +export function getHostReviewSessionFromRequest( + request: NextRequest, + expectedReportId: string, +): HostReviewSession | null { + if (!hostReviewSessionIsConfigured()) return null; + const value = request.cookies.get(HOST_REVIEW_COOKIE_NAME)?.value; + if (!value) return null; + const session = verifyCookie(value); + if (!session || session.matchReportId !== expectedReportId) return null; + return session; +} diff --git a/src/lib/match-report-host/contracts.test.ts b/src/lib/match-report-host/contracts.test.ts new file mode 100644 index 0000000..278fc95 --- /dev/null +++ b/src/lib/match-report-host/contracts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { diagnosticsSchema, reviewedGamesSchema } from "./contracts"; + +describe("match report host contracts", () => { + it("accepts canonical text player IDs instead of requiring UUIDs", () => { + const games = [{ + gameNumber: 1, + winningSide: "home" as const, + players: [ + ...Array.from({ length: 5 }, (_, index) => ({ + ign: `Home ${index}`, + side: "home" as const, + kills: 1, + deaths: 0, + assists: 2, + playerId: `db02-home-${index + 1}`, + })), + ...Array.from({ length: 5 }, (_, index) => ({ + ign: `Away ${index}`, + side: "away" as const, + kills: 0, + deaths: 1, + assists: 1, + playerId: `player-away-${index + 1}`, + })), + ], + }]; + + expect(reviewedGamesSchema.parse(games)[0]?.players[0]?.playerId).toBe("db02-home-1"); + expect(() => diagnosticsSchema.parse({ + gameCount: 1, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [{ + gameNumber: 1, + players: [{ + index: 0, + side: "home", + rawIgn: "Home 0", + playerId: "db02-home-1", + identityStatus: "linked", + }], + }], + })).not.toThrow(); + }); +}); diff --git a/src/lib/match-report-host/contracts.ts b/src/lib/match-report-host/contracts.ts new file mode 100644 index 0000000..6a1d062 --- /dev/null +++ b/src/lib/match-report-host/contracts.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +const playerIdSchema = z.string().min(1).max(200); + +export const issuedTokenResultSchema = z.object({ + matchReportId: z.string().uuid(), + hostDiscordId: z.string().min(1), + expiresAt: z.string().datetime(), +}).strict(); + +export const consumedTokenResultSchema = z.object({ + matchReportId: z.string().uuid(), + hostDiscordId: z.string().min(1), +}).strict().nullable(); + +export const extractedPlayerSchema = z.object({ + ign: z.string().trim().min(1).max(100), + side: z.enum(["home", "away"]), + god: z.string().trim().min(1).max(100).optional(), + role: z.string().trim().min(1).max(100).optional(), + kills: z.number().int().min(0), + deaths: z.number().int().min(0), + assists: z.number().int().min(0), + damageDealt: z.number().int().min(0).optional(), + damageMitigated: z.number().int().min(0).optional(), + forfeit: z.boolean().optional(), + playerId: playerIdSchema.optional(), +}).strict(); + +export const extractedGameSchema = z.object({ + gameNumber: z.number().int().min(1).max(5), + winningSide: z.enum(["home", "away", "unknown"]), + players: z.array(extractedPlayerSchema).max(10), +}).strict(); + +export const extractedGamesSchema = z.array(extractedGameSchema).max(5); + +export const reviewedGameSchema = z.object({ + gameNumber: z.number().int().min(1).max(5), + winningSide: z.enum(["home", "away"]), + players: z.array(extractedPlayerSchema).length(10), +}).strict().superRefine((game, context) => { + for (const side of ["home", "away"] as const) { + if (game.players.filter((player) => player.side === side).length !== 5) { + context.addIssue({ + code: "custom", + message: `Game ${game.gameNumber} must contain exactly five ${side} players.`, + path: ["players"], + }); + } + } +}); + +export const reviewedGamesSchema = z.array(reviewedGameSchema).min(1).max(5); + +export const diagnosticsSchema = z.object({ + gameCount: z.number().int().min(0).max(5), + duplicateIgns: z.array(z.string()), + unlinkedIgns: z.array(z.string()), + ambiguousIgns: z.array(z.string()), + games: z.array(z.object({ + gameNumber: z.number().int().min(1).max(5), + players: z.array(z.object({ + index: z.number().int().min(0).max(9), + side: z.enum(["home", "away"]), + rawIgn: z.string(), + playerId: playerIdSchema.nullable(), + identityStatus: z.enum(["linked", "duplicate", "unlinked", "ambiguous"]), + }).strict()).max(10), + }).strict()).max(5), +}).strict(); + +export const reviseResultSchema = z.object({ + code: z.literal("revised"), + applied: z.boolean(), + reportId: z.string().uuid(), + revision: z.number().int().min(1), + status: z.literal("review"), + games: reviewedGamesSchema, + diagnostics: diagnosticsSchema, +}).strict(); + +export const submitResultSchema = z.object({ + code: z.enum(["submitted", "already_submitted"]), + applied: z.boolean(), + reportId: z.string().uuid(), + pendingActionId: z.string(), + matchId: z.string(), + revision: z.number().int().min(1), + status: z.literal("host_review"), + hostSubmittedAt: z.string().datetime(), + outboxIds: z.array(z.string()), +}).strict(); diff --git a/src/lib/match-report-host/extract-service.test.ts b/src/lib/match-report-host/extract-service.test.ts new file mode 100644 index 0000000..1968107 --- /dev/null +++ b/src/lib/match-report-host/extract-service.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { completeGamesForHostReview, resetIncompleteExtraction } from "./extract-service"; +import type { HostMatchReportReview } from "@/types/match-report-host"; + +const reportId = "11111111-1111-4111-8111-111111111111"; +const review: HostMatchReportReview = { + report: { + id: reportId, + revision: 1, + status: "extracting", + screenshotUrls: ["https://cdn.example/game.png"], + games: [], + diagnostics: { gameCount: 0, duplicateIgns: [], unlinkedIgns: [], ambiguousIgns: [], games: [] }, + }, + match: { + id: "match-1", + seasonId: "season-1", + divisionId: "terra", + scheduledDate: "2026-08-18", + week: 1, + home: { + id: "home", name: "Home", tag: "H", + roster: Array.from({ length: 5 }, (_, index) => ({ id: `home-${index}`, ign: `Home${index}` })), + }, + away: { + id: "away", name: "Away", tag: "A", + roster: Array.from({ length: 5 }, (_, index) => ({ id: `away-${index}`, ign: `Away${index}` })), + }, + }, +}; + +describe("host OCR recovery", () => { + it("turns an OCR failure into editable 5v5 roster rows while leaving the winner unresolved", () => { + const games = completeGamesForHostReview( + [{ gameNumber: 1, winningSide: "unknown", players: [] }], + review, + ); + + expect(games[0]?.winningSide).toBe("unknown"); + expect(games[0]?.players).toHaveLength(10); + expect(games[0]?.players.filter((player) => player.side === "home")).toHaveLength(5); + expect(games[0]?.players.filter((player) => player.side === "away")).toHaveLength(5); + }); + + it("reports an optimistic conflict when an incomplete-extraction reset loses its revision", async () => { + const query = { + update() { return this; }, + eq() { return this; }, + select() { return this; }, + single: async () => ({ data: null, error: { code: "PGRST116", message: "No row" } }), + }; + + await expect(resetIncompleteExtraction( + { from: () => query }, + { matchReportId: reportId, hostDiscordId: "host-1", extractingRevision: 2, nextRevision: 3 }, + )).rejects.toMatchObject({ code: "40001" }); + }); +}); diff --git a/src/lib/match-report-host/extract-service.ts b/src/lib/match-report-host/extract-service.ts new file mode 100644 index 0000000..a75d859 --- /dev/null +++ b/src/lib/match-report-host/extract-service.ts @@ -0,0 +1,176 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { toDatabaseJson } from "@/lib/database-json"; +import { extractMatchReportGames } from "@/lib/match-report-extraction"; +import type { Database } from "@/types/database.types"; +import type { ExtractedGame } from "@/types/match-report"; +import type { HostMatchReportReview } from "@/types/match-report-host"; +import { readHostMatchReportReview } from "./read-service"; + +type ErrorShape = { message: string; code?: string } | null; +type UntypedResult = PromiseLike<{ data: unknown; error: ErrorShape }>; +interface UntypedQuery extends UntypedResult { + select(columns: string): UntypedQuery; + eq(column: string, value: unknown): UntypedQuery; + update(values: Record): UntypedQuery; + single(): UntypedResult; +} +type UntypedClient = { from(table: string): UntypedQuery }; + +function failure(code: string, message: string) { + return Object.assign(new Error(message), { code }); +} + +function blankGames(review: HostMatchReportReview): ExtractedGame[] { + const gameCount = Math.max(1, review.report.screenshotUrls.length); + return Array.from({ length: gameCount }, (_, index) => ({ + gameNumber: index + 1, + winningSide: "unknown" as const, + players: [ + ...review.match.home.roster.slice(0, 5).map((player) => ({ + ign: player.ign, playerId: player.id, side: "home" as const, + kills: 0, deaths: 0, assists: 0, + })), + ...review.match.away.roster.slice(0, 5).map((player) => ({ + ign: player.ign, playerId: player.id, side: "away" as const, + kills: 0, deaths: 0, assists: 0, + })), + ], + })); +} + +export function completeGamesForHostReview(games: ExtractedGame[], review: HostMatchReportReview) { + const maps = { + home: new Map(review.match.home.roster.map((player) => [player.ign.toLowerCase(), player.id])), + away: new Map(review.match.away.roster.map((player) => [player.ign.toLowerCase(), player.id])), + }; + return games.map((game) => { + const players = (["home", "away"] as const).flatMap((side) => { + const roster = side === "home" ? review.match.home.roster : review.match.away.roster; + const extracted = game.players + .filter((player) => player.side === side && player.ign.trim().length > 0) + .slice(0, 5) + .map((player) => { + const ign = player.ign.trim().slice(0, 100); + return { + ...player, + ign, + god: player.god?.trim().slice(0, 100) || undefined, + role: player.role?.trim().slice(0, 100) || undefined, + playerId: maps[side].get(ign.toLowerCase()), + }; + }); + const usedIds = new Set(extracted.map((player) => player.playerId).filter(Boolean)); + const usedIgns = new Set(extracted.map((player) => player.ign.trim().toLowerCase())); + for (const rosterPlayer of roster) { + if (extracted.length === 5) break; + if (usedIds.has(rosterPlayer.id) || usedIgns.has(rosterPlayer.ign.toLowerCase())) continue; + extracted.push({ + ign: rosterPlayer.ign, + playerId: rosterPlayer.id, + side, + god: undefined, + role: undefined, + kills: 0, + deaths: 0, + assists: 0, + }); + } + return extracted; + }); + return { ...game, players }; + }); +} + +function hasEditableFiveVersusFive(games: ExtractedGame[]) { + return games.length > 0 && games.every((game) => + game.players.length === 10 && + game.players.filter((player) => player.side === "home").length === 5 && + game.players.filter((player) => player.side === "away").length === 5 + ); +} + +export async function resetIncompleteExtraction( + client: unknown, + input: { + matchReportId: string; + hostDiscordId: string; + extractingRevision: number; + nextRevision: number; + }, +) { + const untyped = client as UntypedClient; + const { data, error } = await untyped + .from("match_reports") + .update({ status: "pending", revision: input.nextRevision }) + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .eq("revision", input.extractingRevision) + .select("revision") + .single(); + if (error || !data) throw failure("40001", "Report changed during extraction reset."); +} + +export async function extractHostReviewScreenshots( + client: SupabaseClient, + input: { matchReportId: string; hostDiscordId: string }, +) { + const initial = await readHostMatchReportReview(client, input); + if (!initial) throw failure("P0002", "Report not found."); + if ( + initial.report.status === "host_review" || + initial.report.status === "done" || + initial.report.status === "cancelled" + ) { + throw failure("42501", "Report is no longer editable."); + } + if (initial.report.screenshotUrls.length < 1) throw failure("22023", "Upload a screenshot first."); + + const untyped = client as unknown as UntypedClient; + const extractingRevision = initial.report.revision + 1; + const { data: claimed, error: claimError } = await untyped + .from("match_reports") + .update({ status: "extracting", revision: extractingRevision }) + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .eq("revision", initial.report.revision) + .select("revision") + .single(); + if (claimError || !claimed) throw failure("40001", "Report changed before extraction."); + + const rawGames = process.env.OPENROUTER_API_KEY + ? await extractMatchReportGames({ + screenshotUrls: initial.report.screenshotUrls, + homeOrgName: initial.match.home.name, + homeIgns: initial.match.home.roster.map((player) => player.ign), + awayOrgName: initial.match.away.name, + awayIgns: initial.match.away.roster.map((player) => player.ign), + }) + : blankGames(initial); + const games = completeGamesForHostReview(rawGames, initial); + const reviewRevision = extractingRevision + 1; + if (!hasEditableFiveVersusFive(games)) { + await resetIncompleteExtraction(untyped, { + matchReportId: input.matchReportId, + hostDiscordId: input.hostDiscordId, + extractingRevision, + nextRevision: reviewRevision, + }); + throw failure("55000", "Both season rosters need five players before correction can begin."); + } + const { data: saved, error: saveError } = await untyped + .from("match_reports") + .update({ + status: "review", + revision: reviewRevision, + extracted_data: toDatabaseJson(games), + }) + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .eq("revision", extractingRevision) + .select("revision") + .single(); + if (saveError || !saved) throw failure("40001", "Report changed during extraction."); + const result = await readHostMatchReportReview(client, input); + if (!result) throw failure("P0002", "Report not found."); + return result; +} diff --git a/src/lib/match-report-host/http.ts b/src/lib/match-report-host/http.ts new file mode 100644 index 0000000..059458c --- /dev/null +++ b/src/lib/match-report-host/http.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { requestUsesCanonicalOrigin, sensitiveJsonResponse } from "@/lib/bug-reports/http"; + +export { requestUsesCanonicalOrigin }; + +export function getCanonicalSiteOrigin(): string | null { + const raw = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (!raw) return null; + try { + const url = new URL(raw); + if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + return null; + } + return url.origin; + } catch { + return null; + } +} + +export function hostReviewJson(body: T, status = 200): NextResponse { + return sensitiveJsonResponse(body, { status }); +} + +export function privateHostReviewNotFound() { + return hostReviewJson( + { ok: false as const, error: "This private match report could not be opened." }, + 404, + ); +} diff --git a/src/lib/match-report-host/persistence.ts b/src/lib/match-report-host/persistence.ts new file mode 100644 index 0000000..de3b7d4 --- /dev/null +++ b/src/lib/match-report-host/persistence.ts @@ -0,0 +1,105 @@ +import { getSupabaseServerClient } from "@/lib/supabase-server"; +import { readHostMatchReportReview } from "./read-service"; +import { + consumedTokenResultSchema, + issuedTokenResultSchema, + reviseResultSchema, + submitResultSchema, +} from "./contracts"; +import type { HostMatchReportReview } from "@/types/match-report-host"; +import type { ExtractedGame } from "@/types/match-report"; +import { uploadHostReviewScreenshots } from "./upload-service"; +import { extractHostReviewScreenshots } from "./extract-service"; + +export interface ConsumedHostReviewToken { + matchReportId: string; + hostDiscordId: string; +} + +export interface MatchReportHostPersistence { + consumeToken(tokenHash: string): Promise; + issueToken(input: { + matchReportId: string; + hostDiscordId: string; + tokenHash: string; + expiresAt: string; + }): Promise; + readReview(input: { + matchReportId: string; + hostDiscordId: string; + }): Promise; + reviseReview(input: { + matchReportId: string; + hostDiscordId: string; + expectedRevision: number; + games: ExtractedGame[]; + }): Promise>; + submitReview(input: { + matchReportId: string; + hostDiscordId: string; + expectedRevision: number; + }): Promise>; + uploadScreenshots(input: { + matchReportId: string; + hostDiscordId: string; + files: File[]; + }): Promise<{ urls: string[]; allUrls: string[]; revision: number }>; + extractReview(input: { + matchReportId: string; + hostDiscordId: string; + }): Promise; +} + +type UntypedRpcClient = { + rpc: ( + name: string, + args: Record, + ) => PromiseLike<{ data: unknown; error: { message: string; code?: string } | null }>; +}; + +export function getMatchReportHostPersistence(): MatchReportHostPersistence | null { + const client = getSupabaseServerClient(); + if (!client) return null; + const rpc = client as unknown as UntypedRpcClient; + return { + extractReview: (input) => extractHostReviewScreenshots(client, input), + uploadScreenshots: (input) => uploadHostReviewScreenshots(client, input), + readReview: (input) => readHostMatchReportReview(client, input), + async reviseReview(input) { + const { data, error } = await rpc.rpc("revise_match_report_extraction", { + p_match_report_id: input.matchReportId, + p_host_discord_id: input.hostDiscordId, + p_expected_revision: input.expectedRevision, + p_games: input.games, + }); + if (error) throw error; + return reviseResultSchema.parse(data); + }, + async submitReview(input) { + const { data, error } = await rpc.rpc("submit_match_report_host_review", { + p_match_report_id: input.matchReportId, + p_host_discord_id: input.hostDiscordId, + p_expected_revision: input.expectedRevision, + }); + if (error) throw error; + return submitResultSchema.parse(data); + }, + async issueToken(input) { + const { data, error } = await rpc.rpc("issue_match_report_host_token", { + p_match_report_id: input.matchReportId, + p_host_discord_id: input.hostDiscordId, + p_token_hash: input.tokenHash, + p_expires_at: input.expiresAt, + }); + if (error) throw error; + issuedTokenResultSchema.parse(data); + }, + async consumeToken(tokenHash) { + const { data, error } = await rpc.rpc("consume_match_report_host_token", { + p_token_hash: tokenHash, + }); + if (error) throw error; + return consumedTokenResultSchema.parse(data); + }, + }; +} diff --git a/src/lib/match-report-host/read-service.test.ts b/src/lib/match-report-host/read-service.test.ts new file mode 100644 index 0000000..de64b95 --- /dev/null +++ b/src/lib/match-report-host/read-service.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadActiveRosterPlayers, readExtractionDiagnostics } from "./read-service"; + +describe("host review diagnostics read", () => { + it("opens a brand-new report without calling the diagnostics RPC with zero games", async () => { + const rpc = vi.fn(); + + await expect(readExtractionDiagnostics(rpc, "report-1", [])).resolves.toEqual({ + gameCount: 0, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [], + }); + expect(rpc).not.toHaveBeenCalled(); + }); + + it("keeps an incomplete OCR draft recoverable without invoking strict diagnostics", async () => { + const rpc = vi.fn(); + const games = [{ gameNumber: 1, winningSide: "unknown" as const, players: [] }]; + + await expect(readExtractionDiagnostics(rpc, "report-1", games)).resolves.toMatchObject({ + gameCount: 1, + games: [], + }); + expect(rpc).not.toHaveBeenCalled(); + }); + + it("only loads active, non-deleting roster identities offered by the host UI", async () => { + const calls: Array<[string, unknown]> = []; + const query = { + select(columns: string) { calls.push(["select", columns]); return this; }, + in(column: string, values: unknown) { calls.push([`in:${column}`, values]); return this; }, + is(column: string, value: unknown) { calls.push([`is:${column}`, value]); return this; }, + then(resolve: (value: unknown) => unknown) { + return Promise.resolve({ data: [{ id: "db02-home-1", ign: "Home One" }], error: null }).then(resolve); + }, + }; + const client = { from: vi.fn(() => query) }; + + await expect(loadActiveRosterPlayers(client, ["db02-home-1"])).resolves.toEqual([ + { id: "db02-home-1", ign: "Home One" }, + ]); + expect(calls).toContainEqual(["is:archived_at", null]); + expect(calls).toContainEqual(["is:deletion_scheduled_at", null]); + }); +}); diff --git a/src/lib/match-report-host/read-service.ts b/src/lib/match-report-host/read-service.ts new file mode 100644 index 0000000..39f7924 --- /dev/null +++ b/src/lib/match-report-host/read-service.ts @@ -0,0 +1,161 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { diagnosticsSchema, extractedGamesSchema } from "./contracts"; +import type { Database } from "@/types/database.types"; +import type { HostMatchReportReview, HostReviewTeam } from "@/types/match-report-host"; +import type { ExtractedGame } from "@/types/match-report"; + +type ErrorShape = { message: string; code?: string } | null; +type UntypedResult = PromiseLike<{ data: unknown; error: ErrorShape }>; +interface UntypedQuery extends UntypedResult { + select(columns: string): UntypedQuery; + eq(column: string, value: unknown): UntypedQuery; + in(column: string, values: unknown[]): UntypedQuery; + is(column: string, value: unknown): UntypedQuery; + single(): UntypedResult; +} +type UntypedClient = { + from(table: string): UntypedQuery; + rpc(name: string, args: Record): UntypedResult; +}; + +export async function loadActiveRosterPlayers(client: unknown, playerIds: string[]) { + if (playerIds.length === 0) return []; + const untyped = client as UntypedClient; + const { data, error } = await untyped + .from("players") + .select("id,ign") + .in("id", playerIds) + .is("archived_at", null) + .is("deletion_scheduled_at", null); + if (error) throw error; + if (!Array.isArray(data)) throw new Error("Roster identities have an invalid shape."); + return data as Array<{ id: string; ign: string }>; +} + +export async function readExtractionDiagnostics( + rpc: UntypedClient["rpc"], + reportId: string, + games: ExtractedGame[], +) { + const structurallyReady = games.length > 0 && games.every((game) => + game.winningSide !== "unknown" && + game.players.length === 10 && + game.players.filter((player) => player.side === "home").length === 5 && + game.players.filter((player) => player.side === "away").length === 5 + ); + if (!structurallyReady) { + return { + gameCount: games.length, + duplicateIgns: [], + unlinkedIgns: [], + ambiguousIgns: [], + games: [], + }; + } + const { data, error } = await rpc("match_report_extraction_diagnostics", { + p_match_report_id: reportId, + p_games: games, + }); + if (error) throw error; + return diagnosticsSchema.parse(data); +} + +type ReportRow = { + id: string; + match_id: string; + revision: number; + status: HostMatchReportReview["report"]["status"]; + screenshot_urls: string[] | null; + extracted_data: unknown; +}; + +export async function readHostMatchReportReview( + client: SupabaseClient, + input: { matchReportId: string; hostDiscordId: string }, +): Promise { + const untyped = client as unknown as UntypedClient; + const { data: rawReport, error: reportError } = await untyped + .from("match_reports") + .select("id,match_id,revision,status,screenshot_urls,extracted_data") + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .single(); + if (reportError) { + if (reportError.code === "PGRST116") return null; + throw reportError; + } + if (!rawReport || typeof rawReport !== "object") return null; + const report = rawReport as ReportRow; + if (report.status === "cancelled") return null; + const gamesResult = extractedGamesSchema.safeParse(report.extracted_data ?? []); + if (!gamesResult.success) throw new Error("Match report extraction has an invalid shape."); + + const { data: match, error: matchError } = await client + .from("matches") + .select("id,season_id,division_id,scheduled_date,week,home_org_id,away_org_id") + .eq("id", report.match_id) + .single(); + if (matchError || !match) throw matchError ?? new Error("Match not found."); + + const { data: orgRows, error: orgError } = await client + .from("orgs") + .select("id,name,tag") + .in("id", [match.home_org_id, match.away_org_id]); + if (orgError) throw orgError; + + const { data: rosterRows, error: rosterError } = await client + .from("season_rosters") + .select("player_id,org_id") + .eq("season_id", match.season_id ?? "") + .eq("division_id", match.division_id) + .in("org_id", [match.home_org_id, match.away_org_id]) + .eq("roster_status", "active"); + if (rosterError) throw rosterError; + const playerIds = [...new Set((rosterRows ?? []).map((row) => row.player_id))]; + const playerRows = await loadActiveRosterPlayers(client, playerIds); + const playerMap = new Map(playerRows.map((row) => [row.id, row.ign])); + const orgMap = new Map((orgRows ?? []).map((row) => [row.id, row])); + + const team = (orgId: string): HostReviewTeam => { + const org = orgMap.get(orgId); + if (!org) throw new Error("Match organization not found."); + return { + id: org.id, + name: org.name, + tag: org.tag, + roster: (rosterRows ?? []) + .filter((row) => row.org_id === orgId) + .flatMap((row) => { + const ign = playerMap.get(row.player_id); + return ign ? [{ id: row.player_id, ign }] : []; + }) + .sort((a, b) => a.ign.localeCompare(b.ign)), + }; + }; + + const diagnostics = await readExtractionDiagnostics( + untyped.rpc.bind(untyped), + report.id, + gamesResult.data, + ); + + return { + report: { + id: report.id, + revision: report.revision, + status: report.status, + screenshotUrls: report.screenshot_urls ?? [], + games: gamesResult.data, + diagnostics, + }, + match: { + id: match.id, + seasonId: match.season_id ?? "", + divisionId: match.division_id, + scheduledDate: match.scheduled_date, + week: match.week, + home: team(match.home_org_id), + away: team(match.away_org_id), + }, + }; +} diff --git a/src/lib/match-report-host/upload-service.test.ts b/src/lib/match-report-host/upload-service.test.ts new file mode 100644 index 0000000..5bd7f15 --- /dev/null +++ b/src/lib/match-report-host/upload-service.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { uploadHostReviewScreenshots, validateHostReviewScreenshotFiles } from "./upload-service"; + +describe("host screenshot limits", () => { + it("accepts one image per request, five total, below Vercel's 4.5 MB body limit", () => { + const png = new File([new Uint8Array([1])], "score.png", { type: "image/png" }); + expect(() => validateHostReviewScreenshotFiles(4, [png])).not.toThrow(); + + expect(() => validateHostReviewScreenshotFiles(5, [png])).toThrow(/at most 5/); + expect(() => validateHostReviewScreenshotFiles(0, [png, png])).toThrow(/one screenshot at a time/); + expect(() => validateHostReviewScreenshotFiles(0, [ + new File([new Uint8Array([1])], "score.svg", { type: "image/svg+xml" }), + ])).toThrow(/PNG, JPEG, or WebP/); + expect(() => validateHostReviewScreenshotFiles(0, [ + new File([new Uint8Array(4 * 1024 * 1024 + 1)], "huge.png", { type: "image/png" }), + ])).toThrow(/4 MB/); + expect(() => validateHostReviewScreenshotFiles(0, [ + new File([new Uint8Array(4 * 1024 * 1024)], "limit.png", { type: "image/png" }), + ])).not.toThrow(); + }); + + it("does not store a screenshot for a cancelled report", async () => { + const query = { + select() { return this; }, + eq() { return this; }, + single: async () => ({ + data: { id: "report-1", status: "cancelled", revision: 3, screenshot_urls: [] }, + error: null, + }), + }; + const storageFrom = vi.fn(); + const client = { from: () => query, storage: { from: storageFrom } }; + const screenshot = new File([new Uint8Array([1])], "score.png", { type: "image/png" }); + + await expect(uploadHostReviewScreenshots(client as never, { + matchReportId: "11111111-1111-4111-8111-111111111111", + hostDiscordId: "host-1", + files: [screenshot], + })).rejects.toMatchObject({ code: "42501" }); + expect(storageFrom).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/match-report-host/upload-service.ts b/src/lib/match-report-host/upload-service.ts new file mode 100644 index 0000000..b464cf0 --- /dev/null +++ b/src/lib/match-report-host/upload-service.ts @@ -0,0 +1,100 @@ +import { randomUUID } from "crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/types/database.types"; + +const MAX_SCREENSHOTS = 5; +// Vercel Functions reject the entire request above 4.5 MB. One file per +// multipart request with a 4 MB payload cap leaves room for form overhead. +const MAX_FILE_BYTES = 4 * 1024 * 1024; +const CONTENT_TYPES: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; + +type ErrorShape = { message: string; code?: string } | null; +type UntypedResult = PromiseLike<{ data: unknown; error: ErrorShape }>; +interface UntypedQuery extends UntypedResult { + select(columns: string): UntypedQuery; + eq(column: string, value: unknown): UntypedQuery; + update(values: Record): UntypedQuery; + single(): UntypedResult; +} +type UntypedClient = { from(table: string): UntypedQuery }; + +function failure(code: string, message: string) { + return Object.assign(new Error(message), { code }); +} + +export function validateHostReviewScreenshotFiles(existingCount: number, files: File[]) { + if (files.length !== 1) { + throw failure("22023", "Upload one screenshot at a time."); + } + if (files.length < 1 || existingCount + files.length > MAX_SCREENSHOTS) { + throw failure("22023", `A report can contain at most ${MAX_SCREENSHOTS} screenshots.`); + } + for (const file of files) { + if (!CONTENT_TYPES[file.type] || file.size < 1 || file.size > MAX_FILE_BYTES) { + throw failure("22023", "Screenshots must be PNG, JPEG, or WebP files no larger than 4 MB."); + } + } +} + +export async function uploadHostReviewScreenshots( + client: SupabaseClient, + input: { matchReportId: string; hostDiscordId: string; files: File[] }, +) { + const untyped = client as unknown as UntypedClient; + const { data: rawReport, error: reportError } = await untyped + .from("match_reports") + .select("id,status,revision,screenshot_urls") + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .single(); + if (reportError || !rawReport) throw reportError ?? failure("P0002", "Report not found."); + const report = rawReport as { + status: string; + revision: number; + screenshot_urls: string[] | null; + }; + if (report.status === "host_review" || report.status === "done" || report.status === "cancelled") { + throw failure("42501", "Report is no longer editable."); + } + const existingUrls = report.screenshot_urls ?? []; + validateHostReviewScreenshotFiles(existingUrls.length, input.files); + + const storage = client.storage.from("match-screenshots"); + const storedPaths: string[] = []; + const newUrls: string[] = []; + try { + for (const file of input.files) { + const path = `${input.matchReportId}/${randomUUID()}.${CONTENT_TYPES[file.type]}`; + const { error } = await storage.upload(path, Buffer.from(await file.arrayBuffer()), { + contentType: file.type, + upsert: false, + }); + if (error) throw error; + storedPaths.push(path); + newUrls.push(storage.getPublicUrl(path).data.publicUrl); + } + + const nextRevision = report.revision + 1; + const { data: updated, error: updateError } = await untyped + .from("match_reports") + .update({ + screenshot_urls: [...existingUrls, ...newUrls], + status: "pending", + revision: nextRevision, + }) + .eq("id", input.matchReportId) + .eq("host_discord_id", input.hostDiscordId) + .eq("revision", report.revision) + .select("revision") + .single(); + if (updateError || !updated) throw failure("40001", "Report changed during upload."); + return { urls: newUrls, allUrls: [...existingUrls, ...newUrls], revision: nextRevision }; + } catch (error) { + if (storedPaths.length > 0) await storage.remove(storedPaths); + throw error; + } +} diff --git a/src/types/match-report-host.ts b/src/types/match-report-host.ts new file mode 100644 index 0000000..c2d3bfe --- /dev/null +++ b/src/types/match-report-host.ts @@ -0,0 +1,54 @@ +import type { MatchReportStatus } from "@/types/match-report"; + +export type HostIdentityStatus = "linked" | "duplicate" | "unlinked" | "ambiguous"; + +export interface HostReviewPlayer { + index: number; + side: "home" | "away"; + rawIgn: string; + playerId: string | null; + identityStatus: HostIdentityStatus; +} + +export interface HostReviewDiagnostics { + gameCount: number; + duplicateIgns: string[]; + unlinkedIgns: string[]; + ambiguousIgns: string[]; + games: Array<{ + gameNumber: number; + players: HostReviewPlayer[]; + }>; +} + +export interface HostReviewRosterPlayer { + id: string; + ign: string; +} + +export interface HostReviewTeam { + id: string; + name: string; + tag: string; + roster: HostReviewRosterPlayer[]; +} + +export interface HostMatchReportReview { + report: { + id: string; + revision: number; + status: MatchReportStatus; + screenshotUrls: string[]; + games: import("@/types/match-report").ExtractedGame[]; + diagnostics: HostReviewDiagnostics; + }; + match: { + id: string; + seasonId: string; + divisionId: string; + scheduledDate: string; + week: number; + home: HostReviewTeam; + away: HostReviewTeam; + }; +} diff --git a/src/types/match-report.ts b/src/types/match-report.ts index dfe2b37..b5f8418 100644 --- a/src/types/match-report.ts +++ b/src/types/match-report.ts @@ -1,6 +1,6 @@ import type { DivisionId } from "@/types/league"; -export type MatchReportStatus = "pending" | "extracting" | "review" | "done"; +export type MatchReportStatus = "pending" | "extracting" | "review" | "host_review" | "done" | "cancelled"; export interface MatchReport { id: string; @@ -17,6 +17,8 @@ export interface MatchReport { createdAt: string; reviewedAt?: string; reviewedBy?: string; + revision?: number; + hostSubmittedAt?: string; } export interface PlayerMatchStat { @@ -57,6 +59,7 @@ export interface ExtractedPlayer { damageDealt?: number; damageMitigated?: number; forfeit?: boolean; + playerId?: string; } // Used in MatchReportClient — enriched with match metadata for display From 343e4a0f3c920ead83bc2e3988238736743ac91d Mon Sep 17 00:00:00 2001 From: "dustin.nieves" Date: Tue, 18 Aug 2026 23:55:01 -0400 Subject: [PATCH 2/2] fix: update nanoid security patch --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6cf9cb1..47b6e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7550,9 +7550,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github",