website refactor

This commit is contained in:
2026-01-14 02:02:24 +01:00
parent 8d7c709e0c
commit 4522d41aef
291 changed files with 12763 additions and 9309 deletions

View File

@@ -1,50 +1,49 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { Result } from '@/lib/contracts/Result';
import { AdminService } from '@/lib/services/admin/AdminService';
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
/**
* AdminDashboardPageQuery
*
* Server-side composition for admin dashboard page.
* Fetches dashboard statistics from API and transforms to View Data using builders.
*
* Follows Clean Architecture: DTOs never leak into application code.
* Fetches dashboard statistics from API and transforms to ViewData.
*
* Follows Clean Architecture: Uses builders for transformation.
*/
export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
async execute(): Promise<PageQueryResult<AdminDashboardViewData>> {
async execute(): Promise<Result<AdminDashboardViewData, PresentationError>> {
try {
// Create required dependencies
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: false,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Manual construction: Service creates its own dependencies
const adminService = new AdminService();
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const adminService = new AdminService(apiClient);
// Fetch dashboard stats
const apiDtoResult = await adminService.getDashboardStats();
// Fetch dashboard stats (API DTO)
const apiDto = await adminService.getDashboardStats();
if (apiDtoResult.isErr()) {
return Result.err(mapToPresentationError(apiDtoResult.getError()));
}
// Transform API DTO to View Data using builder
const viewData = AdminDashboardViewDataBuilder.build(apiDto);
// Transform to ViewData using builder
const viewData = AdminDashboardViewDataBuilder.build(apiDtoResult.unwrap());
return { status: 'ok', dto: viewData };
} catch (error) {
console.error('AdminDashboardPageQuery failed:', error);
return Result.ok(viewData);
} catch (err) {
console.error('AdminDashboardPageQuery failed:', err);
if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) {
return { status: 'notFound' };
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
return Result.err('notFound');
}
return { status: 'error', errorId: 'admin_dashboard_fetch_failed' };
return Result.err('serverError');
}
}
}
// Static method to avoid object construction in server code
static async execute(): Promise<Result<AdminDashboardViewData, PresentationError>> {
const query = new AdminDashboardPageQuery();
return query.execute();
}
}

View File

