From a22c801f0cbbdb45b418ab06a4824654f35ad3da Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Tue, 31 Mar 2026 16:16:13 -0700 Subject: [PATCH] feat: add match management admin panel with delete functionality --- src/app/admin/matches/page.tsx | 188 ++++++++++++++++++++++++++++++ src/app/api/matches/[id]/route.ts | 65 +++++++++++ src/app/api/matches/route.ts | 99 ++++++++++++++++ 3 files changed, 352 insertions(+) create mode 100644 src/app/admin/matches/page.tsx create mode 100644 src/app/api/matches/[id]/route.ts diff --git a/src/app/admin/matches/page.tsx b/src/app/admin/matches/page.tsx new file mode 100644 index 0000000..d1e1df2 --- /dev/null +++ b/src/app/admin/matches/page.tsx @@ -0,0 +1,188 @@ +"use client" + +import { useState, useEffect } from "react" +import Navigation from "@/components/Navigation" +import Link from "next/link" + +interface Match { + id: number + playedAt: string | null + team1Score: number + team2Score: number + status: string + isCasual: boolean + event?: { + id: number + name: string + } | null + team1P1: { id: number; name: string } + team1P2: { id: number; name: string } + team2P1: { id: number; name: string } + team2P2: { id: number; name: string } +} + +export default function AdminMatchesPage() { + const [matches, setMatches] = useState([]) + const [error, setError] = useState("") + const [loading, setLoading] = useState(true) + const [deletingId, setDeletingId] = useState(null) + + useEffect(() => { + fetchMatches() + }, []) + + const fetchMatches = async () => { + try { + const response = await fetch("/api/matches") + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to fetch matches") + } + const data = await response.json() + setMatches(data) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Unknown error") + } finally { + setLoading(false) + } + } + + const handleDelete = async (matchId: number) => { + if (!confirm("Are you sure you want to delete this match? This action cannot be undone.")) { + return + } + + setDeletingId(matchId) + try { + const response = await fetch(`/api/matches/${matchId}`, { + method: "DELETE", + }) + + const data = await response.json() + + if (data.success) { + setMatches(matches.filter(m => m.id !== matchId)) + } else { + alert(`Error: ${data.error}`) + } + } catch (err: unknown) { + alert(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`) + } finally { + setDeletingId(null) + } + } + + if (loading) { + return ( +
+ +
+
+

Loading matches...

+
+
+
+ ) + } + + return ( +
+ + +
+
+ {/* Page Header */} +
+
+

Match Management

+

+ Manage {matches.length} match{matches.length !== 1 ? 'es' : ''} in the system +

+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Match Table */} +
+ + + + + + + + + + + + + + {matches.map((match) => ( + + + + + + + + + + ))} + +
+ Date + + Tournament + + Team 1 + + Score + + Team 2 + + Score + + Actions +
+ {match.playedAt + ? new Date(match.playedAt).toLocaleDateString() + : "N/A"} + + {match.event ? ( + + {match.event.name} + + ) : ( + Casual + )} + + {match.team1P1.name} & {match.team1P2.name} + + {match.team1Score} + + {match.team2P1.name} & {match.team2P2.name} + + {match.team2Score} + + +
+
+
+
+
+ ) +} diff --git a/src/app/api/matches/[id]/route.ts b/src/app/api/matches/[id]/route.ts new file mode 100644 index 0000000..66e2c5a --- /dev/null +++ b/src/app/api/matches/[id]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { canManageMatch } from "@/lib/permissions"; + +/** + * DELETE /api/matches/[id] + * + * Delete a match + * Requires: club_admin, site_admin, or tournament_admin role (for their own tournaments) + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const matchId = parseInt(id); + + // Validate match ID + if (isNaN(matchId)) { + return NextResponse.json( + { error: "Invalid match ID" }, + { status: 400 } + ); + } + + // Check permissions + const permission = await canManageMatch(matchId); + if (!permission.allowed) { + return NextResponse.json( + { error: permission.reason || "Not authorized to delete this match" }, + { status: 403 } + ); + } + + // Verify match exists + const existingMatch = await prisma.match.findUnique({ + where: { id: matchId }, + }); + + if (!existingMatch) { + return NextResponse.json( + { error: "Match not found" }, + { status: 404 } + ); + } + + // Delete the match (cascading deletes will handle related records) + await prisma.match.delete({ + where: { id: matchId }, + }); + + return NextResponse.json({ + success: true, + message: "Match deleted successfully", + }); + } catch (error: unknown) { + console.error("Error deleting match:", error); + const message = error instanceof Error ? error.message : "Failed to delete match"; + return NextResponse.json( + { error: message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/matches/route.ts b/src/app/api/matches/route.ts index 5105863..5f216a4 100644 --- a/src/app/api/matches/route.ts +++ b/src/app/api/matches/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getSession } from "@/lib/auth-simple"; +import { hasRole, canManageMatch } from "@/lib/permissions"; import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils"; import { calculateOpenSkillRatings, fromOpenSkillRating } from "@/lib/openskill-utils"; @@ -8,6 +9,104 @@ import { calculateGlicko2Ratings } from "@/lib/glicko2-utils"; const K_FACTOR = 32; // Standard K-factor for Elo calculations +/** + * GET /api/matches + * + * Get matches (admin only) + * Requires: club_admin, site_admin, or tournament_admin role + */ +export async function GET() { + try { + const session = await getSession(); + if (!session) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 403 } + ); + } + + const userId = session.user?.id; + if (!userId) { + return NextResponse.json( + { error: "User ID not found in session" }, + { status: 403 } + ); + } + + // Fetch user from database to get current role + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true } + }); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 403 } + ); + } + + const userRole = user.role as 'player' | 'tournament_admin' | 'club_admin' | 'site_admin'; + + let matches; + if (userRole === 'club_admin' || userRole === 'site_admin') { + // Club admins and site admins can see all matches + matches = await prisma.match.findMany({ + include: { + event: { + select: { + id: true, + name: true, + }, + }, + team1P1: { select: { id: true, name: true } }, + team1P2: { select: { id: true, name: true } }, + team2P1: { select: { id: true, name: true } }, + team2P2: { select: { id: true, name: true } }, + }, + orderBy: { playedAt: 'desc' }, + }); + } else if (userRole === 'tournament_admin') { + // Tournament admins can only see matches in their own tournaments + matches = await prisma.match.findMany({ + where: { + event: { + ownerId: userId, + }, + }, + include: { + event: { + select: { + id: true, + name: true, + }, + }, + team1P1: { select: { id: true, name: true } }, + team1P2: { select: { id: true, name: true } }, + team2P1: { select: { id: true, name: true } }, + team2P2: { select: { id: true, name: true } }, + }, + orderBy: { playedAt: 'desc' }, + }); + } else { + // Regular players cannot view matches via this API (should not happen if UI is protected) + return NextResponse.json( + { error: "Insufficient permissions to view matches" }, + { status: 403 } + ); + } + + return NextResponse.json(matches); + } catch (error: unknown) { + console.error("Error fetching matches:", error); + const message = error instanceof Error ? error.message : "Failed to fetch matches"; + return NextResponse.json( + { error: message }, + { status: 500 } + ); + } +} + /** * POST /api/matches *