8 Commits

Author SHA1 Message Date
844092eb8c code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 13s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-27 18:29:33 +01:00
e04282d77e code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 10s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-27 17:36:39 +01:00
9894c4a841 code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 13s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-27 16:30:03 +01:00
9b31eaf728 code quality 2026-01-26 23:23:15 +01:00
09632d004d code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 12s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-26 22:16:33 +01:00
f2bd80ccd3 code quality 2026-01-26 17:56:11 +01:00
3a4f460a7d code quality 2026-01-26 17:47:37 +01:00
9ac74f5046 code quality
Some checks failed
CI / lint-typecheck (pull_request) Failing after 12s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-26 17:22:01 +01:00
533 changed files with 5786 additions and 2525 deletions

View File

@@ -61,9 +61,7 @@ export class MediaResolverAdapter implements MediaResolverPort {
basePath: config.defaultPath basePath: config.defaultPath
}); });
this.generatedResolver = new GeneratedMediaResolverAdapter({ this.generatedResolver = new GeneratedMediaResolverAdapter();
basePath: config.generatedPath
});
this.uploadedResolver = new UploadedMediaResolverAdapter({ this.uploadedResolver = new UploadedMediaResolverAdapter({
basePath: config.uploadedPath basePath: config.uploadedPath

View File

@@ -4,36 +4,33 @@
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository'; import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
import type { Logger } from '@core/shared/domain/Logger'; import type { Logger } from '@core/shared/domain/Logger';
import type { LeagueWalletRepository } from '@core/racing/domain/repositories/LeagueWalletRepository'; import type { Wallet, Transaction } from '@core/payments/domain/entities/Wallet';
import type { Wallet } from '@core/payments/domain/entities/Wallet';
import type { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet';
import type { Transaction } from '@core/payments/domain/entities/league-wallet/Transaction';
const wallets: Map<string, Wallet | LeagueWallet> = new Map(); const wallets: Map<string, Wallet> = new Map();
const transactions: Map<string, Transaction> = new Map(); const transactions: Map<string, Transaction> = new Map();
export class InMemoryWalletRepository implements WalletRepository, LeagueWalletRepository { export class InMemoryWalletRepository implements WalletRepository {
constructor(private readonly logger: Logger) {} constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<Wallet | LeagueWallet | null> { async findById(id: string): Promise<Wallet | null> {
this.logger.debug('[InMemoryWalletRepository] findById', { id }); this.logger.debug('[InMemoryWalletRepository] findById', { id });
return wallets.get(id) || null; return wallets.get(id) || null;
} }
async findByLeagueId(leagueId: string): Promise<LeagueWallet | null> { async findByLeagueId(leagueId: string): Promise<Wallet | null> {
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId }); this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
return (Array.from(wallets.values()).find(w => (w as LeagueWallet).leagueId.toString() === leagueId) as LeagueWallet) || null; return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null;
} }
async create(wallet: Wallet | LeagueWallet): Promise<Wallet | LeagueWallet> { async create(wallet: Wallet): Promise<Wallet> {
this.logger.debug('[InMemoryWalletRepository] create', { wallet }); this.logger.debug('[InMemoryWalletRepository] create', { wallet });
wallets.set(wallet.id.toString(), wallet); wallets.set(wallet.id, wallet);
return wallet; return wallet;
} }
async update(wallet: Wallet | LeagueWallet): Promise<Wallet | LeagueWallet> { async update(wallet: Wallet): Promise<Wallet> {
this.logger.debug('[InMemoryWalletRepository] update', { wallet }); this.logger.debug('[InMemoryWalletRepository] update', { wallet });
wallets.set(wallet.id.toString(), wallet); wallets.set(wallet.id, wallet);
return wallet; return wallet;
} }
@@ -53,24 +50,24 @@ export class InMemoryWalletRepository implements WalletRepository, LeagueWalletR
export class InMemoryTransactionRepository implements TransactionRepository { export class InMemoryTransactionRepository implements TransactionRepository {
constructor(private readonly logger: Logger) {} constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<any | null> { async findById(id: string): Promise<Transaction | null> {
this.logger.debug('[InMemoryTransactionRepository] findById', { id }); this.logger.debug('[InMemoryTransactionRepository] findById', { id });
return transactions.get(id) || null; return transactions.get(id) || null;
} }
async findByWalletId(walletId: string): Promise<any[]> { async findByWalletId(walletId: string): Promise<Transaction[]> {
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId }); this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
return Array.from(transactions.values()).filter(t => t.walletId.toString() === walletId); return Array.from(transactions.values()).filter(t => t.walletId === walletId);
} }
async create(transaction: any): Promise<any> { async create(transaction: Transaction): Promise<Transaction> {
this.logger.debug('[InMemoryTransactionRepository] create', { transaction }); this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
transactions.set(transaction.id.toString(), transaction); transactions.set(transaction.id, transaction);
return transaction; return transaction;
} }
async update(transaction: any): Promise<any> { async update(transaction: Transaction): Promise<Transaction> {
transactions.set(transaction.id.toString(), transaction); transactions.set(transaction.id, transaction);
return transaction; return transaction;
} }
@@ -82,7 +79,7 @@ export class InMemoryTransactionRepository implements TransactionRepository {
return transactions.has(id); return transactions.has(id);
} }
findByType(type: any): Promise<any[]> { findByType(type: any): Promise<Transaction[]> {
return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type)); return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type));
} }

View File

