website refactor
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
46
apps/website/lib/page-queries/auth/LoginPageQuery.ts
Normal file
46
apps/website/lib/page-queries/auth/LoginPageQuery.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts
Normal file
46
apps/website/lib/page-queries/auth/ResetPasswordPageQuery.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/website/lib/page-queries/auth/SignupPageQuery.ts
Normal file
46
apps/website/lib/page-queries/auth/SignupPageQuery.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
@@ -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[] };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user