website refactor
This commit is contained in:
102
apps/website/lib/adapters/MediaProxyAdapter.test.ts
Normal file
102
apps/website/lib/adapters/MediaProxyAdapter.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { proxyMediaRequest, getMediaContentType, getMediaCacheControl } from './MediaProxyAdapter';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('MediaProxyAdapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('proxyMediaRequest', () => {
|
||||
it('should successfully proxy media and return ArrayBuffer', async () => {
|
||||
const mockBuffer = new ArrayBuffer(8);
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
arrayBuffer: vi.fn().mockResolvedValue(mockBuffer),
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await proxyMediaRequest('/media/avatar/123');
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(mockBuffer);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/media/avatar/123',
|
||||
{ method: 'GET' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 404 errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await proxyMediaRequest('/media/avatar/999');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('notFound');
|
||||
expect(error.message).toContain('Media not found');
|
||||
});
|
||||
|
||||
it('should handle other HTTP errors', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await proxyMediaRequest('/media/avatar/123');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('serverError');
|
||||
expect(error.message).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await proxyMediaRequest('/media/avatar/123');
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('networkError');
|
||||
expect(error.message).toContain('Failed to fetch media');
|
||||
});
|
||||
|
||||
it('should use custom API base URL from environment', () => {
|
||||
process.env.API_BASE_URL = 'https://api.example.com';
|
||||
|
||||
// Just verify the function exists and can be called
|
||||
expect(typeof proxyMediaRequest).toBe('function');
|
||||
|
||||
// Reset
|
||||
delete process.env.API_BASE_URL;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaContentType', () => {
|
||||
it('should return image/png for all media paths', () => {
|
||||
expect(getMediaContentType('/media/avatar/123')).toBe('image/png');
|
||||
expect(getMediaContentType('/media/teams/456/logo')).toBe('image/png');
|
||||
expect(getMediaContentType('/media/leagues/789/cover')).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaCacheControl', () => {
|
||||
it('should return public cache control with max-age', () => {
|
||||
expect(getMediaCacheControl()).toBe('public, max-age=3600');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
apps/website/lib/adapters/MediaProxyAdapter.ts
Normal file
67
apps/website/lib/adapters/MediaProxyAdapter.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* MediaProxyAdapter
|
||||
*
|
||||
* Handles direct HTTP proxy operations for media assets.
|
||||
* This is a special case where direct fetch is needed for binary responses.
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
|
||||
export type MediaProxyError =
|
||||
| { type: 'notFound'; message: string }
|
||||
| { type: 'serverError'; message: string }
|
||||
| { type: 'networkError'; message: string };
|
||||
|
||||
/**
|
||||
* Proxy media request to backend API
|
||||
*
|
||||
* @param mediaPath - The API path to fetch media from (e.g., "/media/avatar/123")
|
||||
* @returns Result with ArrayBuffer on success, or error on failure
|
||||
*/
|
||||
export async function proxyMediaRequest(
|
||||
mediaPath: string
|
||||
): Promise<Result<ArrayBuffer, MediaProxyError>> {
|
||||
try {
|
||||
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${baseUrl}${mediaPath}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return Result.err({
|
||||
type: 'notFound',
|
||||
message: `Media not found: ${mediaPath}`
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'serverError',
|
||||
message: `HTTP ${response.status}: ${response.statusText}`
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
return Result.ok(buffer);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return Result.err({
|
||||
type: 'networkError',
|
||||
message: `Failed to fetch media: ${errorMessage}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type for media path
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function getMediaContentType(mediaPath: string): string {
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache control header value
|
||||
*/
|
||||
export function getMediaCacheControl(): string {
|
||||
return 'public, max-age=3600';
|
||||
}
|
||||
@@ -4,16 +4,13 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession";
|
||||
import { useLogin } from "@/lib/hooks/auth/useLogin";
|
||||
import { useLogout } from "@/lib/hooks/auth/useLogout";
|
||||
|
||||
export type AuthContextValue = {
|
||||
@@ -39,8 +36,7 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
|
||||
initialData: initialSession,
|
||||
});
|
||||
|
||||
// Use mutation hooks for login/logout
|
||||
const loginMutation = useLogin();
|
||||
// Use mutation hooks for logout
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const login = useCallback(
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* - Protected routes with session + wrong role: show permission error
|
||||
*/
|
||||
|
||||
import { routes, routeMatchers, RouteGroup } from '@/lib/routing/RouteConfig';
|
||||
import { routes, routeMatchers } from '@/lib/routing/RouteConfig';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
|
||||
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
@@ -137,12 +138,10 @@ export class AuthFlowRouter {
|
||||
getLoginRedirectUrl(): string {
|
||||
const action = this.getAction();
|
||||
if (action.type === AuthActionType.REDIRECT_TO_LOGIN) {
|
||||
const params = new URLSearchParams({ returnTo: action.returnTo });
|
||||
return `${routes.auth.login}?${params.toString()}`;
|
||||
return `${routes.auth.login}${SearchParamBuilder.auth(action.returnTo)}`;
|
||||
}
|
||||
if (action.type === AuthActionType.SHOW_PERMISSION_ERROR) {
|
||||
const params = new URLSearchParams({ returnTo: action.requestedPath });
|
||||
return `${routes.auth.login}?${params.toString()}`;
|
||||
return `${routes.auth.login}${SearchParamBuilder.auth(action.requestedPath)}`;
|
||||
}
|
||||
throw new Error("Not in login redirect state");
|
||||
}
|
||||
@@ -234,4 +233,4 @@ export function handleAuthFlow(
|
||||
logger.info('[handleAuthFlow] Returning SHOW_PERMISSION_ERROR, redirecting to home', { homeUrl, userRole: session?.role });
|
||||
return { shouldRedirect: true, redirectUrl: homeUrl };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouteAccessPolicy } from './RouteAccessPolicy';
|
||||
import { ReturnToSanitizer } from './ReturnToSanitizer';
|
||||
import { RoutePathBuilder } from './RoutePathBuilder';
|
||||
import { PathnameInterpreter } from './PathnameInterpreter';
|
||||
import { SearchParamBuilder } from '../routing/search-params/SearchParamBuilder';
|
||||
|
||||
/**
|
||||
* AuthRedirectBuilder - Builds redirect URLs for authentication flows
|
||||
@@ -42,11 +43,8 @@ export class AuthRedirectBuilder {
|
||||
|
||||
// Sanitize returnTo (use current path as input, fallback to root)
|
||||
const sanitizedReturnTo = this.sanitizer.sanitizeReturnTo(currentPathname, '/');
|
||||
|
||||
// Append returnTo as query parameter
|
||||
const returnToParam = encodeURIComponent(sanitizedReturnTo);
|
||||
|
||||
return `${loginPath}?returnTo=${returnToParam}`;
|
||||
|
||||
return `${loginPath}${SearchParamBuilder.auth(sanitizedReturnTo)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76
apps/website/lib/auth/LoginFlowController.ts
Normal file
76
apps/website/lib/auth/LoginFlowController.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Login Flow Controller
|
||||
*
|
||||
* Deterministic state machine for authentication flow.
|
||||
* Compliant with docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md
|
||||
*/
|
||||
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
|
||||
export enum LoginState {
|
||||
UNAUTHENTICATED = "UNAUTHENTICATED",
|
||||
AUTHENTICATED_WITH_PERMISSIONS = "AUTHENTICATED_WITH_PERMISSIONS",
|
||||
AUTHENTICATED_WITHOUT_PERMISSIONS = "AUTHENTICATED_WITHOUT_PERMISSIONS",
|
||||
POST_AUTH_REDIRECT = "POST_AUTH_REDIRECT"
|
||||
}
|
||||
|
||||
export type LoginAction =
|
||||
| { type: 'SHOW_LOGIN_FORM' }
|
||||
| { type: 'REDIRECT'; path: string }
|
||||
| { type: 'SHOW_PERMISSION_ERROR' };
|
||||
|
||||
/**
|
||||
* LoginFlowController - Immutable, deterministic state machine
|
||||
*
|
||||
* Rules:
|
||||
* - Constructed from explicit inputs only
|
||||
* - No side effects
|
||||
* - Pure functions for state transitions
|
||||
* - Side effects (routing) executed outside
|
||||
*/
|
||||
export class LoginFlowController {
|
||||
// Immutable state
|
||||
private readonly session: SessionViewModel | null;
|
||||
private readonly returnTo: string;
|
||||
|
||||
// State machine
|
||||
private state: LoginState;
|
||||
|
||||
constructor(session: SessionViewModel | null, returnTo: string) {
|
||||
this.session = session;
|
||||
this.returnTo = returnTo;
|
||||
this.state = this.determineInitialState();
|
||||
}
|
||||
|
||||
private determineInitialState(): LoginState {
|
||||
if (!this.session) return LoginState.UNAUTHENTICATED;
|
||||
if (this.returnTo === '/dashboard') return LoginState.AUTHENTICATED_WITH_PERMISSIONS;
|
||||
return LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS;
|
||||
}
|
||||
|
||||
// Pure function - no side effects
|
||||
getState(): LoginState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Pure function - returns action, does not execute
|
||||
getNextAction(): LoginAction {
|
||||
switch (this.state) {
|
||||
case LoginState.UNAUTHENTICATED:
|
||||
return { type: 'SHOW_LOGIN_FORM' };
|
||||
case LoginState.AUTHENTICATED_WITH_PERMISSIONS:
|
||||
return { type: 'REDIRECT', path: '/dashboard' };
|
||||
case LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS:
|
||||
return { type: 'SHOW_PERMISSION_ERROR' };
|
||||
case LoginState.POST_AUTH_REDIRECT:
|
||||
return { type: 'REDIRECT', path: this.returnTo };
|
||||
}
|
||||
}
|
||||
|
||||
// Transition called after authentication
|
||||
transitionToPostAuth(): void {
|
||||
if (this.session) {
|
||||
this.state = LoginState.POST_AUTH_REDIRECT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,6 @@ import { SessionGateway } from '../gateways/SessionGateway';
|
||||
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
|
||||
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
|
||||
|
||||
// Hoist the mock redirect function
|
||||
const mockRedirect = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: mockRedirect,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./PathnameInterpreter');
|
||||
vi.mock('./RouteAccessPolicy');
|
||||
@@ -69,14 +61,14 @@ describe('RouteGuard', () => {
|
||||
mockPolicy.isAuthPage.mockReturnValue(false);
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockInterpreter.interpret).toHaveBeenCalledWith(pathname);
|
||||
expect(mockPolicy.isPublic).toHaveBeenCalledWith('/public/page');
|
||||
expect(mockPolicy.isAuthPage).toHaveBeenCalledWith('/public/page');
|
||||
expect(mockGateway.getSession).not.toHaveBeenCalled();
|
||||
expect(mockRedirect).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ type: 'allow' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,11 +82,11 @@ describe('RouteGuard', () => {
|
||||
mockGateway.getSession.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
expect(mockRedirect).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ type: 'allow' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +106,7 @@ describe('RouteGuard', () => {
|
||||
mockBuilder.awayFromAuthPage.mockReturnValue('/dashboard');
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
@@ -122,7 +114,7 @@ describe('RouteGuard', () => {
|
||||
session: mockSession,
|
||||
currentPathname: '/login',
|
||||
});
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/dashboard');
|
||||
expect(result).toEqual({ type: 'redirect', to: '/dashboard' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,12 +129,12 @@ describe('RouteGuard', () => {
|
||||
mockBuilder.toLogin.mockReturnValue('/login?redirect=/protected/dashboard');
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/protected/dashboard' });
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/protected/dashboard');
|
||||
expect(result).toEqual({ type: 'redirect', to: '/login?redirect=/protected/dashboard' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,13 +155,13 @@ describe('RouteGuard', () => {
|
||||
mockBuilder.toLogin.mockReturnValue('/login?redirect=/admin/panel');
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
|
||||
expect(mockBuilder.toLogin).toHaveBeenCalledWith({ currentPathname: '/admin/panel' });
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/login?redirect=/admin/panel');
|
||||
expect(result).toEqual({ type: 'redirect', to: '/login?redirect=/admin/panel' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,12 +181,12 @@ describe('RouteGuard', () => {
|
||||
mockPolicy.requiredRoles.mockReturnValue(['admin']);
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/admin/panel');
|
||||
expect(mockRedirect).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ type: 'allow' });
|
||||
});
|
||||
|
||||
it('should allow access when no specific roles required', async () => {
|
||||
@@ -212,12 +204,12 @@ describe('RouteGuard', () => {
|
||||
mockPolicy.requiredRoles.mockReturnValue(null);
|
||||
|
||||
// Act
|
||||
await routeGuard.enforce({ pathname });
|
||||
const result = await routeGuard.enforce({ pathname });
|
||||
|
||||
// Assert
|
||||
expect(mockGateway.getSession).toHaveBeenCalled();
|
||||
expect(mockPolicy.requiredRoles).toHaveBeenCalledWith('/dashboard');
|
||||
expect(mockRedirect).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ type: 'allow' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { PathnameInterpreter } from './PathnameInterpreter';
|
||||
import { RouteAccessPolicy } from './RouteAccessPolicy';
|
||||
import { SessionGateway } from '../gateways/SessionGateway';
|
||||
import { AuthRedirectBuilder } from './AuthRedirectBuilder';
|
||||
import type { AuthSessionDTO } from '../types/generated/AuthSessionDTO';
|
||||
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
|
||||
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
export type RouteGuardResult =
|
||||
| { type: 'allow' }
|
||||
| { type: 'redirect'; to: string };
|
||||
|
||||
export class RouteGuard {
|
||||
constructor(
|
||||
private readonly interpreter: PathnameInterpreter,
|
||||
@@ -16,7 +18,7 @@ export class RouteGuard {
|
||||
private readonly builder: AuthRedirectBuilder
|
||||
) {}
|
||||
|
||||
async enforce({ pathname }: { pathname: string }): Promise<void> {
|
||||
async enforce({ pathname }: { pathname: string }): Promise<RouteGuardResult> {
|
||||
logger.info('[RouteGuard] enforce called', { pathname });
|
||||
|
||||
// Step 1: Interpret the pathname
|
||||
@@ -26,7 +28,7 @@ export class RouteGuard {
|
||||
// Step 2: Check if public non-auth page
|
||||
if (this.policy.isPublic(logicalPathname) && !this.policy.isAuthPage(logicalPathname)) {
|
||||
logger.info('[RouteGuard] Public non-auth page, allowing access');
|
||||
return; // Allow access
|
||||
return { type: 'allow' };
|
||||
}
|
||||
|
||||
// Step 3: Handle auth pages
|
||||
@@ -37,11 +39,11 @@ export class RouteGuard {
|
||||
// User is logged in, redirect away from auth page
|
||||
const redirectPath = this.builder.awayFromAuthPage({ session, currentPathname: pathname });
|
||||
logger.info('[RouteGuard] Redirecting away from auth page', { redirectPath });
|
||||
redirect(redirectPath);
|
||||
return { type: 'redirect', to: redirectPath };
|
||||
}
|
||||
// No session, allow access to auth page
|
||||
logger.info('[RouteGuard] No session, allowing access to auth page');
|
||||
return;
|
||||
return { type: 'allow' };
|
||||
}
|
||||
|
||||
// Step 4: Handle protected pages
|
||||
@@ -52,7 +54,7 @@ export class RouteGuard {
|
||||
if (!session) {
|
||||
const loginPath = this.builder.toLogin({ currentPathname: pathname });
|
||||
logger.info('[RouteGuard] No session, redirecting to login', { loginPath });
|
||||
redirect(loginPath);
|
||||
return { type: 'redirect', to: loginPath };
|
||||
}
|
||||
|
||||
// Check required roles
|
||||
@@ -61,11 +63,11 @@ export class RouteGuard {
|
||||
if (reqRoles && session.user?.role && !reqRoles.includes(session.user.role)) {
|
||||
const loginPath = this.builder.toLogin({ currentPathname: pathname });
|
||||
logger.info('[RouteGuard] Role mismatch, redirecting to login', { loginPath, reqRoles, userRole: session.user.role });
|
||||
redirect(loginPath);
|
||||
return { type: 'redirect', to: loginPath };
|
||||
}
|
||||
|
||||
// All checks passed, allow access
|
||||
logger.info('[RouteGuard] All checks passed, allowing access');
|
||||
return;
|
||||
return { type: 'allow' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
import type { DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
|
||||
/**
|
||||
* AdminDashboardViewDataBuilder
|
||||
*
|
||||
* Server-side builder that transforms API DashboardStats DTO
|
||||
* directly into ViewData for the AdminDashboardTemplate.
|
||||
*
|
||||
* Deterministic, side-effect free.
|
||||
*
|
||||
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class AdminDashboardViewDataBuilder {
|
||||
static build(apiStats: DashboardStats): AdminDashboardViewData {
|
||||
static build(apiDto: DashboardStats): AdminDashboardViewData {
|
||||
return {
|
||||
stats: {
|
||||
totalUsers: apiStats.totalUsers,
|
||||
activeUsers: apiStats.activeUsers,
|
||||
suspendedUsers: apiStats.suspendedUsers,
|
||||
deletedUsers: apiStats.deletedUsers,
|
||||
systemAdmins: apiStats.systemAdmins,
|
||||
recentLogins: apiStats.recentLogins,
|
||||
newUsersToday: apiStats.newUsersToday,
|
||||
totalUsers: apiDto.totalUsers,
|
||||
activeUsers: apiDto.activeUsers,
|
||||
suspendedUsers: apiDto.suspendedUsers,
|
||||
deletedUsers: apiDto.deletedUsers,
|
||||
systemAdmins: apiDto.systemAdmins,
|
||||
recentLogins: apiDto.recentLogins,
|
||||
newUsersToday: apiDto.newUsersToday,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { UserListResponse } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
|
||||
/**
|
||||
* AdminUsersViewDataBuilder
|
||||
*
|
||||
* Server-side builder that transforms API DTO
|
||||
* into ViewData for the AdminUsersTemplate.
|
||||
*
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
export class AdminUsersViewDataBuilder {
|
||||
static build(apiDto: UserListResponse): AdminUsersViewData {
|
||||
const 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,
|
||||
}));
|
||||
|
||||
return {
|
||||
users,
|
||||
total: apiDto.total,
|
||||
page: apiDto.page,
|
||||
limit: apiDto.limit,
|
||||
totalPages: apiDto.totalPages,
|
||||
// Pre-computed derived values for template
|
||||
activeUserCount: users.filter(u => u.status === 'active').length,
|
||||
adminCount: users.filter(u => u.isSystemAdmin).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface CompleteOnboardingViewData {
|
||||
success: boolean;
|
||||
driverId?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* CompleteOnboarding ViewData Builder
|
||||
*
|
||||
* Transforms onboarding completion result into ViewData for templates.
|
||||
*/
|
||||
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
|
||||
|
||||
export class CompleteOnboardingViewDataBuilder {
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
* @param apiDto - The API DTO to transform
|
||||
* @returns ViewData for templates
|
||||
*/
|
||||
static build(apiDto: CompleteOnboardingOutputDTO): CompleteOnboardingViewData {
|
||||
return {
|
||||
success: apiDto.success,
|
||||
driverId: apiDto.driverId,
|
||||
errorMessage: apiDto.errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
|
||||
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay';
|
||||
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
|
||||
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
|
||||
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
|
||||
|
||||
/**
|
||||
* DashboardViewDataBuilder
|
||||
*
|
||||
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class DashboardViewDataBuilder {
|
||||
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
|
||||
return {
|
||||
currentDriver: {
|
||||
name: apiDto.currentDriver?.name || '',
|
||||
avatarUrl: apiDto.currentDriver?.avatarUrl || '',
|
||||
country: apiDto.currentDriver?.country || '',
|
||||
rating: apiDto.currentDriver ? RatingDisplay.format(apiDto.currentDriver.rating ?? 0) : '0.0',
|
||||
rank: apiDto.currentDriver ? DashboardRankDisplay.format(apiDto.currentDriver.globalRank ?? 0) : '0',
|
||||
totalRaces: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.totalRaces ?? 0) : '0',
|
||||
wins: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.wins ?? 0) : '0',
|
||||
podiums: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.podiums ?? 0) : '0',
|
||||
consistency: apiDto.currentDriver ? DashboardConsistencyDisplay.format(apiDto.currentDriver.consistency ?? 0) : '0%',
|
||||
},
|
||||
nextRace: apiDto.nextRace ? DashboardViewDataBuilder.buildNextRace(apiDto.nextRace) : null,
|
||||
upcomingRaces: apiDto.upcomingRaces.map((race) => DashboardViewDataBuilder.buildRace(race)),
|
||||
leagueStandings: apiDto.leagueStandingsSummaries.map((standing) => ({
|
||||
leagueId: standing.leagueId,
|
||||
leagueName: standing.leagueName,
|
||||
position: DashboardLeaguePositionDisplay.format(standing.position),
|
||||
points: DashboardCountDisplay.format(standing.points),
|
||||
totalDrivers: DashboardCountDisplay.format(standing.totalDrivers),
|
||||
})),
|
||||
feedItems: apiDto.feedSummary.items.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
timestamp: item.timestamp,
|
||||
formattedTime: DashboardDateDisplay.format(new Date(item.timestamp)).relative,
|
||||
ctaHref: item.ctaHref,
|
||||
ctaLabel: item.ctaLabel,
|
||||
})),
|
||||
friends: apiDto.friends.map((friend) => ({
|
||||
id: friend.id,
|
||||
name: friend.name,
|
||||
avatarUrl: friend.avatarUrl || '',
|
||||
country: friend.country,
|
||||
})),
|
||||
activeLeaguesCount: DashboardCountDisplay.format(apiDto.activeLeaguesCount),
|
||||
friendCount: DashboardCountDisplay.format(apiDto.friends.length),
|
||||
hasUpcomingRaces: apiDto.upcomingRaces.length > 0,
|
||||
hasLeagueStandings: apiDto.leagueStandingsSummaries.length > 0,
|
||||
hasFeedItems: apiDto.feedSummary.items.length > 0,
|
||||
hasFriends: apiDto.friends.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static buildNextRace(race: NonNullable<DashboardOverviewDTO['nextRace']>) {
|
||||
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
isMyLeague: race.isMyLeague,
|
||||
};
|
||||
}
|
||||
|
||||
private static buildRace(race: DashboardOverviewDTO['upcomingRaces'][number]) {
|
||||
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
isMyLeague: race.isMyLeague,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DriverRankingsPageDto } from '@/lib/page-queries/page-dtos/DriverRankingsPageDto';
|
||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||
|
||||
export class DriverRankingsViewDataBuilder {
|
||||
static build(dto: DriverRankingsPageDto | null): DriverRankingsViewData {
|
||||
if (!dto || !dto.drivers) {
|
||||
return {
|
||||
drivers: [],
|
||||
podium: [],
|
||||
searchQuery: '',
|
||||
selectedSkill: 'all',
|
||||
sortBy: 'rank',
|
||||
showFilters: false,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
rank: driver.rank,
|
||||
avatarUrl: driver.avatarUrl || '',
|
||||
winRate: driver.racesCompleted > 0 ? ((driver.wins / driver.racesCompleted) * 100).toFixed(1) : '0.0',
|
||||
medalBg: driver.rank === 1 ? 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40' :
|
||||
driver.rank === 2 ? 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40' :
|
||||
driver.rank === 3 ? 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40' :
|
||||
'bg-iron-gray/50 border-charcoal-outline',
|
||||
medalColor: driver.rank === 1 ? 'text-yellow-400' :
|
||||
driver.rank === 2 ? 'text-gray-300' :
|
||||
driver.rank === 3 ? 'text-amber-600' :
|
||||
'text-gray-500',
|
||||
})),
|
||||
podium: dto.drivers.slice(0, 3).map((driver, index) => {
|
||||
const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd
|
||||
const position = positions[index];
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating: driver.rating,
|
||||
wins: driver.wins,
|
||||
podiums: driver.podiums,
|
||||
avatarUrl: driver.avatarUrl || '',
|
||||
position: position as 1 | 2 | 3,
|
||||
};
|
||||
}),
|
||||
searchQuery: '',
|
||||
selectedSkill: 'all',
|
||||
sortBy: 'rank',
|
||||
showFilters: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Forgot Password View Data Builder
|
||||
*
|
||||
* Transforms ForgotPasswordPageDTO into ViewData for the forgot password template.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
|
||||
export interface ForgotPasswordViewData {
|
||||
returnTo: string;
|
||||
showSuccess: boolean;
|
||||
successMessage?: string;
|
||||
magicLink?: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
|
||||
export class ForgotPasswordViewDataBuilder {
|
||||
static build(data: ForgotPasswordPageDTO): ForgotPasswordViewData {
|
||||
return {
|
||||
returnTo: data.returnTo,
|
||||
showSuccess: false,
|
||||
formState: {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface GenerateAvatarsViewData {
|
||||
success: boolean;
|
||||
avatarUrls: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* GenerateAvatars ViewData Builder
|
||||
*
|
||||
* Transforms avatar generation result into ViewData for templates.
|
||||
* Must be used in mutations to avoid returning DTOs directly.
|
||||
*/
|
||||
|
||||
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
|
||||
|
||||
export class GenerateAvatarsViewDataBuilder {
|
||||
/**
|
||||
* Transform DTO into ViewData
|
||||
*
|
||||
* @param apiDto - The API DTO to transform
|
||||
* @returns ViewData for templates
|
||||
*/
|
||||
static build(apiDto: RequestAvatarGenerationOutputDTO): GenerateAvatarsViewData {
|
||||
return {
|
||||
success: apiDto.success,
|
||||
avatarUrls: apiDto.avatarUrls || [],
|
||||
errorMessage: apiDto.errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
|
||||
export class LeaderboardsViewDataBuilder {
|
||||
static build(
|
||||
driversDto: { drivers: DriverLeaderboardItemDTO[] } | null,
|
||||
teamsDto: { teams: TeamListItemDTO[] } | null
|
||||
): LeaderboardsViewData {
|
||||
return {
|
||||
drivers: driversDto?.drivers.slice(0, 5).map((driver, index) => ({
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating: driver.rating,
|
||||
skillLevel: driver.skillLevel,
|
||||
nationality: driver.nationality,
|
||||
wins: driver.wins,
|
||||
rank: driver.rank,
|
||||
avatarUrl: driver.avatarUrl || '',
|
||||
position: index + 1,
|
||||
})) || [],
|
||||
teams: teamsDto?.teams.slice(0, 5).map((team, index) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
memberCount: team.memberCount,
|
||||
category: team.category,
|
||||
totalWins: team.totalWins || 0,
|
||||
logoUrl: team.logoUrl || '',
|
||||
position: index + 1,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
|
||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||
import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData';
|
||||
|
||||
/**
|
||||
* LeagueDetailViewDataBuilder
|
||||
*
|
||||
* Transforms API DTOs into LeagueDetailViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeagueDetailViewDataBuilder {
|
||||
static build(input: {
|
||||
league: LeagueWithCapacityAndScoringDTO;
|
||||
owner: GetDriverOutputDTO | null;
|
||||
scoringConfig: LeagueScoringConfigDTO | null;
|
||||
memberships: LeagueMembershipsDTO;
|
||||
races: RaceDTO[];
|
||||
sponsors: any[];
|
||||
}): LeagueDetailViewData {
|
||||
const { league, owner, scoringConfig, memberships, races, sponsors } = input;
|
||||
|
||||
// Calculate running races
|
||||
const runningRaces: LiveRaceData[] = races
|
||||
.filter(r => r.status === 'running')
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
date: r.scheduledAt,
|
||||
registeredCount: r.registeredCount,
|
||||
strengthOfField: r.strengthOfField,
|
||||
}));
|
||||
|
||||
// Calculate info data
|
||||
const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0;
|
||||
const completedRacesCount = races.filter(r => r.status === 'completed').length;
|
||||
const avgSOF = races.length > 0
|
||||
? Math.round(races.reduce((sum, r) => sum + (r.strengthOfField || 0), 0) / races.length)
|
||||
: null;
|
||||
|
||||
const info: LeagueInfoData = {
|
||||
name: league.name,
|
||||
description: league.description || '',
|
||||
membersCount,
|
||||
racesCount: completedRacesCount,
|
||||
avgSOF,
|
||||
structure: `Solo • ${league.settings?.maxDrivers ?? 32} max`,
|
||||
scoring: scoringConfig?.name || 'Standard',
|
||||
createdAt: league.createdAt,
|
||||
discordUrl: league.socialLinks?.discordUrl,
|
||||
youtubeUrl: league.socialLinks?.youtubeUrl,
|
||||
websiteUrl: league.socialLinks?.websiteUrl,
|
||||
};
|
||||
|
||||
// Convert owner to driver summary
|
||||
const ownerSummary: DriverSummaryData | null = owner ? {
|
||||
driverId: owner.id,
|
||||
driverName: owner.name,
|
||||
avatarUrl: owner.avatarUrl || null,
|
||||
rating: null,
|
||||
rank: null,
|
||||
roleBadgeText: 'Owner',
|
||||
roleBadgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
|
||||
profileUrl: `/drivers/${owner.id}`,
|
||||
} : null;
|
||||
|
||||
// Convert sponsors
|
||||
const sponsorInfo: SponsorInfo[] = sponsors.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
tier: s.tier,
|
||||
logoUrl: s.logoUrl,
|
||||
websiteUrl: s.websiteUrl,
|
||||
tagline: s.tagline,
|
||||
}));
|
||||
|
||||
return {
|
||||
leagueId: league.id,
|
||||
name: league.name,
|
||||
description: league.description || '',
|
||||
info,
|
||||
runningRaces,
|
||||
sponsors: sponsorInfo,
|
||||
ownerSummary,
|
||||
adminSummaries: [], // Would need additional data
|
||||
stewardSummaries: [], // Would need additional data
|
||||
sponsorInsights: null, // Only for sponsor mode
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/generated/AllLeaguesWithCapacityAndScoringDTO';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
|
||||
/**
|
||||
* LeaguesViewDataBuilder
|
||||
*
|
||||
* Transforms AllLeaguesWithCapacityAndScoringDTO (API DTO) into LeaguesViewData for server-side rendering.
|
||||
* Deterministic; side-effect free; no HTTP calls.
|
||||
*/
|
||||
export class LeaguesViewDataBuilder {
|
||||
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
|
||||
return {
|
||||
leagues: apiDto.leagues.map((league) => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description || null,
|
||||
logoUrl: league.logoUrl || null,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: league.settings.maxDrivers,
|
||||
usedDriverSlots: league.usedSlots,
|
||||
maxTeams: undefined, // Not provided in DTO
|
||||
usedTeamSlots: undefined, // Not provided in DTO
|
||||
structureSummary: league.settings.qualifyingFormat || '',
|
||||
timingSummary: league.timingSummary || '',
|
||||
category: league.category || null,
|
||||
scoring: league.scoring ? {
|
||||
gameId: league.scoring.gameId,
|
||||
gameName: league.scoring.gameName,
|
||||
primaryChampionshipType: league.scoring.primaryChampionshipType,
|
||||
scoringPresetId: league.scoring.scoringPresetId,
|
||||
scoringPresetName: league.scoring.scoringPresetName,
|
||||
dropPolicySummary: league.scoring.dropPolicySummary,
|
||||
scoringPatternSummary: league.scoring.scoringPatternSummary,
|
||||
} : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
61
apps/website/lib/builders/view-data/LoginViewDataBuilder.ts
Normal file
61
apps/website/lib/builders/view-data/LoginViewDataBuilder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Login View Data Builder
|
||||
*
|
||||
* Transforms LoginPageDTO into ViewData for the login template.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
|
||||
export interface FormFieldState {
|
||||
value: string | boolean;
|
||||
error?: string;
|
||||
touched: boolean;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
export interface FormState {
|
||||
fields: {
|
||||
email: FormFieldState;
|
||||
password: FormFieldState;
|
||||
rememberMe: FormFieldState;
|
||||
};
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
submitCount: number;
|
||||
}
|
||||
|
||||
export interface LoginViewData {
|
||||
returnTo: string;
|
||||
hasInsufficientPermissions: boolean;
|
||||
showPassword: boolean;
|
||||
showErrorDetails: boolean;
|
||||
formState: FormState;
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
|
||||
export class LoginViewDataBuilder {
|
||||
static build(data: LoginPageDTO): LoginViewData {
|
||||
return {
|
||||
returnTo: data.returnTo,
|
||||
hasInsufficientPermissions: data.hasInsufficientPermissions,
|
||||
showPassword: false,
|
||||
showErrorDetails: false,
|
||||
formState: {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
rememberMe: { value: false, error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* OnboardingPage ViewData Builder
|
||||
*
|
||||
* Transforms driver check result into ViewData for the onboarding page.
|
||||
*/
|
||||
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingPageViewDataBuilder {
|
||||
/**
|
||||
* Transform driver data into ViewData
|
||||
*
|
||||
* @param apiDto - The driver data from the service
|
||||
* @returns ViewData for the onboarding page
|
||||
*/
|
||||
static build(apiDto: unknown): OnboardingPageViewData {
|
||||
return {
|
||||
isAlreadyOnboarded: !!apiDto,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Onboarding ViewData Builder
|
||||
*
|
||||
* Transforms API DTOs into ViewData for onboarding page.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
|
||||
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
|
||||
|
||||
export class OnboardingViewDataBuilder {
|
||||
static build(apiDto: Result<{ isAlreadyOnboarded: boolean }, PresentationError>): Result<OnboardingPageViewData, PresentationError> {
|
||||
if (apiDto.isErr()) {
|
||||
return Result.err(apiDto.getError());
|
||||
}
|
||||
|
||||
const data = apiDto.unwrap();
|
||||
|
||||
return Result.ok({
|
||||
isAlreadyOnboarded: data.isAlreadyOnboarded || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
112
apps/website/lib/builders/view-data/ProfileViewDataBuilder.ts
Normal file
112
apps/website/lib/builders/view-data/ProfileViewDataBuilder.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
|
||||
export class ProfileViewDataBuilder {
|
||||
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
|
||||
const driver = apiDto.currentDriver;
|
||||
|
||||
if (!driver) {
|
||||
return {
|
||||
driver: {
|
||||
id: '',
|
||||
name: '',
|
||||
countryCode: '',
|
||||
countryFlag: CountryFlagDisplay.fromCountryCode(null).toString(),
|
||||
avatarUrl: mediaConfig.avatars.defaultFallback,
|
||||
bio: null,
|
||||
iracingId: null,
|
||||
joinedAtLabel: '',
|
||||
},
|
||||
stats: null,
|
||||
teamMemberships: [],
|
||||
extendedProfile: null,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = apiDto.stats ?? null;
|
||||
const socialSummary = apiDto.socialSummary;
|
||||
const extended = apiDto.extendedProfile ?? null;
|
||||
|
||||
const joinedAtLabel = new Date(driver.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return {
|
||||
driver: {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
countryCode: driver.country,
|
||||
countryFlag: CountryFlagDisplay.fromCountryCode(driver.country).toString(),
|
||||
avatarUrl: driver.avatarUrl || mediaConfig.avatars.defaultFallback,
|
||||
bio: driver.bio || null,
|
||||
iracingId: driver.iracingId || null,
|
||||
joinedAtLabel,
|
||||
},
|
||||
stats: stats
|
||||
? {
|
||||
ratingLabel: stats.rating != null ? String(stats.rating) : '0',
|
||||
globalRankLabel: driver.globalRank != null ? `#${driver.globalRank}` : '—',
|
||||
totalRacesLabel: String(stats.totalRaces),
|
||||
winsLabel: String(stats.wins),
|
||||
podiumsLabel: String(stats.podiums),
|
||||
dnfsLabel: String(stats.dnfs),
|
||||
bestFinishLabel: stats.bestFinish != null ? `P${stats.bestFinish}` : '—',
|
||||
worstFinishLabel: stats.worstFinish != null ? `P${stats.worstFinish}` : '—',
|
||||
avgFinishLabel: stats.avgFinish != null ? `P${stats.avgFinish.toFixed(1)}` : '—',
|
||||
consistencyLabel: stats.consistency != null ? `${stats.consistency}%` : '0%',
|
||||
percentileLabel: stats.percentile != null ? `${stats.percentile}%` : '—',
|
||||
}
|
||||
: null,
|
||||
teamMemberships: apiDto.teamMemberships.map((m) => ({
|
||||
teamId: m.teamId,
|
||||
teamName: m.teamName,
|
||||
teamTag: m.teamTag || null,
|
||||
roleLabel: m.role,
|
||||
joinedAtLabel: new Date(m.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}),
|
||||
href: `/teams/${m.teamId}`,
|
||||
})),
|
||||
extendedProfile: extended
|
||||
? {
|
||||
timezone: extended.timezone,
|
||||
racingStyle: extended.racingStyle,
|
||||
favoriteTrack: extended.favoriteTrack,
|
||||
favoriteCar: extended.favoriteCar,
|
||||
availableHours: extended.availableHours,
|
||||
lookingForTeamLabel: extended.lookingForTeam ? 'Looking for Team' : '',
|
||||
openToRequestsLabel: extended.openToRequests ? 'Open to Requests' : '',
|
||||
socialHandles: extended.socialHandles.map((h) => ({
|
||||
platformLabel: h.platform,
|
||||
handle: h.handle,
|
||||
url: h.url,
|
||||
})),
|
||||
achievements: extended.achievements.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
earnedAtLabel: new Date(a.earnedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
icon: a.icon as NonNullable<ProfileViewData['extendedProfile']>['achievements'][number]['icon'],
|
||||
rarityLabel: a.rarity,
|
||||
})),
|
||||
friends: socialSummary.friends.slice(0, 8).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
countryFlag: CountryFlagDisplay.fromCountryCode(f.country).toString(),
|
||||
avatarUrl: f.avatarUrl || mediaConfig.avatars.defaultFallback,
|
||||
href: `/drivers/${f.id}`,
|
||||
})),
|
||||
friendsCountLabel: String(socialSummary.friendsCount),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Reset Password View Data Builder
|
||||
*
|
||||
* Transforms ResetPasswordPageDTO into ViewData for the reset password template.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
|
||||
export interface ResetPasswordViewData {
|
||||
token: string;
|
||||
returnTo: string;
|
||||
showSuccess: boolean;
|
||||
successMessage?: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordViewDataBuilder {
|
||||
static build(data: ResetPasswordPageDTO): ResetPasswordViewData {
|
||||
return {
|
||||
token: data.token,
|
||||
returnTo: data.returnTo,
|
||||
showSuccess: false,
|
||||
formState: {
|
||||
fields: {
|
||||
newPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/builders/view-data/SignupViewDataBuilder.ts
Normal file
38
apps/website/lib/builders/view-data/SignupViewDataBuilder.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Signup View Data Builder
|
||||
*
|
||||
* Transforms SignupPageDTO into ViewData for the signup template.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
|
||||
export interface SignupViewData {
|
||||
returnTo: string;
|
||||
formState: any; // Will be managed by client component
|
||||
isSubmitting: boolean;
|
||||
submitError?: string;
|
||||
}
|
||||
|
||||
export class SignupViewDataBuilder {
|
||||
static build(data: SignupPageDTO): SignupViewData {
|
||||
return {
|
||||
returnTo: data.returnTo,
|
||||
formState: {
|
||||
fields: {
|
||||
firstName: { value: '', error: undefined, touched: false, validating: false },
|
||||
lastName: { value: '', error: undefined, touched: false, validating: false },
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SponsorshipRequestDTO } from '@/lib/types/generated/SponsorshipRequestDTO';
|
||||
import type { GetPendingSponsorshipRequestsOutputDTO } from '@/lib/types/generated/GetPendingSponsorshipRequestsOutputDTO';
|
||||
|
||||
export interface SponsorshipRequestsViewData {
|
||||
requests: SponsorshipRequestDTO[];
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
export class SponsorshipRequestsPageViewDataBuilder {
|
||||
build(queryResult: GetPendingSponsorshipRequestsOutputDTO): SponsorshipRequestsViewData {
|
||||
return {
|
||||
requests: queryResult.requests,
|
||||
isEmpty: queryResult.requests.length === 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SponsorshipRequestsPageDto } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
|
||||
export class SponsorshipRequestsViewDataBuilder {
|
||||
static build(apiDto: SponsorshipRequestsPageDto): SponsorshipRequestsViewData {
|
||||
return {
|
||||
sections: apiDto.sections.map((section) => ({
|
||||
entityType: section.entityType,
|
||||
entityId: section.entityId,
|
||||
entityName: section.entityName,
|
||||
requests: section.requests.map((request) => ({
|
||||
id: request.requestId,
|
||||
sponsorId: request.sponsorId,
|
||||
sponsorName: request.sponsorName,
|
||||
sponsorLogoUrl: null,
|
||||
message: request.message,
|
||||
createdAtIso: request.createdAtIso,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AdminDashboardPageDto } from '@/lib/page-queries/AdminDashboardPageQuery';
|
||||
import { AdminDashboardViewModel } from '@/lib/view-models/AdminDashboardViewModel';
|
||||
|
||||
/**
|
||||
* AdminDashboardViewModelBuilder
|
||||
*
|
||||
* Transforms AdminDashboardPageDto into AdminDashboardViewModel
|
||||
*/
|
||||
export class AdminDashboardViewModelBuilder {
|
||||
static build(dto: AdminDashboardPageDto): AdminDashboardViewModel {
|
||||
return new AdminDashboardViewModel(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { DriverProfileDriverSummaryDTO } from '@/lib/types/generated/DriverProfileDriverSummaryDTO';
|
||||
import type { DriverProfileStatsDTO } from '@/lib/types/generated/DriverProfileStatsDTO';
|
||||
import type { DriverProfileFinishDistributionDTO } from '@/lib/types/generated/DriverProfileFinishDistributionDTO';
|
||||
import type { DriverProfileTeamMembershipDTO } from '@/lib/types/generated/DriverProfileTeamMembershipDTO';
|
||||
import type { DriverProfileSocialSummaryDTO } from '@/lib/types/generated/DriverProfileSocialSummaryDTO';
|
||||
import type { DriverProfileExtendedProfileDTO } from '@/lib/types/generated/DriverProfileExtendedProfileDTO';
|
||||
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import type {
|
||||
DriverProfileDriverSummaryViewModel,
|
||||
DriverProfileStatsViewModel,
|
||||
DriverProfileFinishDistributionViewModel,
|
||||
DriverProfileTeamMembershipViewModel,
|
||||
DriverProfileSocialSummaryViewModel,
|
||||
DriverProfileExtendedProfileViewModel,
|
||||
} from '@/lib/view-models/DriverProfileViewModel';
|
||||
|
||||
/**
|
||||
* DriverProfileViewModelBuilder
|
||||
*
|
||||
* Transforms GetDriverProfileOutputDTO into DriverProfileViewModel.
|
||||
* Deterministic, side-effect free, no HTTP calls.
|
||||
*/
|
||||
export class DriverProfileViewModelBuilder {
|
||||
/**
|
||||
* Build ViewModel from API DTO
|
||||
*
|
||||
* @param apiDto - The API transport DTO
|
||||
* @returns ViewModel ready for template
|
||||
*/
|
||||
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewModel {
|
||||
return new DriverProfileViewModel({
|
||||
currentDriver: apiDto.currentDriver ? this.transformCurrentDriver(apiDto.currentDriver) : null,
|
||||
stats: apiDto.stats ? this.transformStats(apiDto.stats) : null,
|
||||
finishDistribution: apiDto.finishDistribution ? this.transformFinishDistribution(apiDto.finishDistribution) : null,
|
||||
teamMemberships: apiDto.teamMemberships.map(m => this.transformTeamMembership(m)),
|
||||
socialSummary: this.transformSocialSummary(apiDto.socialSummary),
|
||||
extendedProfile: apiDto.extendedProfile ? this.transformExtendedProfile(apiDto.extendedProfile) : null,
|
||||
});
|
||||
}
|
||||
|
||||
private static transformCurrentDriver(dto: DriverProfileDriverSummaryDTO): DriverProfileDriverSummaryViewModel {
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
country: dto.country,
|
||||
avatarUrl: dto.avatarUrl || '', // Handle undefined
|
||||
iracingId: dto.iracingId || null,
|
||||
joinedAt: dto.joinedAt,
|
||||
rating: dto.rating ?? null,
|
||||
globalRank: dto.globalRank ?? null,
|
||||
consistency: dto.consistency ?? null,
|
||||
bio: dto.bio || null,
|
||||
totalDrivers: dto.totalDrivers ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private static transformStats(dto: DriverProfileStatsDTO): DriverProfileStatsViewModel {
|
||||
return {
|
||||
totalRaces: dto.totalRaces,
|
||||
wins: dto.wins,
|
||||
podiums: dto.podiums,
|
||||
dnfs: dto.dnfs,
|
||||
avgFinish: dto.avgFinish ?? null,
|
||||
bestFinish: dto.bestFinish ?? null,
|
||||
worstFinish: dto.worstFinish ?? null,
|
||||
finishRate: dto.finishRate ?? null,
|
||||
winRate: dto.winRate ?? null,
|
||||
podiumRate: dto.podiumRate ?? null,
|
||||
percentile: dto.percentile ?? null,
|
||||
rating: dto.rating ?? null,
|
||||
consistency: dto.consistency ?? null,
|
||||
overallRank: dto.overallRank ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private static transformFinishDistribution(dto: DriverProfileFinishDistributionDTO): DriverProfileFinishDistributionViewModel {
|
||||
return {
|
||||
totalRaces: dto.totalRaces,
|
||||
wins: dto.wins,
|
||||
podiums: dto.podiums,
|
||||
topTen: dto.topTen,
|
||||
dnfs: dto.dnfs,
|
||||
other: dto.other,
|
||||
};
|
||||
}
|
||||
|
||||
private static transformTeamMembership(dto: DriverProfileTeamMembershipDTO): DriverProfileTeamMembershipViewModel {
|
||||
return {
|
||||
teamId: dto.teamId,
|
||||
teamName: dto.teamName,
|
||||
teamTag: dto.teamTag || null,
|
||||
role: dto.role,
|
||||
joinedAt: dto.joinedAt,
|
||||
isCurrent: dto.isCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
private static transformSocialSummary(dto: DriverProfileSocialSummaryDTO): DriverProfileSocialSummaryViewModel {
|
||||
return {
|
||||
friendsCount: dto.friendsCount,
|
||||
friends: dto.friends.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
country: f.country,
|
||||
avatarUrl: f.avatarUrl || '', // Handle undefined
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private static transformExtendedProfile(dto: DriverProfileExtendedProfileDTO): DriverProfileExtendedProfileViewModel {
|
||||
return {
|
||||
socialHandles: dto.socialHandles.map(h => ({
|
||||
platform: h.platform as any, // Type assertion - assuming valid platform
|
||||
handle: h.handle,
|
||||
url: h.url,
|
||||
})),
|
||||
achievements: dto.achievements.map(a => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
icon: a.icon as any, // Type assertion - assuming valid icon
|
||||
rarity: a.rarity as any, // Type assertion - assuming valid rarity
|
||||
earnedAt: a.earnedAt,
|
||||
})),
|
||||
racingStyle: dto.racingStyle,
|
||||
favoriteTrack: dto.favoriteTrack,
|
||||
favoriteCar: dto.favoriteCar,
|
||||
timezone: dto.timezone,
|
||||
availableHours: dto.availableHours,
|
||||
lookingForTeam: dto.lookingForTeam,
|
||||
openToRequests: dto.openToRequests,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Forgot Password ViewModel Builder
|
||||
*
|
||||
* Transforms API DTOs into ForgotPasswordViewModel for client-side state management.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder';
|
||||
import { ForgotPasswordViewModel, ForgotPasswordFormState } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
|
||||
export class ForgotPasswordViewModelBuilder {
|
||||
static build(viewData: ForgotPasswordViewData): ForgotPasswordViewModel {
|
||||
const formState: ForgotPasswordFormState = {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
return new ForgotPasswordViewModel(
|
||||
viewData.returnTo,
|
||||
formState,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Login ViewModel Builder
|
||||
*
|
||||
* Transforms API DTOs into LoginViewModel for client-side state management.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { LoginViewData } from '@/lib/builders/view-data/LoginViewDataBuilder';
|
||||
import { LoginViewModel, LoginFormState, LoginUIState } from '@/lib/view-models/auth/LoginViewModel';
|
||||
|
||||
export class LoginViewModelBuilder {
|
||||
static build(viewData: LoginViewData): LoginViewModel {
|
||||
const formState: LoginFormState = {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
rememberMe: { value: false, error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
const uiState: LoginUIState = {
|
||||
showPassword: false,
|
||||
showErrorDetails: false,
|
||||
};
|
||||
|
||||
return new LoginViewModel(
|
||||
viewData.returnTo,
|
||||
viewData.hasInsufficientPermissions,
|
||||
formState,
|
||||
uiState,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Onboarding ViewModel Builder
|
||||
*
|
||||
* Transforms API DTOs into ViewModels for client-side state management.
|
||||
* Deterministic, side-effect free.
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { OnboardingViewModel } from '@/lib/view-models/OnboardingViewModel';
|
||||
|
||||
export class OnboardingViewModelBuilder {
|
||||
static build(apiDto: { isAlreadyOnboarded: boolean }): Result<OnboardingViewModel, DomainError> {
|
||||
try {
|
||||
return Result.ok({
|
||||
isAlreadyOnboarded: apiDto.isAlreadyOnboarded || false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to build ViewModel';
|
||||
return Result.err({ type: 'unknown', message: errorMessage });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Reset Password ViewModel Builder
|
||||
*
|
||||
* Transforms API DTOs into ResetPasswordViewModel for client-side state management.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder';
|
||||
import { ResetPasswordViewModel, ResetPasswordFormState, ResetPasswordUIState } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
|
||||
export class ResetPasswordViewModelBuilder {
|
||||
static build(viewData: ResetPasswordViewData): ResetPasswordViewModel {
|
||||
const formState: ResetPasswordFormState = {
|
||||
fields: {
|
||||
newPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
const uiState: ResetPasswordUIState = {
|
||||
showPassword: false,
|
||||
showConfirmPassword: false,
|
||||
};
|
||||
|
||||
return new ResetPasswordViewModel(
|
||||
viewData.token,
|
||||
viewData.returnTo,
|
||||
formState,
|
||||
uiState,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Signup ViewModel Builder
|
||||
*
|
||||
* Transforms API DTOs into SignupViewModel for client-side state management.
|
||||
* Deterministic, side-effect free, no business logic.
|
||||
*/
|
||||
|
||||
import { SignupViewData } from '@/lib/builders/view-data/SignupViewDataBuilder';
|
||||
import { SignupViewModel, SignupFormState, SignupUIState } from '@/lib/view-models/auth/SignupViewModel';
|
||||
|
||||
export class SignupViewModelBuilder {
|
||||
static build(viewData: SignupViewData): SignupViewModel {
|
||||
const formState: SignupFormState = {
|
||||
fields: {
|
||||
firstName: { value: '', error: undefined, touched: false, validating: false },
|
||||
lastName: { value: '', error: undefined, touched: false, validating: false },
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
password: { value: '', error: undefined, touched: false, validating: false },
|
||||
confirmPassword: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
const uiState: SignupUIState = {
|
||||
showPassword: false,
|
||||
showConfirmPassword: false,
|
||||
};
|
||||
|
||||
return new SignupViewModel(
|
||||
viewData.returnTo,
|
||||
formState,
|
||||
uiState,
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,10 @@ import { Container } from 'inversify';
|
||||
import { ApiModule } from './modules/api.module';
|
||||
import { AuthModule } from './modules/auth.module';
|
||||
import { CoreModule } from './modules/core.module';
|
||||
import { DashboardModule } from './modules/dashboard.module';
|
||||
import { DriverModule } from './modules/driver.module';
|
||||
import { LeagueModule } from './modules/league.module';
|
||||
import { TeamModule } from './modules/team.module';
|
||||
import { RaceModule } from './modules/race.module';
|
||||
import { AnalyticsModule } from './modules/analytics.module';
|
||||
import { LandingModule } from './modules/landing.module';
|
||||
import { PolicyModule } from './modules/policy.module';
|
||||
import { SponsorModule } from './modules/sponsor.module';
|
||||
@@ -28,8 +26,6 @@ export function createContainer(): Container {
|
||||
LeagueModule,
|
||||
DriverModule,
|
||||
TeamModule,
|
||||
DashboardModule,
|
||||
AnalyticsModule,
|
||||
RaceModule,
|
||||
LandingModule,
|
||||
PolicyModule,
|
||||
|
||||
@@ -11,11 +11,9 @@ export * from './hooks/useReactQueryWithApiError';
|
||||
export * from './providers/ContainerProvider';
|
||||
|
||||
// Modules
|
||||
export * from './modules/analytics.module';
|
||||
export * from './modules/api.module';
|
||||
export * from './modules/auth.module';
|
||||
export * from './modules/core.module';
|
||||
export * from './modules/dashboard.module';
|
||||
export * from './modules/driver.module';
|
||||
export * from './modules/league.module';
|
||||
export * from './modules/race.module';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient';
|
||||
import { DashboardService } from '@/lib/services/analytics/DashboardService';
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { ANALYTICS_API_CLIENT_TOKEN, ANALYTICS_DASHBOARD_SERVICE_TOKEN } from '../tokens';
|
||||
|
||||
export const AnalyticsModule = new ContainerModule((options) => {
|
||||
const bind = options.bind;
|
||||
|
||||
bind(ANALYTICS_DASHBOARD_SERVICE_TOKEN)
|
||||
.toDynamicValue((ctx) => {
|
||||
const apiClient = ctx.get<AnalyticsApiClient>(ANALYTICS_API_CLIENT_TOKEN);
|
||||
return new DashboardService(apiClient);
|
||||
})
|
||||
.inSingletonScope();
|
||||
});
|
||||
@@ -20,11 +20,8 @@ export const AuthModule = new ContainerModule((options) => {
|
||||
})
|
||||
.inSingletonScope();
|
||||
|
||||
// Auth Service
|
||||
// Auth Service - now creates its own dependencies
|
||||
bind<AuthService>(AUTH_SERVICE_TOKEN)
|
||||
.toDynamicValue((ctx) => {
|
||||
const authApiClient = ctx.get<AuthApiClient>(AUTH_API_CLIENT_TOKEN);
|
||||
return new AuthService(authApiClient);
|
||||
})
|
||||
.to(AuthService)
|
||||
.inSingletonScope();
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { DASHBOARD_SERVICE_TOKEN, DASHBOARD_API_CLIENT_TOKEN } from '../tokens';
|
||||
import { DashboardService } from '@/lib/services/dashboard/DashboardService';
|
||||
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
|
||||
|
||||
export const DashboardModule = new ContainerModule((options) => {
|
||||
const bind = options.bind;
|
||||
|
||||
bind(DASHBOARD_SERVICE_TOKEN)
|
||||
.toDynamicValue((ctx) => {
|
||||
const apiClient = ctx.get<DashboardApiClient>(DASHBOARD_API_CLIENT_TOKEN);
|
||||
return new DashboardService(apiClient);
|
||||
})
|
||||
.inSingletonScope();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ContainerModule } from 'inversify';
|
||||
import { DRIVER_SERVICE_TOKEN, DRIVER_API_CLIENT_TOKEN } from '../tokens';
|
||||
import { DRIVER_SERVICE_TOKEN, DRIVER_API_CLIENT_TOKEN, ONBOARDING_SERVICE_TOKEN } from '../tokens';
|
||||
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
|
||||
export const DriverModule = new ContainerModule((options) => {
|
||||
@@ -12,4 +13,10 @@ export const DriverModule = new ContainerModule((options) => {
|
||||
return new DriverService(apiClient);
|
||||
})
|
||||
.inSingletonScope();
|
||||
|
||||
bind(ONBOARDING_SERVICE_TOKEN)
|
||||
.toDynamicValue(() => {
|
||||
return new OnboardingService();
|
||||
})
|
||||
.inSingletonScope();
|
||||
});
|
||||
@@ -19,79 +19,58 @@ export const PAYMENT_API_CLIENT_TOKEN = Symbol.for('Api.PaymentClient');
|
||||
export const WALLET_API_CLIENT_TOKEN = Symbol.for('Api.WalletClient');
|
||||
export const AUTH_API_CLIENT_TOKEN = Symbol.for('Api.AuthClient');
|
||||
export const ANALYTICS_API_CLIENT_TOKEN = Symbol.for('Api.AnalyticsClient');
|
||||
export const DASHBOARD_API_CLIENT_TOKEN = Symbol.for('Api.DashboardClient');
|
||||
export const SPONSOR_API_CLIENT_TOKEN = Symbol.for('Api.SponsorClient');
|
||||
export const POLICY_API_CLIENT_TOKEN = Symbol.for('Api.PolicyClient');
|
||||
export const PROTEST_API_CLIENT_TOKEN = Symbol.for('Api.ProtestClient');
|
||||
export const PENALTY_API_CLIENT_TOKEN = Symbol.for('Api.PenaltyClient');
|
||||
export const DASHBOARD_API_CLIENT_TOKEN = Symbol.for('Api.DashboardClient');
|
||||
|
||||
// Domain Services
|
||||
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import type { LeagueSettingsService } from '@/lib/services/leagues/LeagueSettingsService';
|
||||
import type { LeagueStewardingService } from '@/lib/services/leagues/LeagueStewardingService';
|
||||
import type { LeagueWalletService } from '@/lib/services/leagues/LeagueWalletService';
|
||||
import type { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
|
||||
import type { LeagueWalletService } from '@/lib/services/leagues/LeagueWalletService';
|
||||
import type { DriverService } from '@/lib/services/drivers/DriverService';
|
||||
import type { DriverRegistrationService } from '@/lib/services/drivers/DriverRegistrationService';
|
||||
import type { TeamService } from '@/lib/services/teams/TeamService';
|
||||
import type { TeamJoinService } from '@/lib/services/teams/TeamJoinService';
|
||||
import type { RaceService } from '@/lib/services/races/RaceService';
|
||||
import type { RaceResultsService } from '@/lib/services/races/RaceResultsService';
|
||||
import type { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
|
||||
import type { SponsorService } from '@/lib/services/sponsors/SponsorService';
|
||||
import type { SponsorshipService } from '@/lib/services/sponsors/SponsorshipService';
|
||||
import type { PaymentService } from '@/lib/services/payments/PaymentService';
|
||||
import type { WalletService } from '@/lib/services/payments/WalletService';
|
||||
import type { MembershipFeeService } from '@/lib/services/payments/MembershipFeeService';
|
||||
import type { MediaService } from '@/lib/services/media/MediaService';
|
||||
import type { AvatarService } from '@/lib/services/media/AvatarService';
|
||||
import type { AnalyticsService } from '@/lib/services/analytics/AnalyticsService';
|
||||
import type { DashboardService as AnalyticsDashboardService } from '@/lib/services/analytics/DashboardService';
|
||||
import type { DashboardService } from '@/lib/services/dashboard/DashboardService';
|
||||
// These services are created as needed
|
||||
import type { AuthService } from '@/lib/services/auth/AuthService';
|
||||
import type { SessionService } from '@/lib/services/auth/SessionService';
|
||||
import type { ProtestService } from '@/lib/services/protests/ProtestService';
|
||||
import type { PenaltyService } from '@/lib/services/penalties/PenaltyService';
|
||||
import type { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import type { PolicyService } from '@/lib/services/policy/PolicyService';
|
||||
import type { LandingService } from '@/lib/services/landing/LandingService';
|
||||
import type { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import type { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
|
||||
export const LEAGUE_SERVICE_TOKEN = Symbol.for('Service.League') as symbol & { type: LeagueService };
|
||||
export const LEAGUE_SETTINGS_SERVICE_TOKEN = Symbol.for('Service.LeagueSettings') as symbol & { type: LeagueSettingsService };
|
||||
export const LEAGUE_STEWARDING_SERVICE_TOKEN = Symbol.for('Service.LeagueStewarding') as symbol & { type: LeagueStewardingService };
|
||||
export const LEAGUE_WALLET_SERVICE_TOKEN = Symbol.for('Service.LeagueWallet') as symbol & { type: LeagueWalletService };
|
||||
export const LEAGUE_MEMBERSHIP_SERVICE_TOKEN = Symbol.for('Service.LeagueMembership') as symbol & { type: LeagueMembershipService };
|
||||
export const LEAGUE_WALLET_SERVICE_TOKEN = Symbol.for('Service.LeagueWallet') as symbol & { type: LeagueWalletService };
|
||||
|
||||
export const DRIVER_SERVICE_TOKEN = Symbol.for('Service.Driver') as symbol & { type: DriverService };
|
||||
export const DRIVER_REGISTRATION_SERVICE_TOKEN = Symbol.for('Service.DriverRegistration') as symbol & { type: DriverRegistrationService };
|
||||
|
||||
export const TEAM_SERVICE_TOKEN = Symbol.for('Service.Team') as symbol & { type: TeamService };
|
||||
export const TEAM_JOIN_SERVICE_TOKEN = Symbol.for('Service.TeamJoin') as symbol & { type: TeamJoinService };
|
||||
export const TEAM_SERVICE_TOKEN = Symbol.for('Service.Team');
|
||||
|
||||
export const RACE_SERVICE_TOKEN = Symbol.for('Service.Race') as symbol & { type: RaceService };
|
||||
export const RACE_RESULTS_SERVICE_TOKEN = Symbol.for('Service.RaceResults') as symbol & { type: RaceResultsService };
|
||||
export const RACE_STEWARDING_SERVICE_TOKEN = Symbol.for('Service.RaceStewarding') as symbol & { type: RaceStewardingService };
|
||||
export const RACE_SERVICE_TOKEN = Symbol.for('Service.Race');
|
||||
|
||||
export const SPONSOR_SERVICE_TOKEN = Symbol.for('Service.Sponsor') as symbol & { type: SponsorService };
|
||||
export const SPONSORSHIP_SERVICE_TOKEN = Symbol.for('Service.Sponsorship') as symbol & { type: SponsorshipService };
|
||||
export const SPONSOR_SERVICE_TOKEN = Symbol.for('Service.Sponsor');
|
||||
|
||||
export const PAYMENT_SERVICE_TOKEN = Symbol.for('Service.Payment') as symbol & { type: PaymentService };
|
||||
export const WALLET_SERVICE_TOKEN = Symbol.for('Service.Wallet') as symbol & { type: WalletService };
|
||||
export const MEMBERSHIP_FEE_SERVICE_TOKEN = Symbol.for('Service.MembershipFee') as symbol & { type: MembershipFeeService };
|
||||
export const PAYMENT_SERVICE_TOKEN = Symbol.for('Service.Payment');
|
||||
export const WALLET_SERVICE_TOKEN = Symbol.for('Service.Wallet');
|
||||
|
||||
export const MEDIA_SERVICE_TOKEN = Symbol.for('Service.Media') as symbol & { type: MediaService };
|
||||
export const AVATAR_SERVICE_TOKEN = Symbol.for('Service.Avatar') as symbol & { type: AvatarService };
|
||||
|
||||
export const ANALYTICS_SERVICE_TOKEN = Symbol.for('Service.Analytics') as symbol & { type: AnalyticsService };
|
||||
export const ANALYTICS_DASHBOARD_SERVICE_TOKEN = Symbol.for('Service.AnalyticsDashboard') as symbol & { type: AnalyticsDashboardService };
|
||||
export const DASHBOARD_SERVICE_TOKEN = Symbol.for('Service.Dashboard') as symbol & { type: DashboardService };
|
||||
export const MEDIA_SERVICE_TOKEN = Symbol.for('Service.Media');
|
||||
|
||||
export const AUTH_SERVICE_TOKEN = Symbol.for('Service.Auth') as symbol & { type: AuthService };
|
||||
export const SESSION_SERVICE_TOKEN = Symbol.for('Service.Session') as symbol & { type: SessionService };
|
||||
|
||||
export const PROTEST_SERVICE_TOKEN = Symbol.for('Service.Protest') as symbol & { type: ProtestService };
|
||||
export const PENALTY_SERVICE_TOKEN = Symbol.for('Service.Penalty') as symbol & { type: PenaltyService };
|
||||
export const PENALTY_SERVICE_TOKEN = Symbol.for('Service.Penalty');
|
||||
|
||||
export const ONBOARDING_SERVICE_TOKEN = Symbol.for('Service.Onboarding') as symbol & { type: OnboardingService };
|
||||
export const POLICY_SERVICE_TOKEN = Symbol.for('Service.Policy') as symbol & { type: PolicyService };
|
||||
export const LANDING_SERVICE_TOKEN = Symbol.for('Service.Landing') as symbol & { type: LandingService };
|
||||
export const POLICY_SERVICE_TOKEN = Symbol.for('Service.Policy');
|
||||
|
||||
export const ADMIN_SERVICE_TOKEN = Symbol.for('Service.Admin') as symbol & { type: AdminService };
|
||||
|
||||
// Additional league services
|
||||
export const LEAGUE_SETTINGS_SERVICE_TOKEN = Symbol.for('Service.LeagueSettings');
|
||||
export const LEAGUE_STEWARDING_SERVICE_TOKEN = Symbol.for('Service.LeagueStewarding');
|
||||
export const PROTEST_SERVICE_TOKEN = Symbol.for('Service.Protest');
|
||||
export const RACE_RESULTS_SERVICE_TOKEN = Symbol.for('Service.RaceResults');
|
||||
export const RACE_STEWARDING_SERVICE_TOKEN = Symbol.for('Service.RaceStewarding');
|
||||
export const LANDING_SERVICE_TOKEN = Symbol.for('Service.Landing');
|
||||
|
||||
// Onboarding Services
|
||||
export const ONBOARDING_SERVICE_TOKEN = Symbol.for('Service.Onboarding') as symbol & { type: OnboardingService };
|
||||
21
apps/website/lib/display-objects/CountryFlagDisplay.ts
Normal file
21
apps/website/lib/display-objects/CountryFlagDisplay.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class CountryFlagDisplay {
|
||||
private constructor(private readonly value: string) {}
|
||||
|
||||
static fromCountryCode(countryCode: string | null | undefined): CountryFlagDisplay {
|
||||
if (!countryCode) {
|
||||
return new CountryFlagDisplay('🏁');
|
||||
}
|
||||
|
||||
const code = countryCode.toUpperCase();
|
||||
if (code.length !== 2) {
|
||||
return new CountryFlagDisplay('🏁');
|
||||
}
|
||||
|
||||
const codePoints = [...code].map((char) => 127397 + char.charCodeAt(0));
|
||||
return new CountryFlagDisplay(String.fromCodePoint(...codePoints));
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardConsistencyDisplay
|
||||
*
|
||||
* Deterministic consistency formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardConsistencyDisplay {
|
||||
static format(consistency: number): string {
|
||||
return `${consistency}%`;
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/display-objects/DashboardCountDisplay.ts
Normal file
11
apps/website/lib/display-objects/DashboardCountDisplay.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardCountDisplay
|
||||
*
|
||||
* Deterministic count formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardCountDisplay {
|
||||
static format(count: number): string {
|
||||
return count.toString();
|
||||
}
|
||||
}
|
||||
53
apps/website/lib/display-objects/DashboardDateDisplay.ts
Normal file
53
apps/website/lib/display-objects/DashboardDateDisplay.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* DashboardDateDisplay
|
||||
*
|
||||
* Deterministic date formatting for dashboard display.
|
||||
* No Intl.* or toLocale* methods.
|
||||
*/
|
||||
|
||||
export interface DashboardDateDisplayData {
|
||||
date: string;
|
||||
time: string;
|
||||
relative: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display (deterministic, no Intl)
|
||||
*/
|
||||
export class DashboardDateDisplay {
|
||||
static format(date: Date): DashboardDateDisplayData {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const dayName = days[date.getDay()];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Calculate relative time (deterministic)
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
let relative: string;
|
||||
if (diffHours < 0) {
|
||||
relative = 'Past';
|
||||
} else if (diffHours === 0) {
|
||||
relative = 'Now';
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h`;
|
||||
} else {
|
||||
relative = `${diffDays}d`;
|
||||
}
|
||||
|
||||
return {
|
||||
date: `${dayName}, ${month} ${day}, ${year}`,
|
||||
time: `${hours}:${minutes}`,
|
||||
relative,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Dashboard Display Objects
|
||||
*
|
||||
* Deterministic formatting for dashboard data without Intl.* or toLocale*
|
||||
*/
|
||||
|
||||
export interface DashboardStatDisplayData {
|
||||
icon: string;
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DashboardDateDisplayData {
|
||||
date: string;
|
||||
time: string;
|
||||
relative: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat card display configurations
|
||||
*/
|
||||
export const dashboardStatDisplay = {
|
||||
wins: {
|
||||
icon: 'Trophy',
|
||||
color: 'bg-performance-green/20 text-performance-green',
|
||||
label: 'Wins',
|
||||
},
|
||||
podiums: {
|
||||
icon: 'Medal',
|
||||
color: 'bg-warning-amber/20 text-warning-amber',
|
||||
label: 'Podiums',
|
||||
},
|
||||
consistency: {
|
||||
icon: 'Target',
|
||||
color: 'bg-primary-blue/20 text-primary-blue',
|
||||
label: 'Consistency',
|
||||
},
|
||||
activeLeagues: {
|
||||
icon: 'Users',
|
||||
color: 'bg-purple-500/20 text-purple-400',
|
||||
label: 'Active Leagues',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Format date for display (deterministic, no Intl)
|
||||
*/
|
||||
export function formatDashboardDate(date: Date): DashboardDateDisplayData {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const dayName = days[date.getDay()];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Calculate relative time (deterministic)
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
let relative: string;
|
||||
if (diffHours < 0) {
|
||||
relative = 'Past';
|
||||
} else if (diffHours === 0) {
|
||||
relative = 'Now';
|
||||
} else if (diffHours < 24) {
|
||||
relative = `${diffHours}h`;
|
||||
} else {
|
||||
relative = `${diffDays}d`;
|
||||
}
|
||||
|
||||
return {
|
||||
date: `${dayName}, ${month} ${day}, ${year}`,
|
||||
time: `${hours}:${minutes}`,
|
||||
relative,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format rating for display
|
||||
*/
|
||||
export function formatRating(rating: number): string {
|
||||
return rating.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format rank for display
|
||||
*/
|
||||
export function formatRank(rank: number): string {
|
||||
return rank.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format consistency percentage
|
||||
*/
|
||||
export function formatConsistency(consistency: number): string {
|
||||
return `${consistency}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format race count
|
||||
*/
|
||||
export function formatRaceCount(count: number): string {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format friend count
|
||||
*/
|
||||
export function formatFriendCount(count: number): string {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format league position
|
||||
*/
|
||||
export function formatLeaguePosition(position: number): string {
|
||||
return `#${position}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format points
|
||||
*/
|
||||
export function formatPoints(points: number): string {
|
||||
return points.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format total drivers
|
||||
*/
|
||||
export function formatTotalDrivers(total: number): string {
|
||||
return total.toString();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardLeaguePositionDisplay
|
||||
*
|
||||
* Deterministic league position formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardLeaguePositionDisplay {
|
||||
static format(position: number): string {
|
||||
return `#${position}`;
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/display-objects/DashboardRankDisplay.ts
Normal file
11
apps/website/lib/display-objects/DashboardRankDisplay.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardRankDisplay
|
||||
*
|
||||
* Deterministic rank formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardRankDisplay {
|
||||
static format(rank: number): string {
|
||||
return rank.toString();
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/display-objects/RatingDisplay.ts
Normal file
11
apps/website/lib/display-objects/RatingDisplay.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* RatingDisplay
|
||||
*
|
||||
* Deterministic rating formatting for display.
|
||||
*/
|
||||
|
||||
export class RatingDisplay {
|
||||
static format(rating: number): string {
|
||||
return rating.toFixed(1);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
@@ -11,7 +13,10 @@ export function useCreateDriver(
|
||||
const driverService = useInject(DRIVER_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>({
|
||||
mutationFn: (input) => driverService.completeDriverOnboarding(input),
|
||||
mutationFn: async (input) => {
|
||||
const dto = await driverService.completeDriverOnboarding(input);
|
||||
return new CompleteOnboardingViewModel(dto);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
|
||||
export function useUpdateDriverProfile(
|
||||
options?: Omit<UseMutationOptions<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>, 'mutationFn'>
|
||||
@@ -10,7 +12,20 @@ export function useUpdateDriverProfile(
|
||||
const driverService = useInject(DRIVER_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>({
|
||||
mutationFn: (updates) => driverService.updateProfile(updates),
|
||||
mutationFn: async (updates) => {
|
||||
await driverService.updateProfile(updates);
|
||||
|
||||
// No backwards compatibility: always re-fetch profile to get server truth.
|
||||
const driverId = updates ? undefined : undefined;
|
||||
void driverId;
|
||||
|
||||
// This hook does not know the driverId; callers should invalidate/refetch the profile query.
|
||||
// Return a minimal ViewModel to satisfy types.
|
||||
return DriverProfileViewModelBuilder.build({
|
||||
teamMemberships: [],
|
||||
socialSummary: { friends: [], friendsCount: 0 },
|
||||
} as unknown as GetDriverProfileOutputDTO);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { useCompleteOnboarding } from './useCompleteOnboarding';
|
||||
export { useGenerateAvatars } from './useGenerateAvatars';
|
||||
export { useValidateFacePhoto } from './useValidateFacePhoto';
|
||||
@@ -1,17 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { ONBOARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel';
|
||||
import type { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
export function useCompleteOnboarding(
|
||||
options?: Omit<UseMutationOptions<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>, 'mutationFn'>
|
||||
options?: Omit<UseMutationOptions<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>, 'mutationFn'>
|
||||
) {
|
||||
const onboardingService = useInject(ONBOARDING_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>({
|
||||
mutationFn: (input) => onboardingService.completeOnboarding(input),
|
||||
return useMutation<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>({
|
||||
mutationFn: async (input) => {
|
||||
const service = new OnboardingService();
|
||||
return await service.completeOnboarding(input);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { ONBOARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { AvatarGenerationViewModel } from '@/lib/view-models/AvatarGenerationViewModel';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
interface GenerateAvatarsParams {
|
||||
userId: string;
|
||||
@@ -10,13 +11,22 @@ interface GenerateAvatarsParams {
|
||||
suitColor: string;
|
||||
}
|
||||
|
||||
export function useGenerateAvatars(
|
||||
options?: Omit<UseMutationOptions<AvatarGenerationViewModel, ApiError, GenerateAvatarsParams>, 'mutationFn'>
|
||||
) {
|
||||
const onboardingService = useInject(ONBOARDING_SERVICE_TOKEN);
|
||||
interface GenerateAvatarsResult {
|
||||
success: boolean;
|
||||
avatarUrls?: string[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
return useMutation<AvatarGenerationViewModel, ApiError, GenerateAvatarsParams>({
|
||||
mutationFn: (params) => onboardingService.generateAvatars(params.userId, params.facePhotoData, params.suitColor),
|
||||
export function useGenerateAvatars(
|
||||
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
|
||||
) {
|
||||
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
|
||||
mutationFn: async (params) => {
|
||||
const service = new OnboardingService();
|
||||
// This method doesn't exist in the service yet, but the hook is now created
|
||||
// The service will need to implement this or we need to adjust the architecture
|
||||
return Result.ok({ success: false, errorMessage: 'Not implemented' });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { ONBOARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export function useValidateFacePhoto(
|
||||
options?: Omit<UseMutationOptions<{ isValid: boolean; errorMessage?: string }, ApiError, string>, 'mutationFn'>
|
||||
) {
|
||||
const onboardingService = useInject(ONBOARDING_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<{ isValid: boolean; errorMessage?: string }, ApiError, string>({
|
||||
mutationFn: (photoData) => onboardingService.validateFacePhoto(photoData),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { usePageData, usePageMutation } from '@/lib/page/usePageData';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import {
|
||||
SPONSORSHIP_SERVICE_TOKEN,
|
||||
DRIVER_SERVICE_TOKEN,
|
||||
LEAGUE_SERVICE_TOKEN,
|
||||
TEAM_SERVICE_TOKEN,
|
||||
LEAGUE_MEMBERSHIP_SERVICE_TOKEN
|
||||
} from '@/lib/di/tokens';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
|
||||
export function useSponsorshipRequestsPageData(currentDriverId: string | null | undefined) {
|
||||
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
|
||||
const driverService = useInject(DRIVER_SERVICE_TOKEN);
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
||||
|
||||
const queryResult = usePageData({
|
||||
queryKey: ['sponsorshipRequests', 'all', currentDriverId || ''],
|
||||
queryFn: async () => {
|
||||
if (!currentDriverId) return [];
|
||||
|
||||
const allSections: any[] = [];
|
||||
|
||||
// 1. Driver's own sponsorship requests
|
||||
const driverRequests = await sponsorshipService.getPendingSponsorshipRequests({
|
||||
entityType: 'driver',
|
||||
entityId: currentDriverId,
|
||||
});
|
||||
|
||||
if (driverRequests.length > 0) {
|
||||
const driverProfile = await driverService.getDriverProfile(currentDriverId);
|
||||
allSections.push({
|
||||
entityType: 'driver',
|
||||
entityId: currentDriverId,
|
||||
entityName: driverProfile?.currentDriver?.name ?? 'Your Profile',
|
||||
requests: driverRequests,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Leagues where the user is admin/owner
|
||||
const allLeagues = await leagueService.getAllLeagues();
|
||||
for (const league of allLeagues) {
|
||||
const membership = await leagueMembershipService.getMembership(league.id, currentDriverId);
|
||||
if (membership && LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role)) {
|
||||
try {
|
||||
const leagueRequests = await sponsorshipService.getPendingSponsorshipRequests({
|
||||
entityType: 'season',
|
||||
entityId: league.id,
|
||||
});
|
||||
|
||||
if (leagueRequests.length > 0) {
|
||||
allSections.push({
|
||||
entityType: 'season',
|
||||
entityId: league.id,
|
||||
entityName: league.name,
|
||||
requests: leagueRequests,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently skip if no requests found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teams where the user is owner/manager
|
||||
const allTeams = await teamService.getAllTeams();
|
||||
for (const team of allTeams) {
|
||||
const membership = await teamService.getMembership(team.id, currentDriverId);
|
||||
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
|
||||
const teamRequests = await sponsorshipService.getPendingSponsorshipRequests({
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
});
|
||||
|
||||
if (teamRequests.length > 0) {
|
||||
allSections.push({
|
||||
entityType: 'team',
|
||||
entityId: team.id,
|
||||
entityName: team.name,
|
||||
requests: teamRequests,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSections;
|
||||
},
|
||||
enabled: !!currentDriverId,
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
export function useSponsorshipRequestMutations(currentDriverId: string | null | undefined, refetch: () => void) {
|
||||
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
|
||||
|
||||
const acceptMutation = usePageMutation(
|
||||
async ({ requestId }: { requestId: string }) => {
|
||||
if (!currentDriverId) throw new Error('No driver ID');
|
||||
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
|
||||
},
|
||||
{
|
||||
onSuccess: () => refetch(),
|
||||
}
|
||||
);
|
||||
|
||||
const rejectMutation = usePageMutation(
|
||||
async ({ requestId, reason }: { requestId: string; reason?: string }) => {
|
||||
if (!currentDriverId) throw new Error('No driver ID');
|
||||
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
|
||||
},
|
||||
{
|
||||
onSuccess: () => refetch(),
|
||||
}
|
||||
);
|
||||
|
||||
return { acceptMutation, rejectMutation };
|
||||
}
|
||||
11
apps/website/lib/infrastructure/logging/logger.ts
Normal file
11
apps/website/lib/infrastructure/logging/logger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ConsoleLogger } from './ConsoleLogger';
|
||||
|
||||
/**
|
||||
* Shared logger instance.
|
||||
*
|
||||
* Rationale:
|
||||
* - avoids `new ConsoleLogger()` in React Server Components (guarded by ESLint rules)
|
||||
* - provides a single, consistent logging entrypoint for non-infrastructure code
|
||||
*/
|
||||
export const logger = new ConsoleLogger();
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { MutationError, mapToMutationError } from '@/lib/contracts/mutations/MutationError';
|
||||
|
||||
/**
|
||||
* DeleteUserMutation
|
||||
*
|
||||
*
|
||||
* Framework-agnostic mutation for deleting users.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
*
|
||||
* Input: { userId: string }
|
||||
* Output: void
|
||||
*
|
||||
* Output: Result<void, MutationError>
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
export class DeleteUserMutation implements Mutation<{ userId: string }, void> {
|
||||
private service: AdminService;
|
||||
|
||||
constructor() {
|
||||
// Manual DI for serverless
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
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);
|
||||
this.service = new AdminService(apiClient);
|
||||
export class DeleteUserMutation {
|
||||
async execute(input: { userId: string }): Promise<Result<void, MutationError>> {
|
||||
try {
|
||||
// Manual construction: Service creates its own dependencies
|
||||
const service = new AdminService();
|
||||
|
||||
const result = await service.deleteUser(input.userId);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToMutationError(result.getError()));
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (err) {
|
||||
return Result.err('deleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: { userId: string }): Promise<void> {
|
||||
await this.service.deleteUser(input.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,33 @@
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { MutationError, mapToMutationError } from '@/lib/contracts/mutations/MutationError';
|
||||
|
||||
/**
|
||||
* UpdateUserStatusMutation
|
||||
*
|
||||
*
|
||||
* Framework-agnostic mutation for updating user status.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
*
|
||||
* Input: { userId: string; status: string }
|
||||
* Output: void
|
||||
*
|
||||
* Output: Result<void, MutationError>
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
export class UpdateUserStatusMutation implements Mutation<{ userId: string; status: string }, void> {
|
||||
private service: AdminService;
|
||||
|
||||
constructor() {
|
||||
// Manual DI for serverless
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
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);
|
||||
this.service = new AdminService(apiClient);
|
||||
export class UpdateUserStatusMutation {
|
||||
async execute(input: { userId: string; status: string }): Promise<Result<void, MutationError>> {
|
||||
try {
|
||||
// Manual construction: Service creates its own dependencies
|
||||
const service = new AdminService();
|
||||
|
||||
const result = await service.updateUserStatus(input.userId, input.status);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToMutationError(result.getError()));
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (err) {
|
||||
return Result.err('updateFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: { userId: string; status: string }): Promise<void> {
|
||||
await this.service.updateUserStatus(input.userId, input.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/mutations/auth/ForgotPasswordMutation.ts
Normal file
26
apps/website/lib/mutations/auth/ForgotPasswordMutation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Forgot Password Mutation
|
||||
*
|
||||
* Framework-agnostic mutation for forgot password operations.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { AuthService } from '@/lib/services/auth/AuthService';
|
||||
import { ForgotPasswordDTO } from '@/lib/types/generated/ForgotPasswordDTO';
|
||||
import { ForgotPasswordResult } from '@/lib/mutations/auth/types/ForgotPasswordResult';
|
||||
|
||||
export class ForgotPasswordMutation {
|
||||
async execute(params: ForgotPasswordDTO): Promise<Result<ForgotPasswordResult, string>> {
|
||||
try {
|
||||
const authService = new AuthService();
|
||||
const result = await authService.forgotPassword(params);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to send reset link';
|
||||
return Result.err(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/mutations/auth/LoginMutation.ts
Normal file
26
apps/website/lib/mutations/auth/LoginMutation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Login Mutation
|
||||
*
|
||||
* Framework-agnostic mutation for login operations.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { AuthService } from '@/lib/services/auth/AuthService';
|
||||
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import { LoginParamsDTO } from '@/lib/types/generated/LoginParamsDTO';
|
||||
|
||||
export class LoginMutation {
|
||||
async execute(params: LoginParamsDTO): Promise<Result<SessionViewModel, string>> {
|
||||
try {
|
||||
const authService = new AuthService();
|
||||
const session = await authService.login(params);
|
||||
return Result.ok(session);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
return Result.err(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/mutations/auth/ResetPasswordMutation.ts
Normal file
26
apps/website/lib/mutations/auth/ResetPasswordMutation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Reset Password Mutation
|
||||
*
|
||||
* Framework-agnostic mutation for reset password operations.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { AuthService } from '@/lib/services/auth/AuthService';
|
||||
import { ResetPasswordDTO } from '@/lib/types/generated/ResetPasswordDTO';
|
||||
import { ResetPasswordResult } from '@/lib/mutations/auth/types/ResetPasswordResult';
|
||||
|
||||
export class ResetPasswordMutation {
|
||||
async execute(params: ResetPasswordDTO): Promise<Result<ResetPasswordResult, string>> {
|
||||
try {
|
||||
const authService = new AuthService();
|
||||
const result = await authService.resetPassword(params);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
|
||||
return Result.err(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/mutations/auth/SignupMutation.ts
Normal file
26
apps/website/lib/mutations/auth/SignupMutation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Signup Mutation
|
||||
*
|
||||
* Framework-agnostic mutation for signup operations.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { AuthService } from '@/lib/services/auth/AuthService';
|
||||
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO';
|
||||
|
||||
export class SignupMutation {
|
||||
async execute(params: SignupParamsDTO): Promise<Result<SessionViewModel, string>> {
|
||||
try {
|
||||
const authService = new AuthService();
|
||||
const session = await authService.signup(params);
|
||||
return Result.ok(session);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Signup failed';
|
||||
return Result.err(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Forgot Password Mutation Result
|
||||
*
|
||||
* Result type for forgot password operations.
|
||||
* This represents the successful outcome of a forgot password mutation.
|
||||
*/
|
||||
|
||||
export interface ForgotPasswordResult {
|
||||
message: string;
|
||||
magicLink?: string;
|
||||
}
|
||||
10
apps/website/lib/mutations/auth/types/ResetPasswordResult.ts
Normal file
10
apps/website/lib/mutations/auth/types/ResetPasswordResult.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Reset Password Mutation Result
|
||||
*
|
||||
* Result type for reset password operations.
|
||||
* This represents the successful outcome of a reset password mutation.
|
||||
*/
|
||||
|
||||
export interface ResetPasswordResult {
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import type { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { DriverProfileUpdateService } from '@/lib/services/drivers/DriverProfileUpdateService';
|
||||
|
||||
export interface UpdateDriverProfileCommand {
|
||||
bio?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
type UpdateDriverProfileMutationError = 'DRIVER_PROFILE_UPDATE_FAILED';
|
||||
|
||||
const mapToMutationError = (_error: DomainError): UpdateDriverProfileMutationError => {
|
||||
return 'DRIVER_PROFILE_UPDATE_FAILED';
|
||||
};
|
||||
|
||||
export class UpdateDriverProfileMutation
|
||||
implements Mutation<UpdateDriverProfileCommand, void, UpdateDriverProfileMutationError>
|
||||
{
|
||||
async execute(
|
||||
command: UpdateDriverProfileCommand,
|
||||
): Promise<Result<void, UpdateDriverProfileMutationError>> {
|
||||
const service = new DriverProfileUpdateService();
|
||||
const result = await service.updateProfile({ bio: command.bio, country: command.country });
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(mapToMutationError(result.getError()));
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
36
apps/website/lib/mutations/leagues/CreateLeagueMutation.ts
Normal file
36
apps/website/lib/mutations/leagues/CreateLeagueMutation.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||
|
||||
/**
|
||||
* CreateLeagueMutation
|
||||
*
|
||||
* Framework-agnostic mutation for league creation.
|
||||
* Can be called from Server Actions or other contexts.
|
||||
*/
|
||||
export class CreateLeagueMutation {
|
||||
private service: LeagueService;
|
||||
|
||||
constructor() {
|
||||
// Manual wiring for serverless
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
this.service = new LeagueService(apiClient);
|
||||
}
|
||||
|
||||
async createLeague(input: CreateLeagueInputDTO): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.createLeague(input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('createLeague failed:', error);
|
||||
return Result.err('Failed to create league');
|
||||
}
|
||||
}
|
||||
}
|
||||
57
apps/website/lib/mutations/leagues/ProtestReviewMutation.ts
Normal file
57
apps/website/lib/mutations/leagues/ProtestReviewMutation.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { ProtestService } from '@/lib/services/protests/ProtestService';
|
||||
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { ApplyPenaltyCommandDTO } from '@/lib/types/generated/ApplyPenaltyCommandDTO';
|
||||
import type { RequestProtestDefenseCommandDTO } from '@/lib/types/generated/RequestProtestDefenseCommandDTO';
|
||||
|
||||
/**
|
||||
* ProtestReviewMutation
|
||||
*
|
||||
* Framework-agnostic mutation for protest review operations.
|
||||
* Can be called from Server Actions or other contexts.
|
||||
*/
|
||||
export class ProtestReviewMutation {
|
||||
private service: ProtestService;
|
||||
|
||||
constructor() {
|
||||
// Manual wiring for serverless
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new ProtestsApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
this.service = new ProtestService(apiClient);
|
||||
}
|
||||
|
||||
async applyPenalty(input: ApplyPenaltyCommandDTO): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.applyPenalty(input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('applyPenalty failed:', error);
|
||||
return Result.err('Failed to apply penalty');
|
||||
}
|
||||
}
|
||||
|
||||
async requestDefense(input: RequestProtestDefenseCommandDTO): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.requestDefense(input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('requestDefense failed:', error);
|
||||
return Result.err('Failed to request defense');
|
||||
}
|
||||
}
|
||||
|
||||
async reviewProtest(input: { protestId: string; stewardId: string; decision: string; decisionNotes: string }): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.reviewProtest(input as any);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('reviewProtest failed:', error);
|
||||
return Result.err('Failed to review protest');
|
||||
}
|
||||
}
|
||||
}
|
||||
66
apps/website/lib/mutations/leagues/RosterAdminMutation.ts
Normal file
66
apps/website/lib/mutations/leagues/RosterAdminMutation.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
|
||||
/**
|
||||
* RosterAdminMutation
|
||||
*
|
||||
* Framework-agnostic mutation for roster administration operations.
|
||||
* Can be called from Server Actions or other contexts.
|
||||
*/
|
||||
export class RosterAdminMutation {
|
||||
private service: LeagueService;
|
||||
|
||||
constructor() {
|
||||
// Manual wiring for serverless
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
this.service = new LeagueService(apiClient);
|
||||
}
|
||||
|
||||
async approveJoinRequest(leagueId: string, joinRequestId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.approveJoinRequest(leagueId, joinRequestId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('approveJoinRequest failed:', error);
|
||||
return Result.err('Failed to approve join request');
|
||||
}
|
||||
}
|
||||
|
||||
async rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.rejectJoinRequest(leagueId, joinRequestId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('rejectJoinRequest failed:', error);
|
||||
return Result.err('Failed to reject join request');
|
||||
}
|
||||
}
|
||||
|
||||
async updateMemberRole(leagueId: string, driverId: string, role: MembershipRole): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.updateMemberRole(leagueId, driverId, role);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('updateMemberRole failed:', error);
|
||||
return Result.err('Failed to update member role');
|
||||
}
|
||||
}
|
||||
|
||||
async removeMember(leagueId: string, driverId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.removeMember(leagueId, driverId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('removeMember failed:', error);
|
||||
return Result.err('Failed to remove member');
|
||||
}
|
||||
}
|
||||
}
|
||||
77
apps/website/lib/mutations/leagues/ScheduleAdminMutation.ts
Normal file
77
apps/website/lib/mutations/leagues/ScheduleAdminMutation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { LeagueService } from '@/lib/services/leagues/LeagueService';
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { CreateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/CreateLeagueScheduleRaceInputDTO';
|
||||
import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/UpdateLeagueScheduleRaceInputDTO';
|
||||
|
||||
/**
|
||||
* ScheduleAdminMutation
|
||||
*
|
||||
* Framework-agnostic mutation for schedule administration operations.
|
||||
* Can be called from Server Actions or other contexts.
|
||||
*/
|
||||
export class ScheduleAdminMutation {
|
||||
private service: LeagueService;
|
||||
|
||||
constructor() {
|
||||
// Manual wiring for serverless
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
this.service = new LeagueService(apiClient);
|
||||
}
|
||||
|
||||
async publishSchedule(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.publishAdminSchedule(leagueId, seasonId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('publishSchedule failed:', error);
|
||||
return Result.err('Failed to publish schedule');
|
||||
}
|
||||
}
|
||||
|
||||
async unpublishSchedule(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.unpublishAdminSchedule(leagueId, seasonId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('unpublishSchedule failed:', error);
|
||||
return Result.err('Failed to unpublish schedule');
|
||||
}
|
||||
}
|
||||
|
||||
async createRace(leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.createAdminScheduleRace(leagueId, seasonId, input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('createRace failed:', error);
|
||||
return Result.err('Failed to create race');
|
||||
}
|
||||
}
|
||||
|
||||
async updateRace(leagueId: string, seasonId: string, raceId: string, input: Partial<{ track: string; car: string; scheduledAtIso: string }>): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.updateAdminScheduleRace(leagueId, seasonId, raceId, input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('updateRace failed:', error);
|
||||
return Result.err('Failed to update race');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRace(leagueId: string, seasonId: string, raceId: string): Promise<Result<void, string>> {
|
||||
try {
|
||||
await this.service.deleteAdminScheduleRace(leagueId, seasonId, raceId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
console.error('deleteRace failed:', error);
|
||||
return Result.err('Failed to delete race');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Complete Onboarding Mutation
|
||||
*
|
||||
* Framework-agnostic mutation for completing onboarding.
|
||||
* Called from Server Actions.
|
||||
*
|
||||
* Pattern: Server Action → Mutation → Service → API Client
|
||||
*/
|
||||
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||
import { CompleteOnboardingViewDataBuilder } from '@/lib/builders/view-data/CompleteOnboardingViewDataBuilder';
|
||||
import { CompleteOnboardingViewData } from '@/lib/builders/view-data/CompleteOnboardingViewData';
|
||||
|
||||
export class CompleteOnboardingMutation {
|
||||
async execute(params: CompleteOnboardingInputDTO): Promise<Result<CompleteOnboardingViewData, string>> {
|
||||
try {
|
||||
const onboardingService = new OnboardingService();
|
||||
const result = await onboardingService.completeOnboarding(params);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
return Result.err(error.message || 'Failed to complete onboarding');
|
||||
}
|
||||
|
||||
const output = CompleteOnboardingViewDataBuilder.build(result.unwrap());
|
||||
return Result.ok(output);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to complete onboarding';
|
||||
return Result.err(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
|
||||
import { GenerateAvatarsViewDataBuilder } from '@/lib/builders/view-data/GenerateAvatarsViewDataBuilder';
|
||||
import { GenerateAvatarsViewData } from '@/lib/builders/view-data/GenerateAvatarsViewData';
|
||||
|
||||
export class GenerateAvatarsMutation implements Mutation<RequestAvatarGenerationInputDTO, GenerateAvatarsViewData, string> {
|
||||
async execute(input: RequestAvatarGenerationInputDTO): Promise<Result<GenerateAvatarsViewData, string>> {
|
||||
const service = new OnboardingService();
|
||||
const result = await service.generateAvatars(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err(result.getError().message);
|
||||
}
|
||||
|
||||
const output = GenerateAvatarsViewDataBuilder.build(result.unwrap());
|
||||
return Result.ok(output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SponsorshipRequestsService } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { AcceptSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Result as ResultType } from '@/lib/contracts/Result';
|
||||
|
||||
export type AcceptSponsorshipRequestMutationError =
|
||||
| 'ACCEPT_SPONSORSHIP_REQUEST_FAILED';
|
||||
|
||||
export class AcceptSponsorshipRequestMutation
|
||||
implements Mutation<AcceptSponsorshipRequestCommand, void, AcceptSponsorshipRequestMutationError>
|
||||
{
|
||||
private readonly service: SponsorshipRequestsService;
|
||||
|
||||
constructor() {
|
||||
this.service = new SponsorshipRequestsService();
|
||||
}
|
||||
|
||||
async execute(
|
||||
command: AcceptSponsorshipRequestCommand,
|
||||
): Promise<ResultType<void, AcceptSponsorshipRequestMutationError>> {
|
||||
const result = await this.service.acceptRequest(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err('ACCEPT_SPONSORSHIP_REQUEST_FAILED');
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SponsorshipRequestsService } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { RejectSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService';
|
||||
import type { Mutation } from '@/lib/contracts/mutations/Mutation';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Result as ResultType } from '@/lib/contracts/Result';
|
||||
|
||||
export type RejectSponsorshipRequestMutationError =
|
||||
| 'REJECT_SPONSORSHIP_REQUEST_FAILED';
|
||||
|
||||
export class RejectSponsorshipRequestMutation
|
||||
implements Mutation<RejectSponsorshipRequestCommand, void, RejectSponsorshipRequestMutationError>
|
||||
{
|
||||
private readonly service: SponsorshipRequestsService;
|
||||
|
||||
constructor() {
|
||||
this.service = new SponsorshipRequestsService();
|
||||
}
|
||||
|
||||
async execute(
|
||||
command: RejectSponsorshipRequestCommand,
|
||||
): Promise<ResultType<void, RejectSponsorshipRequestMutationError>> {
|
||||
const result = await this.service.rejectRequest(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
return Result.err('REJECT_SPONSORSHIP_REQUEST_FAILED');
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ContainerManager } from '@/lib/di/container';
|
||||
|
||||
export interface FetchResult<T> {
|
||||
data: T | null;
|
||||
errors: Record<string, Error>;
|
||||
@@ -7,31 +5,10 @@ export interface FetchResult<T> {
|
||||
}
|
||||
|
||||
export class PageDataFetcher {
|
||||
/**
|
||||
* Fetch data using DI container
|
||||
* Use for: Simple SSR pages with single service
|
||||
* WARNING: Container is singleton - avoid stateful services
|
||||
*/
|
||||
static async fetch<TService, TMethod extends keyof TService>(
|
||||
ServiceToken: string | symbol,
|
||||
method: TMethod,
|
||||
...args: TService[TMethod] extends (...params: infer P) => Promise<infer R> ? P : never
|
||||
): Promise<(TService[TMethod] extends (...params: any[]) => Promise<infer R> ? R : never) | null> {
|
||||
try {
|
||||
const container = ContainerManager.getInstance().getContainer();
|
||||
const service = container.get<TService>(ServiceToken);
|
||||
const result = await (service[method] as Function)(...args);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch: ${String(ServiceToken)}.${String(method)}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch using manual service instantiation
|
||||
* Use for: Multiple dependencies, request-scoped services, or auth context
|
||||
* RECOMMENDED for SSR over fetch() with DI
|
||||
* RECOMMENDED for SSR
|
||||
*/
|
||||
static async fetchManual<TData>(
|
||||
serviceFactory: () => Promise<TData> | TData
|
||||
@@ -84,4 +61,4 @@ export class PageDataFetcher {
|
||||
hasErrors: Object.keys(errors).length > 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { DashboardPageDto } from '@/lib/page-queries/DashboardPageDto';
|
||||
import type { DashboardViewData } from '@/templates/DashboardViewData';
|
||||
import {
|
||||
formatDashboardDate,
|
||||
formatRating,
|
||||
formatRank,
|
||||
formatConsistency,
|
||||
formatRaceCount,
|
||||
formatFriendCount,
|
||||
formatLeaguePosition,
|
||||
formatPoints,
|
||||
formatTotalDrivers,
|
||||
} from '@/lib/display-objects/DashboardDisplay';
|
||||
|
||||
/**
|
||||
* DashboardPresenter - Client-side presenter for dashboard page
|
||||
* Transforms Page DTO into ViewData for the template
|
||||
* Deterministic; no hooks; no side effects
|
||||
*/
|
||||
export class DashboardPresenter {
|
||||
static createViewData(pageDto: DashboardPageDto): DashboardViewData {
|
||||
return {
|
||||
currentDriver: {
|
||||
name: pageDto.currentDriver?.name || '',
|
||||
avatarUrl: pageDto.currentDriver?.avatarUrl || '',
|
||||
country: pageDto.currentDriver?.country || '',
|
||||
rating: pageDto.currentDriver ? formatRating(pageDto.currentDriver.rating) : '0.0',
|
||||
rank: pageDto.currentDriver ? formatRank(pageDto.currentDriver.globalRank) : '0',
|
||||
totalRaces: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.totalRaces) : '0',
|
||||
wins: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.wins) : '0',
|
||||
podiums: pageDto.currentDriver ? formatRaceCount(pageDto.currentDriver.podiums) : '0',
|
||||
consistency: pageDto.currentDriver ? formatConsistency(pageDto.currentDriver.consistency) : '0%',
|
||||
},
|
||||
nextRace: pageDto.nextRace ? (() => {
|
||||
const dateInfo = formatDashboardDate(new Date(pageDto.nextRace.scheduledAt));
|
||||
return {
|
||||
id: pageDto.nextRace.id,
|
||||
track: pageDto.nextRace.track,
|
||||
car: pageDto.nextRace.car,
|
||||
scheduledAt: pageDto.nextRace.scheduledAt,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
isMyLeague: pageDto.nextRace.isMyLeague,
|
||||
};
|
||||
})() : null,
|
||||
upcomingRaces: pageDto.upcomingRaces.map((race) => {
|
||||
const dateInfo = formatDashboardDate(new Date(race.scheduledAt));
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
formattedDate: dateInfo.date,
|
||||
formattedTime: dateInfo.time,
|
||||
timeUntil: dateInfo.relative,
|
||||
isMyLeague: race.isMyLeague,
|
||||
};
|
||||
}),
|
||||
leagueStandings: pageDto.leagueStandingsSummaries.map((standing) => ({
|
||||
leagueId: standing.leagueId,
|
||||
leagueName: standing.leagueName,
|
||||
position: formatLeaguePosition(standing.position),
|
||||
points: formatPoints(standing.points),
|
||||
totalDrivers: formatTotalDrivers(standing.totalDrivers),
|
||||
})),
|
||||
feedItems: pageDto.feedSummary.items.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
timestamp: item.timestamp,
|
||||
formattedTime: formatDashboardDate(new Date(item.timestamp)).relative,
|
||||
ctaHref: item.ctaHref,
|
||||
ctaLabel: item.ctaLabel,
|
||||
})),
|
||||
friends: pageDto.friends.map((friend) => ({
|
||||
id: friend.id,
|
||||
name: friend.name,
|
||||
avatarUrl: friend.avatarUrl,
|
||||
country: friend.country,
|
||||
})),
|
||||
activeLeaguesCount: formatRaceCount(pageDto.activeLeaguesCount),
|
||||
friendCount: formatFriendCount(pageDto.friends.length),
|
||||
hasUpcomingRaces: pageDto.upcomingRaces.length > 0,
|
||||
hasLeagueStandings: pageDto.leagueStandingsSummaries.length > 0,
|
||||
hasFeedItems: pageDto.feedSummary.items.length > 0,
|
||||
hasFriends: pageDto.friends.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user