website refactor

This commit is contained in:
2026-01-12 01:01:49 +01:00
parent 5ca6023a5a
commit fefd8d1cd6
294 changed files with 4628 additions and 4991 deletions

View File

@@ -0,0 +1,84 @@
/**
* Dashboard Page DTO
* Contains raw JSON-serializable values only
* Derived from DashboardOverviewDTO with ISO string timestamps
*/
export interface DashboardPageDto {
currentDriver?: {
id: string;
name: string;
avatarUrl: string;
country: string;
totalRaces: number;
wins: number;
podiums: number;
rating: number;
globalRank: number;
consistency: number;
};
myUpcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
otherUpcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
upcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
activeLeaguesCount: number;
nextRace?: {
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
};
recentResults: Array<{
id: string;
track: string;
car: string;
position: number;
date: string; // ISO string
}>;
leagueStandingsSummaries: Array<{
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}>;
feedSummary: {
notificationCount: number;
items: Array<{
id: string;
type: string;
headline: string;
body?: string;
timestamp: string; // ISO string
ctaHref?: string;
ctaLabel?: string;
}>;
};
friends: Array<{
id: string;
name: string;
avatarUrl: string;
country: string;
}>;
}

View File

@@ -1,24 +1,19 @@
import { notFound, redirect } from 'next/navigation';
import { ContainerManager } from '@/lib/di/container';
import { DASHBOARD_API_CLIENT_TOKEN } from '@/lib/di/tokens';
import type { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
import type { DashboardPageDto } from '@/lib/page-queries/page-dtos/DashboardPageDto';
interface ErrorWithStatusCode extends Error {
statusCode?: number;
}
/**
* PageQueryResult discriminated union for SSR page queries
* Transform DashboardOverviewDTO to DashboardPageDto
* Converts Date objects to ISO strings for JSON serialization
*/
export type PageQueryResult<TData> =
| { status: 'ok'; data: TData }
| { status: 'notFound' }
| { status: 'redirect'; destination: string }
| { status: 'error'; error: Error };
/**
* Transform DashboardOverviewDTO to DashboardOverviewViewModelData
* Converts string dates to ISO strings for JSON serialization
*/
function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOverviewViewModelData {
function transformDtoToPageDto(dto: DashboardOverviewDTO): DashboardPageDto {
return {
currentDriver: dto.currentDriver ? {
id: dto.currentDriver.id,
@@ -101,40 +96,54 @@ function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOvervi
}
/**
* Dashboard page query that returns transformed ViewModelData
* Returns a discriminated union instead of nullable data
* Dashboard page query with manual wiring
* Returns PageQueryResult<DashboardPageDto>
* No DI container usage - constructs dependencies explicitly
*/
export class DashboardPageQuery {
static async execute(): Promise<PageQueryResult<DashboardOverviewViewModelData>> {
/**
* Execute the dashboard page query
* Constructs API client manually with required dependencies
*/
static async execute(): Promise<PageQueryResult<DashboardPageDto>> {
try {
const container = ContainerManager.getInstance().getContainer();
const apiClient = container.get<DashboardApiClient>(DASHBOARD_API_CLIENT_TOKEN);
// Manual wiring: construct dependencies explicitly
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
// Construct API client with required dependencies
// Using environment variable for base URL, fallback to empty string
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new DashboardApiClient(baseUrl, errorReporter, logger);
// Fetch data
const dto = await apiClient.getDashboardOverview();
if (!dto) {
return { status: 'notFound' };
}
const viewModelData = transformDtoToViewModelData(dto);
return { status: 'ok', data: viewModelData };
// Transform to Page DTO
const pageDto = transformDtoToPageDto(dto);
return { status: 'ok', dto: pageDto };
} catch (error) {
// Handle specific error types
if (error instanceof Error) {
// Check if it's a not found error
if (error.message.includes('not found') || (error as any).statusCode === 404) {
const errorWithStatus = error as ErrorWithStatusCode;
if (errorWithStatus.message?.includes('not found') || errorWithStatus.statusCode === 404) {
return { status: 'notFound' };
}
// Check if it's a redirect error
if (error.message.includes('redirect') || (error as any).statusCode === 302) {
return { status: 'redirect', destination: '/' };
if (errorWithStatus.message?.includes('redirect') || errorWithStatus.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', error };
return { status: 'error', errorId: 'DASHBOARD_FETCH_FAILED' };
}
return { status: 'error', error: new Error(String(error)) };
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -0,0 +1,132 @@
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { ApiClient } from '@/lib/api';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
/**
* Page DTO for Profile Leagues page
* JSON-serializable data structure for server-to-client communication
*/
export interface ProfileLeaguesPageDto {
ownedLeagues: Array<{
leagueId: string;
name: string;
description: string;
membershipRole: 'owner' | 'admin' | 'steward' | 'member';
}>;
memberLeagues: Array<{
leagueId: string;
name: string;
description: string;
membershipRole: 'owner' | 'admin' | 'steward' | 'member';
}>;
}
interface MembershipDTO {
driverId: string;
role: string;
status?: 'active' | 'inactive';
}
/**
* Profile Leagues Page Query
*
* Server-side composition that:
* 1. Reads session to determine currentDriverId
* 2. Calls API clients directly (manual wiring)
* 3. Assembles Page DTO
* 4. Returns PageQueryResult
*/
export class ProfileLeaguesPageQuery {
static async execute(): Promise<PageQueryResult<ProfileLeaguesPageDto>> {
try {
// Get session server-side
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
if (!session?.user?.primaryDriverId) {
return { status: 'notFound' };
}
const currentDriverId = session.user.primaryDriverId;
// Manual wiring: construct API client explicitly
const apiBaseUrl = getWebsiteApiBaseUrl();
const apiClient = new ApiClient(apiBaseUrl);
// Fetch all leagues
const leaguesDto = await apiClient.leagues.getAllWithCapacity();
if (!leaguesDto?.leagues) {
return { status: 'notFound' };
}
// Fetch memberships for each league and categorize
const owned: ProfileLeaguesPageDto['ownedLeagues'] = [];
const member: ProfileLeaguesPageDto['memberLeagues'] = [];
for (const league of leaguesDto.leagues) {
try {
const membershipsDto = await apiClient.leagues.getMemberships(league.id);
// Handle both possible response structures with proper type checking
let memberships: MembershipDTO[] = [];
if (membershipsDto && typeof membershipsDto === 'object') {
if ('members' in membershipsDto && Array.isArray((membershipsDto as { members?: unknown }).members)) {
memberships = (membershipsDto as { members: MembershipDTO[] }).members;
} else if ('memberships' in membershipsDto && Array.isArray((membershipsDto as { memberships?: unknown }).memberships)) {
memberships = (membershipsDto as { memberships: MembershipDTO[] }).memberships;
}
}
const currentMembership = memberships.find(
(m) => m.driverId === currentDriverId
);
if (currentMembership && currentMembership.status === 'active') {
const leagueData = {
leagueId: league.id,
name: league.name,
description: league.description,
membershipRole: currentMembership.role as 'owner' | 'admin' | 'steward' | 'member',
};
if (currentMembership.role === 'owner') {
owned.push(leagueData);
} else {
member.push(leagueData);
}
}
} catch (error) {
// Skip leagues where membership fetch fails
console.warn(`Failed to fetch memberships for league ${league.id}:`, error);
continue;
}
}
return {
status: 'ok',
dto: {
ownedLeagues: owned,
memberLeagues: member,
},
};
} catch (error) {
console.error('ProfileLeaguesPageQuery failed:', error);
if (error instanceof Error) {
// Check for specific error properties
const errorAny = error as { statusCode?: number };
if (error.message.includes('not found') || errorAny.statusCode === 404) {
return { status: 'notFound' };
}
if (error.message.includes('redirect') || errorAny.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'PROFILE_LEAGUES_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -2,16 +2,7 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import type { DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
// ============================================================================
// TYPES
// ============================================================================
export type PageQueryResult =
| { status: 'ok'; dto: DriverProfileViewModelData }
| { status: 'notFound' }
| { status: 'redirect'; to: string }
| { status: 'error'; errorId: string };
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
// ============================================================================
// SERVER QUERY CLASS
@@ -31,7 +22,7 @@ export class ProfilePageQuery {
* @param driverId - The driver ID to fetch profile for
* @returns PageQueryResult with discriminated union of states
*/
static async execute(driverId: string | null): Promise<PageQueryResult> {
static async execute(driverId: string | null): Promise<PageQueryResult<DriverProfileViewModelData>> {
// Handle missing driver ID
if (!driverId) {
return { status: 'notFound' };

View File

@@ -0,0 +1,137 @@
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { TeamService } from '@/lib/services/teams/TeamService';
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
/**
* TeamDetailPageDto - Raw serializable data for team detail page
* Contains only raw data, no derived/computed properties
*/
export interface TeamDetailPageDto {
team: {
id: string;
name: string;
tag: string;
description?: string;
ownerId: string;
leagues: string[];
createdAt?: string;
specialization?: string;
region?: string;
languages?: string[];
category?: string;
membership?: {
role: string;
joinedAt: string;
isActive: boolean;
} | null;
canManage: boolean;
};
memberships: Array<{
driverId: string;
driverName: string;
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
avatarUrl: string;
}>;
currentDriverId: string;
}
/**
* TeamDetailPageQuery - Server-side composition for team detail page
* Manual wiring only; no ContainerManager; no PageDataFetcher
* Returns raw serializable DTO
*/
export class TeamDetailPageQuery {
static async execute(teamId: string): Promise<PageQueryResult<TeamDetailPageDto>> {
try {
// Validate teamId
if (!teamId) {
return { status: 'notFound' };
}
// Get session to determine current driver
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
if (!session?.user?.primaryDriverId) {
return { status: 'notFound' };
}
const currentDriverId = session.user.primaryDriverId;
// Manual dependency creation
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
const service = new TeamService(teamsApiClient);
// Fetch team details
const teamData = await service.getTeamDetails(teamId, currentDriverId);
if (!teamData) {
return { status: 'notFound' };
}
// Fetch team members
const membersData = await service.getTeamMembers(teamId, currentDriverId, teamData.ownerId);
// Transform to raw serializable DTO
const dto: TeamDetailPageDto = {
team: {
id: teamData.id,
name: teamData.name,
tag: teamData.tag,
description: teamData.description,
ownerId: teamData.ownerId,
leagues: teamData.leagues,
createdAt: teamData.createdAt,
specialization: teamData.specialization,
region: teamData.region,
languages: teamData.languages,
category: teamData.category,
membership: teamData.membership ? {
role: teamData.membership.role,
joinedAt: teamData.membership.joinedAt,
isActive: teamData.membership.isActive,
} : null,
canManage: teamData.canManage,
},
memberships: membersData.map((member: TeamMemberViewModel) => ({
driverId: member.driverId,
driverName: member.driverName,
role: member.role,
joinedAt: member.joinedAt,
isActive: member.isActive,
avatarUrl: member.avatarUrl,
})),
currentDriverId,
};
return { status: 'ok', dto };
} catch (error) {
// Handle specific error types
if (error instanceof Error) {
const errorAny = error as { statusCode?: number; message?: string };
if (errorAny.message?.includes('not found') || errorAny.statusCode === 404) {
return { status: 'notFound' };
}
if (errorAny.message?.includes('redirect') || errorAny.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'TEAM_DETAIL_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -0,0 +1,98 @@
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { TeamService } from '@/lib/services/teams/TeamService';
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
/**
* TeamsPageDto - Raw serializable data for teams page
* Contains only raw data, no derived/computed properties
*/
export interface TeamsPageDto {
teams: Array<{
id: string;
name: string;
tag: string;
memberCount: number;
description?: string;
totalWins: number;
totalRaces: number;
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
isRecruiting: boolean;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages: string[];
leagues: string[];
logoUrl?: string;
rating?: number;
category?: string;
}>;
}
/**
* TeamsPageQuery - Server-side composition for teams list page
* Manual wiring only; no ContainerManager; no PageDataFetcher
* Returns raw serializable DTO
*/
export class TeamsPageQuery {
static async execute(): Promise<PageQueryResult<TeamsPageDto>> {
try {
// Manual dependency creation
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3101';
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
const service = new TeamService(teamsApiClient);
// Fetch teams
const teams = await service.getAllTeams();
if (!teams || teams.length === 0) {
return { status: 'notFound' };
}
// Transform to raw serializable DTO
const dto: TeamsPageDto = {
teams: teams.map((team: TeamSummaryViewModel) => ({
id: team.id,
name: team.name,
tag: team.tag,
memberCount: team.memberCount,
description: team.description,
totalWins: team.totalWins,
totalRaces: team.totalRaces,
performanceLevel: team.performanceLevel,
isRecruiting: team.isRecruiting,
specialization: team.specialization,
region: team.region,
languages: team.languages,
leagues: team.leagues,
logoUrl: team.logoUrl,
rating: team.rating,
category: team.category,
})),
};
return { status: 'ok', dto };
} catch (error) {
// Handle specific error types
if (error instanceof Error) {
const errorAny = error as { statusCode?: number; message?: string };
if (errorAny.message?.includes('not found') || errorAny.statusCode === 404) {
return { status: 'notFound' };
}
if (errorAny.message?.includes('redirect') || errorAny.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'TEAMS_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* PageQueryResult discriminated union
*
* Canonical result type for all server-side page queries.
* Defines the explicit outcome of a page query execution.
*
* Based on WEBSITE_PAGE_QUERIES.md:
* - ok with { dto }
* - notFound
* - redirect with { to }
* - error with { errorId }
*/
export type PageQueryResult<TPageDto> =
| { status: 'ok'; dto: TPageDto }
| { status: 'notFound' }
| { status: 'redirect'; to: string }
| { status: 'error'; errorId: string };