diff --git a/prisma/migrations/20260330214501_add_site_admin_and_casual_match/migration.sql b/prisma/migrations/20260330214501_add_site_admin_and_casual_match/migration.sql
new file mode 100644
index 0000000..5bdd608
--- /dev/null
+++ b/prisma/migrations/20260330214501_add_site_admin_and_casual_match/migration.sql
@@ -0,0 +1,2 @@
+-- Add isCasual field to Match model
+ALTER TABLE "matches" ADD COLUMN "isCasual" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a07bfc7..1f84057 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -41,7 +41,7 @@ model User {
emailVerified Boolean @default(false)
name String?
image String?
- role String @default("player")
+ role String @default("player") // player, tournament_admin, club_admin, site_admin
playerId Int? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -163,6 +163,7 @@ model Match {
team1Score Int
team2Score Int
status String @default("completed")
+ isCasual Boolean @default(false)
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/src/app/admin/tournaments/[id]/page.tsx b/src/app/admin/tournaments/[id]/page.tsx
index 20754a7..6c02516 100644
--- a/src/app/admin/tournaments/[id]/page.tsx
+++ b/src/app/admin/tournaments/[id]/page.tsx
@@ -2,8 +2,9 @@ import { prisma } from "@/lib/prisma"
import Navigation from "@/components/Navigation"
import Link from "next/link"
import { notFound, redirect } from "next/navigation"
-import { canManageTournament, hasRole } from "@/lib/permissions"
+import { canManageTournament, canDeleteTournament } from "@/lib/permissions"
import { getTournamentStatus } from "@/lib/tournamentUtils"
+import { DeleteTournamentButton } from "@/components/DeleteTournamentButton"
interface PageProps {
params: {
@@ -20,8 +21,8 @@ export default async function TournamentDetailPage({ params }: PageProps) {
redirect("/auth/login")
}
- // Check if user can create tournaments (tournament_admin or club_admin)
- const canCreate = await hasRole('tournament_admin')
+ // Check if user can delete this tournament
+ const deletePermission = await canDeleteTournament(tournamentId)
let tournament = await prisma.event.findUnique({
where: { id: tournamentId },
@@ -119,6 +120,8 @@ export default async function TournamentDetailPage({ params }: PageProps) {
orderBy: { playedAt: "desc" },
})
+ const matchCount = matches.length
+
return (
@@ -173,6 +176,13 @@ export default async function TournamentDetailPage({ params }: PageProps) {
>
Export CSV
+ {deletePermission.allowed && (
+
+ )}
>
)}
diff --git a/src/app/api/admin/tournaments/[id]/route.ts b/src/app/api/admin/tournaments/[id]/route.ts
new file mode 100644
index 0000000..7ca9c7e
--- /dev/null
+++ b/src/app/api/admin/tournaments/[id]/route.ts
@@ -0,0 +1,108 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { canDeleteTournament } from "@/lib/permissions";
+
+/**
+ * DELETE /api/admin/tournaments/[id]
+ *
+ * Delete a tournament and optionally its associated matches
+ * Options:
+ * - deleteMatches: Delete all matches associated with the tournament
+ * - orphanMatches: Keep matches but remove tournament association (eventId becomes null)
+ */
+export async function DELETE(
+ request: Request,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const tournamentId = parseInt(params.id);
+ const body = await request.json();
+ const { deleteMatches = false } = body;
+
+ // Validate tournament ID
+ if (isNaN(tournamentId)) {
+ return NextResponse.json(
+ { error: "Invalid tournament ID" },
+ { status: 400 }
+ );
+ }
+
+ // Check if user has permission to delete this tournament
+ const permission = await canDeleteTournament(tournamentId);
+ if (!permission.allowed) {
+ return NextResponse.json(
+ { error: permission.reason || "Not authorized to delete this tournament" },
+ { status: 403 }
+ );
+ }
+
+ // Verify tournament exists
+ const tournament = await prisma.event.findUnique({
+ where: { id: tournamentId },
+ include: { matches: true },
+ });
+
+ if (!tournament) {
+ return NextResponse.json(
+ { error: "Tournament not found" },
+ { status: 404 }
+ );
+ }
+
+ const matchCount = tournament.matches.length;
+
+ // Handle matches based on user choice
+ if (deleteMatches) {
+ // Delete all matches associated with the tournament
+ await prisma.match.deleteMany({
+ where: { eventId: tournamentId },
+ });
+ } else {
+ // Orphan matches - remove tournament association
+ await prisma.match.updateMany({
+ where: { eventId: tournamentId },
+ data: { eventId: null },
+ });
+ }
+
+ // Delete event participants
+ await prisma.eventParticipant.deleteMany({
+ where: { eventId: tournamentId },
+ });
+
+ // Delete teams
+ await prisma.team.deleteMany({
+ where: { eventId: tournamentId },
+ });
+
+ // Delete tournament rounds
+ await prisma.tournamentRound.deleteMany({
+ where: { eventId: tournamentId },
+ });
+
+ // Delete bracket matchups
+ await prisma.bracketMatchup.deleteMany({
+ where: { eventId: tournamentId },
+ });
+
+ // Delete the tournament itself
+ await prisma.event.delete({
+ where: { id: tournamentId },
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: "Tournament deleted successfully",
+ deletedTournament: tournament.name,
+ deletedMatches: deleteMatches ? matchCount : 0,
+ orphanedMatches: deleteMatches ? 0 : matchCount,
+ });
+ } catch (error: unknown) {
+ console.error("Error deleting tournament:", error);
+ const message = error instanceof Error ? error.message : "Failed to delete tournament";
+ return NextResponse.json(
+ { error: message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/matches/route.ts b/src/app/api/matches/route.ts
new file mode 100644
index 0000000..18c069d
--- /dev/null
+++ b/src/app/api/matches/route.ts
@@ -0,0 +1,193 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { getSession } from "@/lib/auth-simple";
+
+import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils";
+
+const K_FACTOR = 32; // Standard K-factor for Elo calculations
+
+/**
+ * POST /api/matches
+ *
+ * Create a casual match (not associated with a tournament)
+ * Requires: player role or higher
+ */
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const {
+ team1P1Id,
+ team1P2Id,
+ team2P1Id,
+ team2P2Id,
+ team1Score,
+ team2Score,
+ playedAt,
+ isCasual = true,
+ } = body;
+
+ // Validate required fields
+ if (!team1P1Id || !team1P2Id || !team2P1Id || !team2P2Id) {
+ return NextResponse.json(
+ { error: "All four player IDs are required" },
+ { status: 400 }
+ );
+ }
+
+ if (team1Score === undefined || team2Score === undefined) {
+ return NextResponse.json(
+ { error: "Both team scores are required" },
+ { status: 400 }
+ );
+ }
+
+ // Check permissions - need at least player role
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json(
+ { error: "Not authenticated" },
+ { status: 403 }
+ );
+ }
+
+ const userId = session.user?.id || null;
+
+ // Fetch player data
+ const [player1, player2, player3, player4] = await Promise.all([
+ prisma.player.findUnique({ where: { id: parseInt(team1P1Id) } }),
+ prisma.player.findUnique({ where: { id: parseInt(team1P2Id) } }),
+ prisma.player.findUnique({ where: { id: parseInt(team2P1Id) } }),
+ prisma.player.findUnique({ where: { id: parseInt(team2P2Id) } }),
+ ]);
+
+ // Check all players exist
+ const missingPlayers: number[] = [];
+ if (!player1) missingPlayers.push(parseInt(team1P1Id));
+ if (!player2) missingPlayers.push(parseInt(team1P2Id));
+ if (!player3) missingPlayers.push(parseInt(team2P1Id));
+ if (!player4) missingPlayers.push(parseInt(team2P2Id));
+
+ if (missingPlayers.length > 0) {
+ return NextResponse.json(
+ { error: `Players not found: ${missingPlayers.join(", ")}` },
+ { status: 400 }
+ );
+ }
+
+ // Calculate Elo changes
+ const team1Rating = calculateTeamElo(player1!.currentElo, player2!.currentElo);
+ const team2Rating = calculateTeamElo(player3!.currentElo, player4!.currentElo);
+
+ const team1Won = team1Score > team2Score;
+ const team2Won = team2Score > team1Score;
+ const isTie = team1Score === team2Score;
+
+ const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0);
+ const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0);
+
+ const team1EloChange = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreActual, K_FACTOR);
+ const team2EloChange = calculateTeamEloChange(team2Rating, team1Rating, team2ScoreActual, K_FACTOR);
+
+ const player1EloChange = team1EloChange / 2;
+ const player2EloChange = team1EloChange / 2;
+ const player3EloChange = team2EloChange / 2;
+ const player4EloChange = team2EloChange / 2;
+
+ // Create match (eventId is null for casual matches)
+ const match = await prisma.match.create({
+ data: {
+ eventId: null, // No tournament association for casual matches
+ isCasual: isCasual,
+ playedAt: playedAt ? new Date(playedAt) : new Date(),
+ team1P1Id: parseInt(team1P1Id),
+ team1P2Id: parseInt(team1P2Id),
+ team2P1Id: parseInt(team2P1Id),
+ team2P2Id: parseInt(team2P2Id),
+ team1Score,
+ team2Score,
+ status: "completed",
+ createdById: userId,
+ },
+ });
+
+ // Update player stats (only if it's a casual match - tournament matches update via their own flow)
+ if (isCasual) {
+ await updatePlayerStats(parseInt(team1P1Id), team1Won, player1EloChange);
+ await updatePlayerStats(parseInt(team1P2Id), team1Won, player2EloChange);
+ await updatePlayerStats(parseInt(team2P1Id), team2Won, player3EloChange);
+ await updatePlayerStats(parseInt(team2P2Id), team2Won, player4EloChange);
+
+ // Update partnership stats for casual matches
+ await updatePartnershipStats(parseInt(team1P1Id), parseInt(team1P2Id), team1Won, player1EloChange);
+ await updatePartnershipStats(parseInt(team2P1Id), parseInt(team2P2Id), team2Won, player3EloChange);
+ }
+
+ return NextResponse.json({
+ success: true,
+ match,
+ });
+ } catch (error: unknown) {
+ console.error("Error creating casual match:", error);
+ const message = error instanceof Error ? error.message : "Failed to create match";
+ return NextResponse.json(
+ { error: message },
+ { status: 500 }
+ );
+ }
+}
+
+async function updatePlayerStats(playerId: number, won: boolean, eloChange: number) {
+ const player = await prisma.player.findUnique({
+ where: { id: playerId },
+ });
+
+ if (!player) {
+ throw new Error(`Player ${playerId} not found`);
+ }
+
+ await prisma.player.update({
+ where: { id: playerId },
+ data: {
+ currentElo: player.currentElo + Math.round(eloChange),
+ gamesPlayed: player.gamesPlayed + 1,
+ wins: won ? player.wins + 1 : player.wins,
+ losses: won ? player.losses : player.losses + 1,
+ },
+ });
+}
+
+async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number) {
+ const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id];
+
+ const existing = await prisma.partnershipStat.findFirst({
+ where: {
+ player1Id: smallId,
+ player2Id: largeId,
+ },
+ });
+
+ if (existing) {
+ await prisma.partnershipStat.update({
+ where: { id: existing.id },
+ data: {
+ gamesPlayed: { increment: 1 },
+ wins: won ? { increment: 1 } : undefined,
+ losses: won ? undefined : { increment: 1 },
+ totalEloChange: { increment: eloChange },
+ lastPlayed: new Date(),
+ },
+ });
+ } else {
+ await prisma.partnershipStat.create({
+ data: {
+ player1Id: smallId,
+ player2Id: largeId,
+ gamesPlayed: 1,
+ wins: won ? 1 : 0,
+ losses: won ? 0 : 1,
+ totalEloChange: eloChange,
+ lastPlayed: new Date(),
+ },
+ });
+ }
+}
diff --git a/src/components/DeleteTournamentButton.tsx b/src/components/DeleteTournamentButton.tsx
new file mode 100644
index 0000000..cd2542e
--- /dev/null
+++ b/src/components/DeleteTournamentButton.tsx
@@ -0,0 +1,124 @@
+"use client"
+
+import { useState } from "react"
+
+interface DeleteTournamentButtonProps {
+ tournamentId: number
+ tournamentName: string
+ matchCount: number
+}
+
+export function DeleteTournamentButton({ tournamentId, tournamentName, matchCount }: DeleteTournamentButtonProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [deleteOption, setDeleteOption] = useState<'delete' | 'orphan'>('orphan')
+
+ const handleDelete = async () => {
+ setIsDeleting(true)
+ try {
+ const response = await fetch(`/api/admin/tournaments/${tournamentId}`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ deleteMatches: deleteOption === 'delete',
+ }),
+ })
+
+ const data = await response.json()
+
+ if (data.success) {
+ alert(`Tournament "${tournamentName}" deleted successfully!`)
+ window.location.href = "/admin/tournaments"
+ } else {
+ alert(`Error: ${data.error}`)
+ }
+ } catch (err: unknown) {
+ alert(`Error: ${err instanceof Error ? err.message : 'Unknown error occurred'}`)
+ } finally {
+ setIsDeleting(false)
+ setIsOpen(false)
+ }
+ }
+
+ return (
+ <>
+
+
+ {isOpen && (
+
+
+
+ Delete Tournament: {tournamentName}
+
+
+
+ This tournament has {matchCount} game{matchCount !== 1 ? 's' : ''} associated with it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
index ef40366..ef88380 100644
--- a/src/lib/permissions.ts
+++ b/src/lib/permissions.ts
@@ -1,7 +1,7 @@
import { getSession } from './auth-simple';
import { prisma } from './prisma';
-export type UserRole = 'player' | 'tournament_admin' | 'club_admin';
+export type UserRole = 'player' | 'tournament_admin' | 'club_admin' | 'site_admin';
export interface PermissionCheck {
allowed: boolean;
@@ -36,11 +36,12 @@ export async function hasRole(requiredRole: UserRole): Promise
const userRole = user.role as UserRole;
- // Role hierarchy: player < tournament_admin < club_admin
+ // Role hierarchy: player < tournament_admin < club_admin < site_admin
const roleHierarchy: Record = {
'player': 1,
'tournament_admin': 2,
- 'club_admin': 3
+ 'club_admin': 3,
+ 'site_admin': 4
};
const hasRequiredRole = roleHierarchy[userRole] >= roleHierarchy[requiredRole];
@@ -232,3 +233,64 @@ export async function canEditUserProfiles(): Promise {
export async function canRecalculateElo(): Promise {
return hasRole('club_admin');
}
+
+/**
+ * Check if the user can delete tournaments
+ * Site admins can delete all tournaments
+ * Club admins can delete tournaments they can manage
+ * Tournament admins can only delete their own tournaments
+ */
+export async function canDeleteTournament(tournamentId: number): Promise {
+ const session = await getSession();
+
+ if (!session) {
+ return { allowed: false, reason: 'Not authenticated' };
+ }
+
+ const userId = session.user?.id;
+ if (!userId) {
+ return { allowed: false, reason: 'User ID not found in session' };
+ }
+
+ // Fetch user from database to get current role
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { role: true }
+ });
+
+ if (!user) {
+ return { allowed: false, reason: 'User not found in database' };
+ }
+
+ const userRole = user.role as UserRole;
+
+ // Site admins can delete any tournament
+ if (userRole === 'site_admin') {
+ return { allowed: true };
+ }
+
+ // Club admins can delete tournaments they can manage
+ if (userRole === 'club_admin') {
+ return { allowed: true };
+ }
+
+ // Tournament admins can only delete their own tournaments
+ if (userRole === 'tournament_admin') {
+ const tournament = await prisma.event.findUnique({
+ where: { id: tournamentId },
+ select: { ownerId: true }
+ });
+
+ if (!tournament) {
+ return { allowed: false, reason: 'Tournament not found' };
+ }
+
+ if (tournament.ownerId === userId) {
+ return { allowed: true };
+ }
+
+ return { allowed: false, reason: 'Tournament admin can only delete their own tournaments' };
+ }
+
+ return { allowed: false, reason: 'Players cannot delete tournaments' };
+}