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:
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
]
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' }
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user