@@ -1,57 +1,24 @@
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { AdminService } from '@/lib/services/admin/AdminService';
export interface AdminUsersPageDto {
users: Array<{
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
primaryDriverId?: string;
}>;
total: number;
page: number;
limit: number;
totalPages: number;
}
import { AdminUsersViewDataBuilder } from '@/lib/builders/view-data/AdminUsersViewDataBuilder';
import type { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
/**
* AdminUsersPageQuery
*
*
* Server-side composition for admin users page.
* Fetches user list from API with filtering and assembles Page DTO.
* Fetches user list from API and transforms to ViewData.
*/
export class AdminUsersPageQuery {
async execute(query: {
search?: string;
role?: string;
status?: string;
page?: number;
limit?: number;
}): Promise<PageQueryResult<AdminUsersPageDto>> {
export class AdminUsersPageQuery implements PageQuery<AdminUsersViewData, { search?: string; role?: string; status?: string; page?: number; limit?: number }> {
async execute(query: { search?: string; role?: string; status?: string; page?: number; limit?: number }): Promise<Result<AdminUsersViewData, PresentationError>> {
try {
// Create required dependencies
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: false,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const adminService = new AdminService(apiClient);
// Manual construction: Service creates its own dependencies
const adminService = new AdminService();
// Fetch user list via service
const apiDto = await adminService.listUsers({
const apiDtoResult = await adminService.listUsers({
search: query.search,
role: query.role,
status: query.status,
@@ -59,35 +26,28 @@ export class AdminUsersPageQuery {
limit: query.limit || 50,
});
// Assemble Page DTO (raw values only)
const pageDto: AdminUsersPageDto = {
users: apiDto.users.map(user => ({
id: user.id,
email: user.email,
displayName: user.displayName,
roles: user.roles,
status: user.status,
isSystemAdmin: user.isSystemAdmin,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString(),
primaryDriverId: user.primaryDriverId,
})),
total: apiDto.total,
page: apiDto.page,
limit: apiDto.limit,
totalPages: apiDto.totalPages,
};
if (apiDtoResult.isErr()) {
return Result.err(mapToPresentationError(apiDtoResult.getError()));
}
return { status: 'ok', dto: pageDto };
// Transform to ViewData using builder
const viewData = AdminUsersViewDataBuilder.build(apiDtoResult.unwrap());
return Result.ok(viewData);
} catch (error) {
console.error('AdminUsersPageQuery failed:', error);
if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) {
return { status: 'notFound' };
return Result.err('notFound');
}
return { status: 'error', errorId: 'admin_users_fetch_failed' };
return Result.err('serverError');
}
}
// Static method to avoid object construction in server code
static async execute(query: { search?: string; role?: string; status?: string; page?: number; limit?: number }): Promise<Result<AdminUsersViewData, PresentationError>> {
const queryInstance = new AdminUsersPageQuery();
return queryInstance.execute(query);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Forgot Password Page Query
*
* Composes data for the forgot password page using RSC pattern.
* No business logic, only data composition.
*/
import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { ForgotPasswordViewDataBuilder, ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder';
import { AuthPageService } from '@/lib/services/auth/AuthPageService';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
export class ForgotPasswordPageQuery implements PageQuery<ForgotPasswordViewData, URLSearchParams> {
async execute(searchParams: URLSearchParams): Promise<Result<ForgotPasswordViewData, string>> {
// Parse and validate search parameters
const parsedResult = SearchParamParser.parseAuth(searchParams);
if (parsedResult.isErr()) {
return Result.err(`Invalid search parameters: ${parsedResult.getError()}`);
}
const { returnTo, token } = parsedResult.unwrap();
try {
// Use service to process parameters
const authService = new AuthPageService();
const serviceResult = await authService.processForgotPasswordParams({ returnTo, token });
if (serviceResult.isErr()) {
return Result.err(serviceResult.getError());
}
// Transform to ViewData using builder
const viewData = ForgotPasswordViewDataBuilder.build(serviceResult.unwrap());
return Result.ok(viewData);
} catch (error) {
return Result.err('Failed to execute forgot password page query');
}
}
// Static factory method for convenience
static async execute(searchParams: URLSearchParams): Promise<Result<ForgotPasswordViewData, string>> {
const query = new ForgotPasswordPageQuery();
return query.execute(searchParams);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Login Page Query
*
* Composes data for the login page using RSC pattern.
* No business logic, only data composition.
*/
import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { LoginViewDataBuilder, LoginViewData } from '@/lib/builders/view-data/LoginViewDataBuilder';
import { AuthPageService } from '@/lib/services/auth/AuthPageService';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
export class LoginPageQuery implements PageQuery<LoginViewData, URLSearchParams> {
async execute(searchParams: URLSearchParams): Promise<Result<LoginViewData, string>> {
// Parse and validate search parameters
const parsedResult = SearchParamParser.parseAuth(searchParams);
if (parsedResult.isErr()) {
return Result.err(`Invalid search parameters: ${parsedResult.getError()}`);
}
const { returnTo, token } = parsedResult.unwrap();
try {
// Use service to process parameters
const authService = new AuthPageService();
const serviceResult = await authService.processLoginParams({ returnTo, token });
if (serviceResult.isErr()) {
return Result.err(serviceResult.getError());
}
// Transform to ViewData using builder
const viewData = LoginViewDataBuilder.build(serviceResult.unwrap());
return Result.ok(viewData);
} catch (error) {
return Result.err('Failed to execute login page query');
}
}
// Static factory method for convenience
static async execute(searchParams: URLSearchParams): Promise<Result<LoginViewData, string>> {
const query = new LoginPageQuery();
return query.execute(searchParams);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Reset Password Page Query
*
* Composes data for the reset password page using RSC pattern.
* No business logic, only data composition.
*/
import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { ResetPasswordViewDataBuilder, ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder';
import { AuthPageService } from '@/lib/services/auth/AuthPageService';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
export class ResetPasswordPageQuery implements PageQuery<ResetPasswordViewData, URLSearchParams> {
async execute(searchParams: URLSearchParams): Promise<Result<ResetPasswordViewData, string>> {
// Parse and validate search parameters
const parsedResult = SearchParamParser.parseAuth(searchParams);
if (parsedResult.isErr()) {
return Result.err(`Invalid search parameters: ${parsedResult.getError()}`);
}
const { returnTo, token } = parsedResult.unwrap();
try {
// Use service to process parameters
const authService = new AuthPageService();
const serviceResult = await authService.processResetPasswordParams({ returnTo, token });
if (serviceResult.isErr()) {
return Result.err(serviceResult.getError());
}
// Transform to ViewData using builder
const viewData = ResetPasswordViewDataBuilder.build(serviceResult.unwrap());
return Result.ok(viewData);
} catch (error) {
return Result.err('Failed to execute reset password page query');
}
}
// Static factory method for convenience
static async execute(searchParams: URLSearchParams): Promise<Result<ResetPasswordViewData, string>> {
const query = new ResetPasswordPageQuery();
return query.execute(searchParams);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Signup Page Query
*
* Composes data for the signup page using RSC pattern.
* No business logic, only data composition.
*/
import { SignupViewData, SignupViewDataBuilder } from '@/lib/builders/view-data/SignupViewDataBuilder';
import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { AuthPageService } from '@/lib/services/auth/AuthPageService';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
export class SignupPageQuery implements PageQuery<SignupViewData, URLSearchParams> {
async execute(searchParams: URLSearchParams): Promise<Result<SignupViewData, string>> {
// Parse and validate search parameters
const parsedResult = SearchParamParser.parseAuth(searchParams);
if (parsedResult.isErr()) {
return Result.err(`Invalid search parameters: ${parsedResult.getError()}`);
}
const { returnTo, token } = parsedResult.unwrap();
try {
// Use service to process parameters
const authService = new AuthPageService();
const serviceResult = await authService.processSignupParams({ returnTo, token });
if (serviceResult.isErr()) {
return Result.err(serviceResult.getError());
}
// Transform to ViewData using builder
const viewData = SignupViewDataBuilder.build(serviceResult.unwrap());
return Result.ok(viewData);
} catch (error) {
return Result.err('Failed to execute signup page query');
}
}
// Static factory method for convenience
static async execute(searchParams: URLSearchParams): Promise<Result<SignupViewData, string>> {
const query = new SignupPageQuery();
return query.execute(searchParams);
}
}

View File

@@ -1,84 +0,0 @@
/**
* 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

@@ -0,0 +1,20 @@
/**
* DriverRankingsPageDto - Raw data structure for Driver Rankings page
* Plain data, no methods, no business logic
*/
export interface DriverRankingsPageDto {
drivers: {
id: string;
name: string;
rating: number;
skillLevel: string;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
avatarUrl?: string;
}[];
}

View File

@@ -0,0 +1,7 @@
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
export interface LeaderboardsPageDto {
drivers: { drivers: DriverLeaderboardItemDTO[] };
teams: { teams: TeamListItemDTO[] };
}

View File

@@ -1,149 +1,50 @@
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 { 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;
}
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { DashboardViewDataBuilder } from '@/lib/builders/view-data/DashboardViewDataBuilder';
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
import { DashboardService } from '@/lib/services/analytics/DashboardService';
import { mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
/**
* Transform DashboardOverviewDTO to DashboardPageDto
* Converts Date objects to ISO strings for JSON serialization
*/
function transformDtoToPageDto(dto: DashboardOverviewDTO): DashboardPageDto {
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 with manual wiring
* Returns PageQueryResult<DashboardPageDto>
* Dashboard page query
* Returns Result<DashboardViewData, PresentationError>
* No DI container usage - constructs dependencies explicitly
*/
export class DashboardPageQuery {
/**
* Execute the dashboard page query
* Constructs API client manually with required dependencies
*/
static async execute(): Promise<PageQueryResult<DashboardPageDto>> {
try {
// Manual wiring: construct dependencies explicitly
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
async execute(): Promise<Result<DashboardViewData, 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
// Manual wiring: Service creates its own dependencies
const dashboardService = new DashboardService();
// Fetch data using service
const serviceResult = await dashboardService.getDashboardOverview();
if (serviceResult.isErr()) {
const serviceError = serviceResult.getError();
// 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' };
// Map domain errors to presentation errors
switch (serviceError.type) {
case 'notFound':
return Result.err('notFound');
case 'unauthorized':
return Result.err('redirect');
case 'serverError':
case 'networkError':
case 'unknown':
return Result.err('DASHBOARD_FETCH_FAILED');
default:
return Result.err('UNKNOWN_ERROR');
}
// Transform to Page DTO
const pageDto = transformDtoToPageDto(dto);
return { status: 'ok', dto: pageDto };
} catch (error) {
// Handle specific error types
if (error instanceof Error) {
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 (errorWithStatus.message?.includes('redirect') || errorWithStatus.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'DASHBOARD_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
// Transform to ViewData using builder
const apiDto = serviceResult.unwrap();
const viewData = DashboardViewDataBuilder.build(apiDto);
return Result.ok(viewData);
}
// Static method to avoid object construction in server code
static async execute(): Promise<Result<DashboardViewData, 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
const query = new DashboardPageQuery();
return query.execute();
}
}

View File

@@ -0,0 +1,49 @@
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
/**
* DriverProfilePageQuery
*
* Server-side data fetcher for the driver profile page.
* Returns a discriminated union with all possible page states.
* API DTO is already JSON-serializable.
*/
export class DriverProfilePageQuery {
/**
* Execute the driver 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<GetDriverProfileOutputDTO>> {
// Handle missing driver ID
if (!driverId) {
return { status: 'notFound' };
}
try {
// Manual wiring: construct dependencies explicitly
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const dto = await apiClient.getDriverProfile(driverId);
if (!dto.currentDriver) {
return { status: 'notFound' };
}
// API DTO is already JSON-serializable
return { status: 'ok', dto };
} catch (error) {
console.error('DriverProfilePageQuery failed:', error);
return { status: 'error', errorId: 'DRIVER_PROFILE_FETCH_FAILED' };
}
}
}

View File

@@ -0,0 +1,77 @@
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { DriverRankingsPageDto } from '@/lib/page-queries/page-dtos/DriverRankingsPageDto';
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
interface ErrorWithStatusCode extends Error {
statusCode?: number;
}
/**
* Transform DriverLeaderboardItemDTO to DriverRankingsPageDto
*/
function transformDtoToPageDto(dto: { drivers: DriverLeaderboardItemDTO[] }): DriverRankingsPageDto {
return {
drivers: dto.drivers.map(driver => ({
id: driver.id,
name: driver.name,
rating: driver.rating,
skillLevel: driver.skillLevel,
nationality: driver.nationality,
racesCompleted: driver.racesCompleted,
wins: driver.wins,
podiums: driver.podiums,
isActive: driver.isActive,
rank: driver.rank,
avatarUrl: driver.avatarUrl || '',
})),
};
}
/**
* Driver Rankings page query with manual wiring
* Returns PageQueryResult<DriverRankingsPageDto>
*/
export class DriverRankingsPageQuery {
/**
* Execute the driver rankings page query
*/
static async execute(): Promise<PageQueryResult<DriverRankingsPageDto>> {
try {
// Manual wiring
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
// Fetch data
const dto = await apiClient.getLeaderboard();
if (!dto || !dto.drivers) {
return { status: 'notFound' };
}
// Transform to Page DTO
const pageDto = transformDtoToPageDto(dto);
return { status: 'ok', dto: pageDto };
} catch (error) {
if (error instanceof Error) {
const errorWithStatus = error as ErrorWithStatusCode;
if (errorWithStatus.message?.includes('not found') || errorWithStatus.statusCode === 404) {
return { status: 'notFound' };
}
if (errorWithStatus.message?.includes('redirect') || errorWithStatus.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'DRIVER_RANKINGS_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -0,0 +1,50 @@
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
/**
* DriversPageQuery
*
* Server-side data fetcher for the drivers listing page.
* Returns a discriminated union with all possible page states.
* API DTO is already JSON-serializable.
*/
export class DriversPageQuery {
/**
* Execute the drivers page query
*
* @returns PageQueryResult with discriminated union of states
*/
static async execute(): Promise<PageQueryResult<DriversLeaderboardDTO>> {
try {
// Manual wiring: construct dependencies explicitly
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const result = await apiClient.getLeaderboard();
if (!result || !result.drivers) {
return { status: 'notFound' };
}
// Transform to the expected DTO format
const dto: DriversLeaderboardDTO = {
drivers: result.drivers,
totalRaces: result.drivers.reduce((sum, driver) => sum + driver.racesCompleted, 0),
totalWins: result.drivers.reduce((sum, driver) => sum + driver.wins, 0),
activeCount: result.drivers.filter(driver => driver.isActive).length,
};
return { status: 'ok', dto };
} catch (error) {
console.error('DriversPageQuery failed:', error);
return { status: 'error', errorId: 'DRIVERS_FETCH_FAILED' };
}
}
}

View File

@@ -0,0 +1,64 @@
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { LeaderboardsPageDto } from '@/lib/page-queries/page-dtos/LeaderboardsPageDto';
interface ErrorWithStatusCode extends Error {
statusCode?: number;
}
/**
* Leaderboards page query with manual wiring
* Returns PageQueryResult<LeaderboardsPageDto>
*/
export class LeaderboardsPageQuery {
/**
* Execute the leaderboards page query
*/
static async execute(): Promise<PageQueryResult<LeaderboardsPageDto>> {
try {
// Manual wiring
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
// Fetch data in parallel
const [driverResult, teamResult] = await Promise.all([
driversApiClient.getLeaderboard(),
teamsApiClient.getAll(),
]);
if (!driverResult && !teamResult) {
return { status: 'notFound' };
}
// Transform to Page DTO
const pageDto: LeaderboardsPageDto = {
drivers: driverResult || { drivers: [] },
teams: teamResult || { teams: [] },
};
return { status: 'ok', dto: pageDto };
} catch (error) {
if (error instanceof Error) {
const errorWithStatus = error as ErrorWithStatusCode;
if (errorWithStatus.message?.includes('not found') || errorWithStatus.statusCode === 404) {
return { status: 'notFound' };
}
if (errorWithStatus.message?.includes('redirect') || errorWithStatus.statusCode === 302) {
return { status: 'redirect', to: '/' };
}
return { status: 'error', errorId: 'LEADERBOARDS_FETCH_FAILED' };
}
return { status: 'error', errorId: 'UNKNOWN_ERROR' };
}
}
}

View File

@@ -0,0 +1,63 @@
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
/**
* LeagueDetail page query
* Returns the raw API DTO for the league detail page
* No DI container usage - constructs dependencies explicitly
*/
export class LeagueDetailPageQuery implements PageQuery<any, string> {
async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'LEAGUE_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
// Manual wiring: create API client
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
// Fetch data using API client
try {
const apiDto = await apiClient.getAllWithCapacityAndScoring();
if (!apiDto || !apiDto.leagues) {
return Result.err('notFound');
}
// Find the specific league
const league = apiDto.leagues.find(l => l.id === leagueId);
if (!league) {
return Result.err('notFound');
}
// Return the raw DTO - the page will handle ViewModel conversion
return Result.ok({
league,
apiDto,
});
} catch (error) {
console.error('LeagueDetailPageQuery failed:', error);
if (error instanceof Error) {
if (error.message.includes('403') || error.message.includes('401')) {
return Result.err('redirect');
}
if (error.message.includes('404')) {
return Result.err('notFound');
}
if (error.message.includes('5') || error.message.includes('server')) {
return Result.err('LEAGUE_FETCH_FAILED');
}
}
return Result.err('UNKNOWN_ERROR');
}
}
// Static method to avoid object construction in server code
static async execute(leagueId: string): Promise<Result<any, 'notFound' | 'redirect' | 'LEAGUE_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
const query = new LeagueDetailPageQuery();
return query.execute(leagueId);
}
}

View File

@@ -0,0 +1,58 @@
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { LeaguesViewDataBuilder } from '@/lib/builders/view-data/LeaguesViewDataBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DomainError } from '@/lib/contracts/services/Service';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
/**
* Leagues page query
* Returns Result<LeaguesViewData, PresentationError>
* No DI container usage - constructs dependencies explicitly
*/
export class LeaguesPageQuery implements PageQuery<LeaguesViewData, void> {
async execute(): Promise<Result<LeaguesViewData, 'notFound' | 'redirect' | 'LEAGUES_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
// Manual wiring: create API client
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
// Fetch data using API client
try {
const apiDto = await apiClient.getAllWithCapacityAndScoring();
if (!apiDto || !apiDto.leagues) {
return Result.err('notFound');
}
// Transform to ViewData using builder
const viewData = LeaguesViewDataBuilder.build(apiDto);
return Result.ok(viewData);
} catch (error) {
console.error('LeaguesPageQuery failed:', error);
if (error instanceof Error) {
if (error.message.includes('403') || error.message.includes('401')) {
return Result.err('redirect');
}
if (error.message.includes('404')) {
return Result.err('notFound');
}
if (error.message.includes('5') || error.message.includes('server')) {
return Result.err('LEAGUES_FETCH_FAILED');
}
}
return Result.err('UNKNOWN_ERROR');
}
}
// Static method to avoid object construction in server code
static async execute(): Promise<Result<LeaguesViewData, 'notFound' | 'redirect' | 'LEAGUES_FETCH_FAILED' | 'UNKNOWN_ERROR'>> {
const query = new LeaguesPageQuery();
return query.execute();
}
}

View File

@@ -0,0 +1,48 @@
/**
* Onboarding Page Query
*
* Handles authentication and driver status checks for the onboarding page.
*/
import { Result } from '@/lib/contracts/Result';
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
import { OnboardingPageViewDataBuilder } from '@/lib/builders/view-data/OnboardingPageViewDataBuilder';
export class OnboardingPageQuery implements PageQuery<OnboardingPageViewData, void, PresentationError> {
async execute(): Promise<Result<OnboardingPageViewData, PresentationError>> {
const onboardingService = new OnboardingService();
// Check if user is already onboarded
const driverCheckResult = await onboardingService.checkCurrentDriver();
if (driverCheckResult.isErr()) {
const error = driverCheckResult.getError();
// Map domain errors to presentation errors
if (error.type === 'unauthorized') {
return Result.err('unauthorized');
} else if (error.type === 'notFound') {
// No driver found means not onboarded yet - this is OK
const output = OnboardingPageViewDataBuilder.build(null);
return Result.ok(output);
} else if (error.type === 'serverError' || error.type === 'networkError') {
return Result.err('serverError');
} else {
return Result.err('unknown');
}
}
const driver = driverCheckResult.unwrap();
const output = OnboardingPageViewDataBuilder.build(driver);
return Result.ok(output);
}
// Static factory method for convenience
static async execute(): Promise<Result<OnboardingPageViewData, PresentationError>> {
const query = new OnboardingPageQuery();
return query.execute();
}
}

View File

@@ -1,7 +1,7 @@
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';
import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
/**
* Page DTO for Profile Leagues page

View File

@@ -1,105 +1,38 @@
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';
import type { PageQueryResult } from '@/lib/page-queries/page-query-result/PageQueryResult';
import { ProfileViewDataBuilder } from '@/lib/builders/view-data/ProfileViewDataBuilder';
import { Result } from '@/lib/contracts/Result';
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { DriverProfileService } from '@/lib/services/drivers/DriverProfileService';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
// ============================================================================
// SERVER QUERY CLASS
// ============================================================================
export class ProfilePageQuery implements PageQuery<ProfileViewData, void, PresentationError> {
async execute(): Promise<Result<ProfileViewData, PresentationError>> {
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
/**
* 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<DriverProfileViewModelData>> {
// Handle missing driver ID
if (!driverId) {
return { status: 'notFound' };
if (!session?.user?.primaryDriverId) {
return Result.err('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);
});
const service = new DriverProfileService();
const profileResult = await service.getDriverProfile(session.user.primaryDriverId);
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' };
if (profileResult.isErr()) {
const error = profileResult.getError();
if (error === 'notFound') return Result.err('notFound');
if (error === 'unauthorized') return Result.err('unauthorized');
if (error === 'serverError') return Result.err('serverError');
return Result.err('unknown');
}
const dto = profileResult.unwrap();
const output = ProfileViewDataBuilder.build(dto);
return Result.ok(output);
}
/**
* 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,
};
static async execute(): Promise<Result<ProfileViewData, PresentationError>> {
const query = new ProfilePageQuery();
return query.execute();
}
}

View File

@@ -0,0 +1,15 @@
export interface SponsorshipRequestsPageDto {
sections: Array<{
entityType: 'driver' | 'team' | 'season';
entityId: string;
entityName: string;
requests: Array<{
requestId: string;
sponsorId: string;
sponsorName: string;
message: string | null;
createdAtIso: string;
raw: unknown;
}>;
}>;
}

View File

@@ -0,0 +1,47 @@
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { isProductionEnvironment } from '@/lib/config/env';
import type { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import type { Result as ResultType } from '@/lib/contracts/Result';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import type { GetPendingSponsorshipRequestsOutputDTO } from '@/lib/types/generated/GetPendingSponsorshipRequestsOutputDTO';
export type SponsorshipRequestsPageQueryError =
| 'SPONSORSHIP_REQUESTS_NOT_FOUND';
export class SponsorshipRequestsPageQuery
implements PageQuery<GetPendingSponsorshipRequestsOutputDTO, Record<string, string>, SponsorshipRequestsPageQueryError>
{
private readonly client: SponsorsApiClient;
constructor() {
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: isProductionEnvironment(),
});
this.client = new SponsorsApiClient(baseUrl, errorReporter, logger);
}
async execute(
params: Record<string, string>,
): Promise<ResultType<GetPendingSponsorshipRequestsOutputDTO, SponsorshipRequestsPageQueryError>> {
try {
// For now, we'll use hardcoded entityType/entityId
// In a real implementation, these would come from the user session
const result = await this.client.getPendingSponsorshipRequests({
entityType: 'driver',
entityId: 'current-user-id', // This would come from session
});
return Result.ok(result);
} catch {
return Result.err('SPONSORSHIP_REQUESTS_NOT_FOUND');
}
}
}

View File

@@ -1,10 +1,10 @@
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 type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { TeamService } from '@/lib/services/teams/TeamService';
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
/**
* TeamDetailPageDto - Raw serializable data for team detail page

View File

@@ -1,9 +1,9 @@
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 type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { TeamService } from '@/lib/services/teams/TeamService';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
/**
* TeamsPageDto - Raw serializable data for teams page