import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/shared/database"; import { tournamentEdit, tournamentSettings, type TournamentView, } from "@/shared/tournaments"; import { datasetSchema, faces, panoramaPath, viewpointSchema, } from "@/shared/protocol"; import { roomGuessSchema, roomRoundSchema } from "@/shared/multiplayer"; import { ServiceError, checkServiceError } from "./service-error"; type Dependencies = { db: SupabaseClient; account: () => Promise<{ id: string } | null>; jsonBody: (request: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; mediaUrls: (paths: (string | null)[]) => Promise<(string | null)[]>; announce: (target?: string) => void; serverTimezone: string; }; function unwrap(result: { data: T; error: { message: string; code?: string } | null; }): NonNullable { checkServiceError(result.error); if (result.data == null) throw new ServiceError("Tournament not found", 404); return result.data; } export async function tournamentApi( request: NextRequest, path: string[], deps: Dependencies, ) { const { db } = deps; const respond = (value: unknown) => NextResponse.json(value, { headers: { "Cache-Control": "no-store" } }); if (path.length === 1 && request.method === "GET") { const tournaments = unwrap( await db .from("tournaments") .select( "id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord,finished_at,winner_id", ) .order("starts_at", { ascending: false }) .limit(100), ); return respond({ tournaments, serverNow: Date.now(), serverTimezone: deps.serverTimezone, }); } if (path.length === 1 && request.method === "POST") { const account = await deps.account(); if (!account) throw new ServiceError("Sign in to create a tournament", 401); const body = tournamentSettings.parse(await deps.jsonBody(request)); const start = Date.parse(body.startsAt), now = Date.now(); if (start <= now || start > now + 90 * 86400000) throw new ServiceError( "Schedule the tournament within the next 90 days", 400, ); await deps.limit("tournaments:create:" + account.id, 5, 86400); const id = unwrap( await db.rpc("create_tournament", { p_owner: account.id, p_payload: { ...body, serverTimezone: deps.serverTimezone }, }), ); deps.announce(String(id)); return respond({ id }); } if (path.length < 2 || path.length > 3) throw new ServiceError("Not found", 404); const id = z.string().uuid().parse(path[1]); const action = path[2] ?? "view"; const read = request.method === "GET" && (action === "view" || action === "media"); const write = request.method === "POST" && [ "join", "leave", "cancel", "edit", "start", "ready", "guess", "next", ].includes(action); if (!read && !write) throw new ServiceError("Not found", 404); const account = await deps.account(); if (!account && (write || action === "media")) throw new ServiceError("Sign in to join or play a tournament", 401); let payload = {}; if (write) { const body = await deps.jsonBody(request); payload = action === "guess" ? roomGuessSchema .extend({ tournamentRound: z.number().int().min(0).max(9) }) .strict() .parse(body) : ["ready", "next"].includes(action) ? roomRoundSchema .extend({ tournamentRound: z.number().int().min(0).max(9) }) .strict() .parse(body) : action === "edit" ? tournamentEdit.parse(body) : z.object({}).strict().parse(body); await deps.limit( `tournaments:${action}:${account!.id}`, action === "guess" || action === "ready" ? 180 : 30, 60, ); } const view = unwrap( await db.rpc("tournament_action", { p_tournament: id, p_account: account?.id ?? null, p_action: read ? "view" : action, p_payload: payload, }), ) as unknown as TournamentView; if (write && action === "edit") deps.announce(id); if (action !== "media") return respond(view); const requestedRound = Number( z .string() .regex(/^[0-9]$/) .parse(request.nextUrl.searchParams.get("tournamentRound") ?? undefined), ); if (requestedRound !== view.currentRound) throw new ServiceError( "The tournament advanced. Reconnect to load the current round.", 409, ); const room = view.currentRoom; if ( !room?.you || !room.id || !["loading", "playing", "results", "finished"].includes(room.status) ) throw new ServiceError("This round is not available yet", 409); const [record, point] = await Promise.all([ db .from("multiplayer_rooms") .select("revision_id") .eq("id", room.id) .single(), db .from("multiplayer_rounds") .select("viewpoint") .eq("room_id", room.id) .eq("round_number", room.round) .single(), ]); const revision = unwrap( await db .from("revisions") .select("asset_root,manifest") .eq("id", unwrap(record).revision_id) .single(), ); const manifest = datasetSchema.parse(revision.manifest); const viewpoint = viewpointSchema.parse(unwrap(point).viewpoint); const signed = await deps.mediaUrls([ manifest.mapPath ?? `${revision.asset_root}/map.webp`, ...faces.map((face) => panoramaPath(revision.asset_root, viewpoint, `${face}.webp`), ), ]); return respond({ round: room.round, map: signed[0], bounds: manifest.bounds, faces: signed.slice(1), }); }