docs
This commit is contained in:
140
apps/website/lib/page-queries/DashboardPageQuery.ts
Normal file
140
apps/website/lib/page-queries/DashboardPageQuery.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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 type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
|
||||
|
||||
/**
|
||||
* PageQueryResult discriminated union for SSR page queries
|
||||
*/
|
||||
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 {
|
||||
return {
|
||||
currentDriver: dto.currentDriver ? {
|
||||
id: dto.currentDriver.id,
|
||||
name: dto.currentDriver.name,
|
||||
avatarUrl: dto.currentDriver.avatarUrl || '',
|
||||
country: dto.currentDriver.country,
|
||||
totalRaces: dto.currentDriver.totalRaces,
|
||||
wins: dto.currentDriver.wins,
|
||||
podiums: dto.currentDriver.podiums,
|
||||
rating: dto.currentDriver.rating ?? 0,
|
||||
globalRank: dto.currentDriver.globalRank ?? 0,
|
||||
consistency: dto.currentDriver.consistency ?? 0,
|
||||
} : undefined,
|
||||
myUpcomingRaces: dto.myUpcomingRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: new Date(race.scheduledAt).toISOString(),
|
||||
status: race.status,
|
||||
isMyLeague: race.isMyLeague,
|
||||
})),
|
||||
otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: new Date(race.scheduledAt).toISOString(),
|
||||
status: race.status,
|
||||
isMyLeague: race.isMyLeague,
|
||||
})),
|
||||
upcomingRaces: dto.upcomingRaces.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: new Date(race.scheduledAt).toISOString(),
|
||||
status: race.status,
|
||||
isMyLeague: race.isMyLeague,
|
||||
})),
|
||||
activeLeaguesCount: dto.activeLeaguesCount,
|
||||
nextRace: dto.nextRace ? {
|
||||
id: dto.nextRace.id,
|
||||
track: dto.nextRace.track,
|
||||
car: dto.nextRace.car,
|
||||
scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(),
|
||||
status: dto.nextRace.status,
|
||||
isMyLeague: dto.nextRace.isMyLeague,
|
||||
} : undefined,
|
||||
recentResults: dto.recentResults.map(result => ({
|
||||
id: result.raceId,
|
||||
track: result.raceName,
|
||||
car: '', // Not in DTO, will need to handle
|
||||
position: result.position,
|
||||
date: new Date(result.finishedAt).toISOString(),
|
||||
})),
|
||||
leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({
|
||||
leagueId: standing.leagueId,
|
||||
leagueName: standing.leagueName,
|
||||
position: standing.position,
|
||||
points: standing.points,
|
||||
totalDrivers: standing.totalDrivers,
|
||||
})),
|
||||
feedSummary: {
|
||||
notificationCount: dto.feedSummary.notificationCount,
|
||||
items: dto.feedSummary.items.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
timestamp: new Date(item.timestamp).toISOString(),
|
||||
ctaHref: item.ctaHref,
|
||||
ctaLabel: item.ctaLabel,
|
||||
})),
|
||||
},
|
||||
friends: dto.friends.map(friend => ({
|
||||
id: friend.id,
|
||||
name: friend.name,
|
||||
avatarUrl: friend.avatarUrl || '',
|
||||
country: friend.country,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page query that returns transformed ViewModelData
|
||||
* Returns a discriminated union instead of nullable data
|
||||
*/
|
||||
export class DashboardPageQuery {
|
||||
static async execute(): Promise<PageQueryResult<DashboardOverviewViewModelData>> {
|
||||
try {
|
||||
const container = ContainerManager.getInstance().getContainer();
|
||||
const apiClient = container.get<DashboardApiClient>(DASHBOARD_API_CLIENT_TOKEN);
|
||||
|
||||
const dto = await apiClient.getDashboardOverview();
|
||||
|
||||
if (!dto) {
|
||||
return { status: 'notFound' };
|
||||
}
|
||||
|
||||
const viewModelData = transformDtoToViewModelData(dto);
|
||||
return { status: 'ok', data: viewModelData };
|
||||
} 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) {
|
||||
return { status: 'notFound' };
|
||||
}
|
||||
|
||||
// Check if it's a redirect error
|
||||
if (error.message.includes('redirect') || (error as any).statusCode === 302) {
|
||||
return { status: 'redirect', destination: '/' };
|
||||
}
|
||||
|
||||
return { status: 'error', error };
|
||||
}
|
||||
|
||||
return { status: 'error', error: new Error(String(error)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
114
apps/website/lib/page-queries/ProfilePageQuery.ts
Normal file
114
apps/website/lib/page-queries/ProfilePageQuery.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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 };
|
||||
|
||||
// ============================================================================
|
||||
// SERVER QUERY CLASS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* ProfilePageQuery
|
||||
*
|
||||
* Server-side data fetcher for the profile page.
|
||||
* Returns a discriminated union with all possible page states.
|
||||
* Ensures JSON-serializable DTO with no null leakage.
|
||||
*/
|
||||
export class ProfilePageQuery {
|
||||
/**
|
||||
* Execute the profile page query
|
||||
*
|
||||
* @param driverId - The driver ID to fetch profile for
|
||||
* @returns PageQueryResult with discriminated union of states
|
||||
*/
|
||||
static async execute(driverId: string | null): Promise<PageQueryResult> {
|
||||
// Handle missing driver ID
|
||||
if (!driverId) {
|
||||
return { status: 'notFound' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch using PageDataFetcher to avoid direct DI in page
|
||||
const driverService = await PageDataFetcher.fetchManual(async () => {
|
||||
const container = (await import('@/lib/di/container')).ContainerManager.getInstance().getContainer();
|
||||
return container.get<DriverService>(DRIVER_SERVICE_TOKEN);
|
||||
});
|
||||
|
||||
if (!driverService) {
|
||||
return { status: 'error', errorId: 'SERVICE_UNAVAILABLE' };
|
||||
}
|
||||
|
||||
const viewModel = await driverService.getDriverProfile(driverId);
|
||||
|
||||
// Convert to DTO and ensure JSON-serializable
|
||||
const dto = this.toSerializableDTO(viewModel.toDTO());
|
||||
|
||||
if (!dto.currentDriver) {
|
||||
return { status: 'notFound' };
|
||||
}
|
||||
|
||||
return { status: 'ok', dto };
|
||||
|
||||
} catch (error) {
|
||||
console.error('ProfilePageQuery failed:', error);
|
||||
return { status: 'error', errorId: 'FETCH_FAILED' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert DTO to ensure JSON-serializability
|
||||
* - Dates become ISO strings
|
||||
* - Undefined becomes null
|
||||
* - No Date objects remain
|
||||
*/
|
||||
private static toSerializableDTO(dto: DriverProfileViewModelData): DriverProfileViewModelData {
|
||||
return {
|
||||
currentDriver: dto.currentDriver ? {
|
||||
...dto.currentDriver,
|
||||
joinedAt: dto.currentDriver.joinedAt, // Already ISO string
|
||||
} : null,
|
||||
stats: dto.stats ? {
|
||||
...dto.stats,
|
||||
// Ensure all nullable numbers are properly handled
|
||||
avgFinish: dto.stats.avgFinish ?? null,
|
||||
bestFinish: dto.stats.bestFinish ?? null,
|
||||
worstFinish: dto.stats.worstFinish ?? null,
|
||||
finishRate: dto.stats.finishRate ?? null,
|
||||
winRate: dto.stats.winRate ?? null,
|
||||
podiumRate: dto.stats.podiumRate ?? null,
|
||||
percentile: dto.stats.percentile ?? null,
|
||||
rating: dto.stats.rating ?? null,
|
||||
consistency: dto.stats.consistency ?? null,
|
||||
overallRank: dto.stats.overallRank ?? null,
|
||||
} : null,
|
||||
finishDistribution: dto.finishDistribution ? { ...dto.finishDistribution } : null,
|
||||
teamMemberships: dto.teamMemberships.map(m => ({
|
||||
...m,
|
||||
joinedAt: m.joinedAt, // Already ISO string
|
||||
})),
|
||||
socialSummary: {
|
||||
friendsCount: dto.socialSummary.friendsCount,
|
||||
friends: dto.socialSummary.friends.map(f => ({
|
||||
...f,
|
||||
})),
|
||||
},
|
||||
extendedProfile: dto.extendedProfile ? {
|
||||
...dto.extendedProfile,
|
||||
achievements: dto.extendedProfile.achievements.map(a => ({
|
||||
...a,
|
||||
earnedAt: a.earnedAt, // Already ISO string
|
||||
})),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user