website refactor
This commit is contained in:
@@ -3,7 +3,9 @@ import type { UserDto, DashboardStats, UserListResponse, ListUsersQuery } from '
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { DomainError, Service } from '@/lib/contracts/services/Service';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { getWebsiteServerEnv } from '@/lib/config/env';
|
||||
|
||||
/**
|
||||
* Admin Service - DTO Only
|
||||
@@ -12,16 +14,17 @@ import { DomainError } from '@/lib/contracts/services/Service';
|
||||
* All client-side presentation logic must be handled by presenters/templates.
|
||||
* @server-safe
|
||||
*/
|
||||
export class AdminService {
|
||||
export class AdminService implements Service {
|
||||
private apiClient: AdminApiClient;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const { NODE_ENV } = getWebsiteServerEnv();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: false,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
reportToExternal: NODE_ENV === 'production',
|
||||
});
|
||||
this.apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
|
||||
import { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { DomainError, Service } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
|
||||
/**
|
||||
* DashboardService
|
||||
@@ -11,11 +13,11 @@ import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
* Pure service that creates its own API client and returns Result types.
|
||||
* No business logic, only data fetching and error mapping.
|
||||
*/
|
||||
export class DashboardService {
|
||||
export class DashboardService implements Service {
|
||||
private apiClient: DashboardApiClient;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
this.apiClient = new DashboardApiClient(baseUrl, errorReporter, logger);
|
||||
@@ -26,18 +28,26 @@ export class DashboardService {
|
||||
const dto = await this.apiClient.getDashboardOverview();
|
||||
return Result.ok(dto);
|
||||
} catch (error) {
|
||||
console.error('DashboardService.getDashboardOverview failed:', error);
|
||||
// Convert ApiError to DomainError
|
||||
if (error instanceof ApiError) {
|
||||
switch (error.type) {
|
||||
case 'NOT_FOUND':
|
||||
return Result.err({ type: 'notFound', message: error.message });
|
||||
case 'AUTH_ERROR':
|
||||
return Result.err({ type: 'unauthorized', message: error.message });
|
||||
case 'SERVER_ERROR':
|
||||
return Result.err({ type: 'serverError', message: error.message });
|
||||
case 'NETWORK_ERROR':
|
||||
case 'TIMEOUT_ERROR':
|
||||
return Result.err({ type: 'networkError', message: error.message });
|
||||
default:
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-ApiError cases
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('403') || error.message.includes('401')) {
|
||||
return Result.err({ type: 'unauthorized', message: error.message });
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
return Result.err({ type: 'notFound', message: error.message });
|
||||
}
|
||||
if (error.message.includes('5') || error.message.includes('server')) {
|
||||
return Result.err({ type: 'serverError', message: error.message });
|
||||
}
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
|
||||
return Result.err({ type: 'unknown', message: 'Dashboard fetch failed' });
|
||||
|
||||
10
apps/website/lib/services/auth/AuthPageParams.ts
Normal file
10
apps/website/lib/services/auth/AuthPageParams.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Auth Page Parameters
|
||||
*
|
||||
* Input parameters for auth page processing.
|
||||
*/
|
||||
|
||||
export interface AuthPageParams {
|
||||
returnTo?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
@@ -10,11 +10,7 @@ import { LoginPageDTO } from './types/LoginPageDTO';
|
||||
import { ForgotPasswordPageDTO } from './types/ForgotPasswordPageDTO';
|
||||
import { ResetPasswordPageDTO } from './types/ResetPasswordPageDTO';
|
||||
import { SignupPageDTO } from './types/SignupPageDTO';
|
||||
|
||||
export interface AuthPageParams {
|
||||
returnTo?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
import { AuthPageParams } from './AuthPageParams';
|
||||
|
||||
export class AuthPageService {
|
||||
async processLoginParams(params: AuthPageParams): Promise<Result<LoginPageDTO, string>> {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { isProductionEnvironment } from '@/lib/config/env';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
|
||||
type DriverProfilePageServiceError = 'notFound' | 'unauthorized' | 'serverError' | 'unknown';
|
||||
|
||||
/**
|
||||
* DriverProfilePageService
|
||||
*
|
||||
* Service for the driver profile page.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
*/
|
||||
export class DriverProfilePageService implements Service {
|
||||
async getDriverProfile(driverId: string): Promise<Result<GetDriverProfileOutputDTO, DriverProfilePageServiceError>> {
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
try {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: isProductionEnvironment(),
|
||||
});
|
||||
|
||||
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const dto = await apiClient.getDriverProfile(driverId);
|
||||
|
||||
if (!dto.currentDriver) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
return Result.ok(dto);
|
||||
} catch (error) {
|
||||
const errorAny = error as { statusCode?: number; message?: string };
|
||||
|
||||
if (errorAny.statusCode === 401 || errorAny.message?.toLowerCase().includes('unauthorized')) {
|
||||
return Result.err('unauthorized');
|
||||
}
|
||||
|
||||
if (errorAny.statusCode === 404 || errorAny.message?.toLowerCase().includes('not found')) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
logger.error('DriverProfilePageService failed', error instanceof Error ? error : undefined, { error: errorAny });
|
||||
|
||||
if (errorAny.statusCode && errorAny.statusCode >= 500) {
|
||||
return Result.err('serverError');
|
||||
}
|
||||
|
||||
return Result.err('unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/website/lib/services/drivers/DriversPageService.ts
Normal file
58
apps/website/lib/services/drivers/DriversPageService.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { isProductionEnvironment } from '@/lib/config/env';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
|
||||
type DriversPageServiceError = 'notFound' | 'serverError' | 'unknown';
|
||||
|
||||
/**
|
||||
* DriversPageService
|
||||
*
|
||||
* Service for the drivers listing page.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
*/
|
||||
export class DriversPageService implements Service {
|
||||
async getLeaderboard(): Promise<Result<DriversLeaderboardDTO, DriversPageServiceError>> {
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
try {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: isProductionEnvironment(),
|
||||
});
|
||||
|
||||
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const result = await apiClient.getLeaderboard();
|
||||
|
||||
if (!result || !result.drivers) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
// Transform to the expected DTO format
|
||||
const dto: DriversLeaderboardDTO = {
|
||||
drivers: result.drivers,
|
||||
totalRaces: result.drivers.reduce((sum, driver) => sum + driver.racesCompleted, 0),
|
||||
totalWins: result.drivers.reduce((sum, driver) => sum + driver.wins, 0),
|
||||
activeCount: result.drivers.filter(driver => driver.isActive).length,
|
||||
};
|
||||
|
||||
return Result.ok(dto);
|
||||
} catch (error) {
|
||||
const errorAny = error as { statusCode?: number; message?: string };
|
||||
|
||||
logger.error('DriversPageService failed', error instanceof Error ? error : undefined, { error: errorAny });
|
||||
|
||||
if (errorAny.statusCode && errorAny.statusCode >= 500) {
|
||||
return Result.err('serverError');
|
||||
}
|
||||
|
||||
return Result.err('unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/services/drivers/LiveryService.ts
Normal file
17
apps/website/lib/services/drivers/LiveryService.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
|
||||
/**
|
||||
* Livery Service
|
||||
*
|
||||
* Currently not implemented - returns NotImplemented errors for all endpoints.
|
||||
*/
|
||||
export class LiveryService implements Service {
|
||||
async getLiveries(): Promise<Result<void, 'NOT_IMPLEMENTED'>> {
|
||||
return Result.err('NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
async uploadLivery(): Promise<Result<void, 'NOT_IMPLEMENTED'>> {
|
||||
return Result.err('NOT_IMPLEMENTED');
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/services/drivers/SettingsService.ts
Normal file
17
apps/website/lib/services/drivers/SettingsService.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
|
||||
/**
|
||||
* Settings Service
|
||||
*
|
||||
* Currently not implemented - returns NotImplemented errors for all endpoints.
|
||||
*/
|
||||
export class SettingsService implements Service {
|
||||
async getSettings(): Promise<Result<void, 'NOT_IMPLEMENTED'>> {
|
||||
return Result.err('NOT_IMPLEMENTED');
|
||||
}
|
||||
|
||||
async updateSettings(): Promise<Result<void, 'NOT_IMPLEMENTED'>> {
|
||||
return Result.err('NOT_IMPLEMENTED');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
|
||||
|
||||
// API Clients
|
||||
@@ -44,7 +45,7 @@ export async function getHomeData(): Promise<HomeDataDTO> {
|
||||
// Check session and redirect if logged in
|
||||
const session = await sessionService.getSession();
|
||||
if (session) {
|
||||
redirect('/dashboard');
|
||||
redirect(routes.protected.dashboard);
|
||||
}
|
||||
|
||||
// Get feature flags
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
/**
|
||||
* Landing Service - DTO Only
|
||||
*
|
||||
@@ -10,4 +13,8 @@ export class LandingService {
|
||||
async getLandingData(): Promise<any> {
|
||||
return { featuredLeagues: [], stats: {} };
|
||||
}
|
||||
|
||||
async signup(email: string): Promise<Result<void, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'Email signup endpoint' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export interface DriverRankingsData {
|
||||
drivers: DriverLeaderboardItemDTO[];
|
||||
}
|
||||
|
||||
export class DriverRankingsService implements Service {
|
||||
async getDriverRankings(): Promise<Result<DriverRankingsData, DomainError>> {
|
||||
try {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
const apiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const result = await apiClient.getLeaderboard();
|
||||
|
||||
if (!result || !result.drivers) {
|
||||
return Result.err({ type: 'notFound', message: 'No driver rankings available' });
|
||||
}
|
||||
|
||||
const data: DriverRankingsData = {
|
||||
drivers: result.drivers,
|
||||
};
|
||||
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
// Convert ApiError to DomainError
|
||||
if (error instanceof ApiError) {
|
||||
switch (error.type) {
|
||||
case 'NOT_FOUND':
|
||||
return Result.err({ type: 'notFound', message: error.message });
|
||||
case 'AUTH_ERROR':
|
||||
return Result.err({ type: 'unauthorized', message: error.message });
|
||||
case 'SERVER_ERROR':
|
||||
return Result.err({ type: 'serverError', message: error.message });
|
||||
case 'NETWORK_ERROR':
|
||||
case 'TIMEOUT_ERROR':
|
||||
return Result.err({ type: 'networkError', message: error.message });
|
||||
default:
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-ApiError cases
|
||||
if (error instanceof Error) {
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
|
||||
return Result.err({ type: 'unknown', message: 'Driver rankings fetch failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export interface LeaderboardsData {
|
||||
drivers: { drivers: DriverLeaderboardItemDTO[] };
|
||||
teams: { teams: TeamListItemDTO[] };
|
||||
}
|
||||
|
||||
export class LeaderboardsService implements Service {
|
||||
async getLeaderboards(): Promise<Result<LeaderboardsData, DomainError>> {
|
||||
try {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
const logger = new ConsoleLogger();
|
||||
|
||||
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
const [driverResult, teamResult] = await Promise.all([
|
||||
driversApiClient.getLeaderboard(),
|
||||
teamsApiClient.getAll(),
|
||||
]);
|
||||
|
||||
if (!driverResult && !teamResult) {
|
||||
return Result.err({ type: 'notFound', message: 'No leaderboard data available' });
|
||||
}
|
||||
|
||||
// Check if team ranking is needed but not provided by API
|
||||
// TeamListItemDTO does not have a rank field, so we cannot provide ranked team data
|
||||
if (teamResult && teamResult.teams.length > 0) {
|
||||
const hasRankField = teamResult.teams.some(team => 'rank' in team);
|
||||
if (!hasRankField) {
|
||||
return Result.err({ type: 'serverError', message: 'Team ranking not implemented' });
|
||||
}
|
||||
}
|
||||
|
||||
const data: LeaderboardsData = {
|
||||
drivers: driverResult || { drivers: [] },
|
||||
teams: teamResult || { teams: [] },
|
||||
};
|
||||
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
// Convert ApiError to DomainError
|
||||
if (error instanceof ApiError) {
|
||||
switch (error.type) {
|
||||
case 'NOT_FOUND':
|
||||
return Result.err({ type: 'notFound', message: error.message });
|
||||
case 'AUTH_ERROR':
|
||||
return Result.err({ type: 'unauthorized', message: error.message });
|
||||
case 'SERVER_ERROR':
|
||||
return Result.err({ type: 'serverError', message: error.message });
|
||||
case 'NETWORK_ERROR':
|
||||
case 'TIMEOUT_ERROR':
|
||||
return Result.err({ type: 'networkError', message: error.message });
|
||||
default:
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-ApiError cases
|
||||
if (error instanceof Error) {
|
||||
return Result.err({ type: 'unknown', message: error.message });
|
||||
}
|
||||
|
||||
return Result.err({ type: 'unknown', message: 'Leaderboards fetch failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
/**
|
||||
* League Settings Service - DTO Only
|
||||
@@ -25,4 +27,20 @@ export class LeagueSettingsService {
|
||||
async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<{ success: boolean }> {
|
||||
return this.leagueApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId);
|
||||
}
|
||||
|
||||
async selectScoringPreset(leagueId: string, preset: string): Promise<Result<void, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'selectScoringPreset' });
|
||||
}
|
||||
|
||||
async toggleCustomScoring(leagueId: string, enabled: boolean): Promise<Result<void, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'toggleCustomScoring' });
|
||||
}
|
||||
|
||||
getPresetEmoji(preset: string): string {
|
||||
return '🏆';
|
||||
}
|
||||
|
||||
getPresetDescription(preset: string): string {
|
||||
return `Scoring preset: ${preset}`;
|
||||
}
|
||||
}
|
||||
97
apps/website/lib/services/leagues/ProfileLeaguesService.ts
Normal file
97
apps/website/lib/services/leagues/ProfileLeaguesService.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ApiClient } from '@/lib/api';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
|
||||
type ProfileLeaguesServiceError = 'notFound' | 'redirect' | 'unknown';
|
||||
|
||||
interface ProfileLeaguesPageDto {
|
||||
ownedLeagues: Array<{
|
||||
leagueId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
membershipRole: 'owner' | 'admin' | 'steward' | 'member';
|
||||
}>;
|
||||
memberLeagues: Array<{
|
||||
leagueId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
membershipRole: 'owner' | 'admin' | 'steward' | 'member';
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MembershipDTO {
|
||||
driverId: string;
|
||||
role: string;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export class ProfileLeaguesService implements Service {
|
||||
async getProfileLeagues(driverId: string): Promise<Result<ProfileLeaguesPageDto, ProfileLeaguesServiceError>> {
|
||||
try {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const apiClient = new ApiClient(baseUrl);
|
||||
|
||||
const leaguesDto = await apiClient.leagues.getAllWithCapacity();
|
||||
|
||||
if (!leaguesDto?.leagues) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
// Fetch all memberships in parallel
|
||||
const leagueMemberships = await Promise.all(
|
||||
leaguesDto.leagues.map(async (league) => {
|
||||
try {
|
||||
const membershipsDto = await apiClient.leagues.getMemberships(league.id);
|
||||
|
||||
let memberships: MembershipDTO[] = [];
|
||||
if (membershipsDto && typeof membershipsDto === 'object') {
|
||||
if ('members' in membershipsDto && Array.isArray((membershipsDto as { members?: unknown }).members)) {
|
||||
memberships = (membershipsDto as { members: MembershipDTO[] }).members;
|
||||
} else if ('memberships' in membershipsDto && Array.isArray((membershipsDto as { memberships?: unknown }).memberships)) {
|
||||
memberships = (membershipsDto as { memberships: MembershipDTO[] }).memberships;
|
||||
}
|
||||
}
|
||||
|
||||
const currentMembership = memberships.find((m) => m.driverId === driverId);
|
||||
|
||||
if (currentMembership && currentMembership.status === 'active') {
|
||||
return {
|
||||
leagueId: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
membershipRole: currentMembership.role as 'owner' | 'admin' | 'steward' | 'member',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter and categorize
|
||||
const validLeagues = leagueMemberships.filter((l): l is NonNullable<typeof l> => l !== null);
|
||||
|
||||
const ownedLeagues = validLeagues.filter((l) => l.membershipRole === 'owner');
|
||||
const memberLeagues = validLeagues.filter((l) => l.membershipRole !== 'owner');
|
||||
|
||||
return Result.ok({
|
||||
ownedLeagues,
|
||||
memberLeagues,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorAny = error as { statusCode?: number; message?: string };
|
||||
|
||||
if (errorAny.statusCode === 404 || errorAny.message?.toLowerCase().includes('not found')) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
if (errorAny.statusCode === 302 || errorAny.message?.toLowerCase().includes('redirect')) {
|
||||
return Result.err('redirect');
|
||||
}
|
||||
|
||||
return Result.err('unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,21 @@
|
||||
import { describe, it, expect, vi, Mocked } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MediaService } from './MediaService';
|
||||
import { MediaApiClient } from '@/lib/api/media/MediaApiClient';
|
||||
import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
|
||||
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
|
||||
import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
|
||||
// Simple test that verifies the service structure
|
||||
describe('MediaService', () => {
|
||||
let mockApiClient: Mocked<MediaApiClient>;
|
||||
let service: MediaService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiClient = {
|
||||
uploadMedia: vi.fn(),
|
||||
getMedia: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
requestAvatarGeneration: vi.fn(),
|
||||
getAvatar: vi.fn(),
|
||||
updateAvatar: vi.fn(),
|
||||
validateFacePhoto: vi.fn(),
|
||||
} as unknown as Mocked<MediaApiClient>;
|
||||
|
||||
service = new MediaService(mockApiClient);
|
||||
it('should be defined', () => {
|
||||
expect(MediaService).toBeDefined();
|
||||
});
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('should call apiClient.uploadMedia with correct input and return view model', async () => {
|
||||
const input = {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
type: 'image',
|
||||
category: 'avatar' as const,
|
||||
};
|
||||
|
||||
const expectedOutput = { success: true, mediaId: 'media-123', url: 'https://example.com/media.jpg' };
|
||||
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.uploadMedia(input);
|
||||
|
||||
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
|
||||
expect(result).toBeInstanceOf(UploadMediaViewModel);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mediaId).toBe('media-123');
|
||||
expect(result.url).toBe('https://example.com/media.jpg');
|
||||
expect(result.isSuccessful).toBe(true);
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error response', async () => {
|
||||
const input = {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
type: 'image',
|
||||
};
|
||||
|
||||
const expectedOutput = { success: false, error: 'Upload failed' };
|
||||
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.uploadMedia(input);
|
||||
|
||||
expect(result).toBeInstanceOf(UploadMediaViewModel);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Upload failed');
|
||||
expect(result.isSuccessful).toBe(false);
|
||||
expect(result.hasError).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle upload without category', async () => {
|
||||
const input = {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
type: 'image',
|
||||
};
|
||||
|
||||
const expectedOutput = { success: true, mediaId: 'media-456', url: 'https://example.com/media2.jpg' };
|
||||
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.uploadMedia(input);
|
||||
|
||||
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
|
||||
expect(result).toBeInstanceOf(UploadMediaViewModel);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMedia', () => {
|
||||
it('should call apiClient.getMedia with mediaId and return view model', async () => {
|
||||
const mediaId = 'media-123';
|
||||
|
||||
const expectedOutput = {
|
||||
id: 'media-123',
|
||||
url: 'https://example.com/image.jpg',
|
||||
type: 'image',
|
||||
category: 'avatar',
|
||||
uploadedAt: '2023-01-15T00:00:00.000Z',
|
||||
size: 2048000,
|
||||
};
|
||||
mockApiClient.getMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.getMedia(mediaId);
|
||||
|
||||
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(result).toBeInstanceOf(MediaViewModel);
|
||||
expect(result.id).toBe('media-123');
|
||||
expect(result.url).toBe('https://example.com/image.jpg');
|
||||
expect(result.type).toBe('image');
|
||||
expect(result.category).toBe('avatar');
|
||||
expect(result.uploadedAt).toEqual(new Date('2023-01-15T00:00:00.000Z'));
|
||||
expect(result.size).toBe(2048000);
|
||||
expect(result.formattedSize).toBe('1.95 MB');
|
||||
});
|
||||
|
||||
it('should handle media without category and size', async () => {
|
||||
const mediaId = 'media-456';
|
||||
|
||||
const expectedOutput = {
|
||||
id: 'media-456',
|
||||
url: 'https://example.com/video.mp4',
|
||||
type: 'video',
|
||||
uploadedAt: '2023-02-20T00:00:00.000Z',
|
||||
};
|
||||
mockApiClient.getMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.getMedia(mediaId);
|
||||
|
||||
expect(result).toBeInstanceOf(MediaViewModel);
|
||||
expect(result.id).toBe('media-456');
|
||||
expect(result.type).toBe('video');
|
||||
expect(result.category).toBeUndefined();
|
||||
expect(result.size).toBeUndefined();
|
||||
expect(result.formattedSize).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia', () => {
|
||||
it('should call apiClient.deleteMedia with mediaId and return view model', async () => {
|
||||
const mediaId = 'media-123';
|
||||
|
||||
const expectedOutput = { success: true };
|
||||
mockApiClient.deleteMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.deleteMedia(mediaId);
|
||||
|
||||
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
|
||||
expect(result).toBeInstanceOf(DeleteMediaViewModel);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isSuccessful).toBe(true);
|
||||
expect(result.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error response', async () => {
|
||||
const mediaId = 'media-456';
|
||||
|
||||
const expectedOutput = { success: false, error: 'Deletion failed' };
|
||||
mockApiClient.deleteMedia.mockResolvedValue(expectedOutput);
|
||||
|
||||
const result = await service.deleteMedia(mediaId);
|
||||
|
||||
expect(result).toBeInstanceOf(DeleteMediaViewModel);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Deletion failed');
|
||||
expect(result.isSuccessful).toBe(false);
|
||||
expect(result.hasError).toBe(true);
|
||||
});
|
||||
it('should have all required methods', () => {
|
||||
const service = new MediaService();
|
||||
expect(typeof service.getAvatar).toBe('function');
|
||||
expect(typeof service.getCategoryIcon).toBe('function');
|
||||
expect(typeof service.getLeagueCover).toBe('function');
|
||||
expect(typeof service.getLeagueLogo).toBe('function');
|
||||
expect(typeof service.getSponsorLogo).toBe('function');
|
||||
expect(typeof service.getTeamLogo).toBe('function');
|
||||
expect(typeof service.getTrackImage).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,75 @@
|
||||
/**
|
||||
* Media Service - DTO Only
|
||||
* MediaService
|
||||
*
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
* Frontend orchestration service for media operations.
|
||||
* Handles binary media fetching with proper error handling.
|
||||
*/
|
||||
export class MediaService {
|
||||
constructor(private readonly apiClient: any) {}
|
||||
|
||||
async getMediaById(mediaId: string): Promise<any> {
|
||||
return { id: mediaId, url: '' };
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
||||
import { MediaAdapter } from '@/lib/adapters/MediaAdapter';
|
||||
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
/**
|
||||
* MediaService
|
||||
*
|
||||
* Handles media asset fetching with proper error handling.
|
||||
* Creates its own dependencies and orchestrates adapter calls.
|
||||
*/
|
||||
export class MediaService implements Service {
|
||||
private adapter: MediaAdapter;
|
||||
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
this.adapter = new MediaAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar for a driver
|
||||
*/
|
||||
async getAvatar(driverId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/avatar/${driverId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category icon
|
||||
*/
|
||||
async getCategoryIcon(categoryId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/categories/${categoryId}/icon`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league cover
|
||||
*/
|
||||
async getLeagueCover(leagueId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/leagues/${leagueId}/cover`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league logo
|
||||
*/
|
||||
async getLeagueLogo(leagueId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/leagues/${leagueId}/logo`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsor logo
|
||||
*/
|
||||
async getSponsorLogo(sponsorId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/sponsors/${sponsorId}/logo`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team logo
|
||||
*/
|
||||
async getTeamLogo(teamId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/teams/${teamId}/logo`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get track image
|
||||
*/
|
||||
async getTrackImage(trackId: string): Promise<Result<MediaBinaryDTO, DomainError>> {
|
||||
return this.adapter.fetchMedia(`/media/tracks/${trackId}/image`);
|
||||
}
|
||||
}
|
||||
@@ -54,22 +54,12 @@ export class OnboardingService implements Service {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async generateAvatars(input: RequestAvatarGenerationInputDTO): Promise<Result<RequestAvatarGenerationOutputDTO, DomainError>> {
|
||||
try {
|
||||
// TODO: Implement actual API call when available
|
||||
// For now, return mock result
|
||||
const mockResult: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [
|
||||
`https://api.example.com/avatars/${input.userId}/1.png`,
|
||||
`https://api.example.com/avatars/${input.userId}/2.png`,
|
||||
`https://api.example.com/avatars/${input.userId}/3.png`,
|
||||
],
|
||||
};
|
||||
return Result.ok(mockResult);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to generate avatars';
|
||||
return Result.err({ type: 'unknown', message: errorMessage });
|
||||
}
|
||||
// Endpoint not implemented yet - return NotImplemented error
|
||||
return Result.err({
|
||||
type: 'notImplemented',
|
||||
message: 'Avatar generation endpoint is not implemented yet'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,87 @@
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
/**
|
||||
* Race Results Service - DTO Only
|
||||
*
|
||||
* Race Results Service
|
||||
*
|
||||
* Orchestration service for race results operations.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
export class RaceResultsService {
|
||||
constructor(private readonly apiClient: any) {}
|
||||
private apiClient: RacesApiClient;
|
||||
|
||||
async getRaceResults(raceId: string): Promise<any> {
|
||||
return { raceId, results: [] };
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
|
||||
this.apiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race results detail
|
||||
* Returns results for a specific race
|
||||
*/
|
||||
async getRaceResultsDetail(raceId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getResultsDetail(raceId);
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch race results'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race with strength of field
|
||||
* Returns race data with SOF calculation
|
||||
*/
|
||||
async getWithSOF(raceId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getWithSOF(raceId);
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch race SOF'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapApiErrorType(apiErrorType: string): DomainError['type'] {
|
||||
switch (apiErrorType) {
|
||||
case 'NOT_FOUND':
|
||||
return 'notFound';
|
||||
case 'AUTH_ERROR':
|
||||
return 'unauthorized';
|
||||
case 'VALIDATION_ERROR':
|
||||
return 'validation';
|
||||
case 'SERVER_ERROR':
|
||||
return 'serverError';
|
||||
case 'NETWORK_ERROR':
|
||||
return 'networkError';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,9 @@ export class RaceService {
|
||||
async getRacesByLeagueId(leagueId: string): Promise<any> {
|
||||
return this.apiClient.getPageData(leagueId);
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<any[]> {
|
||||
const result = await this.apiClient.getPageData(leagueId);
|
||||
return result.races || [];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,110 @@
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
|
||||
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
/**
|
||||
* Race Stewarding Service - DTO Only
|
||||
*
|
||||
* Race Stewarding Service
|
||||
*
|
||||
* Orchestration service for race stewarding operations.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
export class RaceStewardingService {
|
||||
constructor(private readonly raceApiClient: any, private readonly protestApiClient: any, private readonly penaltyApiClient: any) {}
|
||||
private racesApiClient: RacesApiClient;
|
||||
private protestsApiClient: ProtestsApiClient;
|
||||
private penaltiesApiClient: PenaltiesApiClient;
|
||||
|
||||
async getRaceStewarding(raceId: string): Promise<any> {
|
||||
return { raceId, protests: [], penalties: [] };
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
|
||||
this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
this.protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger);
|
||||
this.penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race stewarding data
|
||||
* Returns protests and penalties for a race
|
||||
*/
|
||||
async getRaceStewarding(raceId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
// Fetch data in parallel
|
||||
const [raceDetail, protests, penalties] = await Promise.all([
|
||||
this.racesApiClient.getDetail(raceId, ''),
|
||||
this.protestsApiClient.getRaceProtests(raceId),
|
||||
this.penaltiesApiClient.getRacePenalties(raceId),
|
||||
]);
|
||||
|
||||
// Transform data to match view model structure
|
||||
const protestsData = protests.protests.map((p: any) => ({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
incident: {
|
||||
lap: p.lap,
|
||||
description: p.description,
|
||||
},
|
||||
filedAt: p.filedAt,
|
||||
status: p.status,
|
||||
}));
|
||||
|
||||
const pendingProtests = protestsData.filter((p: any) => p.status === 'pending' || p.status === 'under_review');
|
||||
const resolvedProtests = protestsData.filter((p: any) =>
|
||||
p.status === 'upheld' ||
|
||||
p.status === 'dismissed' ||
|
||||
p.status === 'withdrawn'
|
||||
);
|
||||
|
||||
const data = {
|
||||
race: raceDetail.race,
|
||||
league: raceDetail.league,
|
||||
protests: protestsData,
|
||||
penalties: penalties.penalties,
|
||||
driverMap: { ...protests.driverMap, ...penalties.driverMap },
|
||||
pendingProtests,
|
||||
resolvedProtests,
|
||||
pendingCount: pendingProtests.length,
|
||||
resolvedCount: resolvedProtests.length,
|
||||
penaltiesCount: penalties.penalties.length,
|
||||
};
|
||||
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch stewarding data'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapApiErrorType(apiErrorType: string): DomainError['type'] {
|
||||
switch (apiErrorType) {
|
||||
case 'NOT_FOUND':
|
||||
return 'notFound';
|
||||
case 'AUTH_ERROR':
|
||||
return 'unauthorized';
|
||||
case 'VALIDATION_ERROR':
|
||||
return 'validation';
|
||||
case 'SERVER_ERROR':
|
||||
return 'serverError';
|
||||
case 'NETWORK_ERROR':
|
||||
return 'networkError';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
153
apps/website/lib/services/races/RacesService.ts
Normal file
153
apps/website/lib/services/races/RacesService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
/**
|
||||
* Races Service
|
||||
*
|
||||
* Orchestration service for race-related operations.
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
*/
|
||||
export class RacesService {
|
||||
private apiClient: RacesApiClient;
|
||||
|
||||
constructor() {
|
||||
// Service creates its own dependencies
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new ConsoleErrorReporter();
|
||||
|
||||
this.apiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get races page data
|
||||
* Returns races for the main races page
|
||||
*/
|
||||
async getRacesPageData(): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getPageData();
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch races page data'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race detail
|
||||
* Returns detailed information for a specific race
|
||||
*/
|
||||
async getRaceDetail(raceId: string, driverId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getDetail(raceId, driverId);
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch race detail'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race results detail
|
||||
* Returns results for a specific race
|
||||
*/
|
||||
async getRaceResultsDetail(raceId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getResultsDetail(raceId);
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch race results'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race with strength of field
|
||||
* Returns race data with SOF calculation
|
||||
*/
|
||||
async getRaceWithSOF(raceId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getWithSOF(raceId);
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch race SOF'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all races for the all races page
|
||||
* Returns all races with pagination support
|
||||
*/
|
||||
async getAllRacesPageData(): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const data = await this.apiClient.getPageData();
|
||||
return Result.ok(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return Result.err({
|
||||
type: this.mapApiErrorType(error.type),
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
return Result.err({
|
||||
type: 'unknown',
|
||||
message: 'Failed to fetch all races'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapApiErrorType(apiErrorType: string): DomainError['type'] {
|
||||
switch (apiErrorType) {
|
||||
case 'NOT_FOUND':
|
||||
return 'notFound';
|
||||
case 'AUTH_ERROR':
|
||||
return 'unauthorized';
|
||||
case 'VALIDATION_ERROR':
|
||||
return 'validation';
|
||||
case 'SERVER_ERROR':
|
||||
return 'serverError';
|
||||
case 'NETWORK_ERROR':
|
||||
return 'networkError';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
/**
|
||||
* Sponsor Service - DTO Only
|
||||
*
|
||||
@@ -7,7 +10,102 @@
|
||||
export class SponsorService {
|
||||
constructor(private readonly apiClient: any) {}
|
||||
|
||||
async getSponsorById(sponsorId: string): Promise<any> {
|
||||
return { id: sponsorId, name: 'Sponsor' };
|
||||
async getSponsorById(sponsorId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getSponsor(sponsorId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getSponsorById' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSponsorDashboard(sponsorId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getDashboard(sponsorId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getSponsorDashboard' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSponsorSponsorships(sponsorId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getSponsorships(sponsorId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getSponsorSponsorships' });
|
||||
}
|
||||
}
|
||||
|
||||
async getBilling(sponsorId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getBilling(sponsorId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getBilling' });
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableLeagues(): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getAvailableLeagues();
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getAvailableLeagues' });
|
||||
}
|
||||
}
|
||||
|
||||
async getLeagueDetail(leagueId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getLeagueDetail(leagueId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getLeagueDetail' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSettings(sponsorId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getSettings(sponsorId);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getSettings' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateSettings(sponsorId: string, input: any): Promise<Result<void, DomainError>> {
|
||||
try {
|
||||
await this.apiClient.updateSettings(sponsorId, input);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'updateSettings' });
|
||||
}
|
||||
}
|
||||
|
||||
async acceptSponsorshipRequest(requestId: string, sponsorId: string): Promise<Result<void, DomainError>> {
|
||||
try {
|
||||
await this.apiClient.acceptSponsorshipRequest(requestId, sponsorId);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'acceptSponsorshipRequest' });
|
||||
}
|
||||
}
|
||||
|
||||
async rejectSponsorshipRequest(requestId: string, sponsorId: string, reason?: string): Promise<Result<void, DomainError>> {
|
||||
try {
|
||||
await this.apiClient.rejectSponsorshipRequest(requestId, sponsorId, reason);
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'rejectSponsorshipRequest' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingSponsorshipRequests(input: any): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getPendingSponsorshipRequests(input);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'notImplemented', message: 'getPendingSponsorshipRequests' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,15 @@ import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import type { GetPendingSponsorshipRequestsOutputDTO } from '@/lib/types/generated/GetPendingSponsorshipRequestsOutputDTO';
|
||||
import type { AcceptSponsorshipRequestInputDTO } from '@/lib/types/generated/AcceptSponsorshipRequestInputDTO';
|
||||
import type { RejectSponsorshipRequestInputDTO } from '@/lib/types/generated/RejectSponsorshipRequestInputDTO';
|
||||
|
||||
export interface AcceptSponsorshipRequestCommand {
|
||||
requestId: string;
|
||||
actorDriverId: string;
|
||||
interface GetPendingRequestsInput {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export interface RejectSponsorshipRequestCommand {
|
||||
requestId: string;
|
||||
actorDriverId: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export type AcceptSponsorshipRequestServiceError =
|
||||
| 'ACCEPT_SPONSORSHIP_REQUEST_FAILED';
|
||||
|
||||
export type RejectSponsorshipRequestServiceError =
|
||||
| 'REJECT_SPONSORSHIP_REQUEST_FAILED';
|
||||
|
||||
export class SponsorshipRequestsService implements Service {
|
||||
private readonly client: SponsorsApiClient;
|
||||
|
||||
@@ -38,13 +29,29 @@ export class SponsorshipRequestsService implements Service {
|
||||
this.client = new SponsorsApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
async acceptRequest(
|
||||
command: AcceptSponsorshipRequestCommand,
|
||||
): Promise<Result<void, AcceptSponsorshipRequestServiceError>> {
|
||||
async getPendingRequests(
|
||||
input: GetPendingRequestsInput,
|
||||
): Promise<Result<GetPendingSponsorshipRequestsOutputDTO, 'GET_PENDING_REQUESTS_FAILED'>> {
|
||||
try {
|
||||
await this.client.acceptSponsorshipRequest(command.requestId, {
|
||||
actorDriverId: command.actorDriverId,
|
||||
} as any);
|
||||
const result = await this.client.getPendingSponsorshipRequests({
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
});
|
||||
|
||||
return Result.ok(result);
|
||||
} catch {
|
||||
return Result.err('GET_PENDING_REQUESTS_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
async acceptRequest(
|
||||
command: { requestId: string; actorDriverId: string },
|
||||
): Promise<Result<void, 'ACCEPT_SPONSORSHIP_REQUEST_FAILED'>> {
|
||||
try {
|
||||
const input: AcceptSponsorshipRequestInputDTO = {
|
||||
respondedBy: command.actorDriverId,
|
||||
};
|
||||
await this.client.acceptSponsorshipRequest(command.requestId, input);
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch {
|
||||
@@ -53,13 +60,14 @@ export class SponsorshipRequestsService implements Service {
|
||||
}
|
||||
|
||||
async rejectRequest(
|
||||
command: RejectSponsorshipRequestCommand,
|
||||
): Promise<Result<void, RejectSponsorshipRequestServiceError>> {
|
||||
command: { requestId: string; actorDriverId: string; reason: string | null },
|
||||
): Promise<Result<void, 'REJECT_SPONSORSHIP_REQUEST_FAILED'>> {
|
||||
try {
|
||||
await this.client.rejectSponsorshipRequest(command.requestId, {
|
||||
actorDriverId: command.actorDriverId,
|
||||
reason: command.reason ?? undefined,
|
||||
} as any);
|
||||
const input: RejectSponsorshipRequestInputDTO = {
|
||||
respondedBy: command.actorDriverId,
|
||||
reason: command.reason || undefined,
|
||||
};
|
||||
await this.client.rejectSponsorshipRequest(command.requestId, input);
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch {
|
||||
|
||||
48
apps/website/lib/services/teams/TeamJoinService.ts
Normal file
48
apps/website/lib/services/teams/TeamJoinService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError, Service } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
|
||||
/**
|
||||
* Team Join Service - ViewModels
|
||||
*
|
||||
* Returns ViewModels for team join requests.
|
||||
* Handles presentation logic for join request management.
|
||||
*/
|
||||
export class TeamJoinService implements Service {
|
||||
private apiClient: TeamsApiClient;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
async getJoinRequests(teamId: string, currentDriverId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
|
||||
try {
|
||||
const result = await this.apiClient.getJoinRequests(teamId);
|
||||
return result.requests.map(request =>
|
||||
new TeamJoinRequestViewModel(request, currentDriverId, isOwner)
|
||||
);
|
||||
} catch (error) {
|
||||
// Return empty array on error
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async approveJoinRequest(): Promise<void> {
|
||||
throw new Error('Not implemented: API endpoint for approving join requests');
|
||||
}
|
||||
|
||||
async rejectJoinRequest(): Promise<void> {
|
||||
throw new Error('Not implemented: API endpoint for rejecting join requests');
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,79 @@
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError, Service } from '@/lib/contracts/services/Service';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
|
||||
/**
|
||||
* Team Service - DTO Only
|
||||
*
|
||||
* Returns raw API DTOs. No ViewModels or UX logic.
|
||||
* All client-side presentation logic must be handled by hooks/components.
|
||||
*/
|
||||
export class TeamService {
|
||||
constructor(private readonly apiClient: any) {}
|
||||
export class TeamService implements Service {
|
||||
private apiClient: TeamsApiClient;
|
||||
|
||||
async getTeamById(teamId: string): Promise<any> {
|
||||
return { id: teamId, name: 'Team' };
|
||||
constructor() {
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
async getTeamsByLeagueId(leagueId: string): Promise<any> {
|
||||
return { teams: [] };
|
||||
async getAllTeams(): Promise<Result<TeamSummaryViewModel[], DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getAll();
|
||||
return Result.ok(result.teams.map(team => new TeamSummaryViewModel(team)));
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'unknown', message: 'Failed to fetch teams' });
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamDetails(_teamId: string, _currentDriverId: string): Promise<Result<any, DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getDetails(_teamId);
|
||||
if (!result) {
|
||||
return Result.err({ type: 'notFound', message: 'Team not found' });
|
||||
}
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'unknown', message: 'Failed to fetch team details' });
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: string, currentDriverId: string, ownerId: string): Promise<Result<TeamMemberViewModel[], DomainError>> {
|
||||
try {
|
||||
const result = await this.apiClient.getMembers(teamId);
|
||||
return Result.ok(result.members.map(member => new TeamMemberViewModel(member, currentDriverId, ownerId)));
|
||||
} catch (error) {
|
||||
return Result.err({ type: 'unknown', message: 'Failed to fetch team members' });
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamJoinRequests(_teamId: string): Promise<Result<any, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'getTeamJoinRequests' });
|
||||
}
|
||||
|
||||
async createTeam(_input: any): Promise<Result<any, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'createTeam' });
|
||||
}
|
||||
|
||||
async updateTeam(_teamId: string, _input: any): Promise<Result<any, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'updateTeam' });
|
||||
}
|
||||
|
||||
async getDriverTeam(_driverId: string): Promise<Result<any, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'getDriverTeam' });
|
||||
}
|
||||
|
||||
async getMembership(_teamId: string, _driverId: string): Promise<Result<any, DomainError>> {
|
||||
return Result.err({ type: 'notImplemented', message: 'getMembership' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user