website refactor

This commit is contained in:
2026-01-21 13:34:08 +01:00
parent 7075765d98
commit 69c9305d59
3 changed files with 385 additions and 328 deletions

View File

@@ -1,7 +1,7 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { LeagueModule } from './LeagueModule';
import {

View File

@@ -79,6 +79,42 @@ describe('RouteConfig', () => {
expect(routeMatchers.isPublic('/admin')).toBe(false);
expect(routeMatchers.isPublic('/sponsor/dashboard')).toBe(false);
});
it('should return true for league sub-pages (schedule, standings, roster, rulebook)', () => {
// Test with various league IDs
const leagueId = '123';
const anotherLeagueId = 'abc-def-456';
// Schedule page
expect(routeMatchers.isPublic(`/leagues/${leagueId}/schedule`)).toBe(true);
expect(routeMatchers.isPublic(`/leagues/${anotherLeagueId}/schedule`)).toBe(true);
// Standings page
expect(routeMatchers.isPublic(`/leagues/${leagueId}/standings`)).toBe(true);
expect(routeMatchers.isPublic(`/leagues/${anotherLeagueId}/standings`)).toBe(true);
// Roster page
expect(routeMatchers.isPublic(`/leagues/${leagueId}/roster`)).toBe(true);
expect(routeMatchers.isPublic(`/leagues/${anotherLeagueId}/roster`)).toBe(true);
// Rulebook page
expect(routeMatchers.isPublic(`/leagues/${leagueId}/rulebook`)).toBe(true);
expect(routeMatchers.isPublic(`/leagues/${anotherLeagueId}/rulebook`)).toBe(true);
});
it('should return true for league detail page', () => {
expect(routeMatchers.isPublic('/leagues/123')).toBe(true);
expect(routeMatchers.isPublic('/leagues/abc-def')).toBe(true);
});
it('should return false for league admin pages', () => {
expect(routeMatchers.isPublic('/leagues/123/schedule/admin')).toBe(false);
expect(routeMatchers.isPublic('/leagues/123/roster/admin')).toBe(false);
expect(routeMatchers.isPublic('/leagues/123/settings')).toBe(false);
expect(routeMatchers.isPublic('/leagues/123/sponsorships')).toBe(false);
expect(routeMatchers.isPublic('/leagues/123/stewarding')).toBe(false);
expect(routeMatchers.isPublic('/leagues/123/wallet')).toBe(false);
});
});
describe('routeMatchers.requiresAuth()', () => {

View File

@@ -15,90 +15,91 @@ import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
const logger = new ConsoleLogger();
export interface RouteDefinition {
path: string;
name: string;
description?: string;
path: string;
name: string;
description?: string;
}
export interface RouteGroup {
auth: {
login: string;
signup: string;
forgotPassword: string;
resetPassword: string;
};
public: {
home: string;
leagues: string;
drivers: string;
teams: string;
leaderboards: string;
races: string;
sponsorSignup: string;
};
protected: {
dashboard: string;
onboarding: string;
profile: string;
profileSettings: string;
profileLeagues: string;
profileLiveries: string;
profileLiveryUpload: string;
profileSponsorshipRequests: string;
};
sponsor: {
root: string;
dashboard: string;
billing: string;
campaigns: string;
leagues: string;
leagueDetail: (id: string) => string;
settings: string;
};
admin: {
root: string;
users: string;
};
league: {
detail: (id: string) => string;
rosterAdmin: (id: string) => string;
rulebook: (id: string) => string;
schedule: (id: string) => string;
scheduleAdmin: (id: string) => string;
settings: (id: string) => string;
sponsorships: (id: string) => string;
standings: (id: string) => string;
stewarding: (id: string) => string;
wallet: (id: string) => string;
create: string;
migration: string;
};
race: {
root: string;
all: string;
detail: (id: string) => string;
results: (id: string) => string;
stewarding: (id: string) => string;
};
team: {
root: string;
leaderboard: string;
detail: (id: string) => string;
create: string;
};
driver: {
root: string;
detail: (id: string) => string;
};
leaderboards: {
root: string;
drivers: string;
teams: string;
};
error: {
notFound: string;
serverError: string;
};
auth: {
login: string;
signup: string;
forgotPassword: string;
resetPassword: string;
};
public: {
home: string;
leagues: string;
drivers: string;
teams: string;
leaderboards: string;
races: string;
sponsorSignup: string;
};
protected: {
dashboard: string;
onboarding: string;
profile: string;
profileSettings: string;
profileLeagues: string;
profileLiveries: string;
profileLiveryUpload: string;
profileSponsorshipRequests: string;
};
sponsor: {
root: string;
dashboard: string;
billing: string;
campaigns: string;
leagues: string;
leagueDetail: (id: string) => string;
settings: string;
};
admin: {
root: string;
users: string;
};
league: {
detail: (id: string) => string;
roster: (id: string) => string;
rosterAdmin: (id: string) => string;
rulebook: (id: string) => string;
schedule: (id: string) => string;
scheduleAdmin: (id: string) => string;
settings: (id: string) => string;
sponsorships: (id: string) => string;
standings: (id: string) => string;
stewarding: (id: string) => string;
wallet: (id: string) => string;
create: string;
migration: string;
};
race: {
root: string;
all: string;
detail: (id: string) => string;
results: (id: string) => string;
stewarding: (id: string) => string;
};
team: {
root: string;
leaderboard: string;
detail: (id: string) => string;
create: string;
};
driver: {
root: string;
detail: (id: string) => string;
};
leaderboards: {
root: string;
drivers: string;
teams: string;
};
error: {
notFound: string;
serverError: string;
};
}
/**
@@ -121,239 +122,259 @@ export interface RouteGroup {
* ```
*/
export const routes: RouteGroup & { leaderboards: { root: string; drivers: string; teams: string } } = {
auth: {
login: '/auth/login',
signup: '/auth/signup',
forgotPassword: '/auth/forgot-password',
resetPassword: '/auth/reset-password',
},
public: {
home: '/',
leagues: '/leagues',
drivers: '/drivers',
teams: '/teams',
leaderboards: '/leaderboards',
races: '/races',
sponsorSignup: '/sponsor/signup',
},
protected: {
dashboard: '/dashboard',
onboarding: '/onboarding',
profile: '/profile',
profileSettings: '/profile/settings',
profileLeagues: '/profile/leagues',
profileLiveries: '/profile/liveries',
profileLiveryUpload: '/profile/liveries/upload',
profileSponsorshipRequests: '/profile/sponsorship-requests',
},
sponsor: {
root: '/sponsor',
dashboard: '/sponsor/dashboard',
billing: '/sponsor/billing',
campaigns: '/sponsor/campaigns',
leagues: '/sponsor/leagues',
leagueDetail: (id: string) => `/sponsor/leagues/${id}`,
settings: '/sponsor/settings',
},
admin: {
root: '/admin',
users: '/admin/users',
},
league: {
detail: (id: string) => `/leagues/${id}`,
rosterAdmin: (id: string) => `/leagues/${id}/roster/admin`,
rulebook: (id: string) => `/leagues/${id}/rulebook`,
schedule: (id: string) => `/leagues/${id}/schedule`,
scheduleAdmin: (id: string) => `/leagues/${id}/schedule/admin`,
settings: (id: string) => `/leagues/${id}/settings`,
sponsorships: (id: string) => `/leagues/${id}/sponsorships`,
standings: (id: string) => `/leagues/${id}/standings`,
stewarding: (id: string) => `/leagues/${id}/stewarding`,
wallet: (id: string) => `/leagues/${id}/wallet`,
create: '/leagues/create',
migration: '/leagues/migration',
},
race: {
root: '/races',
all: '/races/all',
detail: (id: string) => `/races/${id}`,
results: (id: string) => `/races/${id}/results`,
stewarding: (id: string) => `/races/${id}/stewarding`,
},
team: {
root: '/teams',
leaderboard: '/teams/leaderboard',
detail: (id: string) => `/teams/${id}`,
create: '/teams/create',
},
driver: {
root: '/drivers',
detail: (id: string) => `/drivers/${id}`,
},
leaderboards: {
root: '/leaderboards',
drivers: '/leaderboards/drivers',
teams: '/leaderboards/teams',
},
error: {
notFound: '/404',
serverError: '/500',
},
auth: {
login: '/auth/login',
signup: '/auth/signup',
forgotPassword: '/auth/forgot-password',
resetPassword: '/auth/reset-password',
},
public: {
home: '/',
leagues: '/leagues',
drivers: '/drivers',
teams: '/teams',
leaderboards: '/leaderboards',
races: '/races',
sponsorSignup: '/sponsor/signup',
},
protected: {
dashboard: '/dashboard',
onboarding: '/onboarding',
profile: '/profile',
profileSettings: '/profile/settings',
profileLeagues: '/profile/leagues',
profileLiveries: '/profile/liveries',
profileLiveryUpload: '/profile/liveries/upload',
profileSponsorshipRequests: '/profile/sponsorship-requests',
},
sponsor: {
root: '/sponsor',
dashboard: '/sponsor/dashboard',
billing: '/sponsor/billing',
campaigns: '/sponsor/campaigns',
leagues: '/sponsor/leagues',
leagueDetail: (id: string) => `/sponsor/leagues/${id}`,
settings: '/sponsor/settings',
},
admin: {
root: '/admin',
users: '/admin/users',
},
league: {
detail: (id: string) => `/leagues/${id}`,
roster: (id: string) => `/leagues/${id}/roster`,
rosterAdmin: (id: string) => `/leagues/${id}/roster/admin`,
rulebook: (id: string) => `/leagues/${id}/rulebook`,
schedule: (id: string) => `/leagues/${id}/schedule`,
scheduleAdmin: (id: string) => `/leagues/${id}/schedule/admin`,
settings: (id: string) => `/leagues/${id}/settings`,
sponsorships: (id: string) => `/leagues/${id}/sponsorships`,
standings: (id: string) => `/leagues/${id}/standings`,
stewarding: (id: string) => `/leagues/${id}/stewarding`,
wallet: (id: string) => `/leagues/${id}/wallet`,
create: '/leagues/create',
migration: '/leagues/migration',
},
race: {
root: '/races',
all: '/races/all',
detail: (id: string) => `/races/${id}`,
results: (id: string) => `/races/${id}/results`,
stewarding: (id: string) => `/races/${id}/stewarding`,
},
team: {
root: '/teams',
leaderboard: '/teams/leaderboard',
detail: (id: string) => `/teams/${id}`,
create: '/teams/create',
},
driver: {
root: '/drivers',
detail: (id: string) => `/drivers/${id}`,
},
leaderboards: {
root: '/leaderboards',
drivers: '/leaderboards/drivers',
teams: '/leaderboards/teams',
},
error: {
notFound: '/404',
serverError: '/500',
},
};
/**
* Route matcher utilities for pattern matching
*/
export const routeMatchers = {
/**
* Check if path matches a pattern
*/
matches(path: string, pattern: string): boolean {
// Exact match
if (pattern === path) return true;
// Wildcard match (starts with)
if (pattern.endsWith('/*') && path.startsWith(pattern.slice(0, -2))) {
return true;
}
// Parameterized match (e.g., /leagues/[id])
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
},
/**
* Check if path matches a pattern
*/
matches(path: string, pattern: string): boolean {
// Exact match
if (pattern === path) return true;
// Wildcard match (ends with /*)
if (pattern.endsWith('/*') && path.startsWith(pattern.slice(0, -2))) {
return true;
}
// Wildcard match (contains /* in the middle)
if (pattern.includes('/*')) {
// Convert wildcard pattern to regex
// e.g., /leagues/*/schedule -> ^/leagues/[^/]+/schedule$
const regexPattern = pattern.replace(/\*/g, '[^/]+');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
// Parameterized match (e.g., /leagues/[id])
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
},
/**
* Check if path is in a route group
*/
isInGroup(path: string, group: keyof RouteGroup): boolean {
const groupRoutes = routes[group];
// Handle nested objects (like sponsor.leagueDetail)
const values = Object.values(groupRoutes);
return values.some(value => {
if (typeof value === 'function') {
// For parameterized routes, check pattern
const pattern = value('placeholder');
return path.startsWith(pattern.replace('/placeholder', ''));
}
return path.startsWith(value as string);
});
},
/**
* Check if path is in a route group
*/
isInGroup(path: string, group: keyof RouteGroup): boolean {
const groupRoutes = routes[group];
// Handle nested objects (like sponsor.leagueDetail)
const values = Object.values(groupRoutes);
return values.some(value => {
if (typeof value === 'function') {
// For parameterized routes, check pattern
const pattern = value('placeholder');
return path.startsWith(pattern.replace('/placeholder', ''));
}
return path.startsWith(value as string);
});
},
/**
* Get all public route patterns
*/
getPublicPatterns(): string[] {
return [
routes.public.home,
routes.public.leagues,
routes.public.drivers,
routes.public.teams,
routes.public.leaderboards,
routes.leaderboards.drivers,
routes.leaderboards.teams,
routes.public.races,
routes.public.sponsorSignup,
routes.auth.login,
routes.auth.signup,
routes.auth.forgotPassword,
routes.auth.resetPassword,
routes.error.notFound,
routes.error.serverError,
];
},
/**
* Get all public route patterns
*/
getPublicPatterns(): string[] {
return [
routes.public.home,
routes.public.leagues,
routes.public.drivers,
routes.public.teams,
routes.public.leaderboards,
routes.leaderboards.drivers,
routes.leaderboards.teams,
routes.public.races,
routes.public.sponsorSignup,
routes.auth.login,
routes.auth.signup,
routes.auth.forgotPassword,
routes.auth.resetPassword,
routes.error.notFound,
routes.error.serverError,
// League sub-pages (public)
routes.league.schedule('*'),
routes.league.standings('*'),
routes.league.roster('*'),
routes.league.rulebook('*'),
];
},
/**
* Check if path is public
*/
isPublic(path: string): boolean {
// logger.info('[RouteConfig] isPublic check', { path });
const publicPatterns = this.getPublicPatterns();
/**
* Check if path is public
*/
isPublic(path: string): boolean {
// logger.info('[RouteConfig] isPublic check', { path });
const publicPatterns = this.getPublicPatterns();
// Check exact matches
if (publicPatterns.includes(path)) {
// logger.info('[RouteConfig] Path is public (exact match)', { path });
return true;
}
// Check exact matches
if (publicPatterns.includes(path)) {
// logger.info('[RouteConfig] Path is public (exact match)', { path });
return true;
}
// Treat top-level detail pages as public (e2e relies on this)
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
const segments = path.split('/').filter(Boolean);
if (segments.length === 2) {
const [group, slug] = segments;
if (group === 'leagues' && slug !== 'create') {
// logger.info('[RouteConfig] Path is public (league detail)', { path });
return true;
}
if (group === 'races') {
// logger.info('[RouteConfig] Path is public (race detail)', { path });
return true;
}
if (group === 'drivers') {
// logger.info('[RouteConfig] Path is public (driver detail)', { path });
return true;
}
if (group === 'teams' && slug !== 'create') {
// logger.info('[RouteConfig] Path is public (team detail)', { path });
return true;
}
}
// Treat top-level detail pages as public (e2e relies on this)
// Examples: /leagues/:id, /races/:id, /drivers/:id, /teams/:id
const segments = path.split('/').filter(Boolean);
if (segments.length === 2) {
const [group, slug] = segments;
if (group === 'leagues' && slug !== 'create') {
// logger.info('[RouteConfig] Path is public (league detail)', { path });
return true;
}
if (group === 'races') {
// logger.info('[RouteConfig] Path is public (race detail)', { path });
return true;
}
if (group === 'drivers') {
// logger.info('[RouteConfig] Path is public (driver detail)', { path });
return true;
}
if (group === 'teams' && slug !== 'create') {
// logger.info('[RouteConfig] Path is public (team detail)', { path });
return true;
}
}
// Check parameterized patterns
const isPublicParam = publicPatterns.some(pattern => {
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
}
return false;
});
// if (isPublicParam) {
// logger.info('[RouteConfig] Path is public (parameterized match)', { path });
// } else {
// logger.info('[RouteConfig] Path is NOT public', { path });
// }
return isPublicParam;
},
// Check parameterized patterns and wildcard patterns
const isPublicParam = publicPatterns.some(pattern => {
// Check for parameterized patterns (e.g., /leagues/[id])
if (pattern.includes('[')) {
const paramPattern = pattern.replace(/\[([^\]]+)\]/g, '([^/]+)');
const regex = new RegExp(`^${paramPattern}$`);
return regex.test(path);
}
// Check for wildcard patterns (e.g., /leagues/*)
if (pattern.includes('/*')) {
return this.matches(path, pattern);
}
return false;
});
// if (isPublicParam) {
// logger.info('[RouteConfig] Path is public (parameterized match)', { path });
// } else {
// logger.info('[RouteConfig] Path is NOT public', { path });
// }
return isPublicParam;
},
/**
* Check if path requires authentication
*/
requiresAuth(path: string): boolean {
return !this.isPublic(path);
},
/**
* Check if path requires authentication
*/
requiresAuth(path: string): boolean {
return !this.isPublic(path);
},
/**
* Check if path requires specific role
*/
requiresRole(path: string): string[] | null {
// logger.info('[RouteConfig] requiresRole check', { path });
// Public routes never require a role
if (this.isPublic(path)) {
// logger.info('[RouteConfig] Path is public, no role required', { path });
return null;
}
if (this.isInGroup(path, 'admin')) {
// Website session roles come from the API and are more specific than just "admin".
// Keep "admin"/"owner" for backwards compatibility.
const roles = ['admin', 'owner', 'league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
// logger.info('[RouteConfig] Path requires admin roles', { path, roles });
return roles;
}
if (this.isInGroup(path, 'sponsor')) {
// logger.info('[RouteConfig] Path requires sponsor role', { path });
return ['sponsor'];
}
// logger.info('[RouteConfig] Path requires no specific role', { path });
return null;
},
/**
* Check if path requires specific role
*/
requiresRole(path: string): string[] | null {
// logger.info('[RouteConfig] requiresRole check', { path });
// Public routes never require a role
if (this.isPublic(path)) {
// logger.info('[RouteConfig] Path is public, no role required', { path });
return null;
}
if (this.isInGroup(path, 'admin')) {
// Website session roles come from the API and are more specific than just "admin".
// Keep "admin"/"owner" for backwards compatibility.
const roles = ['admin', 'owner', 'league-admin', 'league-steward', 'league-owner', 'system-owner', 'super-admin'];
// logger.info('[RouteConfig] Path requires admin roles', { path, roles });
return roles;
}
if (this.isInGroup(path, 'sponsor')) {
// logger.info('[RouteConfig] Path requires sponsor role', { path });
return ['sponsor'];
}
// logger.info('[RouteConfig] Path requires no specific role', { path });
return null;
},
};
/**
@@ -369,31 +390,31 @@ export const routeMatchers = {
* ```
*/
export function buildPath(
routeName: string,
params: Record<string, string> = {},
_: string = ''
routeName: string,
params: Record<string, string> = {},
_: string = ''
): string {
// This is a placeholder for future i18n implementation
// For now, it just builds the path using the route config
const parts = routeName.split('.');
let route: any = routes;
for (const part of parts) {
route = (route as Record<string, any>)[part];
if (!route) {
throw new Error(`Unknown route: ${routeName}`);
}
}
if (typeof route === 'function') {
const paramKeys = Object.keys(params);
const paramKey = paramKeys[0];
if (!paramKey) {
throw new Error(`Route ${routeName} requires parameters`);
}
return route(params[paramKey]);
}
return route as string;
}
// This is a placeholder for future i18n implementation
// For now, it just builds the path using the route config
const parts = routeName.split('.');
let route: any = routes;
for (const part of parts) {
route = (route as Record<string, any>)[part];
if (!route) {
throw new Error(`Unknown route: ${routeName}`);
}
}
if (typeof route === 'function') {
const paramKeys = Object.keys(params);
const paramKey = paramKeys[0];
if (!paramKey) {
throw new Error(`Route ${routeName} requires parameters`);
}
return route(params[paramKey]);
}
return route as string;
}