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' }; +}