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 'reflect-metadata';
import { Test } from '@nestjs/testing'; 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 { LeagueModule } from './LeagueModule';
import { import {

View File

@@ -79,6 +79,42 @@ describe('RouteConfig', () => {
expect(routeMatchers.isPublic('/admin')).toBe(false); expect(routeMatchers.isPublic('/admin')).toBe(false);
expect(routeMatchers.isPublic('/sponsor/dashboard')).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()', () => { describe('routeMatchers.requiresAuth()', () => {

View File

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