From eb8fcd3cf4cd6bfc37213020854b4cf4adcf6d96 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 29 Mar 2026 19:25:19 -0700 Subject: [PATCH] feat(core): implement permission system and Elo rating utilities --- src/lib/elo-utils.ts | 152 ++++++++++++++++++++++++++ src/lib/permissions.ts | 217 +++++++++++++++++++++++++++++++++++++ src/lib/tournamentUtils.ts | 56 ++++++++++ 3 files changed, 425 insertions(+) create mode 100644 src/lib/elo-utils.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/tournamentUtils.ts diff --git a/src/lib/elo-utils.ts b/src/lib/elo-utils.ts new file mode 100644 index 0000000..561e73e --- /dev/null +++ b/src/lib/elo-utils.ts @@ -0,0 +1,152 @@ +/** + * Elo Rating Utilities + * + * Provides functions for calculating Elo ratings and changes + * Based on the standard Elo rating system + */ + +/** + * Calculate the expected score for a player based on rating difference + * @param ratingA Player A's rating + * @param ratingB Player B's rating + * @returns Expected score (0-1) for Player A + */ +export function calculateExpectedScore(ratingA: number, ratingB: number): number { + const ratingDiff = ratingA - ratingB; + return 1 / (1 + Math.pow(10, -ratingDiff / 400)); +} + +/** + * Calculate Elo rating change for a single game + * @param rating Player's current rating + * @param opponentRating Opponent's rating + * @param actualScore Actual score (1 = win, 0.5 = draw, 0 = loss) + * @param kFactor K-factor (how much ratings change per game) + * @returns Elo rating change (positive or negative) + */ +export function calculateEloChange( + rating: number, + opponentRating: number, + actualScore: number, + kFactor: number = 32 +): number { + const expectedScore = calculateExpectedScore(rating, opponentRating); + return kFactor * (actualScore - expectedScore); +} + +/** + * Calculate team Elo rating as average of individual ratings + * @param rating1 Rating of player 1 + * @param rating2 Rating of player 2 + * @returns Team rating + */ +export function calculateTeamElo(rating1: number, rating2: number): number { + return (rating1 + rating2) / 2; +} + +/** + * Update player statistics after a match + * @param currentRating Current player rating + * @param currentGamesPlayed Current games played count + * @param currentWins Current wins count + * @param eloChange Elo rating change + * @param won Whether the player won + * @returns Updated statistics + */ +export function updatePlayerStats( + currentRating: number, + currentGamesPlayed: number, + currentWins: number, + eloChange: number, + won: boolean +): { + newRating: number; + newGamesPlayed: number; + newWins: number; + newLosses: number; + winRate: number; +} { + const newRating = currentRating + Math.round(eloChange); + const newGamesPlayed = currentGamesPlayed + 1; + const newWins = currentWins + (won ? 1 : 0); + const newLosses = newGamesPlayed - newWins; + const winRate = newGamesPlayed > 0 ? newWins / newGamesPlayed : 0; + + return { + newRating, + newGamesPlayed, + newWins, + newLosses, + winRate, + }; +} + +/** + * Calculate new rating after a match + * @param currentRating Current rating + * @param opponentRating Opponent's rating + * @param won Whether the player won + * @param kFactor K-factor (default 32) + * @returns New rating + */ +export function calculateNewRating( + currentRating: number, + opponentRating: number, + won: boolean, + kFactor: number = 32 +): number { + const actualScore = won ? 1 : 0; + const eloChange = calculateEloChange(currentRating, opponentRating, actualScore, kFactor); + return Math.round(currentRating + eloChange); +} + +/** + * Validate rating is within reasonable bounds + * @param rating Rating to validate + * @returns Whether the rating is valid + */ +export function isValidRating(rating: number): boolean { + return rating >= 100 && rating <= 3000; +} + +/** + * Get rating tier name based on rating + * @param rating Player rating + * @returns Tier name + */ +export function getRatingTier(rating: number): string { + if (rating < 1100) return 'Beginner'; + if (rating < 1300) return 'Novice'; + if (rating < 1500) return 'Intermediate'; + if (rating < 1700) return 'Advanced'; + if (rating < 1900) return 'Expert'; + if (rating < 2100) return 'Master'; + return 'Grandmaster'; +} + +/** + * Calculate expected score for a team match + * @param team1Rating Average rating of team 1 + * @param team2Rating Average rating of team 2 + * @returns Expected score for team 1 + */ +export function calculateExpectedTeamScore(team1Rating: number, team2Rating: number): number { + return calculateExpectedScore(team1Rating, team2Rating); +} + +/** + * Calculate Elo change for team match + * @param team1Rating Average rating of team 1 + * @param team2Rating Average rating of team 2 + * @param team1Score Actual score for team 1 (1 = win, 0.5 = draw, 0 = loss) + * @param kFactor K-factor (default 32) + * @returns Elo change for team 1 + */ +export function calculateTeamEloChange( + team1Rating: number, + team2Rating: number, + team1Score: number, + kFactor: number = 32 +): number { + return calculateEloChange(team1Rating, team2Rating, team1Score, kFactor); +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..5485c53 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,217 @@ +import { getSession } from './auth-simple'; +import { prisma } from './prisma'; + +export type UserRole = 'player' | 'tournament_admin' | 'club_admin'; + +export interface PermissionCheck { + allowed: boolean; + reason?: string; +} + +/** + * Check if the current user has the required role + * Reads role from database to avoid session cache issues + */ +export async function hasRole(requiredRole: UserRole): 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 (bypasses session cache) + 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; + + // Role hierarchy: player < tournament_admin < club_admin + const roleHierarchy: Record = { + 'player': 1, + 'tournament_admin': 2, + 'club_admin': 3 + }; + + const hasRequiredRole = roleHierarchy[userRole] >= roleHierarchy[requiredRole]; + + return { + allowed: hasRequiredRole, + reason: hasRequiredRole ? undefined : `Requires ${requiredRole} role, but user has ${userRole}` + }; +} + +/** + * Check if the user owns the tournament (for tournament_admin) + */ +export async function ownsTournament(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' }; + } + + const tournament = await prisma.event.findUnique({ + where: { id: tournamentId }, + select: { ownerId: true } + }); + + if (!tournament) { + return { allowed: false, reason: 'Tournament not found' }; + } + + const isOwner = tournament.ownerId === userId; + + return { + allowed: isOwner, + reason: isOwner ? undefined : 'User does not own this tournament' + }; +} + +/** + * Check if the user can manage a specific tournament + * Tournament admins can only manage their own tournaments + * Club admins can manage all tournaments + * Reads role from database to avoid session cache issues + */ +export async function canManageTournament(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 (bypasses session cache) + 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; + + // Club admins can manage all tournaments + if (userRole === 'club_admin') { + return { allowed: true }; + } + + // Tournament admins can only manage their own tournaments + if (userRole === 'tournament_admin') { + return ownsTournament(tournamentId); + } + + return { allowed: false, reason: 'Insufficient permissions to manage tournament' }; +} + +/** + * Check if the user can create tournaments + */ +export async function canCreateTournaments(): Promise { + return hasRole('tournament_admin'); +} + +/** + * Check if the user can manage a specific match + * Tournament admins can manage matches in their own tournaments + * Club admins can manage all matches + */ +export async function canManageMatch(matchId: number): Promise { + const session = await getSession(); + + if (!session) { + return { allowed: false, reason: 'Not authenticated' }; + } + + const userRole = session.user?.role as UserRole; + + // Club admins can manage all matches + if (userRole === 'club_admin') { + return { allowed: true }; + } + + // Tournament admins can only manage matches in their own tournaments + if (userRole === 'tournament_admin') { + const match = await prisma.match.findUnique({ + where: { id: matchId }, + include: { event: true } + }); + + if (!match || !match.event) { + return { allowed: false, reason: 'Match or tournament not found' }; + } + + return ownsTournament(match.event.id); + } + + return { allowed: false, reason: 'Insufficient permissions to manage match' }; +} + +/** + * Check if the user can assign tournament_admin privileges + * Only club admins can assign tournament_admin role + */ +export async function canAssignTournamentAdmin(): Promise { + return hasRole('club_admin'); +} + +/** + * Get tournaments that the current user can manage + */ +export async function getManageableTournaments() { + const session = await getSession(); + + if (!session) { + return []; + } + + const userRole = session.user?.role as UserRole; + const userId = session.user?.id; + + if (userRole === 'club_admin') { + // Club admins can see all tournaments + return prisma.event.findMany({ + where: { eventType: 'tournament' }, + orderBy: { createdAt: 'desc' } + }); + } + + if (userRole === 'tournament_admin') { + // Tournament admins can only see their own tournaments + return prisma.event.findMany({ + where: { + eventType: 'tournament', + ownerId: userId + }, + orderBy: { createdAt: 'desc' } + }); + } + + // Regular players can only view tournaments (not manage them) + return prisma.event.findMany({ + where: { eventType: 'tournament', status: { not: 'draft' } }, + orderBy: { createdAt: 'desc' } + }); +} diff --git a/src/lib/tournamentUtils.ts b/src/lib/tournamentUtils.ts new file mode 100644 index 0000000..47ed040 --- /dev/null +++ b/src/lib/tournamentUtils.ts @@ -0,0 +1,56 @@ +/** + * Tournament utility functions + * Used for calculating dynamic tournament status based on event date + */ + +/** + * Calculates the tournament status based on the event date + * @param eventDate - The date of the tournament event + * @returns The calculated status string: "planned" or "completed" + */ +export function getTournamentStatus(eventDate: Date | null): string { + if (!eventDate) { + return "planned"; + } + + const now = new Date(); + const eventDateObj = new Date(eventDate); + + // If event date is in the past, it's completed + if (eventDateObj < now) { + return "completed"; + } + + // If event date is today or in the future, it's planned + return "planned"; +} + +/** + * Checks if a tournament date is in the past + * @param eventDate - The date of the tournament event + * @returns true if the event date is in the past, false otherwise + */ +export function isTournamentPast(eventDate: Date | null): boolean { + if (!eventDate) { + return false; + } + + const now = new Date(); + const eventDateObj = new Date(eventDate); + return eventDateObj < now; +} + +/** + * Checks if a tournament date is in the future + * @param eventDate - The date of the tournament event + * @returns true if the event date is in the future, false otherwise + */ +export function isTournamentFuture(eventDate: Date | null): boolean { + if (!eventDate) { + return false; + } + + const now = new Date(); + const eventDateObj = new Date(eventDate); + return eventDateObj > now; +}