feat: add site_admin role, casual match support, and tournament deletion
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add isCasual field to Match model
|
||||||
|
ALTER TABLE "matches" ADD COLUMN "isCasual" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -41,7 +41,7 @@ model User {
|
|||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
name String?
|
name String?
|
||||||
image String?
|
image String?
|
||||||
role String @default("player")
|
role String @default("player") // player, tournament_admin, club_admin, site_admin
|
||||||
playerId Int? @unique
|
playerId Int? @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -163,6 +163,7 @@ model Match {
|
|||||||
team1Score Int
|
team1Score Int
|
||||||
team2Score Int
|
team2Score Int
|
||||||
status String @default("completed")
|
status String @default("completed")
|
||||||
|
isCasual Boolean @default(false)
|
||||||
createdById String?
|
createdById String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { prisma } from "@/lib/prisma"
|
|||||||
import Navigation from "@/components/Navigation"
|
import Navigation from "@/components/Navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound, redirect } from "next/navigation"
|
import { notFound, redirect } from "next/navigation"
|
||||||
import { canManageTournament, hasRole } from "@/lib/permissions"
|
import { canManageTournament, canDeleteTournament } from "@/lib/permissions"
|
||||||
import { getTournamentStatus } from "@/lib/tournamentUtils"
|
import { getTournamentStatus } from "@/lib/tournamentUtils"
|
||||||
|
import { DeleteTournamentButton } from "@/components/DeleteTournamentButton"
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -20,8 +21,8 @@ export default async function TournamentDetailPage({ params }: PageProps) {
|
|||||||
redirect("/auth/login")
|
redirect("/auth/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user can create tournaments (tournament_admin or club_admin)
|
// Check if user can delete this tournament
|
||||||
const canCreate = await hasRole('tournament_admin')
|
const deletePermission = await canDeleteTournament(tournamentId)
|
||||||
|
|
||||||
let tournament = await prisma.event.findUnique({
|
let tournament = await prisma.event.findUnique({
|
||||||
where: { id: tournamentId },
|
where: { id: tournamentId },
|
||||||
@@ -119,6 +120,8 @@ export default async function TournamentDetailPage({ params }: PageProps) {
|
|||||||
orderBy: { playedAt: "desc" },
|
orderBy: { playedAt: "desc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const matchCount = matches.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
@@ -173,6 +176,13 @@ export default async function TournamentDetailPage({ params }: PageProps) {
|
|||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</a>
|
</a>
|
||||||
|
{deletePermission.allowed && (
|
||||||
|
<DeleteTournamentButton
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
tournamentName={tournament.name}
|
||||||
|
matchCount={matchCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Delete Tournament
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl p-6 m-4 max-w-md w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Delete Tournament: {tournamentName}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
This tournament has {matchCount} game{matchCount !== 1 ? 's' : ''} associated with it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="deleteOption"
|
||||||
|
value="orphan"
|
||||||
|
checked={deleteOption === 'orphan'}
|
||||||
|
onChange={() => setDeleteOption('orphan')}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Orphan Games</span>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Keep games in the database but remove their tournament association
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="deleteOption"
|
||||||
|
value="delete"
|
||||||
|
checked={deleteOption === 'delete'}
|
||||||
|
onChange={() => setDeleteOption('delete')}
|
||||||
|
className="mr-3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Delete Games</span>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Permanently delete all games associated with this tournament
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete Tournament'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+65
-3
@@ -1,7 +1,7 @@
|
|||||||
import { getSession } from './auth-simple';
|
import { getSession } from './auth-simple';
|
||||||
import { prisma } from './prisma';
|
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 {
|
export interface PermissionCheck {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
@@ -36,11 +36,12 @@ export async function hasRole(requiredRole: UserRole): Promise<PermissionCheck>
|
|||||||
|
|
||||||
const userRole = user.role as UserRole;
|
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<UserRole, number> = {
|
const roleHierarchy: Record<UserRole, number> = {
|
||||||
'player': 1,
|
'player': 1,
|
||||||
'tournament_admin': 2,
|
'tournament_admin': 2,
|
||||||
'club_admin': 3
|
'club_admin': 3,
|
||||||
|
'site_admin': 4
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasRequiredRole = roleHierarchy[userRole] >= roleHierarchy[requiredRole];
|
const hasRequiredRole = roleHierarchy[userRole] >= roleHierarchy[requiredRole];
|
||||||
@@ -232,3 +233,64 @@ export async function canEditUserProfiles(): Promise<PermissionCheck> {
|
|||||||
export async function canRecalculateElo(): Promise<PermissionCheck> {
|
export async function canRecalculateElo(): Promise<PermissionCheck> {
|
||||||
return hasRole('club_admin');
|
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<PermissionCheck> {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user