nextjs-rewrite (#5)

Reviewed-on: #5
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #5.
This commit is contained in:
2026-03-30 02:30:13 +00:00
committed by david
parent 1a9b3496e1
commit 123df671f5
160 changed files with 19293 additions and 1180 deletions
+20
View File
@@ -0,0 +1,20 @@
import { createAuthClient } from "better-auth/client";
// Get base URL from environment or use localhost as fallback
const getBaseURL = () => {
if (typeof window === 'undefined') {
return process.env.BETTER_AUTH_URL || "http://localhost:3000";
}
// For client-side, use the current origin but allow localhost variations
const origin = window.location.origin;
if (origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('0.0.0.0')) {
return "http://localhost:3000"; // Normalize to localhost for Better Auth
}
return origin;
};
export const authClient = createAuthClient({
baseURL: getBaseURL(),
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Server-side session helper for Better Auth
* This provides getSession() for use in server components
*/
import { cookies } from "next/headers";
export async function getSession() {
try {
const cookieStore = await cookies();
// Get the session token from cookies
const sessionToken = cookieStore.get('better-auth.session_token');
if (!sessionToken) {
return null;
}
// Make a request to the Better Auth API to get the session
// This is the standard way Better Auth handles session verification
// Use disableCookieCache to bypass the cookie cache and get fresh data from the database
const response = await fetch(`${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/api/auth/get-session?disableCookieCache=true`, {
method: 'GET',
headers: {
'Cookie': `${sessionToken.name}=${sessionToken.value}`
},
cache: 'no-store',
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error('Failed to get session:', error);
return null;
}
}
// For backward compatibility with existing code
export type AuthUser = {
user: {
id: string;
email: string;
name?: string;
role?: string;
[key: string]: any;
};
session: {
token: string;
expiresAt: Date;
[key: string]: any;
};
} | null;
+58
View File
@@ -0,0 +1,58 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";
import { testUtils } from "better-auth/plugins";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "sqlite",
}),
emailAndPassword: {
enabled: true,
autoSignIn: true, // Automatically sign in after registration
requireEmailVerification: false, // Don't require email verification for tests
},
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
"http://127.0.0.1:3000",
"http://0.0.0.0:3000",
],
session: {
cookieCache: {
enabled: false, // Disable cookie cache to avoid session cache issues
},
},
databaseHooks: {
user: {
create: {
async after(user) {
// Generate a unique player name using timestamp and random string
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
const baseName = user.name || user.email.split('@')[0];
const uniqueName = `${baseName}-${timestamp}-${randomId}`;
// Create a Player record for the new user
const newPlayer = await prisma.player.create({
data: {
name: uniqueName,
},
});
// Update the User with the playerId
await prisma.user.update({
where: { id: user.id },
data: { playerId: newPlayer.id },
});
},
},
},
},
plugins: [
testUtils()
]
});
+152
View File
@@ -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);
}
+217
View File
@@ -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<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 (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<UserRole, number> = {
'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<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' };
}
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<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 (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<PermissionCheck> {
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<PermissionCheck> {
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<PermissionCheck> {
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' }
});
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+56
View File
@@ -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;
}