From 69c9305d5998dfb563c99fce9a2a305e2f29cc13 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 21 Jan 2026 13:34:08 +0100 Subject: [PATCH] website refactor --- .../src/domain/league/LeagueProviders.test.ts | 2 +- apps/website/lib/routing/RouteConfig.test.ts | 36 + apps/website/lib/routing/RouteConfig.ts | 675 +++++++++--------- 3 files changed, 385 insertions(+), 328 deletions(-) diff --git a/apps/api/src/domain/league/LeagueProviders.test.ts b/apps/api/src/domain/league/LeagueProviders.test.ts index 2a37848e7..6734010f2 100644 --- a/apps/api/src/domain/league/LeagueProviders.test.ts +++ b/apps/api/src/domain/league/LeagueProviders.test.ts @@ -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 { diff --git a/apps/website/lib/routing/RouteConfig.test.ts b/apps/website/lib/routing/RouteConfig.test.ts index 82e4d6cd3..2f3a787eb 100644 --- a/apps/website/lib/routing/RouteConfig.test.ts +++ b/apps/website/lib/routing/RouteConfig.test.ts @@ -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()', () => { diff --git a/apps/website/lib/routing/RouteConfig.ts b/apps/website/lib/routing/RouteConfig.ts index 12192f31b..c76ca078f 100644 --- a/apps/website/lib/routing/RouteConfig.ts +++ b/apps/website/lib/routing/RouteConfig.ts @@ -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 = '' + routeName: string, + params: Record = {}, + _: 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)[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; -} \ No newline at end of file + // 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)[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; +}