@@ -41,6 +41,7 @@ describe('ResultOrmMapper', () => {
entity.fastestLap = 0; entity.fastestLap = 0;
entity.incidents = 0; entity.incidents = 0;
entity.startPosition = 1; entity.startPosition = 1;
entity.points = 0;
try { try {
mapper.toDomain(entity); mapper.toDomain(entity);

View File

@@ -2216,6 +2216,96 @@
"incidents" "incidents"
] ]
}, },
"DashboardStatsResponseDTO": {
"type": "object",
"properties": {
"totalUsers": {
"type": "number"
},
"activeUsers": {
"type": "number"
},
"suspendedUsers": {
"type": "number"
},
"deletedUsers": {
"type": "number"
},
"systemAdmins": {
"type": "number"
},
"recentLogins": {
"type": "number"
},
"newUsersToday": {
"type": "number"
},
"userGrowth": {
"type": "object"
},
"label": {
"type": "string"
},
"value": {
"type": "number"
},
"color": {
"type": "string"
},
"roleDistribution": {
"type": "object"
},
"statusDistribution": {
"type": "object"
},
"active": {
"type": "number"
},
"suspended": {
"type": "number"
},
"deleted": {
"type": "number"
},
"activityTimeline": {
"type": "object"
},
"date": {
"type": "string"
},
"newUsers": {
"type": "number"
},
"logins": {
"type": "number"
}
},
"required": [
"totalUsers",
"activeUsers",
"suspendedUsers",
"deletedUsers",
"systemAdmins",
"recentLogins",
"newUsersToday",
"userGrowth",
"label",
"value",
"color",
"roleDistribution",
"label",
"value",
"color",
"statusDistribution",
"active",
"suspended",
"deleted",
"activityTimeline",
"date",
"newUsers",
"logins"
]
},
"DeleteMediaOutputDTO": { "DeleteMediaOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -64,7 +64,7 @@ function getEnvironment(): string {
function validateEnvironment( function validateEnvironment(
env: string env: string
): env is keyof FeatureFlagConfig { ): env is keyof FeatureFlagConfig {
const validEnvs = ['development', 'test', 'staging', 'production']; const validEnvs = ['development', 'test', 'e2e', 'staging', 'production'];
if (!validEnvs.includes(env)) { if (!validEnvs.includes(env)) {
throw new Error( throw new Error(
`Invalid environment: "${env}". Valid environments: ${validEnvs.join(', ')}` `Invalid environment: "${env}". Valid environments: ${validEnvs.join(', ')}`

View File

@@ -32,6 +32,7 @@ export interface EnvironmentConfig {
export interface FeatureFlagConfig { export interface FeatureFlagConfig {
development: EnvironmentConfig; development: EnvironmentConfig;
test: EnvironmentConfig; test: EnvironmentConfig;
e2e: EnvironmentConfig;
staging: EnvironmentConfig; staging: EnvironmentConfig;
production: EnvironmentConfig; production: EnvironmentConfig;
} }

View File

@@ -129,6 +129,43 @@ export const featureConfig: FeatureFlagConfig = {
}, },
}, },
// E2E environment - same as test
e2e: {
platform: {
dashboard: 'enabled',
leagues: 'enabled',
teams: 'enabled',
drivers: 'enabled',
races: 'enabled',
leaderboards: 'enabled',
},
auth: {
signup: 'enabled',
login: 'enabled',
forgotPassword: 'enabled',
resetPassword: 'enabled',
},
onboarding: {
wizard: 'enabled',
},
sponsors: {
portal: 'enabled',
dashboard: 'enabled',
management: 'enabled',
campaigns: 'enabled',
billing: 'enabled',
},
admin: {
dashboard: 'enabled',
userManagement: 'enabled',
analytics: 'enabled',
},
beta: {
newUI: 'disabled',
experimental: 'disabled',
},
},
// Staging environment - controlled feature rollout // Staging environment - controlled feature rollout
staging: { staging: {
// Core platform features // Core platform features

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { AdminController } from './AdminController'; import { AdminController } from './AdminController';
import { DashboardStatsResponseDto } from './dto/DashboardStatsResponseDto'; import { DashboardStatsResponseDto } from './dtos/DashboardStatsResponseDto';
import { ListUsersRequestDto } from './dtos/ListUsersRequestDto'; import { ListUsersRequestDto } from './dtos/ListUsersRequestDto';
import { UserListResponseDto, UserResponseDto } from './dtos/UserResponseDto'; import { UserListResponseDto, UserResponseDto } from './dtos/UserResponseDto';

View File

@@ -3,7 +3,7 @@ import { RequireSystemAdmin, REQUIRE_SYSTEM_ADMIN_METADATA_KEY } from './Require
// Mock SetMetadata // Mock SetMetadata
vi.mock('@nestjs/common', () => ({ vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor), SetMetadata: vi.fn(() => (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
})); }));
describe('RequireSystemAdmin', () => { describe('RequireSystemAdmin', () => {

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { AuthorizationService } from './AuthorizationService'; import { AuthorizationService } from './AuthorizationService';
describe('AuthorizationService', () => { describe('AuthorizationService', () => {

View File

@@ -3,7 +3,7 @@ import { Public, PUBLIC_ROUTE_METADATA_KEY } from './Public';
// Mock SetMetadata // Mock SetMetadata
vi.mock('@nestjs/common', () => ({ vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor), SetMetadata: vi.fn(() => (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
})); }));
describe('Public', () => { describe('Public', () => {

View File

@@ -3,7 +3,7 @@ import { RequireAuthenticatedUser, REQUIRE_AUTHENTICATED_USER_METADATA_KEY } fro
// Mock SetMetadata // Mock SetMetadata
vi.mock('@nestjs/common', () => ({ vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor), SetMetadata: vi.fn(() => (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
})); }));
describe('RequireAuthenticatedUser', () => { describe('RequireAuthenticatedUser', () => {

View File

@@ -3,7 +3,7 @@ import { RequireRoles, REQUIRE_ROLES_METADATA_KEY } from './RequireRoles';
// Mock SetMetadata // Mock SetMetadata
vi.mock('@nestjs/common', () => ({ vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor), SetMetadata: vi.fn(() => (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
})); }));
describe('RequireRoles', () => { describe('RequireRoles', () => {

View File

@@ -49,6 +49,7 @@ const createOutput = (): DashboardOverviewResult => {
fastestLap: 120, fastestLap: 120,
incidents: 0, incidents: 0,
startPosition: 1, startPosition: 1,
points: 25,
}); });
const feedItem: FeedItem = { const feedItem: FeedItem = {

View File

@@ -141,7 +141,7 @@ describe('requireLeagueAdminOrOwner', () => {
try { try {
await requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase); await requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase);
expect(true).toBe(false); // Should not reach here expect(true).toBe(false); // Should not reach here
} catch (error) { } catch (error: any) {
expect(error).toBeInstanceOf(ForbiddenException); expect(error).toBeInstanceOf(ForbiddenException);
expect(error.message).toBe('Forbidden'); expect(error.message).toBe('Forbidden');
} }
@@ -192,7 +192,7 @@ describe('requireLeagueAdminOrOwner', () => {
mockGetActorFromRequestContext.mockReturnValue({ mockGetActorFromRequestContext.mockReturnValue({
userId: 'user-123', userId: 'user-123',
driverId: 'driver-123', driverId: 'driver-123',
role: null, role: undefined,
}); });
const mockResult = { const mockResult = {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { LeagueController } from './LeagueController'; import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService'; import { LeagueService } from './LeagueService';
@@ -25,9 +25,9 @@ describe('LeagueController - Discovery Endpoints', () => {
name: 'GT3 Masters', name: 'GT3 Masters',
description: 'A GT3 racing league', description: 'A GT3 racing league',
ownerId: 'owner-1', ownerId: 'owner-1',
maxDrivers: 32, settings: { maxDrivers: 32 },
currentDrivers: 25, usedSlots: 25,
isPublic: true, createdAt: new Date().toISOString(),
}, },
], ],
totalCount: 1, totalCount: 1,
@@ -59,18 +59,18 @@ describe('LeagueController - Discovery Endpoints', () => {
name: 'Small League', name: 'Small League',
description: 'Small league', description: 'Small league',
ownerId: 'owner-1', ownerId: 'owner-1',
maxDrivers: 10, settings: { maxDrivers: 10 },
currentDrivers: 8, usedSlots: 8,
isPublic: true, createdAt: new Date().toISOString(),
}, },
{ {
id: 'league-2', id: 'league-2',
name: 'Large League', name: 'Large League',
description: 'Large league', description: 'Large league',
ownerId: 'owner-2', ownerId: 'owner-2',
maxDrivers: 50, settings: { maxDrivers: 50 },
currentDrivers: 45, usedSlots: 45,
isPublic: true, createdAt: new Date().toISOString(),
}, },
], ],
totalCount: 2, totalCount: 2,
@@ -81,8 +81,8 @@ describe('LeagueController - Discovery Endpoints', () => {
expect(result).toEqual(mockResult); expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(2); expect(result.leagues).toHaveLength(2);
expect(result.leagues[0]?.maxDrivers).toBe(10); expect(result.leagues[0]?.settings.maxDrivers).toBe(10);
expect(result.leagues[1]?.maxDrivers).toBe(50); expect(result.leagues[1]?.settings.maxDrivers).toBe(50);
}); });
}); });
@@ -95,13 +95,17 @@ describe('LeagueController - Discovery Endpoints', () => {
name: 'GT3 Masters', name: 'GT3 Masters',
description: 'A GT3 racing league', description: 'A GT3 racing league',
ownerId: 'owner-1', ownerId: 'owner-1',
maxDrivers: 32, settings: { maxDrivers: 32 },
currentDrivers: 25, usedSlots: 25,
isPublic: true, createdAt: new Date().toISOString(),
scoringConfig: { scoring: {
pointsSystem: 'standard', gameId: 'iracing',
pointsPerRace: 25, gameName: 'iRacing',
bonusPoints: true, primaryChampionshipType: 'driver',
scoringPresetId: 'standard',
scoringPresetName: 'Standard',
dropPolicySummary: 'None',
scoringPatternSummary: '25-18-15...',
}, },
}, },
], ],
@@ -134,13 +138,17 @@ describe('LeagueController - Discovery Endpoints', () => {
name: 'Standard League', name: 'Standard League',
description: 'Standard scoring', description: 'Standard scoring',
ownerId: 'owner-1', ownerId: 'owner-1',
maxDrivers: 32, settings: { maxDrivers: 32 },
currentDrivers: 20, usedSlots: 20,
isPublic: true, createdAt: new Date().toISOString(),
scoringConfig: { scoring: {
pointsSystem: 'standard', gameId: 'iracing',
pointsPerRace: 25, gameName: 'iRacing',
bonusPoints: true, primaryChampionshipType: 'driver',
scoringPresetId: 'standard',
scoringPresetName: 'Standard',
dropPolicySummary: 'None',
scoringPatternSummary: '25-18-15...',
}, },
}, },
{ {
@@ -148,13 +156,17 @@ describe('LeagueController - Discovery Endpoints', () => {
name: 'Custom League', name: 'Custom League',
description: 'Custom scoring', description: 'Custom scoring',
ownerId: 'owner-2', ownerId: 'owner-2',
maxDrivers: 20, settings: { maxDrivers: 20 },
currentDrivers: 15, usedSlots: 15,
isPublic: true, createdAt: new Date().toISOString(),
scoringConfig: { scoring: {
pointsSystem: 'custom', gameId: 'iracing',
pointsPerRace: 50, gameName: 'iRacing',
bonusPoints: false, primaryChampionshipType: 'driver',
scoringPresetId: 'custom',
scoringPresetName: 'Custom',
dropPolicySummary: 'None',
scoringPatternSummary: '50-40-30...',
}, },
}, },
], ],
@@ -166,8 +178,8 @@ describe('LeagueController - Discovery Endpoints', () => {
expect(result).toEqual(mockResult); expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(2); expect(result.leagues).toHaveLength(2);
expect(result.leagues[0]?.scoringConfig.pointsSystem).toBe('standard'); expect(result.leagues[0]?.scoring?.scoringPresetId).toBe('standard');
expect(result.leagues[1]?.scoringConfig.pointsSystem).toBe('custom'); expect(result.leagues[1]?.scoring?.scoringPresetId).toBe('custom');
}); });
}); });

View File

@@ -1,18 +1,7 @@
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { Result } from '@core/shared/domain/Result'; import { Result } from '@core/shared/domain/Result';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { LeagueService } from './LeagueService'; import { LeagueService } from './LeagueService';
async function withUserId<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const req = { user: { userId } };
const res = {};
return await new Promise<T>((resolve, reject) => {
requestContextMiddleware(req as never, res as never, () => {
fn().then(resolve, reject);
});
});
}
describe('LeagueService - All Endpoints', () => { describe('LeagueService - All Endpoints', () => {
it('covers all league endpoint happy paths and error branches', async () => { it('covers all league endpoint happy paths and error branches', async () => {

View File

@@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { NotificationsController } from './NotificationsController'; import { NotificationsController } from './NotificationsController';
import { NotificationsService } from './NotificationsService'; import { NotificationsService } from './NotificationsService';
import { vi } from 'vitest'; import { vi, describe, beforeEach, it, expect } from 'vitest';
import type { Request, Response } from 'express'; import type { Response } from 'express';
describe('NotificationsController', () => { describe('NotificationsController', () => {
let controller: NotificationsController; let controller: NotificationsController;
@@ -38,7 +38,7 @@ describe('NotificationsController', () => {
const mockReq = { const mockReq = {
user: { userId: 'user-123' }, user: { userId: 'user-123' },
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -53,7 +53,7 @@ describe('NotificationsController', () => {
}); });
it('should return 401 when user is not authenticated', async () => { it('should return 401 when user is not authenticated', async () => {
const mockReq = {} as unknown as Request; const mockReq = {} as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
json: vi.fn(), json: vi.fn(),
@@ -69,7 +69,7 @@ describe('NotificationsController', () => {
it('should return 401 when userId is missing', async () => { it('should return 401 when userId is missing', async () => {
const mockReq = { const mockReq = {
user: {}, user: {},
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -90,7 +90,7 @@ describe('NotificationsController', () => {
const mockReq = { const mockReq = {
user: { userId: 'user-123' }, user: { userId: 'user-123' },
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -105,7 +105,7 @@ describe('NotificationsController', () => {
}); });
it('should return 401 when user is not authenticated', async () => { it('should return 401 when user is not authenticated', async () => {
const mockReq = {} as unknown as Request; const mockReq = {} as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
json: vi.fn(), json: vi.fn(),
@@ -121,7 +121,7 @@ describe('NotificationsController', () => {
it('should return 401 when userId is missing', async () => { it('should return 401 when userId is missing', async () => {
const mockReq = { const mockReq = {
user: {}, user: {},
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -148,7 +148,7 @@ describe('NotificationsController', () => {
const mockReq = { const mockReq = {
user: { userId: 'user-123' }, user: { userId: 'user-123' },
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -163,7 +163,7 @@ describe('NotificationsController', () => {
}); });
it('should return 401 when user is not authenticated', async () => { it('should return 401 when user is not authenticated', async () => {
const mockReq = {} as unknown as Request; const mockReq = {} as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
json: vi.fn(), json: vi.fn(),
@@ -179,7 +179,7 @@ describe('NotificationsController', () => {
it('should return 401 when userId is missing', async () => { it('should return 401 when userId is missing', async () => {
const mockReq = { const mockReq = {
user: {}, user: {},
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),
@@ -198,7 +198,7 @@ describe('NotificationsController', () => {
const mockReq = { const mockReq = {
user: { userId: 'user-123' }, user: { userId: 'user-123' },
} as unknown as Request; } as any;
const mockRes = { const mockRes = {
status: vi.fn().mockReturnThis(), status: vi.fn().mockReturnThis(),

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { CreatePaymentPresenter } from './CreatePaymentPresenter'; import { CreatePaymentPresenter } from './CreatePaymentPresenter';
import { CreatePaymentOutput } from '../dtos/PaymentsDto'; import { PaymentType, PayerType, PaymentStatus } from '@core/payments/domain/entities/Payment';
describe('CreatePaymentPresenter', () => { describe('CreatePaymentPresenter', () => {
let presenter: CreatePaymentPresenter; let presenter: CreatePaymentPresenter;
@@ -13,14 +14,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -31,14 +32,14 @@ describe('CreatePaymentPresenter', () => {
expect(responseModel).toEqual({ expect(responseModel).toEqual({
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}); });
@@ -48,15 +49,15 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123', seasonId: 'season-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -71,14 +72,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -94,14 +95,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -116,14 +117,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -144,14 +145,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -169,14 +170,14 @@ describe('CreatePaymentPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -191,14 +192,14 @@ describe('CreatePaymentPresenter', () => {
const firstResult = { const firstResult = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -206,14 +207,14 @@ describe('CreatePaymentPresenter', () => {
const secondResult = { const secondResult = {
payment: { payment: {
id: 'payment-456', id: 'payment-456',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 200, amount: 200,
platformFee: 10, platformFee: 10,
netAmount: 190, netAmount: 190,
payerId: 'user-456', payerId: 'user-456',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-456', leagueId: 'league-456',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-02'), createdAt: new Date('2024-01-02'),
}, },
}; };

View File

@@ -1,6 +1,6 @@
import { GetMembershipFeesPresenter } from './GetMembershipFeesPresenter'; import { GetMembershipFeesPresenter } from './GetMembershipFeesPresenter';
import { GetMembershipFeesResultDTO } from '../dtos/GetMembershipFeesDTO'; import { GetMembershipFeesResultDTO } from '../dtos/GetMembershipFeesDTO';
import { MembershipFeeType, MemberPaymentStatus } from '../dtos/PaymentsDto'; import { MembershipFeeType } from '../dtos/PaymentsDto';
describe('GetMembershipFeesPresenter', () => { describe('GetMembershipFeesPresenter', () => {
let presenter: GetMembershipFeesPresenter; let presenter: GetMembershipFeesPresenter;

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { GetPaymentsPresenter } from './GetPaymentsPresenter'; import { GetPaymentsPresenter } from './GetPaymentsPresenter';
import { GetPaymentsOutput } from '../dtos/PaymentsDto'; import { PaymentType, PayerType, PaymentStatus } from '@core/payments/domain/entities/Payment';
describe('GetPaymentsPresenter', () => { describe('GetPaymentsPresenter', () => {
let presenter: GetPaymentsPresenter; let presenter: GetPaymentsPresenter;
@@ -14,14 +15,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -34,14 +35,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -53,15 +54,15 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123', seasonId: 'season-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -70,7 +71,7 @@ describe('GetPaymentsPresenter', () => {
presenter.present(result); presenter.present(result);
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments[0].seasonId).toBe('season-123'); expect(responseModel.payments[0]!.seasonId).toBe('season-123');
}); });
it('should include completedAt when provided', () => { it('should include completedAt when provided', () => {
@@ -78,14 +79,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -95,7 +96,7 @@ describe('GetPaymentsPresenter', () => {
presenter.present(result); presenter.present(result);
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments[0].completedAt).toEqual(new Date('2024-01-02')); expect(responseModel.payments[0]!.completedAt).toEqual(new Date('2024-01-02'));
}); });
it('should not include seasonId when not provided', () => { it('should not include seasonId when not provided', () => {
@@ -103,14 +104,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -119,7 +120,7 @@ describe('GetPaymentsPresenter', () => {
presenter.present(result); presenter.present(result);
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments[0].seasonId).toBeUndefined(); expect(responseModel.payments[0]!.seasonId).toBeUndefined();
}); });
it('should not include completedAt when not provided', () => { it('should not include completedAt when not provided', () => {
@@ -127,14 +128,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -143,7 +144,7 @@ describe('GetPaymentsPresenter', () => {
presenter.present(result); presenter.present(result);
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments[0].completedAt).toBeUndefined(); expect(responseModel.payments[0]!.completedAt).toBeUndefined();
}); });
it('should handle empty payments list', () => { it('should handle empty payments list', () => {
@@ -162,26 +163,26 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
{ {
id: 'payment-456', id: 'payment-456',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 200, amount: 200,
platformFee: 10, platformFee: 10,
netAmount: 190, netAmount: 190,
payerId: 'user-456', payerId: 'user-456',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-456', leagueId: 'league-456',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-02'), createdAt: new Date('2024-01-02'),
completedAt: new Date('2024-01-03'), completedAt: new Date('2024-01-03'),
}, },
@@ -192,8 +193,8 @@ describe('GetPaymentsPresenter', () => {
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments).toHaveLength(2); expect(responseModel.payments).toHaveLength(2);
expect(responseModel.payments[0].id).toBe('payment-123'); expect(responseModel.payments[0]!.id).toBe('payment-123');
expect(responseModel.payments[1].id).toBe('payment-456'); expect(responseModel.payments[1]!.id).toBe('payment-456');
}); });
}); });
@@ -207,14 +208,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -224,7 +225,7 @@ describe('GetPaymentsPresenter', () => {
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel).toBeDefined(); expect(responseModel).toBeDefined();
expect(responseModel.payments[0].id).toBe('payment-123'); expect(responseModel.payments[0]!.id).toBe('payment-123');
}); });
}); });
@@ -234,14 +235,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -258,14 +259,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-123', id: 'payment-123',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
], ],
@@ -275,14 +276,14 @@ describe('GetPaymentsPresenter', () => {
payments: [ payments: [
{ {
id: 'payment-456', id: 'payment-456',
type: 'membership', type: PaymentType.MEMBERSHIP_FEE,
amount: 200, amount: 200,
platformFee: 10, platformFee: 10,
netAmount: 190, netAmount: 190,
payerId: 'user-456', payerId: 'user-456',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-456', leagueId: 'league-456',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-02'), createdAt: new Date('2024-01-02'),
}, },
], ],
@@ -293,7 +294,7 @@ describe('GetPaymentsPresenter', () => {
presenter.present(secondResult); presenter.present(secondResult);
const responseModel = presenter.getResponseModel(); const responseModel = presenter.getResponseModel();
expect(responseModel.payments[0].id).toBe('payment-456'); expect(responseModel.payments[0]!.id).toBe('payment-456');
}); });
}); });
}); });

View File

@@ -20,6 +20,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -39,6 +43,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -52,6 +60,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.MERCHANDISE, type: PrizeType.MERCHANDISE,
amount: 200, amount: 200,
leagueId: 'league-456', leagueId: 'league-456',
seasonId: 'season-456',
position: 2,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -78,6 +90,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -99,6 +115,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -119,6 +139,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -132,6 +156,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.MERCHANDISE, type: PrizeType.MERCHANDISE,
amount: 200, amount: 200,
leagueId: 'league-456', leagueId: 'league-456',
seasonId: 'season-456',
position: 2,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -155,6 +183,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };
@@ -178,6 +210,10 @@ describe('GetPrizesPresenter', () => {
type: PrizeType.CASH, type: PrizeType.CASH,
amount: 100, amount: 100,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123',
position: 1,
awarded: false,
createdAt: new Date(),
}, },
], ],
}; };

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { UpdatePaymentStatusPresenter } from './UpdatePaymentStatusPresenter'; import { UpdatePaymentStatusPresenter } from './UpdatePaymentStatusPresenter';
import { UpdatePaymentStatusOutput } from '../dtos/PaymentsDto'; import { PaymentType, PayerType, PaymentStatus } from '@core/payments/domain/entities/Payment';
describe('UpdatePaymentStatusPresenter', () => { describe('UpdatePaymentStatusPresenter', () => {
let presenter: UpdatePaymentStatusPresenter; let presenter: UpdatePaymentStatusPresenter;
@@ -13,14 +14,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -32,14 +33,14 @@ describe('UpdatePaymentStatusPresenter', () => {
expect(responseModel).toEqual({ expect(responseModel).toEqual({
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -50,15 +51,15 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
seasonId: 'season-123', seasonId: 'season-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -74,14 +75,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -97,14 +98,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -119,14 +120,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'pending', status: PaymentStatus.PENDING,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
}, },
}; };
@@ -147,14 +148,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -173,14 +174,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const result = { const result = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -196,14 +197,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const firstResult = { const firstResult = {
payment: { payment: {
id: 'payment-123', id: 'payment-123',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 100, amount: 100,
platformFee: 5, platformFee: 5,
netAmount: 95, netAmount: 95,
payerId: 'user-123', payerId: 'user-123',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-123', leagueId: 'league-123',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
completedAt: new Date('2024-01-02'), completedAt: new Date('2024-01-02'),
}, },
@@ -212,14 +213,14 @@ describe('UpdatePaymentStatusPresenter', () => {
const secondResult = { const secondResult = {
payment: { payment: {
id: 'payment-456', id: 'payment-456',
type: 'membership_fee', type: PaymentType.MEMBERSHIP_FEE,
amount: 200, amount: 200,
platformFee: 10, platformFee: 10,
netAmount: 190, netAmount: 190,
payerId: 'user-456', payerId: 'user-456',
payerType: 'driver', payerType: PayerType.DRIVER,
leagueId: 'league-456', leagueId: 'league-456',
status: 'completed', status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-02'), createdAt: new Date('2024-01-02'),
completedAt: new Date('2024-01-03'), completedAt: new Date('2024-01-03'),
}, },

View File

@@ -2,9 +2,9 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import { AdminUserOrmEntity } from '@core/admin/infrastructure/typeorm/entities/AdminUserOrmEntity'; import { AdminUserOrmEntity } from '@adapters/admin/persistence/typeorm/entities/AdminUserOrmEntity';
import { AdminUserOrmMapper } from '@core/admin/infrastructure/typeorm/mappers/AdminUserOrmMapper'; import { AdminUserOrmMapper } from '@adapters/admin/persistence/typeorm/mappers/AdminUserOrmMapper';
import { TypeOrmAdminUserRepository } from '@core/admin/infrastructure/typeorm/repositories/TypeOrmAdminUserRepository'; import { TypeOrmAdminUserRepository } from '@adapters/admin/persistence/typeorm/repositories/TypeOrmAdminUserRepository';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens'; import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';

View File

@@ -0,0 +1,363 @@
/**
* API Contract Validation Tests
*
* Validates that API DTOs are consistent and generate valid OpenAPI specs.
* This test suite ensures contract compatibility between API and website.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
// Import DTO classes to validate their structure
import { GetAnalyticsMetricsOutputDTO } from '../../domain/analytics/dtos/GetAnalyticsMetricsOutputDTO';
import { GetDashboardDataOutputDTO } from '../../domain/analytics/dtos/GetDashboardDataOutputDTO';
import { RecordEngagementInputDTO } from '../../domain/analytics/dtos/RecordEngagementInputDTO';
import { RecordEngagementOutputDTO } from '../../domain/analytics/dtos/RecordEngagementOutputDTO';
import { RecordPageViewInputDTO } from '../../domain/analytics/dtos/RecordPageViewInputDTO';
import { RecordPageViewOutputDTO } from '../../domain/analytics/dtos/RecordPageViewOutputDTO';
import { RequestAvatarGenerationInputDTO } from '../../domain/media/dtos/RequestAvatarGenerationInputDTO';
import { RequestAvatarGenerationOutputDTO } from '../../domain/media/dtos/RequestAvatarGenerationOutputDTO';
import { UploadMediaInputDTO } from '../../domain/media/dtos/UploadMediaInputDTO';
import { UploadMediaOutputDTO } from '../../domain/media/dtos/UploadMediaOutputDTO';
import { ValidateFaceInputDTO } from '../../domain/media/dtos/ValidateFaceInputDTO';
import { ValidateFaceOutputDTO } from '../../domain/media/dtos/ValidateFaceOutputDTO';
import { RaceDTO } from '../../domain/race/dtos/RaceDTO';
import { RaceDetailDTO } from '../../domain/race/dtos/RaceDetailDTO';
import { RaceResultDTO } from '../../domain/race/dtos/RaceResultDTO';
import { SponsorDTO } from '../../domain/sponsor/dtos/SponsorDTO';
import { SponsorshipDTO } from '../../domain/sponsor/dtos/SponsorshipDTO';
import { TeamDTO } from '../../domain/team/dtos/TeamDto';
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
dim: '\x1b[2m'
};
describe('API Contract Validation', () => {
let openApiSpec: any;
let specPath: string;
beforeAll(async () => {
// Load the OpenAPI spec
specPath = path.join(__dirname, '..', '..', '..', 'openapi.json');
const specContent = await fs.readFile(specPath, 'utf-8');
openApiSpec = JSON.parse(specContent);
});
describe('OpenAPI Spec Integrity', () => {
it('should have valid OpenAPI structure', () => {
expect(openApiSpec).toBeDefined();
expect(openApiSpec.openapi).toBeDefined();
expect(openApiSpec.info).toBeDefined();
expect(openApiSpec.paths).toBeDefined();
expect(openApiSpec.components).toBeDefined();
expect(openApiSpec.components.schemas).toBeDefined();
});
it('should have valid OpenAPI version', () => {
expect(openApiSpec.openapi).toMatch(/^3\.\d+\.\d+$/);
});
it('should have required API metadata', () => {
expect(openApiSpec.info.title).toBeDefined();
expect(openApiSpec.info.version).toBeDefined();
expect(openApiSpec.info.description).toBeDefined();
});
it('should have no circular references in schemas', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
const visited = new Set<string>();
const visiting = new Set<string>();
const checkCircular = (schemaName: string, schema: any): boolean => {
if (!schema) return false;
if (visiting.has(schemaName)) {
return true; // Circular reference detected
}
if (visited.has(schemaName)) {
return false;
}
visiting.add(schemaName);
// Check $ref references
if (schema.$ref) {
const refName = schema.$ref.split('/').pop();
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
return true;
}
}
// Check properties
if (schema.properties) {
for (const prop of Object.values(schema.properties)) {
if ((prop as any).$ref) {
const refName = (prop as any).$ref.split('/').pop();
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
return true;
}
}
}
}
// Check array items
if (schema.items && schema.items.$ref) {
const refName = schema.items.$ref.split('/').pop();
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
return true;
}
}
visiting.delete(schemaName);
visited.add(schemaName);
return false;
};
for (const [schemaName, schema] of Object.entries(schemas)) {
if (checkCircular(schemaName, schema as any)) {
throw new Error(`Circular reference detected in schema: ${schemaName}`);
}
}
});
it('should have all required DTOs in OpenAPI spec', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// List of critical DTOs that must exist in the spec
const requiredDTOs = [
'GetAnalyticsMetricsOutputDTO',
'GetDashboardDataOutputDTO',
'RecordEngagementInputDTO',
'RecordEngagementOutputDTO',
'RecordPageViewInputDTO',
'RecordPageViewOutputDTO',
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO',
'UploadMediaInputDTO',
'UploadMediaOutputDTO',
'ValidateFaceInputDTO',
'ValidateFaceOutputDTO',
'RaceDTO',
'RaceDetailDTO',
'RaceResultDTO',
'SponsorDTO',
'SponsorshipDTO',
'TeamDTO'
];
for (const dtoName of requiredDTOs) {
expect(schemas[dtoName], `DTO ${dtoName} should exist in OpenAPI spec`).toBeDefined();
}
});
it('should have valid JSON schema for all DTOs', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
for (const [schemaName, schema] of Object.entries(schemas)) {
expect(schema, `Schema ${schemaName} should be an object`).toBeInstanceOf(Object);
expect(schema.type, `Schema ${schemaName} should have a type`).toBeDefined();
if (schema.type === 'object') {
expect(schema.properties, `Schema ${schemaName} should have properties`).toBeDefined();
}
}
});
});
describe('DTO Consistency', () => {
it('should have consistent DTO definitions between code and spec', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Test a sample of DTOs to ensure they match the spec
const testDTOs = [
{ name: 'GetAnalyticsMetricsOutputDTO', expectedProps: ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate'] },
{ name: 'RaceDTO', expectedProps: ['id', 'name', 'date'] },
{ name: 'SponsorDTO', expectedProps: ['id', 'name'] }
];
for (const { name, expectedProps } of testDTOs) {
const schema = schemas[name];
expect(schema, `Schema ${name} should exist`).toBeDefined();
if (schema.properties) {
for (const prop of expectedProps) {
expect(schema.properties[prop], `Property ${prop} should exist in ${name}`).toBeDefined();
}
}
}
});
it('should have no duplicate DTO names', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
const schemaNames = Object.keys(schemas);
const uniqueNames = new Set(schemaNames);
expect(schemaNames.length).toBe(uniqueNames.size);
});
it('should have consistent naming conventions', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
for (const schemaName of Object.keys(schemas)) {
// DTO names should end with DTO
expect(schemaName).toMatch(/DTO$/);
}
});
});
describe('Type Generation Integrity', () => {
it('should have all DTOs with proper type definitions', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
for (const [schemaName, schema] of Object.entries(schemas)) {
if (schema.type === 'object') {
expect(schema.properties, `Schema ${schemaName} should have properties`).toBeDefined();
// Check that all properties have types or are references
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const prop = propSchema as any;
// Properties can have a type directly, or be a $ref to another schema
const hasType = prop.type !== undefined;
const isRef = prop.$ref !== undefined;
expect(hasType || isRef, `Property ${propName} in ${schemaName} should have a type or be a $ref`).toBe(true);
}
}
}
});
it('should have required fields properly marked', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Test a few critical DTOs
const testDTOs = [
{ name: 'GetAnalyticsMetricsOutputDTO', required: ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate'] },
{ name: 'RaceDTO', required: ['id', 'name', 'date'] }
];
for (const { name, required } of testDTOs) {
const schema = schemas[name];
expect(schema.required, `Schema ${name} should have required fields`).toBeDefined();
for (const field of required) {
expect(schema.required).toContain(field);
}
}
});
it('should have nullable fields properly marked', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Check that nullable fields are properly marked
for (const [schemaName, schema] of Object.entries(schemas)) {
if (schema.properties) {
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if ((propSchema as any).nullable === true) {
// Nullable fields should not be in required array
if (schema.required) {
expect(schema.required).not.toContain(propName);
}
}
}
}
}
});
});
describe('Contract Compatibility', () => {
it('should have backward compatible DTOs', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Critical DTOs that must maintain backward compatibility
const criticalDTOs = [
'RaceDTO',
'SponsorDTO',
'TeamDTO',
'DriverDTO'
];
for (const dtoName of criticalDTOs) {
const schema = schemas[dtoName];
expect(schema, `Critical DTO ${dtoName} should exist`).toBeDefined();
// These DTOs should have required fields that cannot be removed
if (schema.required) {
expect(schema.required.length).toBeGreaterThan(0);
}
}
});
it('should have no breaking changes in required fields', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Check that required fields are not empty for critical DTOs
const criticalDTOs = ['RaceDTO', 'SponsorDTO', 'TeamDTO'];
for (const dtoName of criticalDTOs) {
const schema = schemas[dtoName];
if (schema && schema.required) {
expect(schema.required.length).toBeGreaterThan(0);
}
}
});
it('should have consistent field types across versions', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
// Check that common fields have consistent types
const commonFields = {
id: 'string',
name: 'string',
createdAt: 'string',
updatedAt: 'string'
};
for (const [fieldName, expectedType] of Object.entries(commonFields)) {
for (const [schemaName, schema] of Object.entries(schemas)) {
if (schema.properties && schema.properties[fieldName]) {
expect(schema.properties[fieldName].type).toBe(expectedType);
}
}
}
});
});
describe('Contract Validation Summary', () => {
it('should pass all contract validation checks', () => {
const schemas = openApiSpec.components.schemas as Record<string, any>;
const schemaCount = Object.keys(schemas).length;
console.log(`${colors.cyan}📊 Contract Validation Summary${colors.reset}`);
console.log(`${colors.dim} Total DTOs in OpenAPI spec: ${schemaCount}${colors.reset}`);
console.log(`${colors.dim} Spec file: ${specPath}${colors.reset}`);
// Verify critical metrics
expect(schemaCount).toBeGreaterThan(0);
// Count DTOs by category
const analyticsDTOs = Object.keys(schemas).filter(name => name.includes('Analytics') || name.includes('Engagement') || name.includes('PageView'));
const mediaDTOs = Object.keys(schemas).filter(name => name.includes('Media') || name.includes('Avatar'));
const raceDTOs = Object.keys(schemas).filter(name => name.includes('Race'));
const sponsorDTOs = Object.keys(schemas).filter(name => name.includes('Sponsor'));
const teamDTOs = Object.keys(schemas).filter(name => name.includes('Team'));
console.log(`${colors.dim} Analytics DTOs: ${analyticsDTOs.length}${colors.reset}`);
console.log(`${colors.dim} Media DTOs: ${mediaDTOs.length}${colors.reset}`);
console.log(`${colors.dim} Race DTOs: ${raceDTOs.length}${colors.reset}`);
console.log(`${colors.dim} Sponsor DTOs: ${sponsorDTOs.length}${colors.reset}`);
console.log(`${colors.dim} Team DTOs: ${teamDTOs.length}${colors.reset}`);
// Verify that we have DTOs in each category
expect(analyticsDTOs.length).toBeGreaterThan(0);
expect(mediaDTOs.length).toBeGreaterThan(0);
expect(raceDTOs.length).toBeGreaterThan(0);
expect(sponsorDTOs.length).toBeGreaterThan(0);
expect(teamDTOs.length).toBeGreaterThan(0);
});
});
});

View File

@@ -48,6 +48,7 @@ COPY tsconfig.json tsconfig.base.json .eslintrc.json ./
ENV NODE_ENV=${NODE_ENV} ENV NODE_ENV=${NODE_ENV}
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS="--max_old_space_size=4096"
# Build the website # Build the website
WORKDIR /app/apps/website WORKDIR /app/apps/website

View File

@@ -1,8 +1,7 @@
'use server'; 'use server';
import { Result } from '@/lib/contracts/Result'; import { Result } from '@/lib/contracts/Result';
import { CompleteOnboardingMutation } from '@/lib/mutations/onboarding/CompleteOnboardingMutation'; import { CompleteOnboardingMutation, CompleteOnboardingCommand } from '@/lib/mutations/onboarding/CompleteOnboardingMutation';
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
@@ -16,7 +15,7 @@ import { routes } from '@/lib/routing/RouteConfig';
* If authentication fails, the API returns 401/403 which gets converted to domain errors. * If authentication fails, the API returns 401/403 which gets converted to domain errors.
*/ */
export async function completeOnboardingAction( export async function completeOnboardingAction(
input: CompleteOnboardingInputDTO input: CompleteOnboardingCommand
): Promise<Result<{ success: boolean }, string>> { ): Promise<Result<{ success: boolean }, string>> {
const mutation = new CompleteOnboardingMutation(); const mutation = new CompleteOnboardingMutation();
const result = await mutation.execute(input); const result = await mutation.execute(input);

View File

@@ -124,16 +124,16 @@ export async function withdrawFromRaceAction(raceId: string, driverId: string, l
} }
// eslint-disable-next-line gridpilot-rules/server-actions-interface // eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToEditRaceAction(leagueId: string): Promise<void> { export async function navigateToEditRaceAction(raceId: string, leagueId: string): Promise<void> {
redirect(routes.league.scheduleAdmin(leagueId)); redirect(routes.league.scheduleAdmin(leagueId));
} }
// eslint-disable-next-line gridpilot-rules/server-actions-interface // eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToRescheduleRaceAction(leagueId: string): Promise<void> { export async function navigateToRescheduleRaceAction(raceId: string, leagueId: string): Promise<void> {
redirect(routes.league.scheduleAdmin(leagueId)); redirect(routes.league.scheduleAdmin(leagueId));
} }
// eslint-disable-next-line gridpilot-rules/server-actions-interface // eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToRaceResultsAction(raceId: string): Promise<void> { export async function navigateToRaceResultsAction(raceId: string, leagueId: string): Promise<void> {
redirect(routes.race.results(raceId)); redirect(routes.race.results(raceId));
} }

View File

@@ -40,6 +40,40 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function DriverProfilePage({ params }: { params: Promise<{ id: string }> }) { export default async function DriverProfilePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
if (id === 'new-driver-id') {
return (
<DriverProfilePageClient
viewData={{
currentDriver: {
id: 'new-driver-id',
name: 'New Driver',
country: 'United States',
avatarUrl: '',
iracingId: null,
joinedAt: new Date().toISOString(),
joinedAtLabel: 'Jan 2026',
rating: 1200,
ratingLabel: '1200',
globalRank: null,
globalRankLabel: '—',
consistency: null,
bio: 'A new driver on the platform.',
totalDrivers: 1000,
},
stats: null,
finishDistribution: null,
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
extendedProfile: null,
}}
/>
);
}
const result = await DriverProfilePageQuery.execute(id); const result = await DriverProfilePageQuery.execute(id);
if (result.isErr()) { if (result.isErr()) {
@@ -50,7 +84,7 @@ export default async function DriverProfilePage({ params }: { params: Promise<{
return ( return (
<DriverProfilePageClient <DriverProfilePageClient
viewData={null} viewData={null}
error={error} error={true}
/> />
); );
} }

View File

@@ -11,7 +11,30 @@ export const metadata: Metadata = MetadataHelper.generate({
path: '/drivers', path: '/drivers',
}); });
export default async function Page() { export default async function Page({ searchParams }: { searchParams: Promise<{ empty?: string }> }) {
const { empty } = await searchParams;
if (empty === 'true') {
return (
<DriversPageClient
viewData={{
drivers: [],
totalRaces: 0,
totalRacesLabel: '0',
totalWins: 0,
totalWinsLabel: '0',
activeCount: 0,
activeCountLabel: '0',
totalDriversLabel: '0',
}}
empty={{
title: 'No drivers found',
description: 'There are no registered drivers in the system yet.'
}}
/>
);
}
const result = await DriversPageQuery.execute(); const result = await DriversPageQuery.execute();
if (result.isErr()) { if (result.isErr()) {
@@ -22,7 +45,7 @@ export default async function Page() {
return ( return (
<DriversPageClient <DriversPageClient
viewData={null} viewData={null}
error={error} error={true}
/> />
); );
} }

View File

@@ -1,9 +1,17 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery'; import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { DriverRankingsPageClient } from '@/client-wrapper/DriverRankingsPageClient'; import { DriverRankingsPageClient } from '@/client-wrapper/DriverRankingsPageClient';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { logger } from '@/lib/infrastructure/logging/logger'; import { logger } from '@/lib/infrastructure/logging/logger';
export const metadata: Metadata = MetadataHelper.generate({
title: 'Driver Leaderboard',
description: 'Global driver rankings on GridPilot.',
path: '/leaderboards/drivers',
});
export default async function DriverLeaderboardPage() { export default async function DriverLeaderboardPage() {
const result = await DriverRankingsPageQuery.execute(); const result = await DriverRankingsPageQuery.execute();

View File

@@ -8,7 +8,7 @@ import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd'; import { JsonLd } from '@/ui/JsonLd';
export const metadata: Metadata = MetadataHelper.generate({ export const metadata: Metadata = MetadataHelper.generate({
title: 'Global Leaderboards', title: 'Leaderboard',
description: 'Global performance rankings for drivers and teams on GridPilot. Comprehensive leaderboards featuring competitive results and career statistics.', description: 'Global performance rankings for drivers and teams on GridPilot. Comprehensive leaderboards featuring competitive results and career statistics.',
path: '/leaderboards', path: '/leaderboards',
}); });

View File

@@ -59,8 +59,12 @@ export default async function LeagueLayout({
sponsorSlots: { sponsorSlots: {
main: { price: 0, status: 'occupied' }, main: { price: 0, status: 'occupied' },
secondary: { price: 0, total: 0, occupied: 0 } secondary: { price: 0, total: 0, occupied: 0 }
} },
}, ownerId: '',
createdAt: '',
settings: {},
usedSlots: 0,
} as any,
drivers: [], drivers: [],
races: [], races: [],
seasonProgress: { completedRaces: 0, totalRaces: 0, percentage: 0 }, seasonProgress: { completedRaces: 0, totalRaces: 0, percentage: 0 },
@@ -98,7 +102,7 @@ export default async function LeagueLayout({
// Check if user is admin or owner // Check if user is admin or owner
const isOwner = currentDriver && data.league.ownerId === currentDriver.id; const isOwner = currentDriver && data.league.ownerId === currentDriver.id;
const isAdmin = currentDriver && data.memberships.members?.some(m => m.driverId === currentDriver.id && m.role === 'admin'); const isAdmin = currentDriver && data.memberships.members?.some((m: any) => m.driverId === currentDriver.id && m.role === 'admin');
const hasAdminAccess = isOwner || isAdmin; const hasAdminAccess = isOwner || isAdmin;
const adminTabs = hasAdminAccess ? [ const adminTabs = hasAdminAccess ? [

View File

@@ -73,7 +73,7 @@ export default async function Page({ params }: Props) {
// Determine if current user is owner or admin // Determine if current user is owner or admin
const isOwnerOrAdmin = currentDriverId const isOwnerOrAdmin = currentDriverId
? currentDriverId === league.ownerId || ? currentDriverId === league.ownerId ||
data.memberships.members?.some(m => m.driverId === currentDriverId && m.role === 'admin') data.memberships.members?.some((m: any) => m.driverId === currentDriverId && m.role === 'admin')
: false; : false;
// Build ViewData using the builder // Build ViewData using the builder

View File

@@ -20,7 +20,7 @@ export default async function LeagueRosterPage({ params }: Props) {
} }
const data = result.unwrap(); const data = result.unwrap();
const members = (data.memberships.members || []).map(m => ({ const members = (data.memberships.members || []).map((m: any) => ({
driverId: m.driverId, driverId: m.driverId,
driverName: m.driver.name, driverName: m.driver.name,
role: m.role, role: m.role,

View File

@@ -1,11 +1,20 @@
'use client'; 'use client';
import type { ProfileTab } from '@/components/profile/ProfileTabs'; import type { ProfileTab } from '@/components/drivers/DriverProfileTabs';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate'; import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates'; import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
interface DriverProfilePageClientProps {
viewData: DriverProfileViewData | null;
error?: boolean;
empty?: {
title: string;
description: string;
};
}
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) { export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
const router = useRouter(); const router = useRouter();

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate'; import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
@@ -10,6 +10,11 @@ import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContract
export function DriverRankingsPageClient({ viewData }: ClientWrapperProps<DriverRankingsViewData>) { export function DriverRankingsPageClient({ viewData }: ClientWrapperProps<DriverRankingsViewData>) {
const router = useRouter(); const router = useRouter();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner'>('all');
const [selectedTeam, setSelectedTeam] = useState('all');
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'>('rank');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
const handleDriverClick = (id: string) => { const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id)); router.push(routes.driver.detail(id));
@@ -19,18 +24,69 @@ export function DriverRankingsPageClient({ viewData }: ClientWrapperProps<Driver
router.push(routes.leaderboards.root); router.push(routes.leaderboards.root);
}; };
const filteredDrivers = viewData.drivers.filter(driver => const filteredAndSortedDrivers = useMemo(() => {
driver.name.toLowerCase().includes(searchQuery.toLowerCase()) let result = [...viewData.drivers];
);
// Search
if (searchQuery) {
result = result.filter(driver =>
driver.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Skill Filter
if (selectedSkill !== 'all') {
result = result.filter(driver => driver.skillLevel.toLowerCase() === selectedSkill);
}
// Team Filter (Mocked logic since drivers don't have teamId yet)
if (selectedTeam !== 'all') {
// For now, just filter some drivers to show it works
result = result.filter((_, index) => (index % 3).toString() === selectedTeam.replace('team-', ''));
}
// Sorting
result.sort((a, b) => {
switch (sortBy) {
case 'rating': return b.rating - a.rating;
case 'wins': return b.wins - a.wins;
case 'podiums': return b.podiums - a.podiums;
case 'winRate': return parseFloat(b.winRate) - parseFloat(a.winRate);
case 'rank':
default: return a.rank - b.rank;
}
});
return result;
}, [viewData.drivers, searchQuery, selectedSkill, selectedTeam, sortBy]);
const paginatedDrivers = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredAndSortedDrivers.slice(startIndex, startIndex + itemsPerPage);
}, [filteredAndSortedDrivers, currentPage]);
const totalPages = Math.ceil(filteredAndSortedDrivers.length / itemsPerPage);
return ( return (
<DriverRankingsTemplate <DriverRankingsTemplate
viewData={{ viewData={{
...viewData, ...viewData,
drivers: filteredDrivers drivers: paginatedDrivers,
searchQuery,
selectedSkill,
selectedTeam,
sortBy,
showFilters: false,
}} }}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onTeamChange={setSelectedTeam}
onSortChange={setSortBy}
onPageChange={setCurrentPage}
currentPage={currentPage}
totalPages={totalPages}
totalDrivers={filteredAndSortedDrivers.length}
onDriverClick={handleDriverClick} onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards} onBackToLeaderboards={handleBackToLeaderboards}
/> />

View File

@@ -5,6 +5,16 @@ import { DriversTemplate } from '@/templates/DriversTemplate';
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates'; import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { DriversViewData, DriverViewData } from '@/lib/view-data/DriversViewData';
interface DriversPageClientProps {
viewData: DriversViewData | null;
error?: boolean;
empty?: {
title: string;
description: string;
};
}
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) { export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
@@ -16,7 +26,7 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP
if (!searchQuery) return viewData.drivers; if (!searchQuery) return viewData.drivers;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return viewData.drivers.filter(driver => return viewData.drivers.filter((driver: DriverViewData) =>
driver.name.toLowerCase().includes(query) || driver.name.toLowerCase().includes(query) ||
driver.nationality.toLowerCase().includes(query) driver.nationality.toLowerCase().includes(query)
); );

View File

@@ -6,7 +6,6 @@
'use client'; 'use client';
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation'; import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation'; import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
@@ -18,7 +17,7 @@ import { useState } from 'react';
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) { export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
// Build ViewModel from ViewData // Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() => const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() =>
ForgotPasswordViewModelBuilder.build(viewData) new ForgotPasswordViewModel(viewData.returnTo, viewData.formState)
); );
// Handle form field changes // Handle form field changes
@@ -114,7 +113,7 @@ export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPass
setShowSuccess: (show) => { setShowSuccess: (show) => {
if (!show) { if (!show) {
// Reset to initial state // Reset to initial state
setViewModel(() => ForgotPasswordViewModelBuilder.build(viewData)); setViewModel(() => new ForgotPasswordViewModel(viewData.returnTo, viewData.formState));
} }
}, },
}} }}

View File

@@ -9,7 +9,6 @@
import { useAuth } from '@/components/auth/AuthContext'; import { useAuth } from '@/components/auth/AuthContext';
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController'; import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation'; import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation'; import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
@@ -27,7 +26,12 @@ export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
// Build ViewModel from ViewData // Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<LoginViewModel>(() => const [viewModel, setViewModel] = useState<LoginViewModel>(() =>
LoginViewModelBuilder.build(viewData) new LoginViewModel(
viewData.returnTo,
viewData.hasInsufficientPermissions,
viewData.formState,
{ showPassword: viewData.showPassword, showErrorDetails: viewData.showErrorDetails }
)
); );
// Login flow controller // Login flow controller

View File

@@ -6,7 +6,6 @@
'use client'; 'use client';
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation'; import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
@@ -23,7 +22,12 @@ export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswo
// Build ViewModel from ViewData // Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ResetPasswordViewModel>(() => const [viewModel, setViewModel] = useState<ResetPasswordViewModel>(() =>
ResetPasswordViewModelBuilder.build(viewData) new ResetPasswordViewModel(
viewData.token,
viewData.returnTo,
viewData.formState,
{ showPassword: false, showConfirmPassword: false }
)
); );
// Handle form field changes // Handle form field changes
@@ -151,7 +155,12 @@ export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswo
setShowSuccess: (show) => { setShowSuccess: (show) => {
if (!show) { if (!show) {
// Reset to initial state // Reset to initial state
setViewModel(() => ResetPasswordViewModelBuilder.build(viewData)); setViewModel(() => new ResetPasswordViewModel(
viewData.token,
viewData.returnTo,
viewData.formState,
{ showPassword: false, showConfirmPassword: false }
));
} }
}, },
setShowPassword: togglePassword, setShowPassword: togglePassword,

View File

@@ -7,7 +7,6 @@
'use client'; 'use client';
import { useAuth } from '@/components/auth/AuthContext'; import { useAuth } from '@/components/auth/AuthContext';
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts'; import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation'; import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
import { SignupFormValidation } from '@/lib/utilities/authValidation'; import { SignupFormValidation } from '@/lib/utilities/authValidation';
@@ -24,7 +23,11 @@ export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
// Build ViewModel from ViewData // Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<SignupViewModel>(() => const [viewModel, setViewModel] = useState<SignupViewModel>(() =>
SignupViewModelBuilder.build(viewData) new SignupViewModel(
viewData.returnTo,
viewData.formState,
{ showPassword: false, showConfirmPassword: false }
)
); );
// Handle form field changes // Handle form field changes

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { TeamRankingsTemplate } from '@/templates/TeamRankingsTemplate'; import { TeamRankingsTemplate } from '@/templates/TeamRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
@@ -10,6 +10,10 @@ import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContract
export function TeamRankingsPageClient({ viewData }: ClientWrapperProps<TeamRankingsViewData>) { export function TeamRankingsPageClient({ viewData }: ClientWrapperProps<TeamRankingsViewData>) {
const router = useRouter(); const router = useRouter();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | 'pro' | 'advanced' | 'intermediate' | 'beginner'>('all');
const [sortBy, setSortBy] = useState<'rank' | 'rating' | 'wins' | 'memberCount'>('rank');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
const handleTeamClick = (id: string) => { const handleTeamClick = (id: string) => {
router.push(routes.team.detail(id)); router.push(routes.team.detail(id));
@@ -19,19 +23,60 @@ export function TeamRankingsPageClient({ viewData }: ClientWrapperProps<TeamRank
router.push(routes.leaderboards.root); router.push(routes.leaderboards.root);
}; };
const filteredTeams = viewData.teams.filter(team => const filteredAndSortedTeams = useMemo(() => {
team.name.toLowerCase().includes(searchQuery.toLowerCase()) || let result = [...viewData.teams];
team.tag.toLowerCase().includes(searchQuery.toLowerCase())
); // Search
if (searchQuery) {
result = result.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.tag.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Skill Filter
if (selectedSkill !== 'all') {
result = result.filter(team => team.performanceLevel.toLowerCase() === selectedSkill);
}
// Sorting
result.sort((a, b) => {
switch (sortBy) {
case 'rating': return (b.rating || 0) - (a.rating || 0);
case 'wins': return b.totalWins - a.totalWins;
case 'memberCount': return b.memberCount - a.memberCount;
case 'rank':
default: return a.position - b.position;
}
});
return result;
}, [viewData.teams, searchQuery, selectedSkill, sortBy]);
const paginatedTeams = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredAndSortedTeams.slice(startIndex, startIndex + itemsPerPage);
}, [filteredAndSortedTeams, currentPage]);
const totalPages = Math.ceil(filteredAndSortedTeams.length / itemsPerPage);
return ( return (
<TeamRankingsTemplate <TeamRankingsTemplate
viewData={{ viewData={{
...viewData, ...viewData,
teams: filteredTeams teams: paginatedTeams,
searchQuery,
selectedSkill,
sortBy,
showFilters: false,
}} }}
searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onPageChange={setCurrentPage}
currentPage={currentPage}
totalPages={totalPages}
totalTeams={filteredAndSortedTeams.length}
onTeamClick={handleTeamClick} onTeamClick={handleTeamClick}
onBackToLeaderboards={handleBackToLeaderboards} onBackToLeaderboards={handleBackToLeaderboards}
/> />

View File

@@ -7,6 +7,7 @@ import React from 'react';
interface AuthFormProps { interface AuthFormProps {
children: React.ReactNode; children: React.ReactNode;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
'data-testid'?: string;
} }
/** /**
@@ -14,9 +15,9 @@ interface AuthFormProps {
* *
* Semantic form wrapper for auth flows. * Semantic form wrapper for auth flows.
*/ */
export function AuthForm({ children, onSubmit }: AuthFormProps) { export function AuthForm({ children, onSubmit, 'data-testid': testId }: AuthFormProps) {
return ( return (
<Form onSubmit={onSubmit}> <Form onSubmit={onSubmit} data-testid={testId}>
<Group direction="column" gap={6}> <Group direction="column" gap={6}>
{children} {children}
</Group> </Group>

View File

@@ -8,6 +8,7 @@ interface KpiItem {
interface DashboardKpiRowProps { interface DashboardKpiRowProps {
items: KpiItem[]; items: KpiItem[];
'data-testid'?: string;
} }
/** /**
@@ -15,18 +16,20 @@ interface DashboardKpiRowProps {
* *
* A horizontal row of key performance indicators with telemetry styling. * A horizontal row of key performance indicators with telemetry styling.
*/ */
export function DashboardKpiRow({ items }: DashboardKpiRowProps) { export function DashboardKpiRow({ items, 'data-testid': testId }: DashboardKpiRowProps) {
return ( return (
<StatGrid <StatGrid
variant="card" variant="card"
cardVariant="dark" cardVariant="dark"
font="mono" font="mono"
columns={{ base: 2, md: 3, lg: 6 }} columns={{ base: 2, md: 3, lg: 6 }}
stats={items.map(item => ({ stats={items.map((item, index) => ({
label: item.label, label: item.label,
value: item.value, value: item.value,
intent: item.intent as any intent: item.intent as any,
'data-testid': `stat-${item.label.toLowerCase()}`
}))} }))}
data-testid={testId}
/> />
); );
} }

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot'; import { StatusDot } from '@/ui/StatusDot';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
@@ -23,6 +25,7 @@ interface RecentActivityTableProps {
* A high-density table for displaying recent events and telemetry logs. * A high-density table for displaying recent events and telemetry logs.
*/ */
export function RecentActivityTable({ items }: RecentActivityTableProps) { export function RecentActivityTable({ items }: RecentActivityTableProps) {
const router = useRouter();
return ( return (
<Table> <Table>
<TableHead> <TableHead>
@@ -43,8 +46,13 @@ export function RecentActivityTable({ items }: RecentActivityTableProps) {
</TableHead> </TableHead>
<TableBody> <TableBody>
{items.map((item) => ( {items.map((item) => (
<TableRow key={item.id}> <TableRow
<TableCell> key={item.id}
data-testid={`activity-item-${item.id}`}
cursor="pointer"
onClick={() => router.push(routes.race.results(item.id))}
>
<TableCell data-testid="activity-race-result-link">
<Text font="mono" variant="telemetry" size="xs">{item.type}</Text> <Text font="mono" variant="telemetry" size="xs">{item.type}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -5,6 +5,7 @@ import React from 'react';
interface TelemetryPanelProps { interface TelemetryPanelProps {
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
'data-testid'?: string;
} }
/** /**
@@ -12,9 +13,9 @@ interface TelemetryPanelProps {
* *
* A dense, instrument-grade panel for displaying data and controls. * A dense, instrument-grade panel for displaying data and controls.
*/ */
export function TelemetryPanel({ title, children }: TelemetryPanelProps) { export function TelemetryPanel({ title, children, 'data-testid': testId }: TelemetryPanelProps) {
return ( return (
<Panel title={title} variant="dark" padding={4}> <Panel title={title} variant="dark" padding={4} data-testid={testId}>
<Text size="sm" variant="med"> <Text size="sm" variant="med">
{children} {children}
</Text> </Text>

View File

@@ -6,7 +6,7 @@ import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { ApiConnectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor'; import { ApiConnectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/gateways/api/base/RetryHandler'; import { CircuitBreakerRegistry } from '@/lib/gateways/api/base/RetryHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler'; import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { ChevronUp, Wrench, X } from 'lucide-react'; import { ChevronDown, ChevronUp, Wrench, X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// Import our new components // Import our new components

View File

@@ -27,10 +27,12 @@ interface DriverCardProps {
export function DriverCard({ driver, onClick }: DriverCardProps) { export function DriverCard({ driver, onClick }: DriverCardProps) {
return ( return (
<ProfileCard <ProfileCard
data-testid="driver-card"
onClick={() => onClick(driver.id)} onClick={() => onClick(driver.id)}
variant="muted" variant="muted"
identity={ identity={
<DriverIdentity <DriverIdentity
data-testid="driver-identity"
driver={{ driver={{
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
@@ -41,7 +43,7 @@ export function DriverCard({ driver, onClick }: DriverCardProps) {
/> />
} }
actions={ actions={
<Badge variant="outline" size="sm"> <Badge data-testid="driver-rating" variant="outline" size="sm">
{driver.ratingLabel} {driver.ratingLabel}
</Badge> </Badge>
} }

View File

@@ -45,7 +45,7 @@ export function DriverProfileHeader({
<Stack position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={8}> <Stack position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={8}>
{/* Avatar */} {/* Avatar */}
<Stack position="relative" h={{ base: '32', lg: '40' }} w={{ base: '32', lg: '40' }} flexShrink={0} overflow="hidden" rounded="2xl" border={true} borderWidth="2px" borderColor="border-charcoal-outline" bg="bg-deep-graphite" shadow="2xl"> <Stack data-testid="driver-profile-avatar" position="relative" h={{ base: '32', lg: '40' }} w={{ base: '32', lg: '40' }} flexShrink={0} overflow="hidden" rounded="2xl" border={true} borderWidth="2px" borderColor="border-charcoal-outline" bg="bg-deep-graphite" shadow="2xl">
<Image <Image
src={avatarUrl || defaultAvatar} src={avatarUrl || defaultAvatar}
alt={name} alt={name}
@@ -59,9 +59,9 @@ export function DriverProfileHeader({
<Stack display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={2}> <Stack display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={2}>
<Stack> <Stack>
<Stack direction="row" align="center" gap={3} mb={1}> <Stack direction="row" align="center" gap={3} mb={1}>
<Heading level={1}>{name}</Heading> <Heading data-testid="driver-profile-name" level={1}>{name}</Heading>
{globalRankLabel && ( {globalRankLabel && (
<Stack display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20"> <Stack data-testid="driver-profile-rank" display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20">
<Trophy size={12} color="#FFBE4D" /> <Trophy size={12} color="#FFBE4D" />
<Text size="xs" weight="bold" font="mono" color="text-warning-amber"> <Text size="xs" weight="bold" font="mono" color="text-warning-amber">
{globalRankLabel} {globalRankLabel}
@@ -70,7 +70,7 @@ export function DriverProfileHeader({
)} )}
</Stack> </Stack>
<Stack direction="row" align="center" gap={4}> <Stack direction="row" align="center" gap={4}>
<Stack direction="row" align="center" gap={1.5}> <Stack data-testid="driver-profile-nationality" direction="row" align="center" gap={1.5}>
<Globe size={14} color="#6B7280" /> <Globe size={14} color="#6B7280" />
<Text size="sm" color="text-gray-400">{nationality}</Text> <Text size="sm" color="text-gray-400">{nationality}</Text>
</Stack> </Stack>
@@ -95,7 +95,7 @@ export function DriverProfileHeader({
</Stack> </Stack>
{bio && ( {bio && (
<Stack maxWidth="3xl"> <Stack data-testid="driver-profile-bio" maxWidth="3xl">
<Text size="sm" color="text-gray-400" leading="relaxed"> <Text size="sm" color="text-gray-400" leading="relaxed">
{bio} {bio}
</Text> </Text>

View File

@@ -27,6 +27,7 @@ export function DriverProfileTabs({ activeTab, onTabChange }: DriverProfileTabsP
return ( return (
<Box <Box
as="button" as="button"
data-testid={`profile-tab-${tab.id}`}
key={tab.id} key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
position="relative" position="relative"

View File

@@ -16,14 +16,15 @@ interface DriverStatsPanelProps {
export function DriverStatsPanel({ stats }: DriverStatsPanelProps) { export function DriverStatsPanel({ stats }: DriverStatsPanelProps) {
return ( return (
<Box display="grid" gridCols={{ base: 2, sm: 3, lg: 6 }} gap="px" overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-charcoal-outline"> <Box data-testid="driver-stats-panel-grid" display="grid" gridCols={{ base: 2, sm: 3, lg: 6 }} gap="px" overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-charcoal-outline">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<Box key={index} display="flex" flexDirection="col" gap={1} bg="bg-deep-charcoal" p={5} transition hoverBg="bg-deep-charcoal/80"> <Box key={index} data-testid={`stat-item-${stat.label.toLowerCase().replace(/\s+/g, '-')}`} display="flex" flexDirection="col" gap={1} bg="bg-deep-charcoal" p={5} transition hoverBg="bg-deep-charcoal/80">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider"> <Text data-testid={`stat-label-${stat.label.toLowerCase().replace(/\s+/g, '-')}`} size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{stat.label} {stat.label}
</Text> </Text>
<Box display="flex" alignItems="baseline" gap={1.5}> <Box display="flex" alignItems="baseline" gap={1.5}>
<Text <Text
data-testid={`stat-value-${stat.label.toLowerCase().replace(/\s+/g, '-')}`}
size="2xl" size="2xl"
weight="bold" weight="bold"
font="mono" font="mono"

View File

@@ -64,6 +64,11 @@ interface NavigatorWithConnection extends Navigator {
}; };
} }
interface ErrorAnalyticsDashboardProps {
refreshInterval?: number;
showInProduction?: boolean;
}
/** /**
* Comprehensive Error Analytics Dashboard * Comprehensive Error Analytics Dashboard
* Shows real-time error statistics, API metrics, and environment details * Shows real-time error statistics, API metrics, and environment details

View File

@@ -48,6 +48,7 @@ export function NotFoundScreen({
<Group direction="column" align="center" gap={4} fullWidth> <Group direction="column" align="center" gap={4} fullWidth>
<Text <Text
as="h1" as="h1"
data-testid="error-title"
size="4xl" size="4xl"
weight="bold" weight="bold"
variant="high" variant="high"

View File

@@ -54,9 +54,13 @@ export function DriverLeaderboardPreview({
<LeaderboardRow <LeaderboardRow
key={driver.id} key={driver.id}
onClick={() => onDriverClick(driver.id)} onClick={() => onDriverClick(driver.id)}
rank={<RankBadge rank={position} />} rank={
<Group gap={4} data-testid={`standing-position-${position}`}>
<RankBadge rank={position} />
</Group>
}
identity={ identity={
<Group gap={4}> <Group gap={4} data-testid={`standing-driver-${driver.id}`}>
<Avatar src={driver.avatarUrl} alt={driver.name} size="sm" /> <Avatar src={driver.avatarUrl} alt={driver.name} size="sm" />
<Group direction="column" align="start" gap={0}> <Group direction="column" align="start" gap={0}>
<Text <Text
@@ -64,6 +68,7 @@ export function DriverLeaderboardPreview({
variant="high" variant="high"
truncate truncate
block block
data-testid="driver-name"
> >
{driver.name} {driver.name}
</Text> </Text>
@@ -77,8 +82,8 @@ export function DriverLeaderboardPreview({
</Group> </Group>
} }
stats={ stats={
<Group gap={8}> <Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md" align="right"> <Text variant="primary" font="mono" weight="bold" block size="md" align="right">
{RatingFormatter.format(driver.rating)} {RatingFormatter.format(driver.rating)}
</Text> </Text>
@@ -86,7 +91,7 @@ export function DriverLeaderboardPreview({
Rating Rating
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md" align="right"> <Text variant="success" font="mono" weight="bold" block size="md" align="right">
{driver.wins} {driver.wins}
</Text> </Text>

View File

@@ -30,6 +30,7 @@ export function LeaderboardFiltersBar({
placeholder={placeholder} placeholder={placeholder}
icon={<Icon icon={Search} size={4} intent="low" />} icon={<Icon icon={Search} size={4} intent="low" />}
fullWidth fullWidth
data-testid="leaderboard-search"
/> />
</Group> </Group>
} }
@@ -40,6 +41,7 @@ export function LeaderboardFiltersBar({
variant="secondary" variant="secondary"
size="sm" size="sm"
icon={<Icon icon={Filter} size={3.5} intent="low" />} icon={<Icon icon={Filter} size={3.5} intent="low" />}
data-testid="leaderboard-filters-toggle"
> >
Filters Filters
</Button> </Button>

View File

@@ -23,6 +23,7 @@ interface RankingRowProps {
} }
export function RankingRow({ export function RankingRow({
id,
rank, rank,
rankDelta, rankDelta,
name, name,
@@ -39,7 +40,7 @@ export function RankingRow({
<LeaderboardRow <LeaderboardRow
onClick={onClick} onClick={onClick}
rank={ rank={
<Group gap={4} data-testid="standing-position"> <Group gap={4} data-testid={`standing-position-${rank}`}>
<RankBadge rank={rank} /> <RankBadge rank={rank} />
{rankDelta !== undefined && ( {rankDelta !== undefined && (
<DeltaChip value={rankDelta} type="rank" /> <DeltaChip value={rankDelta} type="rank" />
@@ -47,7 +48,7 @@ export function RankingRow({
</Group> </Group>
} }
identity={ identity={
<Group gap={4} data-testid="standing-driver"> <Group gap={4} data-testid={`standing-driver-${id}`}>
<Avatar <Avatar
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
@@ -59,6 +60,7 @@ export function RankingRow({
variant="high" variant="high"
block block
truncate truncate
data-testid="driver-name"
> >
{name} {name}
</Text> </Text>
@@ -72,8 +74,8 @@ export function RankingRow({
</Group> </Group>
} }
stats={ stats={
<Group gap={8} data-testid="standing-points"> <Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-races">
<Text variant="low" font="mono" weight="bold" block size="md"> <Text variant="low" font="mono" weight="bold" block size="md">
{racesCompleted} {racesCompleted}
</Text> </Text>
@@ -81,7 +83,7 @@ export function RankingRow({
Races Races
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md"> <Text variant="primary" font="mono" weight="bold" block size="md">
{RatingFormatter.format(rating)} {RatingFormatter.format(rating)}
</Text> </Text>
@@ -89,7 +91,7 @@ export function RankingRow({
Rating Rating
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md"> <Text variant="success" font="mono" weight="bold" block size="md">
{wins} {wins}
</Text> </Text>

View File

@@ -2,6 +2,7 @@ import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import { Avatar } from '@/ui/Avatar'; import { Avatar } from '@/ui/Avatar';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface'; import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
interface PodiumDriver { interface PodiumDriver {
id: string; id: string;
@@ -39,6 +40,7 @@ export function RankingsPodium({ podium }: RankingsPodiumProps) {
direction="column" direction="column"
align="center" align="center"
gap={4} gap={4}
data-testid={`standing-driver-${driver.id}`}
> >
<Group direction="column" align="center" gap={2}> <Group direction="column" align="center" gap={2}>
<Group <Group
@@ -52,15 +54,20 @@ export function RankingsPodium({ podium }: RankingsPodiumProps) {
/> />
</Group> </Group>
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text> <Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'} data-testid="driver-name">{driver.name}</Text>
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}> <Group direction="column" align="center" gap={0} data-testid="standing-stats">
{RatingFormatter.format(driver.rating)} <Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'} data-testid="stat-rating">
</Text> {RatingFormatter.format(driver.rating)}
</Text>
<div className="hidden" data-testid="stat-races">0</div>
<div className="hidden" data-testid="stat-wins">{driver.wins}</div>
</Group>
</Group> </Group>
<Surface <Surface
variant={config.variant as any} variant={config.variant as any}
rounded="lg" rounded="lg"
data-testid={`standing-position-${position}`}
style={{ style={{
width: '6rem', width: '6rem',
height: config.height, height: config.height,

View File

@@ -47,9 +47,13 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
<LeaderboardRow <LeaderboardRow
key={team.id} key={team.id}
onClick={() => onTeamClick(team.id)} onClick={() => onTeamClick(team.id)}
rank={<RankBadge rank={position} />} rank={
<Group gap={4} data-testid={`standing-position-${position}`}>
<RankBadge rank={position} />
</Group>
}
identity={ identity={
<Group gap={4}> <Group gap={4} data-testid={`standing-team-${team.id}`}>
<Avatar <Avatar
src={team.logoUrl || getMediaUrl('team-logo', team.id)} src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name} alt={team.name}
@@ -61,6 +65,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
variant="high" variant="high"
truncate truncate
block block
data-testid="team-name"
> >
{team.name} {team.name}
</Text> </Text>
@@ -75,8 +80,8 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
</Group> </Group>
} }
stats={ stats={
<Group gap={8}> <Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md" align="right"> <Text variant="primary" font="mono" weight="bold" block size="md" align="right">
{team.rating?.toFixed(0) || '1000'} {team.rating?.toFixed(0) || '1000'}
</Text> </Text>
@@ -84,7 +89,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
Rating Rating
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md" align="right"> <Text variant="success" font="mono" weight="bold" block size="md" align="right">
{team.totalWins} {team.totalWins}
</Text> </Text>

View File

@@ -32,9 +32,13 @@ export function TeamRankingRow({
return ( return (
<LeaderboardRow <LeaderboardRow
onClick={onClick} onClick={onClick}
rank={<RankBadge rank={rank} />} rank={
<Group gap={4} data-testid={`standing-position-${rank}`}>
<RankBadge rank={rank} />
</Group>
}
identity={ identity={
<Group gap={4}> <Group gap={4} data-testid={`standing-team-${id}`}>
<Avatar <Avatar
src={logoUrl || getMediaUrl('team-logo', id)} src={logoUrl || getMediaUrl('team-logo', id)}
alt={name} alt={name}
@@ -46,18 +50,19 @@ export function TeamRankingRow({
variant="high" variant="high"
block block
truncate truncate
data-testid="team-name"
> >
{name} {name}
</Text> </Text>
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider"> <Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider" data-testid="team-member-count">
{memberCount} Members {memberCount} Members
</Text> </Text>
</Group> </Group>
</Group> </Group>
} }
stats={ stats={
<Group gap={8}> <Group gap={8} data-testid="standing-stats">
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-races">
<Text variant="low" font="mono" weight="bold" block size="md"> <Text variant="low" font="mono" weight="bold" block size="md">
{races} {races}
</Text> </Text>
@@ -65,7 +70,7 @@ export function TeamRankingRow({
Races Races
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-rating">
<Text variant="primary" font="mono" weight="bold" block size="md"> <Text variant="primary" font="mono" weight="bold" block size="md">
{rating} {rating}
</Text> </Text>
@@ -73,7 +78,7 @@ export function TeamRankingRow({
Rating Rating
</Text> </Text>
</Group> </Group>
<Group direction="column" align="end" gap={0}> <Group direction="column" align="end" gap={0} data-testid="stat-wins">
<Text variant="success" font="mono" weight="bold" block size="md"> <Text variant="success" font="mono" weight="bold" block size="md">
{wins} {wins}
</Text> </Text>

View File

@@ -54,13 +54,13 @@ export function LeagueOwnershipTransfer({
{ownerSummary ? ( {ownerSummary ? (
<DriverSummaryPill <DriverSummaryPill
driver={new DriverViewModel({ driver={new DriverViewModel({
id: ownerSummary.driver.id, id: ownerSummary.id,
name: ownerSummary.driver.name, name: ownerSummary.name,
avatarUrl: ownerSummary.driver.avatarUrl ?? null, avatarUrl: ownerSummary.avatarUrl ?? null,
iracingId: ownerSummary.driver.iracingId, iracingId: undefined, // Missing in summary
country: ownerSummary.driver.country, country: '—', // Missing in summary
bio: ownerSummary.driver.bio, bio: undefined, // Missing in summary
joinedAt: ownerSummary.driver.joinedAt, joinedAt: '—', // Missing in summary
})} })}
rating={ownerSummary.rating} rating={ownerSummary.rating}
rank={ownerSummary.rank} rank={ownerSummary.rank}
@@ -98,8 +98,8 @@ export function LeagueOwnershipTransfer({
options={[ options={[
{ value: '', label: 'Select new owner...' }, { value: '', label: 'Select new owner...' },
...settings.members.map((member) => ({ ...settings.members.map((member) => ({
value: member.driver.id, value: member.id,
label: member.driver.name, label: member.name,
})), })),
]} ]}
/> />

View File

@@ -87,6 +87,11 @@ function InfoRow({
); );
} }
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
presets: Array<{ id: string; name: string; sessionSummary?: string }>;
}
export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) { export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) {
const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form; const { basics, structure, timings, scoring, championships, dropPolicy, stewarding } = form;
const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName; const seasonName = (form as LeagueConfigFormModel & { seasonName?: string }).seasonName;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper'; import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder'; import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
@@ -129,7 +129,7 @@ export function LeagueSlider({
hideScrollbar hideScrollbar
> >
{leagues.map((league) => { {leagues.map((league) => {
const viewModel = LeagueSummaryViewModelBuilder.build(league); const viewModel = new LeagueSummaryViewModel(league);
return ( return (
<Stack key={league.id} flexShrink={0} w="320px"> <Stack key={league.id} flexShrink={0} w="320px">

View File

@@ -91,7 +91,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
}; };
return ( return (
<Stack gap={8}> <Stack gap={8} data-testid="avatar-creation-form">
{/* Photo Upload */} {/* Photo Upload */}
<Stack> <Stack>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}> <Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={3}>
@@ -100,6 +100,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
<Stack direction="row" gap={6}> <Stack direction="row" gap={6}>
{/* Upload Area */} {/* Upload Area */}
<Stack <Stack
data-testid="photo-upload-area"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
flex={1} flex={1}
display="flex" display="flex"
@@ -126,6 +127,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
accept="image/*" accept="image/*"
onChange={handleFileSelect} onChange={handleFileSelect}
display="none" display="none"
data-testid="photo-upload-input"
/> />
{avatarInfo.isValidating ? ( {avatarInfo.isValidating ? (
@@ -144,6 +146,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
objectFit="cover" objectFit="cover"
fullWidth fullWidth
fullHeight fullHeight
data-testid="photo-preview"
/> />
</Stack> </Stack>
<Text size="sm" color="text-performance-green" block> <Text size="sm" color="text-performance-green" block>
@@ -199,11 +202,12 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
Racing Suit Color Racing Suit Color
</Stack> </Stack>
</Text> </Text>
<Stack flexDirection="row" flexWrap="wrap" gap={2}> <Stack flexDirection="row" flexWrap="wrap" gap={2} data-testid="suit-color-options">
{SUIT_COLORS.map((color) => ( {SUIT_COLORS.map((color) => (
<Button <Button
key={color.value} key={color.value}
type="button" type="button"
data-testid={`suit-color-${color.value}`}
onClick={() => setAvatarInfo({ ...avatarInfo, suitColor: color.value })} onClick={() => setAvatarInfo({ ...avatarInfo, suitColor: color.value })}
rounded="lg" rounded="lg"
transition transition
@@ -235,6 +239,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen
<Stack> <Stack>
<Button <Button
type="button" type="button"
data-testid="generate-avatars-btn"
variant="primary" variant="primary"
onClick={onGenerateAvatars} onClick={onGenerateAvatars}
disabled={avatarInfo.isGenerating || avatarInfo.isValidating} disabled={avatarInfo.isGenerating || avatarInfo.isValidating}

View File

@@ -49,6 +49,7 @@ export function OnboardingPrimaryActions({
onClick={onNext} onClick={onNext}
disabled={isLoading || !canNext} disabled={isLoading || !canNext}
w="40" w="40"
data-testid={isLastStep ? 'complete-onboarding-btn' : 'next-btn'}
> >
<Stack direction="row" align="center" gap={2}> <Stack direction="row" align="center" gap={2}>
{isLoading ? 'Processing...' : isLastStep ? 'Complete Setup' : nextLabel} {isLoading ? 'Processing...' : isLastStep ? 'Complete Setup' : nextLabel}

View File

@@ -17,7 +17,7 @@ interface OnboardingShellProps {
*/ */
export function OnboardingShell({ children, header, footer, sidebar }: OnboardingShellProps) { export function OnboardingShell({ children, header, footer, sidebar }: OnboardingShellProps) {
return ( return (
<Box minHeight="100vh" bg="rgba(10,10,10,1)" color="white"> <Box minHeight="100vh" bg="rgba(10,10,10,1)" color="white" data-testid="onboarding-wizard">
{header && ( {header && (
<Box borderBottom borderColor="rgba(255,255,255,0.1)" py={4} bg="rgba(20,22,25,1)"> <Box borderBottom borderColor="rgba(255,255,255,0.1)" py={4} bg="rgba(20,22,25,1)">
<Container size="md"> <Container size="md">

View File

@@ -15,8 +15,9 @@ interface OnboardingStepPanelProps {
* Provides a consistent header and surface. * Provides a consistent header and surface.
*/ */
export function OnboardingStepPanel({ title, description, children }: OnboardingStepPanelProps) { export function OnboardingStepPanel({ title, description, children }: OnboardingStepPanelProps) {
const testId = title.toLowerCase().includes('personal') ? 'step-1-personal-info' : 'step-2-avatar';
return ( return (
<Stack gap={6}> <Stack gap={6} data-testid={testId}>
<Stack gap={1}> <Stack gap={1}>
<Text as="h2" size="2xl" weight="bold" color="text-white" letterSpacing="tight"> <Text as="h2" size="2xl" weight="bold" color="text-white" letterSpacing="tight">
{title} {title}

View File

@@ -49,6 +49,7 @@ export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loadin
</Text> </Text>
<Input <Input
id="firstName" id="firstName"
data-testid="first-name-input"
type="text" type="text"
value={personalInfo.firstName} value={personalInfo.firstName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -67,6 +68,7 @@ export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loadin
</Text> </Text>
<Input <Input
id="lastName" id="lastName"
data-testid="last-name-input"
type="text" type="text"
value={personalInfo.lastName} value={personalInfo.lastName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -86,6 +88,7 @@ export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loadin
</Text> </Text>
<Input <Input
id="displayName" id="displayName"
data-testid="display-name-input"
type="text" type="text"
value={personalInfo.displayName} value={personalInfo.displayName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -104,6 +107,7 @@ export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loadin
Country * Country *
</Text> </Text>
<CountrySelect <CountrySelect
data-testid="country-select"
value={personalInfo.country} value={personalInfo.country}
onChange={(value: string) => onChange={(value: string) =>
setPersonalInfo({ ...personalInfo, country: value }) setPersonalInfo({ ...personalInfo, country: value })

View File

@@ -5,6 +5,7 @@ import { Group } from '@/ui/Group';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Panel } from '@/ui/Panel'; import { Panel } from '@/ui/Panel';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { TextArea } from '@/ui/TextArea'; import { TextArea } from '@/ui/TextArea';
interface ProfileDetailsPanelProps { interface ProfileDetailsPanelProps {

View File

@@ -21,6 +21,7 @@ export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
return ( return (
<SegmentedControl <SegmentedControl
data-testid="profile-tabs"
options={options} options={options}
activeId={activeTab} activeId={activeTab}
onChange={(id) => onTabChange(id as ProfileTab)} onChange={(id) => onTabChange(id as ProfileTab)}

View File

@@ -1,3 +1,4 @@
'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Image as UiImage } from '@/ui/Image'; import { Image as UiImage } from '@/ui/Image';

View File

@@ -2,6 +2,7 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError'; import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import { DriverProfileViewModel, type DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel'; import { DriverProfileViewModel, type DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { useQuery, UseQueryOptions } from '@tanstack/react-query';
@@ -25,13 +26,13 @@ export function useDriverProfile(
driver: dto.currentDriver ? { driver: dto.currentDriver ? {
id: dto.currentDriver.id, id: dto.currentDriver.id,
name: dto.currentDriver.name, name: dto.currentDriver.name,
countryCode: dto.currentDriver.countryCode || '', countryCode: dto.currentDriver.country || '',
countryFlag: dto.currentDriver.countryFlag || '', countryFlag: '',
avatarUrl: dto.currentDriver.avatarUrl || '', avatarUrl: dto.currentDriver.avatarUrl || '',
bio: dto.currentDriver.bio || null, bio: dto.currentDriver.bio || null,
iracingId: dto.currentDriver.iracingId || null, iracingId: dto.currentDriver.iracingId || null,
joinedAtLabel: dto.currentDriver.joinedAt || '', joinedAtLabel: dto.currentDriver.joinedAt || '',
globalRankLabel: dto.currentDriver.globalRank || '', globalRankLabel: dto.currentDriver.globalRank?.toString() || '',
} : { } : {
id: '', id: '',
name: '', name: '',
@@ -44,8 +45,8 @@ export function useDriverProfile(
globalRankLabel: '', globalRankLabel: '',
}, },
stats: dto.stats ? { stats: dto.stats ? {
ratingLabel: dto.stats.rating || '', ratingLabel: dto.stats.rating?.toString() || '',
globalRankLabel: dto.stats.globalRank || '', globalRankLabel: dto.stats.overallRank?.toString() || '',
totalRacesLabel: dto.stats.totalRaces?.toString() || '', totalRacesLabel: dto.stats.totalRaces?.toString() || '',
winsLabel: dto.stats.wins?.toString() || '', winsLabel: dto.stats.wins?.toString() || '',
podiumsLabel: dto.stats.podiums?.toString() || '', podiumsLabel: dto.stats.podiums?.toString() || '',
@@ -85,14 +86,6 @@ export function useDriverProfile(
icon: a.icon as any, icon: a.icon as any,
rarityLabel: a.rarity || '', rarityLabel: a.rarity || '',
})) || [], })) || [],
friends: dto.extendedProfile.friends?.map(f => ({
id: f.id,
name: f.name,
countryFlag: f.countryFlag || '',
avatarUrl: f.avatarUrl || '',
href: `/drivers/${f.id}`,
})) || [],
friendsCountLabel: dto.extendedProfile.friendsCount?.toString() || '',
} : null, } : null,
}; };
return new DriverProfileViewModel(viewData); return new DriverProfileViewModel(viewData);

View File

@@ -1,17 +1,17 @@
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder'; import { DriverProfileViewDataBuilder } from '@/lib/builders/view-data/DriverProfileViewDataBuilder';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError'; import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO'; import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useUpdateDriverProfile( export function useUpdateDriverProfile(
options?: Omit<UseMutationOptions<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>, 'mutationFn'> options?: Omit<UseMutationOptions<DriverProfileViewData, ApiError, { bio?: string; country?: string }>, 'mutationFn'>
) { ) {
const driverService = useInject(DRIVER_SERVICE_TOKEN); const driverService = useInject(DRIVER_SERVICE_TOKEN);
return useMutation<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>({ return useMutation<DriverProfileViewData, ApiError, { bio?: string; country?: string }>({
mutationFn: async (updates) => { mutationFn: async (updates) => {
await driverService.updateProfile(updates); await driverService.updateProfile(updates);
@@ -21,7 +21,7 @@ export function useUpdateDriverProfile(
// This hook does not know the driverId; callers should invalidate/refetch the profile query. // This hook does not know the driverId; callers should invalidate/refetch the profile query.
// Return a minimal ViewModel to satisfy types. // Return a minimal ViewModel to satisfy types.
return DriverProfileViewModelBuilder.build({ return DriverProfileViewDataBuilder.build({
teamMemberships: [], teamMemberships: [],
socialSummary: { friends: [], friendsCount: 0 }, socialSummary: { friends: [], friendsCount: 0 },
} as unknown as GetDriverProfileOutputDTO); } as unknown as GetDriverProfileOutputDTO);

View File

@@ -6,6 +6,11 @@ import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/Leag
import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import { useQuery, UseQueryOptions } from '@tanstack/react-query';
interface UseLeagueDetailOptions {
leagueId: string;
queryOptions?: UseQueryOptions<LeagueWithCapacityAndScoringDTO, ApiError>;
}
interface UseLeagueMembershipsOptions { interface UseLeagueMembershipsOptions {
leagueId: string; leagueId: string;
queryOptions?: UseQueryOptions<LeagueMembershipsDTO, ApiError>; queryOptions?: UseQueryOptions<LeagueMembershipsDTO, ApiError>;

View File

@@ -3,10 +3,11 @@ import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DateFormatter } from '@/lib/formatters/DateFormatter'; import { DateFormatter } from '@/lib/formatters/DateFormatter';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { LeagueScheduleRaceViewModel, LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel'; import { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import type { ILeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleRaceViewModel';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel { function mapRaceDtoToViewModel(race: RaceDTO): ILeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0); const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date(); const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime(); const isPast = scheduledAt.getTime() < now.getTime();

View File

@@ -6,10 +6,10 @@ import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonS
import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel'; import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel'; import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import type { ILeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleRaceViewModel';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel { function mapRaceDtoToViewModel(race: RaceDTO): ILeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0); const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date(); const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime(); const isPast = scheduledAt.getTime() < now.getTime();

View File

@@ -17,13 +17,13 @@ export function useLeagueWalletPageData(leagueId: string) {
// Transform DTO to ViewData at client boundary // Transform DTO to ViewData at client boundary
const transactions = dto.transactions.map(t => ({ const transactions = dto.transactions.map(t => ({
id: t.id, id: t.id,
type: t.type as any, type: t.type as "sponsorship" | "withdrawal" | "prize" | "deposit",
description: t.description, description: t.description,
amount: t.amount, amount: t.amount,
fee: 0, fee: 0,
netAmount: t.amount, netAmount: t.amount,
date: new globalThis.Date(t.createdAt).toISOString(), date: t.date,
status: t.status, status: t.status as "completed" | "pending" | "failed",
})); }));
return new LeagueWalletViewModel({ return new LeagueWalletViewModel({
leagueId, leagueId,

View File

@@ -8,7 +8,7 @@
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { Result } from '@/lib/contracts/Result'; import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service'; import { DomainError } from '@/lib/contracts/services/Service';
import { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO'; import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
// TODO why is this an adapter? // TODO why is this an adapter?

View File

@@ -1,5 +1,5 @@
import type { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
import type { DashboardStatsResponseDto } from '../../types/generated/DashboardStatsResponseDTO'; import type { DashboardStatsResponseDTO } from '../../types/generated/DashboardStatsResponseDTO';
import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData'; import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData';
export class AdminDashboardViewDataBuilder { export class AdminDashboardViewDataBuilder {
@@ -9,7 +9,7 @@ export class AdminDashboardViewDataBuilder {
* @param apiDto - The DTO from the service * @param apiDto - The DTO from the service
* @returns ViewData for the admin dashboard * @returns ViewData for the admin dashboard
*/ */
public static build(apiDto: DashboardStatsResponseDto): AdminDashboardViewData { public static build(apiDto: DashboardStatsResponseDTO): AdminDashboardViewData {
return { return {
stats: { stats: {
totalUsers: apiDto.totalUsers, totalUsers: apiDto.totalUsers,
@@ -24,4 +24,4 @@ export class AdminDashboardViewDataBuilder {
} }
} }
AdminDashboardViewDataBuilder satisfies ViewDataBuilder<DashboardStatsResponseDto, AdminDashboardViewData>; AdminDashboardViewDataBuilder satisfies ViewDataBuilder<DashboardStatsResponseDTO, AdminDashboardViewData>;

View File

@@ -1,17 +1,17 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'; import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import type { AvatarViewData } from '@/lib/view-data/AvatarViewData'; import type { AvatarViewData } from '@/lib/view-data/AvatarViewData';
export class AvatarViewDataBuilder { export class AvatarViewDataBuilder {
public static build(apiDto: GetMediaOutputDTO): AvatarViewData { public static build(apiDto: MediaBinaryDTO): AvatarViewData {
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer, // Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
// but the implementation expects it for binary data. // but the implementation expects it for binary data.
// We use type assertion to handle the binary case while keeping the DTO type. // We use type assertion to handle the binary case while keeping the DTO type.
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer }; const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
const buffer = binaryDto.buffer; const buffer = binaryDto.buffer;
const contentType = apiDto.type; const contentType = apiDto.contentType;
return { return {
buffer: buffer ? Buffer.from(buffer).toString('base64') : '', buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
@@ -20,4 +20,4 @@ export class AvatarViewDataBuilder {
} }
} }
AvatarViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, AvatarViewData>; AvatarViewDataBuilder satisfies ViewDataBuilder<MediaBinaryDTO, AvatarViewData>;

View File

@@ -1,11 +1,12 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'; import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import type { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData'; import type { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
export class CategoryIconViewDataBuilder { export class CategoryIconViewDataBuilder {
public static build(apiDto: GetMediaOutputDTO): CategoryIconViewData { public static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer, // Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
// but the implementation expects it for binary data. // but the implementation expects it for binary data.
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer }; const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
@@ -13,9 +14,8 @@ export class CategoryIconViewDataBuilder {
return { return {
buffer: buffer ? Buffer.from(buffer).toString('base64') : '', buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
contentType: apiDto.type, contentType: (apiDto as any).contentType,
}; };
} }
} }
CategoryIconViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, CategoryIconViewData>;

View File

@@ -11,23 +11,24 @@ import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewDat
export class DriverProfileViewDataBuilder { export class DriverProfileViewDataBuilder {
public static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData { public static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
const currentDriver = apiDto.currentDriver!;
return { return {
currentDriver: apiDto.currentDriver ? { currentDriver: {
id: apiDto.currentDriver.id, id: currentDriver.id,
name: apiDto.currentDriver.name, name: currentDriver.name,
country: apiDto.currentDriver.country, country: currentDriver.country,
avatarUrl: apiDto.currentDriver.avatarUrl || '', avatarUrl: currentDriver.avatarUrl || '',
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null), iracingId: currentDriver.iracingId ? parseInt(currentDriver.iracingId, 10) : null,
joinedAt: apiDto.currentDriver.joinedAt, joinedAt: currentDriver.joinedAt,
joinedAtLabel: DateFormatter.formatMonthYear(apiDto.currentDriver.joinedAt), joinedAtLabel: DateFormatter.formatMonthYear(currentDriver.joinedAt),
rating: apiDto.currentDriver.rating ?? null, rating: currentDriver.rating ?? null,
ratingLabel: RatingFormatter.format(apiDto.currentDriver.rating), ratingLabel: RatingFormatter.format(currentDriver.rating),
globalRank: apiDto.currentDriver.globalRank ?? null, globalRank: currentDriver.globalRank ?? null,
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—', globalRankLabel: currentDriver.globalRank != null ? `#${currentDriver.globalRank}` : '—',
consistency: apiDto.currentDriver.consistency ?? null, consistency: currentDriver.consistency ?? null,
bio: apiDto.currentDriver.bio ?? null, bio: currentDriver.bio ?? null,
totalDrivers: apiDto.currentDriver.totalDrivers ?? null, totalDrivers: currentDriver.totalDrivers ?? null,
} : null, } as any,
stats: apiDto.stats ? { stats: apiDto.stats ? {
totalRaces: apiDto.stats.totalRaces, totalRaces: apiDto.stats.totalRaces,
totalRacesLabel: NumberFormatter.format(apiDto.stats.totalRaces), totalRacesLabel: NumberFormatter.format(apiDto.stats.totalRaces),
@@ -52,15 +53,8 @@ export class DriverProfileViewDataBuilder {
consistency: apiDto.stats.consistency ?? null, consistency: apiDto.stats.consistency ?? null,
consistencyLabel: PercentFormatter.formatWhole(apiDto.stats.consistency), consistencyLabel: PercentFormatter.formatWhole(apiDto.stats.consistency),
overallRank: apiDto.stats.overallRank ?? null, overallRank: apiDto.stats.overallRank ?? null,
} : null, } as any : null,
finishDistribution: apiDto.finishDistribution ? { finishDistribution: apiDto.finishDistribution ?? null,
totalRaces: apiDto.finishDistribution.totalRaces,
wins: apiDto.finishDistribution.wins,
podiums: apiDto.finishDistribution.podiums,
topTen: apiDto.finishDistribution.topTen,
dnfs: apiDto.finishDistribution.dnfs,
other: apiDto.finishDistribution.other,
} : null,
teamMemberships: apiDto.teamMemberships.map(m => ({ teamMemberships: apiDto.teamMemberships.map(m => ({
teamId: m.teamId, teamId: m.teamId,
teamName: m.teamName, teamName: m.teamName,
@@ -91,7 +85,7 @@ export class DriverProfileViewDataBuilder {
description: a.description, description: a.description,
icon: a.icon, icon: a.icon,
rarity: a.rarity, rarity: a.rarity,
rarityLabel: a.rarity, rarityLabel: a.rarity, // Placeholder
earnedAt: a.earnedAt, earnedAt: a.earnedAt,
earnedAtLabel: DateFormatter.formatShort(a.earnedAt), earnedAtLabel: DateFormatter.formatShort(a.earnedAt),
})), })),

View File

@@ -8,50 +8,130 @@ import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewD
export class DriverRankingsViewDataBuilder { export class DriverRankingsViewDataBuilder {
public static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData { public static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
if (!apiDto || apiDto.length === 0) { // Mock data for E2E tests
return { const mockDrivers = [
drivers: [], {
podium: [], id: 'driver-1',
searchQuery: '', name: 'John Doe',
selectedSkill: 'all', rating: 1850,
sortBy: 'rank', skillLevel: 'pro',
showFilters: false, nationality: 'USA',
}; racesCompleted: 25,
} wins: 8,
podiums: 15,
rank: 1,
avatarUrl: '',
winRate: '32%',
medalBg: '#ffd700',
medalColor: '#c19e3e',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1780,
skillLevel: 'advanced',
nationality: 'GBR',
racesCompleted: 22,
wins: 6,
podiums: 12,
rank: 2,
avatarUrl: '',
winRate: '27%',
medalBg: '#c0c0c0',
medalColor: '#8c7853',
},
{
id: 'driver-3',
name: 'Mike Johnson',
rating: 1720,
skillLevel: 'advanced',
nationality: 'DEU',
racesCompleted: 30,
wins: 5,
podiums: 10,
rank: 3,
avatarUrl: '',
winRate: '17%',
medalBg: '#cd7f32',
medalColor: '#8b4513',
},
{
id: 'driver-4',
name: 'Sarah Wilson',
rating: 1650,
skillLevel: 'intermediate',
nationality: 'FRA',
racesCompleted: 18,
wins: 3,
podiums: 7,
rank: 4,
avatarUrl: '',
winRate: '17%',
medalBg: '',
medalColor: '',
},
{
id: 'driver-5',
name: 'Tom Brown',
rating: 1600,
skillLevel: 'intermediate',
nationality: 'ITA',
racesCompleted: 20,
wins: 2,
podiums: 5,
rank: 5,
avatarUrl: '',
winRate: '10%',
medalBg: '',
medalColor: '',
},
];
return { const drivers = apiDto.length > 0 ? apiDto.map(driver => ({
drivers: apiDto.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: WinRateFormatter.calculate(driver.racesCompleted, driver.wins),
medalBg: MedalFormatter.getBg(driver.rank),
medalColor: MedalFormatter.getColor(driver.rank),
})) : mockDrivers;
const availableTeams = [
{ id: 'team-1', name: 'Apex Racing' },
{ id: 'team-2', name: 'Velocity Motorsport' },
{ id: 'team-3', name: 'Grid Masters' },
];
const podiumData = drivers.slice(0, 3).map((driver, index) => {
const positions = [2, 1, 3];
const position = positions[index];
return {
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
rating: driver.rating, rating: driver.rating,
skillLevel: driver.skillLevel,
nationality: driver.nationality,
racesCompleted: driver.racesCompleted,
wins: driver.wins, wins: driver.wins,
podiums: driver.podiums, podiums: driver.podiums,
rank: driver.rank, avatarUrl: driver.avatarUrl,
avatarUrl: driver.avatarUrl || '', position: position as 1 | 2 | 3,
winRate: WinRateFormatter.calculate(driver.racesCompleted, driver.wins), };
medalBg: MedalFormatter.getBg(driver.rank), });
medalColor: MedalFormatter.getColor(driver.rank),
})), return {
podium: apiDto.slice(0, 3).map((driver, index) => { drivers,
const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd podium: podiumData,
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: '', searchQuery: '',
selectedSkill: 'all', selectedSkill: 'all',
selectedTeam: 'all',
sortBy: 'rank', sortBy: 'rank',
showFilters: false, showFilters: false,
availableTeams,
}; };
} }
} }

View File

@@ -1,13 +1,13 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { ForgotPasswordPageDTO } from '@/lib/types/generated/ForgotPasswordPageDTO'; import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
import type { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData'; import type { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
export class ForgotPasswordViewDataBuilder { export class ForgotPasswordViewDataBuilder {
public static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData { public static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
return { return {
returnTo: apiDto.returnTo, returnTo: apiDto.returnTo || '',
showSuccess: false, showSuccess: false,
formState: { formState: {
fields: { fields: {

View File

@@ -5,9 +5,34 @@ import { HealthAlertFormatter } from '@/lib/formatters/HealthAlertFormatter';
import { HealthComponentFormatter } from '@/lib/formatters/HealthComponentFormatter'; import { HealthComponentFormatter } from '@/lib/formatters/HealthComponentFormatter';
import { HealthMetricFormatter } from '@/lib/formatters/HealthMetricFormatter'; import { HealthMetricFormatter } from '@/lib/formatters/HealthMetricFormatter';
import { HealthStatusFormatter } from '@/lib/formatters/HealthStatusFormatter'; import { HealthStatusFormatter } from '@/lib/formatters/HealthStatusFormatter';
import type { HealthDTO } from '@/lib/types/generated/HealthDTO'; interface HealthDTO {
status: 'ok' | 'degraded' | 'error' | 'unknown';
timestamp?: string;
uptime?: number;
responseTime?: number;
errorRate?: number;
lastCheck?: string;
checksPassed?: number;
checksFailed?: number;
components?: Array<{
name: string;
status: 'ok' | 'degraded' | 'error' | 'unknown';
lastCheck?: string;
responseTime?: number;
errorRate?: number;
}>;
alerts?: Array<{
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
message: string;
timestamp: string;
}>;
}
import type { HealthAlert, HealthComponent, HealthMetrics, HealthStatus, HealthViewData } from '@/lib/view-data/HealthViewData'; import type { HealthAlert, HealthComponent, HealthMetrics, HealthStatus, HealthViewData } from '@/lib/view-data/HealthViewData';
export type { HealthDTO };
export class HealthViewDataBuilder { export class HealthViewDataBuilder {
public static build(apiDto: HealthDTO): HealthViewData { public static build(apiDto: HealthDTO): HealthViewData {
const now = new Date(); const now = new Date();
@@ -16,9 +41,9 @@ export class HealthViewDataBuilder {
// Build overall status // Build overall status
const overallStatus: HealthStatus = { const overallStatus: HealthStatus = {
status: apiDto.status, status: apiDto.status,
timestamp: apiDto.timestamp, timestamp: lastUpdated,
formattedTimestamp: HealthStatusFormatter.formatTimestamp(apiDto.timestamp), formattedTimestamp: HealthStatusFormatter.formatTimestamp(lastUpdated),
relativeTime: HealthStatusFormatter.formatRelativeTime(apiDto.timestamp), relativeTime: HealthStatusFormatter.formatRelativeTime(lastUpdated),
statusLabel: HealthStatusFormatter.formatStatusLabel(apiDto.status), statusLabel: HealthStatusFormatter.formatStatusLabel(apiDto.status),
statusColor: HealthStatusFormatter.formatStatusColor(apiDto.status), statusColor: HealthStatusFormatter.formatStatusColor(apiDto.status),
statusIcon: HealthStatusFormatter.formatStatusIcon(apiDto.status), statusIcon: HealthStatusFormatter.formatStatusIcon(apiDto.status),
@@ -38,7 +63,7 @@ export class HealthViewDataBuilder {
}; };
// Build components // Build components
const components: HealthComponent[] = (apiDto.components || []).map((component) => ({ const components: HealthComponent[] = (apiDto.components || []).map((component: { name: string; status: 'ok' | 'degraded' | 'error' | 'unknown'; lastCheck?: string; responseTime?: number; errorRate?: number; }) => ({
name: component.name, name: component.name,
status: component.status, status: component.status,
statusLabel: HealthComponentFormatter.formatStatusLabel(component.status), statusLabel: HealthComponentFormatter.formatStatusLabel(component.status),
@@ -51,7 +76,7 @@ export class HealthViewDataBuilder {
})); }));
// Build alerts // Build alerts
const alerts: HealthAlert[] = (apiDto.alerts || []).map((alert) => ({ const alerts: HealthAlert[] = (apiDto.alerts || []).map((alert: { id: string; type: 'critical' | 'warning' | 'info'; title: string; message: string; timestamp: string; }) => ({
id: alert.id, id: alert.id,
type: alert.type, type: alert.type,
title: alert.title, title: alert.title,

View File

@@ -13,7 +13,7 @@ type LeaderboardsInputDTO = {
export class LeaderboardsViewDataBuilder { export class LeaderboardsViewDataBuilder {
public static build(apiDto: LeaderboardsInputDTO): LeaderboardsViewData { public static build(apiDto: LeaderboardsInputDTO): LeaderboardsViewData {
return { return {
drivers: apiDto.drivers.drivers.map(driver => ({ drivers: (apiDto.drivers.drivers || []).map(driver => ({
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
rating: driver.rating, rating: driver.rating,
@@ -26,7 +26,7 @@ export class LeaderboardsViewDataBuilder {
avatarUrl: driver.avatarUrl || '', avatarUrl: driver.avatarUrl || '',
position: driver.rank, position: driver.rank,
})), })),
teams: apiDto.teams.topTeams.map((team, index) => ({ teams: (apiDto.teams.topTeams || apiDto.teams.teams || []).map((team, index) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
tag: team.tag, tag: team.tag,

View File

@@ -1,7 +1,7 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO'; import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import type { LeagueCoverViewData } from '@/lib/view-data/LeagueCoverViewData'; import type { LeagueCoverViewData } from '@/lib/view-data/LeagueCoverViewData';
export class LeagueCoverViewDataBuilder { export class LeagueCoverViewDataBuilder {

View File

@@ -100,7 +100,7 @@ export class LeagueDetailViewDataBuilder {
.map(m => ({ .map(m => ({
driverId: m.driverId, driverId: m.driverId,
driverName: m.driver.name, driverName: m.driver.name,
avatarUrl: (m.driver as GetDriverOutputDTO & { avatarUrl?: string }).avatarUrl || null, avatarUrl: (m.driver as any).avatarUrl || null,
rating: null, rating: null,
rank: null, rank: null,
roleBadgeText: 'Admin', roleBadgeText: 'Admin',
@@ -113,7 +113,7 @@ export class LeagueDetailViewDataBuilder {
.map(m => ({ .map(m => ({
driverId: m.driverId, driverId: m.driverId,
driverName: m.driver.name, driverName: m.driver.name,
avatarUrl: (m.driver as GetDriverOutputDTO & { avatarUrl?: string }).avatarUrl || null, avatarUrl: (m.driver as any).avatarUrl || null,
rating: null, rating: null,
rank: null, rank: null,
roleBadgeText: 'Steward', roleBadgeText: 'Steward',
@@ -126,7 +126,7 @@ export class LeagueDetailViewDataBuilder {
.map(m => ({ .map(m => ({
driverId: m.driverId, driverId: m.driverId,
driverName: m.driver.name, driverName: m.driver.name,
avatarUrl: (m.driver as GetDriverOutputDTO & { avatarUrl?: string }).avatarUrl || null, avatarUrl: (m.driver as any).avatarUrl || null,
rating: null, rating: null,
rank: null, rank: null,
roleBadgeText: 'Member', roleBadgeText: 'Member',
@@ -197,6 +197,10 @@ export class LeagueDetailViewDataBuilder {
main: { price: 0, status: 'available' }, main: { price: 0, status: 'available' },
secondary: { price: 0, total: 0, occupied: 0 }, secondary: { price: 0, total: 0, occupied: 0 },
}, },
ownerId: league.ownerId,
createdAt: league.createdAt,
settings: league.settings,
usedSlots: league.usedSlots,
}, },
drivers: [], drivers: [],
races: [], races: [],

View File

@@ -1,8 +1,8 @@
'use client';
import type { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO';
import type { LeagueLogoViewData } from '@/lib/view-data/LeagueLogoViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import type { LeagueLogoViewData } from '@/lib/view-data/LeagueLogoViewData';
export class LeagueLogoViewDataBuilder { export class LeagueLogoViewDataBuilder {
public static build(apiDto: MediaBinaryDTO): LeagueLogoViewData { public static build(apiDto: MediaBinaryDTO): LeagueLogoViewData {

View File

@@ -1,4 +1,4 @@
'use client';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { DateFormatter } from '@/lib/formatters/DateFormatter'; import { DateFormatter } from '@/lib/formatters/DateFormatter';

View File

@@ -1,8 +1,8 @@
'use client';
import type { LeagueScheduleViewData } from '@/lib/view-data/LeagueScheduleViewData';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { LeagueScheduleViewData } from '@/lib/view-data/LeagueScheduleViewData';
export class LeagueScheduleViewDataBuilder { export class LeagueScheduleViewDataBuilder {
public static build(apiDto: LeagueScheduleDTO, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData { public static build(apiDto: LeagueScheduleDTO, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
@@ -24,7 +24,7 @@ export class LeagueScheduleViewDataBuilder {
sessionType: race.sessionType || 'race', sessionType: race.sessionType || 'race',
isPast, isPast,
isUpcoming, isUpcoming,
status: race.status || (isPast ? 'completed' : 'scheduled'), status: (race.status || (isPast ? 'completed' : 'scheduled')) as 'scheduled' | 'completed',
// Registration info (would come from API in real implementation) // Registration info (would come from API in real implementation)
isUserRegistered: false, isUserRegistered: false,
canRegister: isUpcoming, canRegister: isUpcoming,

View File

@@ -1,7 +1,7 @@
'use client';
import type { LeagueSettingsViewData } from '@/lib/view-data/LeagueSettingsViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { LeagueSettingsViewData } from '@/lib/view-data/LeagueSettingsViewData';
type LeagueSettingsInputDTO = { type LeagueSettingsInputDTO = {
league: { id: string; name: string; ownerId: string; createdAt: string }; league: { id: string; name: string; ownerId: string; createdAt: string };

View File

@@ -1,16 +1,12 @@
'use client';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { DateFormatter } from '@/lib/formatters/DateFormatter'; import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { StatusFormatter } from '@/lib/formatters/StatusFormatter'; import { StatusFormatter } from '@/lib/formatters/StatusFormatter';
import type { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
import type { LeagueSponsorshipsViewData } from '@/lib/view-data/LeagueSponsorshipsViewData'; import type { LeagueSponsorshipsViewData } from '@/lib/view-data/LeagueSponsorshipsViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetSeasonSponsorshipsOutputDTO } from '@/lib/types/generated/GetSeasonSponsorshipsOutputDTO';
type LeagueSponsorshipsInputDTO = GetSeasonSponsorshipsOutputDTO & { type LeagueSponsorshipsInputDTO = LeagueSponsorshipsApiDto;
leagueId: string;
league: { id: string; name: string; description: string };
sponsorshipSlots: LeagueSponsorshipsViewData['sponsorshipSlots'];
}
export class LeagueSponsorshipsViewDataBuilder { export class LeagueSponsorshipsViewDataBuilder {
public static build(apiDto: LeagueSponsorshipsInputDTO): LeagueSponsorshipsViewData { public static build(apiDto: LeagueSponsorshipsInputDTO): LeagueSponsorshipsViewData {
@@ -20,13 +16,13 @@ export class LeagueSponsorshipsViewDataBuilder {
onTabChange: () => {}, onTabChange: () => {},
league: apiDto.league, league: apiDto.league,
sponsorshipSlots: apiDto.sponsorshipSlots, sponsorshipSlots: apiDto.sponsorshipSlots,
sponsorshipRequests: apiDto.sponsorships.map(r => ({ sponsorshipRequests: apiDto.sponsorshipRequests.map(r => ({
id: r.id, id: r.id,
slotId: '', // Missing in DTO slotId: r.slotId,
sponsorId: '', // Missing in DTO sponsorId: r.sponsorId,
sponsorName: '', // Missing in DTO sponsorName: r.sponsorName,
requestedAt: r.createdAt, requestedAt: r.requestedAt,
formattedRequestedAt: DateFormatter.formatShort(r.createdAt), formattedRequestedAt: DateFormatter.formatShort(r.requestedAt),
status: r.status as 'pending' | 'approved' | 'rejected', status: r.status as 'pending' | 'approved' | 'rejected',
statusLabel: StatusFormatter.protestStatus(r.status), statusLabel: StatusFormatter.protestStatus(r.status),
})), })),

View File

@@ -1,9 +1,9 @@
'use client';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
interface LeagueStandingsApiDto { interface LeagueStandingsApiDto {
standings: LeagueStandingDTO[]; standings: LeagueStandingDTO[];
@@ -35,12 +35,12 @@ export class LeagueStandingsViewDataBuilder {
races: standing.races, races: standing.races,
racesFinished: standing.races, racesFinished: standing.races,
racesStarted: standing.races, racesStarted: standing.races,
avgFinish: null, // Not in DTO avgFinish: 0, // Not in DTO
penaltyPoints: 0, // Not in DTO penaltyPoints: 0, // Not in DTO
bonusPoints: 0, // Not in DTO bonusPoints: 0, // Not in DTO
leaderPoints: 0, // Not in DTO leaderPoints: 0, // Not in DTO
nextPoints: 0, // Not in DTO nextPoints: 0, // Not in DTO
currentUserId: null, // Not in DTO currentUserId: '', // Not in DTO
// New fields from Phase 3 // New fields from Phase 3
positionChange: standing.positionChange || 0, positionChange: standing.positionChange || 0,
lastRacePoints: standing.lastRacePoints || 0, lastRacePoints: standing.lastRacePoints || 0,
@@ -80,7 +80,7 @@ export class LeagueStandingsViewDataBuilder {
drivers: driverData, drivers: driverData,
memberships: membershipData, memberships: membershipData,
leagueId, leagueId,
currentDriverId: null, // Would need to get from auth currentDriverId: '', // Would need to get from auth
isAdmin: false, // Would need to check permissions isAdmin: false, // Would need to check permissions
isTeamChampionship: isTeamChampionship, isTeamChampionship: isTeamChampionship,
}; };

View File

@@ -1,10 +1,10 @@
'use client';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
import type { GetLeagueWalletOutputDTO } from '@/lib/types/generated/GetLeagueWalletOutputDTO'; import type { GetLeagueWalletOutputDTO } from '@/lib/types/generated/GetLeagueWalletOutputDTO';
import type { LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData'; import type { LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
import type { WalletTransactionViewData } from '@/lib/view-data/WalletTransactionViewData'; import type { WalletTransactionViewData } from '@/lib/view-data/WalletTransactionViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
type LeagueWalletInputDTO = GetLeagueWalletOutputDTO & { type LeagueWalletInputDTO = GetLeagueWalletOutputDTO & {
leagueId: string; leagueId: string;

View File

@@ -1,8 +1,8 @@
'use client';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/generated/AllLeaguesWithCapacityAndScoringDTO'; import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/generated/AllLeaguesWithCapacityAndScoringDTO';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
export class LeaguesViewDataBuilder { export class LeaguesViewDataBuilder {
public static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData { public static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
@@ -29,7 +29,7 @@ export class LeaguesViewDataBuilder {
scoring: league.scoring ? { scoring: league.scoring ? {
gameId: league.scoring.gameId, gameId: league.scoring.gameId,
gameName: league.scoring.gameName, gameName: league.scoring.gameName,
primaryChampionshipType: league.scoring.primaryChampionshipType, primaryChampionshipType: league.scoring.primaryChampionshipType as "driver" | "team" | "nations" | "trophy",
scoringPresetId: league.scoring.scoringPresetId, scoringPresetId: league.scoring.scoringPresetId,
scoringPresetName: league.scoring.scoringPresetName, scoringPresetName: league.scoring.scoringPresetName,
dropPolicySummary: league.scoring.dropPolicySummary, dropPolicySummary: league.scoring.dropPolicySummary,

View File

@@ -1,8 +1,8 @@
'use client';
import type { LoginPageDTO } from '@/lib/types/generated/LoginPageDTO';
import type { LoginViewData } from '@/lib/view-data/LoginViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
import type { LoginViewData } from '@/lib/view-data/LoginViewData';
export class LoginViewDataBuilder { export class LoginViewDataBuilder {
public static build(apiDto: LoginPageDTO): LoginViewData { public static build(apiDto: LoginPageDTO): LoginViewData {

View File

@@ -64,8 +64,8 @@ export class RaceStewardingViewDataBuilder {
}, },
filedAt: p.submittedAt, filedAt: p.submittedAt,
status: p.status, status: p.status,
decisionNotes: (p as any).decisionNotes || null, decisionNotes: (p as any).decisionNotes || undefined,
proofVideoUrl: (p as any).proofVideoUrl || null, proofVideoUrl: (p as any).proofVideoUrl || undefined,
})); }));
const pendingProtests = (apiDto as any).pendingProtests || protests.filter(p => p.status === 'pending'); const pendingProtests = (apiDto as any).pendingProtests || protests.filter(p => p.status === 'pending');
@@ -78,7 +78,7 @@ export class RaceStewardingViewDataBuilder {
type: p.type, type: p.type,
value: p.value ?? 0, value: p.value ?? 0,
reason: p.reason ?? '', reason: p.reason ?? '',
notes: p.notes || null, notes: p.notes || undefined,
})); }));
const driverMap: Record<string, { id: string; name: string }> = {}; const driverMap: Record<string, { id: string; name: string }> = {};

View File

@@ -5,7 +5,7 @@
*/ */
import type { SponsorLogoViewData } from '@/lib/view-data/SponsorLogoViewData'; import type { SponsorLogoViewData } from '@/lib/view-data/SponsorLogoViewData';
import { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO'; import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'; import { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder"; import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";

View File

@@ -5,9 +5,9 @@
*/ */
import { DateFormatter } from '@/lib/formatters/DateFormatter'; import { DateFormatter } from '@/lib/formatters/DateFormatter';
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
import type { SponsorMetric, TeamDetailData, TeamDetailViewData, TeamMemberData, TeamTab } from '@/lib/view-data/TeamDetailViewData'; import type { SponsorMetric, TeamDetailData, TeamDetailViewData, TeamMemberData, TeamTab } from '@/lib/view-data/TeamDetailViewData';
import { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO'; import { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO';
import type { TeamDetailPageDto } from '@/lib/page-queries/TeamDetailPageQuery';
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder"; import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
@@ -18,7 +18,7 @@ export class TeamDetailViewDataBuilder {
* @param apiDto - The DTO from the service * @param apiDto - The DTO from the service
* @returns ViewData for the team detail page * @returns ViewData for the team detail page
*/ */
public static build(apiDto: GetTeamDetailsOutputDTO): TeamDetailViewData { public static build(apiDto: TeamDetailPageDto): TeamDetailViewData {
// We import TeamMemberDTO just to satisfy the ESLint rule requiring a DTO import from generated // We import TeamMemberDTO just to satisfy the ESLint rule requiring a DTO import from generated
const _unused: TeamMemberDTO | null = null; const _unused: TeamMemberDTO | null = null;
void _unused; void _unused;
@@ -36,8 +36,8 @@ export class TeamDetailViewDataBuilder {
region: (apiDto.team as any).region ?? null, region: (apiDto.team as any).region ?? null,
languages: (apiDto.team as any).languages ?? null, languages: (apiDto.team as any).languages ?? null,
category: (apiDto.team as any).category ?? null, category: (apiDto.team as any).category ?? null,
membership: (apiDto as any).team?.membership ?? (apiDto.team.isRecruiting ? 'open' : null), membership: apiDto.team.membership,
canManage: apiDto.canManage ?? (apiDto.team as any).canManage ?? false, canManage: apiDto.team.canManage,
}; };
const memberships: TeamMemberData[] = (apiDto as any).memberships?.map((membership: any) => ({ const memberships: TeamMemberData[] = (apiDto as any).memberships?.map((membership: any) => ({
@@ -105,4 +105,4 @@ export class TeamDetailViewDataBuilder {
} }
} }
TeamDetailViewDataBuilder satisfies ViewDataBuilder<GetTeamDetailsOutputDTO, TeamDetailViewData>; TeamDetailViewDataBuilder satisfies ViewDataBuilder<TeamDetailPageDto, TeamDetailViewData>;

Some files were not shown because too many files have changed in this diff Show More