This commit is contained in:
2026-01-08 21:36:15 +01:00
parent 05cf3bafd2
commit d689df0270
23 changed files with 25233 additions and 88 deletions

View File

@@ -10,6 +10,7 @@ import { HelloModule } from './domain/hello/HelloModule';
import { LeagueModule } from './domain/league/LeagueModule';
import { LoggingModule } from './domain/logging/LoggingModule';
import { MediaModule } from './domain/media/MediaModule';
import { NotificationsModule } from './domain/notifications/NotificationsModule';
import { PaymentsModule } from './domain/payments/PaymentsModule';
import { AdminModule } from './domain/admin/AdminModule';
import { PolicyModule } from './domain/policy/PolicyModule';
@@ -44,6 +45,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
SponsorModule,
DriverModule,
MediaModule,
NotificationsModule,
PaymentsModule,
PolicyModule,
AdminModule,

View File

@@ -57,6 +57,7 @@ export class AnalyticsController {
@Get('metrics')
@ApiOperation({ summary: 'Get analytics metrics' })
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
return this.analyticsService.getAnalyticsMetrics();
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, Inject, Res } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, Inject, Res, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { Public } from './Public';
import { AuthService } from './AuthService';
import { LoginParamsDTO, SignupParamsDTO, SignupSponsorParamsDTO, AuthSessionDTO, ForgotPasswordDTO, ResetPasswordDTO } from './dtos/AuthDto';
@@ -12,7 +12,15 @@ export class AuthController {
@Post('signup')
async signup(@Body() params: SignupParamsDTO): Promise<AuthSessionDTO> {
return this.authService.signupWithEmail(params);
try {
return await this.authService.signupWithEmail(params);
} catch (error) {
if (error instanceof Error) {
// Convert validation or business logic errors to BadRequest
throw new BadRequestException(error.message);
}
throw error;
}
}
@Post('signup-sponsor')
@@ -22,7 +30,15 @@ export class AuthController {
@Post('login')
async login(@Body() params: LoginParamsDTO): Promise<AuthSessionDTO> {
return this.authService.loginWithEmail(params);
try {
return await this.authService.loginWithEmail(params);
} catch (error) {
if (error instanceof Error) {
// Convert authentication errors to Unauthorized
throw new UnauthorizedException(error.message);
}
throw error;
}
}
@Get('session')

View File

@@ -40,6 +40,10 @@ export class SignupParamsDTO {
@MinLength(2)
displayName!: string;
@ApiProperty({ required: false })
@IsOptional()
username?: string; // Ignored, for compatibility with tests
@ApiProperty({ required: false })
iracingCustomerId?: string;

View File

@@ -16,6 +16,7 @@ export class DashboardController {
@ApiOperation({ summary: 'Get dashboard overview' })
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getDashboardOverview(
@Query('driverId') _driverId: string,
@Req() req: AuthenticatedRequest,

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Req, Inject, NotFoundException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
@@ -80,8 +80,12 @@ export class DriverController {
@ApiOperation({ summary: 'Get driver by ID' })
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO | null> {
return await this.driverService.getDriver(driverId);
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO> {
const result = await this.driverService.getDriver(driverId);
if (!result) {
throw new NotFoundException('Driver not found');
}
return result;
}
@Public()
@@ -90,7 +94,11 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriverProfile(@Param('driverId') driverId: string): Promise<GetDriverProfileOutputDTO> {
return await this.driverService.getDriverProfile(driverId);
try {
return await this.driverService.getDriverProfile(driverId);
} catch (error) {
throw new NotFoundException('Driver not found');
}
}
@Put(':driverId/profile')

View File

@@ -75,6 +75,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
await this.driversLeaderboardPresenter!.present(result.unwrap());
return this.driversLeaderboardPresenter!.getResponseModel();
}
@@ -85,6 +86,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
this.driverStatsPresenter!.present(result.unwrap());
return this.driverStatsPresenter!.getResponseModel();
}
@@ -156,6 +158,9 @@ export class DriverService {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
return null;
}
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}

View File

@@ -14,8 +14,13 @@ export class HelloController {
return { status: 'ok' };
}
@Get()
@Get('hello')
getHello() {
return this.helloService.getHello();
}
@Get()
getRoot() {
return this.helloService.getHello();
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Inject, ValidationPipe, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Inject, ValidationPipe, Query, NotFoundException, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService';
@@ -63,6 +63,22 @@ export class LeagueController {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all leagues (alias for all-with-capacity)' })
@ApiResponse({ status: 200, description: 'List of all leagues', type: AllLeaguesWithCapacityDTO })
async getAllLeagues(): Promise<AllLeaguesWithCapacityDTO> {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('available')
@ApiOperation({ summary: 'Get available leagues (alias for all-with-capacity)' })
@ApiResponse({ status: 200, description: 'List of available leagues', type: AllLeaguesWithCapacityDTO })
async getAvailableLeagues(): Promise<AllLeaguesWithCapacityDTO> {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('all-with-capacity-and-scoring')
@ApiOperation({ summary: 'Get all leagues with capacity and scoring information' })
@@ -148,6 +164,21 @@ export class LeagueController {
return this.leagueService.updateLeagueMemberRole(leagueId, targetDriverId, input);
}
@Public()
@Get(':leagueId')
@ApiOperation({ summary: 'Get league by ID' })
@ApiResponse({ status: 200, description: 'League data', type: LeagueOwnerSummaryDTO })
@ApiResponse({ status: 404, description: 'League not found' })
async getLeague(@Param('leagueId') leagueId: string): Promise<LeagueOwnerSummaryDTO> {
try {
// For now, return basic league info - the service will handle 404
const query: GetLeagueOwnerSummaryQueryDTO = { ownerId: 'unknown', leagueId };
return await this.leagueService.getLeagueOwnerSummary(query);
} catch (error) {
throw new NotFoundException('League not found');
}
}
@Public()
@Get(':leagueId/owner-summary/:ownerId')
@ApiOperation({ summary: 'Get owner summary for a league' })
@@ -458,8 +489,25 @@ export class LeagueController {
@Post(':leagueId/join')
@ApiOperation({ summary: 'Join a league (actor derived from session)' })
@ApiResponse({ status: 200, description: 'Joined league successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized - no session' })
@ApiResponse({ status: 400, description: 'Invalid league ID or other validation error' })
async joinLeague(@Param('leagueId') leagueId: string) {
return this.leagueService.joinLeague(leagueId);
try {
return await this.leagueService.joinLeague(leagueId);
} catch (error) {
// Handle authentication errors
if (error instanceof Error && error.message === 'Unauthorized') {
throw new UnauthorizedException('Authentication required');
}
// Handle database/repository errors (invalid UUID, etc.)
if (error instanceof Error && (error.message.includes('REPOSITORY_ERROR') || error.message.includes('invalid input syntax'))) {
throw new BadRequestException('Invalid league ID');
}
// Re-throw other errors
throw error;
}
}
@Post(':leagueId/transfer-ownership')
@@ -514,4 +562,4 @@ export class LeagueController {
): Promise<WithdrawFromLeagueWalletOutputDTO> {
return this.leagueService.withdrawFromLeagueWallet(leagueId, input);
}
}
}

View File

@@ -0,0 +1,72 @@
import { Controller, Get, Post, Param, Req, Inject, HttpStatus, Res } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { NotificationsService } from './NotificationsService';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('notifications')
@Controller('notifications')
export class NotificationsController {
constructor(
@Inject(NotificationsService) private readonly notificationsService: NotificationsService,
) {}
@Get('unread')
@ApiOperation({ summary: 'Get unread notifications for current user' })
@ApiResponse({ status: 200, description: 'List of unread notifications' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getUnreadNotifications(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
const notifications = await this.notificationsService.getUnreadNotifications(userId);
res.status(HttpStatus.OK).json({ notifications });
}
@Post('read/:notificationId')
@ApiOperation({ summary: 'Mark notification as read' })
@ApiResponse({ status: 200, description: 'Notification marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async markAsRead(
@Param('notificationId') notificationId: string,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
await this.notificationsService.markAsRead(notificationId, userId);
res.status(HttpStatus.OK).json({ success: true });
}
@Get('all')
@ApiOperation({ summary: 'Get all notifications for current user' })
@ApiResponse({ status: 200, description: 'List of all notifications' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getAllNotifications(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
): Promise<void> {
const userId = req.user?.userId;
if (!userId) {
res.status(HttpStatus.UNAUTHORIZED).json({ error: 'Unauthorized' });
return;
}
const notifications = await this.notificationsService.getAllNotifications(userId);
res.status(HttpStatus.OK).json({ notifications });
}
}

View File

@@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { NotificationsPersistenceModule } from '../../persistence/notifications/NotificationsPersistenceModule';
import { NotificationsController } from './NotificationsController';
import { NotificationsService } from './NotificationsService';
@Module({
imports: [NotificationsPersistenceModule],
exports: [NotificationsPersistenceModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsPersistenceModule, NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,35 @@
import { Inject, Injectable } from '@nestjs/common';
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
import { NOTIFICATION_REPOSITORY_TOKEN } from '../../persistence/notifications/NotificationsPersistenceTokens';
@Injectable()
export class NotificationsService {
constructor(
@Inject(NOTIFICATION_REPOSITORY_TOKEN)
private readonly notificationRepository: INotificationRepository,
) {}
async getUnreadNotifications(userId: string): Promise<Array<Record<string, unknown>>> {
const notifications = await this.notificationRepository.findUnreadByRecipientId(userId);
return notifications.map(n => n.toJSON() as Record<string, unknown>);
}
async getAllNotifications(userId: string): Promise<Array<Record<string, unknown>>> {
const notifications = await this.notificationRepository.findByRecipientId(userId);
return notifications.map(n => n.toJSON() as Record<string, unknown>);
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
const notification = await this.notificationRepository.findById(notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== userId) {
throw new Error('Unauthorized');
}
const updated = notification.markAsRead();
await this.notificationRepository.update(updated);
}
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common';
import { Controller, Get, Post, Patch, Body, Req, Param, Inject, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
@@ -37,9 +37,13 @@ export class TeamController {
@ApiOperation({ summary: 'Get team details' })
@ApiResponse({ status: 200, description: 'Team details', type: GetTeamDetailsOutputDTO })
@ApiResponse({ status: 404, description: 'Team not found' })
async getDetails(@Param('teamId') teamId: string, @Req() req: RequestWithUser): Promise<GetTeamDetailsOutputDTO | null> {
async getDetails(@Param('teamId') teamId: string, @Req() req: RequestWithUser): Promise<GetTeamDetailsOutputDTO> {
const userId = req.user?.userId;
return await this.teamService.getDetails(teamId, userId);
const result = await this.teamService.getDetails(teamId, userId);
if (!result) {
throw new NotFoundException('Team not found');
}
return result;
}
@Public()
@@ -90,4 +94,15 @@ export class TeamController {
async getMembership(@Param('teamId') teamId: string, @Param('driverId') driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
return await this.teamService.getMembership(teamId, driverId);
}
}
@Post(':teamId/join')
@ApiOperation({ summary: 'Join a team (actor derived from session)' })
@ApiResponse({ status: 200, description: 'Joined team successfully' })
async joinTeam(
@Param('teamId') teamId: string,
@Req() req: RequestWithUser,
): Promise<Record<string, unknown>> {
const userId = req.user?.userId;
return await this.teamService.joinTeam(teamId, userId);
}
}

View File

@@ -25,6 +25,7 @@ import { CreateTeamUseCase, CreateTeamInput } from '@core/racing/application/use
import { UpdateTeamUseCase, UpdateTeamInput } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
import { JoinTeamUseCase } from '@core/racing/application/use-cases/JoinTeamUseCase';
// Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN } from './TeamTokens';
@@ -307,4 +308,22 @@ export class TeamService {
isActive: value.membership.isActive,
} : null;
}
}
async joinTeam(teamId: string, userId?: string): Promise<Record<string, unknown>> {
this.logger.debug(`[TeamService] Joining team ${teamId} for user ${userId}`);
if (!userId) {
throw new Error('User ID is required');
}
const useCase = new JoinTeamUseCase(this.teamRepository, this.membershipRepository, this.logger);
const result = await useCase.execute({ teamId, driverId: userId });
if (result.isErr()) {
const error = result.error;
this.logger.error(`Error joining team ${teamId}: ${error?.details?.message || 'Unknown error'}`);
return { success: false, error: error?.details?.message || 'Unknown error' };
}
return { success: true };
}
}