diff --git a/bun.lock b/bun.lock index 89d617a..c19a9d6 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "bancho.js": "^0.12.3", "discord.js": "^14.25.1", + "zod": "^4.3.6", }, "devDependencies": { "@commitlint/cli": "^20.5.0", diff --git a/package.json b/package.json index eedd5a2..3fcb420 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ }, "dependencies": { "bancho.js": "^0.12.3", - "discord.js": "^14.25.1" + "discord.js": "^14.25.1", + "zod": "^4.3.6" } } diff --git a/scripts/seed-staging.ts b/scripts/seed-staging.ts index c8c13a3..c25be1a 100644 --- a/scripts/seed-staging.ts +++ b/scripts/seed-staging.ts @@ -898,12 +898,10 @@ async function seedMappools(users: UserData[]): Promise { function selectPoolForElo(pools: PoolData[], avgElo: number): PoolData { const targetSr = Math.max(2, Math.min(8, 2 + avgElo / 700)); - let best = pools[0]; - let bestDist = Math.abs(best.avgSr - targetSr); + let bestDist = Math.abs(pools[0].avgSr - targetSr); for (const p of pools) { const dist = Math.abs(p.avgSr - targetSr); if (dist < bestDist) { - best = p; bestDist = dist; } } @@ -996,8 +994,8 @@ async function seedMatches( let matchState: string; const winnerId: string | null = null; let finishedAt: Date | null = null; - let startedAt: Date | null = null; - let gamesToPlay = 0; + let startedAt: Date | null; + let gamesToPlay: number; if (state === 'FINISHED') { matchState = 'FINISHED'; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0890b10..e2b0e7f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -29,6 +29,10 @@ if (!building) { .then(({ initDMHandler }) => initDMHandler()) .catch((err) => log.dm.warn({ err: err.message }, 'DM handler init skipped')); + import('$lib/server/tournament/scheduler') + .then(({ startTournamentScheduler }) => startTournamentScheduler()) + .catch((err) => log.tournament.warn({ err: err.message }, 'Tournament scheduler init skipped')); + process.on('unhandledRejection', (reason) => { log.http.error({ err: reason }, 'Unhandled rejection'); }); @@ -87,9 +91,22 @@ const handleRequestLogging: Handle = async ({ event, resolve }) => { // ── Auth + rate limiting ── const handleBetterAuth: Handle = async ({ event, resolve }) => { - // Rate limiting for API routes: 600 req/min per IP + // Rate limiting for API routes if (event.url.pathname.startsWith('/api/')) { const ip = event.getClientAddress(); + + // Stricter limit for auth endpoints: 10 req/min per IP + if (event.url.pathname.startsWith('/api/auth/')) { + const { ok, retryAfter } = checkRateLimit(`auth:${ip}`, 10, 60_000); + if (!ok) { + return new Response('Too Many Requests', { + status: 429, + headers: { 'Retry-After': String(retryAfter ?? 60), 'Content-Type': 'text/plain' } + }); + } + } + + // General API limit: 600 req/min per IP const { ok, retryAfter } = checkRateLimit(`api:${ip}`, 600, 60_000); if (!ok) { return new Response('Too Many Requests', { diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 1badd26..7f94db5 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -106,6 +106,22 @@ height="7" /> + {:else if name === 'tournaments'} + {:else if name === 'matches'}
+
+
+

Tournaments

+

+ {data.tournaments.length} tournament{data.tournaments.length !== 1 ? 's' : ''} +

+
+ + Create Tournament + +
+ + +
+ {#each [['all', 'All'], ['upcoming', 'Upcoming'], ['ongoing', 'Ongoing'], ['finished', 'Finished']] as [value, label]} + + {/each} +
+ + + {#if filtered.length === 0} +
+

No tournaments found

+
+ {:else} + + {/if} +
diff --git a/src/routes/(app)/tournaments/[id]/+page.server.ts b/src/routes/(app)/tournaments/[id]/+page.server.ts new file mode 100644 index 0000000..20758c1 --- /dev/null +++ b/src/routes/(app)/tournaments/[id]/+page.server.ts @@ -0,0 +1,291 @@ +import { error, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { + tournament, + tournamentRound, + tournamentMatch, + tournamentRegistration, + tournamentGroup, + tournamentGroupEntry, + tournamentStaff, + teamMember, + mappool +} from '$lib/server/db/schema'; +import { eq, and, asc, desc } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ params, locals }) => { + requireAuth(locals); + + const t = await db.query.tournament.findFirst({ + where: eq(tournament.id, params.id), + with: { + rounds: { + orderBy: [asc(tournamentRound.roundOrder)], + with: { mappool: { with: { slots: true } } } + }, + registrations: { + with: { team: true }, + orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)] + }, + staff: true, + groups: { + with: { + entries: { with: { team: true } } + } + } + } + }); + + if (!t) error(404, 'Tournament not found'); + + const matches = await db.query.tournamentMatch.findMany({ + where: eq(tournamentMatch.tournamentId, params.id), + with: { + round: true, + team1: true, + team2: true, + winner: true, + match: { + with: { + participants: { + with: { team: true }, + orderBy: (p: any, { asc }: any) => [asc(p.slot)] + } + } + } + } + }); + + // check if current user is staff + const isStaff = t.staff.some((s) => s.userId === locals.user!.id); + + // check if current user is registered + const userTeams = await db.query.teamMember.findMany({ + where: eq(teamMember.userId, locals.user!.id), + with: { team: true } + }); + + const userRegistration = t.registrations.find((r) => + userTeams.some((ut) => ut.teamId === r.teamId) + ); + + // load mappools for round config (staff only in DRAFT) + let mappools: { id: string; name: string; slotCount: number }[] = []; + if (isStaff) { + const allMappools = await db.query.mappool.findMany({ + with: { slots: { columns: { id: true } } }, + orderBy: desc(mappool.createdAt) + }); + mappools = allMappools.map((m) => ({ + id: m.id, + name: m.name, + slotCount: m.slots.length + })); + } + + return { + tournament: t, + matches, + isStaff, + userTeams: userTeams.map((ut) => ut.team), + userRegistration, + mappools + }; +}; + +export const actions: Actions = { + register: async ({ params, request, locals }) => { + const user = requireAuth(locals); + const form = await request.formData(); + const teamId = form.get('teamId')?.toString(); + + if (!teamId) return { error: 'Select a team' }; + + const { registerForTournament } = await import('$lib/server/tournament/lifecycle'); + try { + await registerForTournament(params.id, teamId, user.id); + return { success: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + withdraw: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + const teamId = form.get('teamId')?.toString(); + + if (!teamId) return { error: 'Missing teamId' }; + + const { withdrawFromTournament } = await import('$lib/server/tournament/lifecycle'); + try { + await withdrawFromTournament(params.id, teamId); + return { withdrawn: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + openRegistration: async ({ params, locals }) => { + requireAuth(locals); + const { openRegistration } = await import('$lib/server/tournament/lifecycle'); + try { + await openRegistration(params.id); + } catch (e: any) { + return { error: e.message }; + } + }, + + closeRegistration: async ({ params, locals }) => { + requireAuth(locals); + const { closeRegistration } = await import('$lib/server/tournament/lifecycle'); + try { + await closeRegistration(params.id); + } catch (e: any) { + return { error: e.message }; + } + }, + + finalizeSeeding: async ({ params, locals }) => { + requireAuth(locals); + const { finalizeSeeding } = await import('$lib/server/tournament/lifecycle'); + try { + await finalizeSeeding(params.id); + } catch (e: any) { + return { error: e.message }; + } + }, + + cancel: async ({ params, locals }) => { + requireAuth(locals); + const { cancelTournament } = await import('$lib/server/tournament/lifecycle'); + try { + await cancelTournament(params.id); + } catch (e: any) { + return { error: e.message }; + } + }, + + addRound: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + + const name = form.get('name')?.toString()?.trim(); + const abbreviation = form.get('abbreviation')?.toString()?.trim() || null; + const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5'); + const mappoolId = form.get('mappoolId')?.toString() || null; + const scheduledAt = form.get('scheduledAt')?.toString(); + + if (!name) return { error: 'Round name is required' }; + if (bestOf < 1 || bestOf % 2 === 0) return { error: 'Best of must be an odd number' }; + + // get current max round order + const existing = await db.query.tournamentRound.findMany({ + where: eq(tournamentRound.tournamentId, params.id), + orderBy: [desc(tournamentRound.roundOrder)] + }); + const nextOrder = existing.length > 0 ? existing[0].roundOrder + 1 : 0; + + try { + await db.insert(tournamentRound).values({ + tournamentId: params.id, + name, + abbreviation, + roundOrder: nextOrder, + bestOf, + mappoolId: mappoolId || null, + scheduledAt: scheduledAt ? new Date(scheduledAt) : null + }); + return { roundAdded: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + updateRound: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + + const roundId = form.get('roundId')?.toString(); + const name = form.get('name')?.toString()?.trim(); + const abbreviation = form.get('abbreviation')?.toString()?.trim() || null; + const bestOf = parseInt(form.get('bestOf')?.toString() ?? '5'); + const mappoolId = form.get('mappoolId')?.toString() || null; + const scheduledAt = form.get('scheduledAt')?.toString(); + + if (!roundId) return { error: 'Missing roundId' }; + if (!name) return { error: 'Round name is required' }; + + try { + await db + .update(tournamentRound) + .set({ + name, + abbreviation, + bestOf, + mappoolId: mappoolId || null, + scheduledAt: scheduledAt ? new Date(scheduledAt) : null + }) + .where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id))); + return { roundUpdated: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + deleteRound: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + const roundId = form.get('roundId')?.toString(); + + if (!roundId) return { error: 'Missing roundId' }; + + try { + await db + .delete(tournamentRound) + .where(and(eq(tournamentRound.id, roundId), eq(tournamentRound.tournamentId, params.id))); + return { roundDeleted: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + addStaff: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + const userId = form.get('userId')?.toString(); + const role = form.get('role')?.toString() ?? 'referee'; + + if (!userId) return { error: 'User is required' }; + + try { + await db.insert(tournamentStaff).values({ + tournamentId: params.id, + userId, + role + }); + return { staffAdded: true }; + } catch (e: any) { + return { error: e.message }; + } + }, + + removeStaff: async ({ params, request, locals }) => { + requireAuth(locals); + const form = await request.formData(); + const staffId = form.get('staffId')?.toString(); + + if (!staffId) return { error: 'Missing staffId' }; + + try { + await db + .delete(tournamentStaff) + .where(and(eq(tournamentStaff.id, staffId), eq(tournamentStaff.tournamentId, params.id))); + return { staffRemoved: true }; + } catch (e: any) { + return { error: e.message }; + } + } +}; diff --git a/src/routes/(app)/tournaments/[id]/+page.svelte b/src/routes/(app)/tournaments/[id]/+page.svelte new file mode 100644 index 0000000..fb5dc25 --- /dev/null +++ b/src/routes/(app)/tournaments/[id]/+page.svelte @@ -0,0 +1,903 @@ + + + + {t.name} — Vash Esports + + +
+ +
+
+
+

{t.name}

+ + {t.state} + +
+ {#if t.description} +

{t.description}

+ {/if} +

+ {formatLabels[t.format] ?? t.format} + · {activeRegs.length}/{t.maxSlots} players + {#if t.startAt} + · starts {new Date(t.startAt).toLocaleDateString()} + {/if} +

+
+
+ +
+
+ + + {#if actionError} +
+ {actionError} + +
+ {/if} + {#if actionSuccess} +
+ {actionSuccess} +
+ {/if} + + + {#if t.state === 'REGISTRATION' && !data.isStaff} +
+ {#if data.userRegistration} +
+ You are registered with {data.userRegistration.team?.name ?? 'your team'} +
handleAction()}> + + +
+
+ {:else if data.userTeams.length > 0} +
handleAction()} + class="flex items-center gap-3" + > + Register as: + + +
+ {:else} +

You need a team to register for this tournament

+ {/if} +
+ {/if} + + +
+ {#each tabs() as [value, label]} + + {/each} +
+ + +
+ {#if activeTab === 'overview'} + {@render overviewTab()} + {:else if activeTab === 'bracket'} + {@render bracketTab()} + {:else if activeTab === 'registrations'} + {@render registrationsTab()} + {:else if activeTab === 'rounds'} + {@render roundsTab()} + {:else if activeTab === 'groups'} + {@render groupsTab()} + {:else if activeTab === 'settings'} + {@render settingsTab()} + {/if} +
+
+ + +{#snippet overviewTab()} +
+ +
+

Details

+
+
+
Format
+
{formatLabels[t.format] ?? t.format}
+
+
+
Players
+
{activeRegs.length} / {t.maxSlots}
+
+
+
Team Size
+
+ {(t.config as any)?.teamSize ?? 1}v{(t.config as any)?.teamSize ?? 1} +
+
+
+
Scoring
+
{(t.config as any)?.scoringType ?? 'score_v2'}
+
+
+
Rounds
+
{t.rounds.length}
+
+ {#if t.startAt} +
+
Start Date
+
{new Date(t.startAt).toLocaleString()}
+
+ {/if} +
+
+ + +
+

Staff

+ {#if t.staff.length === 0} +

No staff assigned

+ {:else} +
+ {#each t.staff as s} +
+ {s.userId.slice(0, 8)}... + {s.role} +
+ {/each} +
+ {/if} +
+ + +
+

Invite Players

+

+ Share this link with players to let them register +

+
+ + +
+
+ + + {#if t.rounds.length > 0} +
+
+

Rounds

+ +
+
+ {#each t.rounds as round} +
+ {round.abbreviation ?? round.name} + BO{round.bestOf} + {#if round.mappool} + · {round.mappool.name} + {:else} + no pool + {/if} +
+ {/each} +
+
+ {/if} +
+{/snippet} + + +{#snippet bracketTab()} + {#if matches.length === 0} +
+

Bracket has not been generated yet

+
+ {:else} + + {#if wbRounds.length > 0} + {#if t.format === 'double_elim'} +

Winners Bracket

+ {/if} +
+ {#each wbRounds as { round, matches: roundMatches }} +
+
+ {round?.name ?? 'Round'} +
+
+ {#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m} + {@render matchCard(m)} + {/each} +
+
+ {/each} +
+ {/if} + + + {#if lbRounds.length > 0} +

Losers Bracket

+
+ {#each lbRounds as { round, matches: roundMatches }} +
+
+ {round?.name ?? 'Round'} +
+
+ {#each roundMatches.sort((a: any, b: any) => a.bracketPosition - b.bracketPosition) as m} + {@render matchCard(m)} + {/each} +
+
+ {/each} +
+ {/if} + + + {#if gfMatches.length > 0} +

Grand Finals

+
+ {#each gfMatches.sort((a: any, b: any) => (a.round?.roundOrder ?? 0) - (b.round?.roundOrder ?? 0)) as m} + {@render matchCard(m)} + {/each} +
+ {/if} + {/if} +{/snippet} + + +{#snippet registrationsTab()} +
+ {#if t.registrations.length === 0} +
No registrations yet
+ {:else} + + + + + + + + + + + {#each t.registrations as reg} + + + + + + + {/each} + +
SeedTeamStatusRegistered
{reg.seed ?? '-'}{reg.team?.name ?? 'Unknown'} + + {reg.status} + + + {new Date(reg.registeredAt).toLocaleDateString()} +
+ {/if} +
+{/snippet} + + +{#snippet roundsTab()} +
+ {#if t.rounds.length === 0 && !showAddRound} +
+

No rounds configured yet

+ {#if data.isStaff && t.state === 'DRAFT'} + + {/if} +
+ {:else} + {#each t.rounds as round} + {#if editingRoundId === round.id && data.isStaff} + +
handleActionWithMessage('Round updated')} + class="rounded-lg border border-accent/20 bg-surface-800 p-4" + > + +
+
+ Name + +
+
+ Abbreviation + +
+
+ Best Of + +
+
+ Mappool + +
+
+ Scheduled At + +
+
+
+ + +
+
+ {:else} + +
+
+
+ {round.name} + {#if round.abbreviation} + {round.abbreviation} + {/if} + BO{round.bestOf} +
+
+ {#if round.mappool} + {round.mappool.name} ({round.mappool.slots.length} maps) + {:else} + no mappool + {/if} + {#if round.scheduledAt} + {new Date(round.scheduledAt).toLocaleString()} + {/if} + {#if data.isStaff && t.state === 'DRAFT'} + +
handleActionWithMessage('Round deleted')} + > + + +
+ {/if} +
+
+
+ {/if} + {/each} + {/if} + + + {#if data.isStaff && t.state === 'DRAFT'} + {#if showAddRound} +
handleActionWithMessage('Round added')} + class="rounded-lg border border-accent/20 bg-surface-800 p-4" + > +

Add Round

+
+
+ Name + +
+
+ Abbreviation + +
+
+ Best Of + +
+
+ Mappool + +
+
+ Scheduled At (optional) + +
+
+
+ + +
+
+ {:else if t.rounds.length > 0} + + {/if} + {/if} +
+{/snippet} + + +{#snippet groupsTab()} +
+ {#each t.groups as group} +
+

{group.name}

+ + + + + + + + + + + {#each group.entries.sort((a: any, b: any) => b.wins - a.wins || b.mapWins - b.mapLosses - (a.mapWins - a.mapLosses)) as entry} + + + + + + + {/each} + +
TeamWLMD
{entry.team?.name ?? 'TBD'}{entry.wins}{entry.losses} + {entry.mapWins - entry.mapLosses > 0 ? '+' : ''}{entry.mapWins - entry.mapLosses} +
+
+ {/each} +
+{/snippet} + + +{#snippet settingsTab()} +
+ +
+

Tournament Actions

+

Manage the tournament state and lifecycle

+
+ {#if t.state === 'DRAFT'} +
handleAction()}> + +
+ {/if} + {#if t.state === 'REGISTRATION'} +
handleAction()}> + +
+ {/if} + {#if t.state === 'SEEDING' || t.state === 'QUALIFIERS'} +
handleAction()}> + +
+ {/if} + {#if t.state !== 'FINISHED' && t.state !== 'CANCELLED'} +
handleAction()}> + +
+ {/if} + {#if t.state === 'FINISHED' || t.state === 'CANCELLED'} +

This tournament has ended

+ {/if} +
+ {#if t.state === 'DRAFT' && t.rounds.length === 0} +

+ You need to add at least one round before opening registration +

+ {/if} +
+ + +
+

Staff

+
+ {#each t.staff as s} +
+
+ {s.userId.slice(0, 8)}... + {s.role} +
+ {#if t.staff.length > 1} +
handleActionWithMessage('Staff removed')} + > + + +
+ {/if} +
+ {/each} +
+
+ + +
+

Info

+
+
+
ID
+
{t.id}
+
+
+
Created
+
{new Date(t.createdAt).toLocaleString()}
+
+ {#if t.registrationOpenAt} +
+
Registration Opened
+
{new Date(t.registrationOpenAt).toLocaleString()}
+
+ {/if} + {#if t.finishedAt} +
+
Finished
+
{new Date(t.finishedAt).toLocaleString()}
+
+ {/if} +
+
+
+{/snippet} + + +{#snippet matchCard(m: any)} +
+
+ + {m.team1?.name ?? 'TBD'} + + {#if m.match?.participants} + {@const p = m.match.participants.find((p: any) => p.teamId === m.team1Id)} + {#if p} + {p.score} + {/if} + {/if} +
+
+ + {m.team2?.name ?? 'TBD'} + + {#if m.match?.participants} + {@const p = m.match.participants.find((p: any) => p.teamId === m.team2Id)} + {#if p} + {p.score} + {/if} + {/if} +
+ {#if m.state === 'LIVE'} +
LIVE
+ {:else if m.state === 'BYE'} +
BYE
+ {:else if m.matchId} + + View Match + + {/if} +
+{/snippet} diff --git a/src/routes/(app)/tournaments/new/+page.server.ts b/src/routes/(app)/tournaments/new/+page.server.ts new file mode 100644 index 0000000..72067ce --- /dev/null +++ b/src/routes/(app)/tournaments/new/+page.server.ts @@ -0,0 +1,55 @@ +import { redirect } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/permissions'; +import { createTournament } from '$lib/server/tournament/lifecycle'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + requireAuth(locals); + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const user = requireAuth(locals); + const form = await request.formData(); + + const name = form.get('name')?.toString()?.trim(); + const description = form.get('description')?.toString()?.trim(); + const format = form.get('format')?.toString() as + | 'single_elim' + | 'double_elim' + | 'groups_bracket'; + const maxSlots = parseInt(form.get('maxSlots')?.toString() ?? '16'); + const teamSize = parseInt(form.get('teamSize')?.toString() ?? '1'); + const scoringType = (form.get('scoringType')?.toString() ?? 'score_v2') as + | 'score' + | 'score_v2' + | 'accuracy' + | 'combo'; + const startAt = form.get('startAt')?.toString(); + + if (!name) return { error: 'Tournament name is required' }; + if (!format) return { error: 'Format is required' }; + + let tournamentId: string; + try { + const t = await createTournament({ + name, + description, + format, + config: { + teamSize, + scoringType + }, + maxSlots, + createdBy: user.id, + startAt: startAt ? new Date(startAt) : undefined + }); + tournamentId = t.id; + } catch (e: any) { + return { error: e.message }; + } + + redirect(303, `/tournaments/${tournamentId}`); + } +}; diff --git a/src/routes/(app)/tournaments/new/+page.svelte b/src/routes/(app)/tournaments/new/+page.svelte new file mode 100644 index 0000000..da9532e --- /dev/null +++ b/src/routes/(app)/tournaments/new/+page.svelte @@ -0,0 +1,158 @@ + + + + Create Tournament — Vash Esports + + +
+
+

Create Tournament

+

+ Set up your tournament, then configure rounds and mappools +

+
+ + {#if error} +

{error}

+ {/if} + +
{ + submitting = true; + error = ''; + return async ({ result, update }) => { + submitting = false; + if ( + result.type === 'failure' || + (result.type === 'success' && (result.data as any)?.error) + ) { + error = (result.data as any)?.error ?? 'Failed to create tournament'; + } else { + await update(); + } + }; + }} + class="mt-6 flex flex-col gap-5" + > + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ +
+ +
+
+
diff --git a/src/routes/api/invites/+server.ts b/src/routes/api/invites/+server.ts index 0fd481c..7dd29f5 100644 --- a/src/routes/api/invites/+server.ts +++ b/src/routes/api/invites/+server.ts @@ -1,26 +1,27 @@ import { json, error } from '@sveltejs/kit'; import { createInvite, getInvitesForUser } from '$lib/server/match/invites'; +import { requireAuth } from '$lib/server/permissions'; +import { createInviteSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) error(401, 'Not logged in'); - const invites = await getInvitesForUser(locals.user.id); + const user = requireAuth(locals); + const invites = await getInvitesForUser(user.id); return json(invites); }; export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user) error(401, 'Not logged in'); + const user = requireAuth(locals); const body = await request.json(); - const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = body; - - if (!creatorTeamId || !invitedTeamId || !config || !mappoolId) { - error(400, 'Missing required fields'); - } + const { creatorTeamId, invitedTeamId, config, mappoolId, name, message, scheduledAt } = parseBody( + createInviteSchema, + body + ); try { const invite = await createInvite({ - createdBy: locals.user.id, + createdBy: user.id, creatorTeamId, invitedTeamId, config, diff --git a/src/routes/api/mappools/+server.ts b/src/routes/api/mappools/+server.ts index 6e40215..b9b4bfe 100644 --- a/src/routes/api/mappools/+server.ts +++ b/src/routes/api/mappools/+server.ts @@ -1,11 +1,13 @@ -import { json, error } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { mappool } from '$lib/server/db/schema'; +import { requireAuth, requireRole } from '$lib/server/permissions'; +import { createMappoolSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; // List all mappools export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) error(401, 'Not logged in'); + requireAuth(locals); const mappools = await db.query.mappool.findMany({ orderBy: (m, { desc }) => [desc(m.createdAt)] @@ -14,18 +16,18 @@ export const GET: RequestHandler = async ({ locals }) => { return json(mappools); }; -// Create a mappool +// Create a mappool (admin only) export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user) error(401, 'Not logged in'); + requireRole(locals, 'admin', 'Only admins can create mappools'); - const { name } = await request.json(); - if (!name?.trim()) error(400, 'Name is required'); + const body = await request.json(); + const { name } = parseBody(createMappoolSchema, body); const [created] = await db .insert(mappool) .values({ - name: name.trim(), - createdBy: locals.user.id + name, + createdBy: locals.user!.id }) .returning(); diff --git a/src/routes/api/mappools/[id]/slots/+server.ts b/src/routes/api/mappools/[id]/slots/+server.ts index 231bd83..fa538ab 100644 --- a/src/routes/api/mappools/[id]/slots/+server.ts +++ b/src/routes/api/mappools/[id]/slots/+server.ts @@ -3,6 +3,7 @@ import { db } from '$lib/server/db'; import { mappool, mappoolSlot } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireOwnerOrAdmin } from '$lib/server/permissions'; +import { createMappoolSlotSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; // Add a slot to a mappool @@ -15,26 +16,20 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { requireOwnerOrAdmin(locals, pool.createdBy, 'Only the mappool creator or an admin can add slots'); - const { beatmapId, category, mods } = await request.json(); - - if (!beatmapId || !category) { - error(400, 'beatmapId and category are required'); - } + const body = await request.json(); + const { beatmapId, category, mods } = parseBody(createMappoolSlotSchema, body); // Count existing slots in this category to auto-increment order const existing = await db.query.mappoolSlot.findMany({ - where: and( - eq(mappoolSlot.mappoolId, params.id), - eq(mappoolSlot.category, category.toUpperCase()) - ) + where: and(eq(mappoolSlot.mappoolId, params.id), eq(mappoolSlot.category, category)) }); const [created] = await db .insert(mappoolSlot) .values({ mappoolId: params.id, - beatmapId: String(beatmapId), - category: category.toUpperCase(), + beatmapId, + category, orderInCategory: existing.length + 1, mods: mods ?? [] }) diff --git a/src/routes/api/matches/+server.ts b/src/routes/api/matches/+server.ts index 2069e3f..0863cfd 100644 --- a/src/routes/api/matches/+server.ts +++ b/src/routes/api/matches/+server.ts @@ -2,10 +2,12 @@ import { json, error } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { createMatch } from '$lib/server/match/engine'; +import { requireAuth, requireRole } from '$lib/server/permissions'; +import { createMatchSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ locals }) => { - if (!locals.user) error(401, 'Not logged in'); + requireAuth(locals); const matches = await db.query.match.findMany({ with: { @@ -18,14 +20,10 @@ export const GET: RequestHandler = async ({ locals }) => { }; export const POST: RequestHandler = async ({ request, locals }) => { - if (!locals.user) error(401, 'Not logged in'); + requireRole(locals, 'referee', 'Only referees or admins can create matches'); const body = await request.json(); - const { name, config, mappoolId, teams } = body; - - if (!name || !config || !mappoolId || !teams?.length) { - error(400, 'Missing required fields'); - } + const { name, config, mappoolId, teams } = parseBody(createMatchSchema, body); try { const result = await createMatch({ @@ -33,7 +31,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { config, mappoolId, teams, - createdBy: locals.user.id + createdBy: locals.user!.id }); return json(result, { status: 201 }); } catch (e: any) { diff --git a/src/routes/api/matches/[id]/pick/+server.ts b/src/routes/api/matches/[id]/pick/+server.ts index 4dd53b4..a5a7bc0 100644 --- a/src/routes/api/matches/[id]/pick/+server.ts +++ b/src/routes/api/matches/[id]/pick/+server.ts @@ -3,15 +3,14 @@ import { json, error } from '@sveltejs/kit'; import { pickMap } from '$lib/server/match/engine'; import { requireAuth, hasRole } from '$lib/server/permissions'; import { getMatchFull } from '$lib/server/match/helpers'; +import { pickMapSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ params, request, locals }) => { const user = requireAuth(locals); - const { mappoolSlotId } = await request.json(); - if (!mappoolSlotId) { - error(400, 'mappoolSlotId required'); - } + const body = await request.json(); + const { mappoolSlotId } = parseBody(pickMapSchema, body); const match = await getMatchFull(params.id); diff --git a/src/routes/api/matches/[id]/roll/+server.ts b/src/routes/api/matches/[id]/roll/+server.ts index b49c0ae..ba8f2e0 100644 --- a/src/routes/api/matches/[id]/roll/+server.ts +++ b/src/routes/api/matches/[id]/roll/+server.ts @@ -3,15 +3,14 @@ import { json, error } from '@sveltejs/kit'; import { submitRoll } from '$lib/server/match/engine'; import { requireAuth, hasRole } from '$lib/server/permissions'; import { getMatchFull } from '$lib/server/match/helpers'; +import { submitRollSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ params, request, locals }) => { const user = requireAuth(locals); - const { value } = await request.json(); - if (value == null) { - error(400, 'value required'); - } + const body = await request.json(); + const { value } = parseBody(submitRollSchema, body); const match = await getMatchFull(params.id); diff --git a/src/routes/api/matches/[id]/score/+server.ts b/src/routes/api/matches/[id]/score/+server.ts index bf95e7a..1edbfc4 100644 --- a/src/routes/api/matches/[id]/score/+server.ts +++ b/src/routes/api/matches/[id]/score/+server.ts @@ -2,15 +2,14 @@ import { json, error } from '@sveltejs/kit'; import { submitGameScores } from '$lib/server/match/engine'; import { requireRole } from '$lib/server/permissions'; +import { submitScoreSchema, parseBody } from '$lib/server/validation'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, locals }) => { requireRole(locals, 'referee', 'Only referees or admins can submit scores'); - const { matchGameId, scores } = await request.json(); - if (!matchGameId || !scores?.length) { - error(400, 'matchGameId and scores required'); - } + const body = await request.json(); + const { matchGameId, scores } = parseBody(submitScoreSchema, body); try { const m = await submitGameScores(matchGameId, scores); diff --git a/src/routes/api/tournaments/+server.ts b/src/routes/api/tournaments/+server.ts new file mode 100644 index 0000000..d73d043 --- /dev/null +++ b/src/routes/api/tournaments/+server.ts @@ -0,0 +1,76 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournament } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { createTournament } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url }) => { + const state = url.searchParams.get('state'); + const format = url.searchParams.get('format'); + + const query = db.query.tournament.findMany({ + with: { + registrations: true, + staff: true + }, + orderBy: [desc(tournament.createdAt)] + }); + + const results = await query; + + // filter in memory (drizzle doesn't support dynamic where chaining easily) + let filtered = results; + if (state) filtered = filtered.filter((t) => t.state === state); + if (format) filtered = filtered.filter((t) => t.format === format); + + return json(filtered); +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + const user = requireAuth(locals); + const body = await request.json(); + + const { + name, + description, + format, + config, + maxSlots, + bannerUrl, + registrationOpenAt, + registrationCloseAt, + startAt + } = body; + + if (!name || !format || !config || !maxSlots) { + error(400, 'Missing required fields: name, format, config, maxSlots'); + } + + if (!['single_elim', 'double_elim', 'groups_bracket'].includes(format)) { + error(400, 'Invalid format. Must be single_elim, double_elim, or groups_bracket'); + } + + if (!config.teamSize || !config.scoringType) { + error(400, 'Config must include teamSize and scoringType'); + } + + try { + const result = await createTournament({ + name, + description, + format, + config, + maxSlots, + bannerUrl, + createdBy: user.id, + registrationOpenAt: registrationOpenAt ? new Date(registrationOpenAt) : undefined, + registrationCloseAt: registrationCloseAt ? new Date(registrationCloseAt) : undefined, + startAt: startAt ? new Date(startAt) : undefined + }); + return json(result, { status: 201 }); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/+server.ts b/src/routes/api/tournaments/[id]/+server.ts new file mode 100644 index 0000000..697e3ea --- /dev/null +++ b/src/routes/api/tournaments/[id]/+server.ts @@ -0,0 +1,83 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournament, tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const result = await getTournamentFull(params.id); + if (!result) error(404, 'Tournament not found'); + return json(result); +}; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + await requireTournamentStaff(params.id, user.id); + + const t = await db.query.tournament.findFirst({ + where: eq(tournament.id, params.id) + }); + if (!t) error(404, 'Tournament not found'); + if (t.state !== 'DRAFT') error(400, 'Can only edit tournaments in DRAFT state'); + + const body = await request.json(); + const allowed = [ + 'name', + 'description', + 'format', + 'config', + 'maxSlots', + 'bannerUrl', + 'registrationOpenAt', + 'registrationCloseAt', + 'startAt' + ]; + const updates: Record = {}; + + for (const key of allowed) { + if (body[key] !== undefined) { + if (key.endsWith('At') && body[key]) { + updates[key] = new Date(body[key]); + } else { + updates[key] = body[key]; + } + } + } + + if (Object.keys(updates).length === 0) { + error(400, 'No valid fields to update'); + } + + await db.update(tournament).set(updates).where(eq(tournament.id, params.id)); + return json(await getTournamentFull(params.id)); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + await requireTournamentStaff(params.id, user.id); + + const t = await db.query.tournament.findFirst({ + where: eq(tournament.id, params.id) + }); + if (!t) error(404, 'Tournament not found'); + + if (t.state === 'DRAFT') { + await db.delete(tournament).where(eq(tournament.id, params.id)); + return json({ deleted: true }); + } + + // for non-draft, cancel instead of delete + const { cancelTournament } = await import('$lib/server/tournament/lifecycle'); + await cancelTournament(params.id); + return json(await getTournamentFull(params.id)); +}; + +async function requireTournamentStaff(tournamentId: string, userId: string) { + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, tournamentId), eq(tournamentStaff.userId, userId)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + return staff; +} diff --git a/src/routes/api/tournaments/[id]/bracket/+server.ts b/src/routes/api/tournaments/[id]/bracket/+server.ts new file mode 100644 index 0000000..64945a4 --- /dev/null +++ b/src/routes/api/tournaments/[id]/bracket/+server.ts @@ -0,0 +1,28 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentMatch, tournamentRound } from '$lib/server/db/schema'; +import { eq, asc } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const rounds = await db.query.tournamentRound.findMany({ + where: eq(tournamentRound.tournamentId, params.id), + orderBy: [asc(tournamentRound.roundOrder)] + }); + + const matches = await db.query.tournamentMatch.findMany({ + where: eq(tournamentMatch.tournamentId, params.id), + with: { + team1: true, + team2: true, + winner: true, + match: { + with: { + participants: { with: { team: true } } + } + } + } + }); + + return json({ rounds, matches }); +}; diff --git a/src/routes/api/tournaments/[id]/cancel/+server.ts b/src/routes/api/tournaments/[id]/cancel/+server.ts new file mode 100644 index 0000000..4cde9ec --- /dev/null +++ b/src/routes/api/tournaments/[id]/cancel/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { cancelTournament, getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + try { + await cancelTournament(params.id); + return json(await getTournamentFull(params.id)); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/close-registration/+server.ts b/src/routes/api/tournaments/[id]/close-registration/+server.ts new file mode 100644 index 0000000..66dca17 --- /dev/null +++ b/src/routes/api/tournaments/[id]/close-registration/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { closeRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + try { + await closeRegistration(params.id); + return json(await getTournamentFull(params.id)); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/finalize-seeding/+server.ts b/src/routes/api/tournaments/[id]/finalize-seeding/+server.ts new file mode 100644 index 0000000..106bf40 --- /dev/null +++ b/src/routes/api/tournaments/[id]/finalize-seeding/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { finalizeSeeding, getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + try { + await finalizeSeeding(params.id); + return json(await getTournamentFull(params.id)); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/matches/+server.ts b/src/routes/api/tournaments/[id]/matches/+server.ts new file mode 100644 index 0000000..f0de822 --- /dev/null +++ b/src/routes/api/tournaments/[id]/matches/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentMatch } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const matches = await db.query.tournamentMatch.findMany({ + where: eq(tournamentMatch.tournamentId, params.id), + with: { + round: true, + team1: true, + team2: true, + winner: true, + match: { + with: { + participants: { with: { team: true } }, + games: { with: { slot: true } } + } + } + } + }); + + return json(matches); +}; diff --git a/src/routes/api/tournaments/[id]/open-registration/+server.ts b/src/routes/api/tournaments/[id]/open-registration/+server.ts new file mode 100644 index 0000000..6bdca88 --- /dev/null +++ b/src/routes/api/tournaments/[id]/open-registration/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { openRegistration, getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + try { + await openRegistration(params.id); + return json(await getTournamentFull(params.id)); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/qualifiers/+server.ts b/src/routes/api/tournaments/[id]/qualifiers/+server.ts new file mode 100644 index 0000000..677bd13 --- /dev/null +++ b/src/routes/api/tournaments/[id]/qualifiers/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import { + getQualifierRankings, + getQualifierMappool, + submitQualifierScore +} from '$lib/server/tournament/qualifiers'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const [rankings, mappool] = await Promise.all([ + getQualifierRankings(params.id), + getQualifierMappool(params.id) + ]); + + return json({ rankings, mappool }); +}; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + + // only staff can submit qualifier scores (referees run qualifier lobbies) + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Only tournament staff can submit qualifier scores'); + + const body = await request.json(); + const { teamId, mappoolSlotId, totalScore, accuracy } = body; + + if (!teamId || !mappoolSlotId || totalScore === undefined) { + error(400, 'Missing required fields: teamId, mappoolSlotId, totalScore'); + } + + try { + const score = await submitQualifierScore({ + tournamentId: params.id, + teamId, + mappoolSlotId, + totalScore, + accuracy + }); + return json(score, { status: 201 }); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/register/+server.ts b/src/routes/api/tournaments/[id]/register/+server.ts new file mode 100644 index 0000000..1c91d38 --- /dev/null +++ b/src/routes/api/tournaments/[id]/register/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import { requireAuth } from '$lib/server/permissions'; +import { registerForTournament, withdrawFromTournament } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + const body = await request.json(); + + if (!body.teamId) error(400, 'Missing teamId'); + + try { + const reg = await registerForTournament(params.id, body.teamId, user.id); + return json(reg, { status: 201 }); + } catch (e: any) { + error(400, e.message); + } +}; + +export const DELETE: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + const body = await request.json(); + + if (!body.teamId) error(400, 'Missing teamId'); + + try { + await withdrawFromTournament(params.id, body.teamId); + return json({ withdrawn: true }); + } catch (e: any) { + error(400, e.message); + } +}; diff --git a/src/routes/api/tournaments/[id]/rounds/+server.ts b/src/routes/api/tournaments/[id]/rounds/+server.ts new file mode 100644 index 0000000..dc36a37 --- /dev/null +++ b/src/routes/api/tournaments/[id]/rounds/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentRound, tournamentStaff } from '$lib/server/db/schema'; +import { eq, and, asc } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const rounds = await db.query.tournamentRound.findMany({ + where: eq(tournamentRound.tournamentId, params.id), + with: { mappool: { with: { slots: true } } }, + orderBy: [asc(tournamentRound.roundOrder)] + }); + return json(rounds); +}; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + const body = await request.json(); + const { name, abbreviation, bestOf, mappoolId, bracketType, scheduledAt } = body; + + if (!name || !bestOf) error(400, 'Missing required fields: name, bestOf'); + + // get next round order + const existing = await db.query.tournamentRound.findMany({ + where: eq(tournamentRound.tournamentId, params.id) + }); + + const [created] = await db + .insert(tournamentRound) + .values({ + tournamentId: params.id, + name, + abbreviation, + roundOrder: existing.length, + bestOf, + mappoolId, + bracketType, + scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined + }) + .returning(); + + return json(created, { status: 201 }); +}; diff --git a/src/routes/api/tournaments/[id]/rounds/[roundId]/+server.ts b/src/routes/api/tournaments/[id]/rounds/[roundId]/+server.ts new file mode 100644 index 0000000..64362d2 --- /dev/null +++ b/src/routes/api/tournaments/[id]/rounds/[roundId]/+server.ts @@ -0,0 +1,50 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentRound, tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { RequestHandler } from './$types'; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + const body = await request.json(); + const updates: Record = {}; + + if (body.name !== undefined) updates.name = body.name; + if (body.abbreviation !== undefined) updates.abbreviation = body.abbreviation; + if (body.bestOf !== undefined) updates.bestOf = body.bestOf; + if (body.mappoolId !== undefined) updates.mappoolId = body.mappoolId; + if (body.scheduledAt !== undefined) + updates.scheduledAt = body.scheduledAt ? new Date(body.scheduledAt) : null; + + if (Object.keys(updates).length === 0) { + error(400, 'No valid fields to update'); + } + + await db.update(tournamentRound).set(updates).where(eq(tournamentRound.id, params.roundId)); + + const updated = await db.query.tournamentRound.findFirst({ + where: eq(tournamentRound.id, params.roundId), + with: { mappool: { with: { slots: true } } } + }); + + return json(updated); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + await db.delete(tournamentRound).where(eq(tournamentRound.id, params.roundId)); + return json({ deleted: true }); +}; diff --git a/src/routes/api/tournaments/[id]/staff/+server.ts b/src/routes/api/tournaments/[id]/staff/+server.ts new file mode 100644 index 0000000..508faa9 --- /dev/null +++ b/src/routes/api/tournaments/[id]/staff/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const staff = await db.query.tournamentStaff.findMany({ + where: eq(tournamentStaff.tournamentId, params.id) + }); + return json(staff); +}; + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const user = requireAuth(locals); + + // only organizers can add staff + const actor = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) { + error(403, 'Only organizers can manage staff'); + } + + const body = await request.json(); + if (!body.userId || !body.role) error(400, 'Missing userId or role'); + + const validRoles = ['organizer', 'admin', 'referee', 'pooler', 'streamer']; + if (!validRoles.includes(body.role)) { + error(400, `Invalid role. Must be one of: ${validRoles.join(', ')}`); + } + + try { + const [created] = await db + .insert(tournamentStaff) + .values({ + tournamentId: params.id, + userId: body.userId, + role: body.role + }) + .returning(); + return json(created, { status: 201 }); + } catch (e: any) { + error(400, 'User is already a staff member'); + } +}; diff --git a/src/routes/api/tournaments/[id]/staff/[staffId]/+server.ts b/src/routes/api/tournaments/[id]/staff/[staffId]/+server.ts new file mode 100644 index 0000000..c6d04a4 --- /dev/null +++ b/src/routes/api/tournaments/[id]/staff/[staffId]/+server.ts @@ -0,0 +1,20 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import type { RequestHandler } from './$types'; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const actor = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!actor || (actor.role !== 'organizer' && actor.role !== 'admin')) { + error(403, 'Only organizers can manage staff'); + } + + await db.delete(tournamentStaff).where(eq(tournamentStaff.id, params.staffId)); + return json({ deleted: true }); +}; diff --git a/src/routes/api/tournaments/[id]/start-qualifiers/+server.ts b/src/routes/api/tournaments/[id]/start-qualifiers/+server.ts new file mode 100644 index 0000000..509c85f --- /dev/null +++ b/src/routes/api/tournaments/[id]/start-qualifiers/+server.ts @@ -0,0 +1,27 @@ +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { tournamentStaff } from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '$lib/server/permissions'; +import { getTournamentFull } from '$lib/server/tournament/lifecycle'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, locals }) => { + const user = requireAuth(locals); + + const staff = await db.query.tournamentStaff.findFirst({ + where: and(eq(tournamentStaff.tournamentId, params.id), eq(tournamentStaff.userId, user.id)) + }); + if (!staff) error(403, 'Not a staff member of this tournament'); + + // qualifiers are started via closeRegistration when qualifiers are enabled + // this endpoint is for manually triggering qualifier finalization + const { finalizeQualifiers } = await import('$lib/server/tournament/qualifiers'); + + try { + await finalizeQualifiers(params.id); + return json(await getTournamentFull(params.id)); + } catch (e: any) { + error(400, e.message); + } +};