import { MediaReference } from '@core/domain/media/MediaReference'; import { MediaGenerationService } from '@core/media/domain/services/MediaGenerationService'; import type { Logger } from '@core/shared/application/Logger'; import { Body, Controller, Delete, Get, HttpStatus, Inject, Param, Post, Put, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiConsumes, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; import fs from 'node:fs/promises'; import path from 'node:path'; import { Public } from '../auth/Public'; import { MediaService } from './MediaService'; import { LOGGER_TOKEN } from './MediaTokens'; import { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO'; import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO'; import { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO'; import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO'; import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO'; import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO'; import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO'; import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO'; import { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO'; import { ValidateFaceInputDTO } from './dtos/ValidateFaceInputDTO'; import { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO'; import type { MulterFile } from './types/MulterFile'; type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO; type UploadMediaInput = UploadMediaInputDTO; type UpdateAvatarInput = UpdateAvatarInputDTO; @ApiTags('media') @Controller('media') export class MediaController { constructor( @Inject(MediaService) private readonly mediaService: MediaService, @Inject(MediaGenerationService) private readonly mediaGenerationService: MediaGenerationService, @Inject(LOGGER_TOKEN) private readonly logger: Logger, ) {} @Post('avatar/generate') @ApiOperation({ summary: 'Request avatar generation' }) @ApiResponse({ status: 201, description: 'Avatar generation request submitted', type: RequestAvatarGenerationOutputDTO }) async requestAvatarGeneration( @Body() input: RequestAvatarGenerationInput, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Requesting avatar generation', { input }); const dto: RequestAvatarGenerationOutputDTO = await this.mediaService.requestAvatarGeneration(input); if (dto.success) { this.logger.info('[MediaController] Avatar generation request successful', { dto }); res.status(HttpStatus.CREATED).json(dto); } else { this.logger.warn('[MediaController] Avatar generation request failed', { dto }); res.status(HttpStatus.BAD_REQUEST).json(dto); } } @Post('upload') @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: 'Upload media file' }) @ApiConsumes('multipart/form-data') @ApiResponse({ status: 201, description: 'Media uploaded successfully', type: UploadMediaOutputDTO }) async uploadMedia( @UploadedFile() file: MulterFile, @Body() input: UploadMediaInput, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Uploading media', { filename: file?.originalname, input }); const dto: UploadMediaOutputDTO = await this.mediaService.uploadMedia({ ...input, file }); if (dto.success) { this.logger.info('[MediaController] Media upload successful', { mediaId: dto.mediaId }); res.status(HttpStatus.CREATED).json(dto); } else { this.logger.warn('[MediaController] Media upload failed', { error: dto.error }); res.status(HttpStatus.BAD_REQUEST).json(dto); } } @Public() @Get('teams/:teamId/logo') @ApiOperation({ summary: 'Get team logo (dynamically generated)' }) @ApiParam({ name: 'teamId', description: 'Team ID' }) async getTeamLogo( @Param('teamId') teamId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Generating team logo', { teamId }); const svg = this.mediaGenerationService.generateTeamLogo(teamId); const svgLength = svg.length; res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.status(HttpStatus.OK).send(svg); this.logger.info('[MediaController] Team logo generated', { teamId, svgLength }); } @Public() @Get('leagues/:leagueId/logo') @ApiOperation({ summary: 'Get league logo (dynamically generated)' }) @ApiParam({ name: 'leagueId', description: 'League ID' }) async getLeagueLogo( @Param('leagueId') leagueId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Generating league logo', { leagueId }); const svg = this.mediaGenerationService.generateLeagueLogo(leagueId); const svgLength = svg.length; res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.status(HttpStatus.OK).send(svg); this.logger.info('[MediaController] League logo generated', { leagueId, svgLength }); } @Public() @Get('leagues/:leagueId/cover') @ApiOperation({ summary: 'Get league cover (dynamically generated)' }) @ApiParam({ name: 'leagueId', description: 'League ID' }) async getLeagueCover( @Param('leagueId') leagueId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Generating league cover', { leagueId }); const svg = this.mediaGenerationService.generateLeagueCover(leagueId); const svgLength = svg.length; res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.status(HttpStatus.OK).send(svg); this.logger.info('[MediaController] League cover generated', { leagueId, svgLength }); } @Public() @Get('avatar/:driverId') @ApiOperation({ summary: 'Get driver avatar (dynamically generated)' }) @ApiParam({ name: 'driverId', description: 'Driver ID' }) async getDriverAvatar( @Param('driverId') driverId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Generating driver avatar', { driverId }); const svg = this.mediaGenerationService.generateDriverAvatar(driverId); const svgLength = svg.length; res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.status(HttpStatus.OK).send(svg); this.logger.info('[MediaController] Driver avatar generated', { driverId, svgLength }); } @Public() @Get('default/:variant') @ApiOperation({ summary: 'Get default media asset (PNG)' }) @ApiParam({ name: 'variant', description: 'Variant name (e.g., male-default-avatar, female-default-avatar, logo)' }) async getDefaultMedia( @Param('variant') variant: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Getting default media', { variant }); // Drivers must use real assets from website public dir. // Supported: // - male-default-avatar // - female-default-avatar // - neutral-default-avatar if ( variant === 'male-default-avatar' || variant === 'female-default-avatar' || variant === 'neutral-default-avatar' ) { const candidates = [`${variant}.jpg`, `${variant}.jpeg`]; // This needs to work in multiple runtimes: // - docker dev (cwd often: /app/apps/api) -> ../website // - local tests (cwd often: repo root) -> apps/website // Prefer a deterministic directory discovery rather than assuming a single cwd. const baseDirs = [ path.resolve(process.cwd(), 'apps', 'website', 'public', 'images', 'avatars'), path.resolve(process.cwd(), '..', 'website', 'public', 'images', 'avatars'), ]; for (const baseDir of baseDirs) { for (const filename of candidates) { const abs = path.join(baseDir, filename); try { const bytes = await fs.readFile(abs); res.setHeader('Content-Type', filename.endsWith('.png') ? 'image/png' : 'image/jpeg'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.status(HttpStatus.OK).send(bytes); this.logger.info('[MediaController] Default avatar served', { variant, filename, baseDir, size: bytes.length }); return; } catch { // try next filename/baseDir } } } this.logger.warn('[MediaController] Default avatar asset not found', { variant, baseDirs, candidates }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Default avatar asset not found' }); return; } // Fallback: generated PNG for other defaults const png = this.mediaGenerationService.generateDefaultPNG(variant); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.status(HttpStatus.OK).send(png); this.logger.info('[MediaController] Default media generated', { variant, size: png.length }); } @Public() @Get('generated/:type/:id') @ApiOperation({ summary: 'Get generated media (SVG)' }) @ApiParam({ name: 'type', description: 'Media type (team, league, driver)' }) @ApiParam({ name: 'id', description: 'Entity ID' }) async getGeneratedMedia( @Param('type') type: string, @Param('id') id: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Generating media', { type, id }); let svg: string; // Route to appropriate generator based on type if (type === 'team') { svg = this.mediaGenerationService.generateTeamLogo(id); } else if (type === 'league') { svg = this.mediaGenerationService.generateLeagueLogo(id); } else if (type === 'driver') { svg = this.mediaGenerationService.generateDriverAvatar(id); } else { // Fallback: generate a generic logo svg = this.mediaGenerationService.generateLeagueLogo(`${type}-${id}`); } const svgLength = svg.length; res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.status(HttpStatus.OK).send(svg); this.logger.info('[MediaController] Generated media served', { type, id, svgLength }); } @Public() @Get('uploaded/:mediaId') @ApiOperation({ summary: 'Get uploaded media' }) @ApiParam({ name: 'mediaId', description: 'Media ID' }) async getUploadedMedia( @Param('mediaId') mediaId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Getting uploaded media', { mediaId }); // Look up the media to get the storage key const media = await this.mediaService.getMedia(mediaId); if (!media) { this.logger.warn('[MediaController] Uploaded media not found', { mediaId }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' }); return; } // Get the storage key from the media reference // The mediaId is used as the storage key const storageKey = `uploaded/${mediaId}`; // Get file bytes from storage via service const result = await this.mediaService.getUploadedMedia(storageKey); if (!result) { this.logger.warn('[MediaController] Uploaded media file not found', { mediaId, storageKey }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Media file not found' }); return; } res.setHeader('Content-Type', result.contentType); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.status(HttpStatus.OK).send(result.bytes); this.logger.info('[MediaController] Uploaded media served', { mediaId, storageKey, size: result.bytes.length }); } @Public() @Get('debug/resolve') @ApiOperation({ summary: 'Debug media reference resolution' }) @ApiResponse({ status: 200, description: 'Resolution debug info' }) async debugResolve( @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Debug resolve requested'); // Parse query parameters const query = res.req.query; let ref: MediaReference | null = null; let refHash: string | null = null; let resolvedPath: string | null = null; let resolver: string | null = null; const notes: string[] = []; try { // Try to construct MediaReference from query params if (query.type === 'system-default' && query.variant) { const variant = query.variant as 'avatar' | 'logo'; const avatarVariant = query.avatarVariant as 'male' | 'female' | 'neutral' | undefined; ref = MediaReference.createSystemDefault(variant, avatarVariant); resolver = 'default'; } else if (query.type === 'generated' && query.generationRequestId) { ref = MediaReference.createGenerated(query.generationRequestId as string); resolver = 'generated'; } else if (query.type === 'uploaded' && query.mediaId) { ref = MediaReference.createUploaded(query.mediaId as string); resolver = 'uploaded'; } else if (query.ref) { // Try to parse base64url JSON try { const decoded = Buffer.from(query.ref as string, 'base64').toString('utf-8'); const props = JSON.parse(decoded); ref = MediaReference.fromJSON(props); resolver = 'auto-detected'; } catch (e) { notes.push('Failed to parse ref as base64url JSON'); } } else { notes.push('No valid query parameters provided'); notes.push('Expected: type, variant, avatarVariant OR generationRequestId OR mediaId OR ref (base64url)'); } if (ref) { refHash = ref.hash(); resolvedPath = await this.mediaService.resolveMediaReference(ref); if (!resolvedPath) { notes.push('Resolver returned null'); } } this.logger.info('[MediaController] Debug resolve completed', { ref: ref ? ref.toJSON() : null, refHash, resolvedPath, resolver, notes, }); res.status(HttpStatus.OK).json({ ref: ref ? ref.toJSON() : null, refHash, resolvedPath, resolver, notes, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error('[MediaController] Debug resolve failed', error instanceof Error ? error : new Error(String(error))); res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: errorMessage, notes: ['Internal error during resolution'], }); } } @Public() @Get(':mediaId') @ApiOperation({ summary: 'Get media by ID' }) @ApiParam({ name: 'mediaId', description: 'Media ID' }) @ApiResponse({ status: 200, description: 'Media details', type: GetMediaOutputDTO }) async getMedia( @Param('mediaId') mediaId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Getting media details', { mediaId }); const dto: GetMediaOutputDTO | null = await this.mediaService.getMedia(mediaId); if (dto) { this.logger.info('[MediaController] Media details found', { mediaId }); res.status(HttpStatus.OK).json(dto); } else { this.logger.warn('[MediaController] Media not found', { mediaId }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' }); } } @Delete(':mediaId') @ApiOperation({ summary: 'Delete media by ID' }) @ApiParam({ name: 'mediaId', description: 'Media ID' }) @ApiResponse({ status: 200, description: 'Media deleted', type: DeleteMediaOutputDTO }) async deleteMedia( @Param('mediaId') mediaId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Deleting media', { mediaId }); const dto: DeleteMediaOutputDTO = await this.mediaService.deleteMedia(mediaId); this.logger.info('[MediaController] Media deletion result', { mediaId, success: dto.success }); res.status(HttpStatus.OK).json(dto); } @Public() @Get('avatar/:driverId/details') @ApiOperation({ summary: 'Get avatar details for driver' }) @ApiParam({ name: 'driverId', description: 'Driver ID' }) @ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO }) async getAvatarDetails( @Param('driverId') driverId: string, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Getting avatar details', { driverId }); const dto: GetAvatarOutputDTO | null = await this.mediaService.getAvatar(driverId); if (dto) { this.logger.info('[MediaController] Avatar details found', { driverId }); res.status(HttpStatus.OK).json(dto); } else { this.logger.warn('[MediaController] Avatar not found', { driverId }); res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' }); } } @Put('avatar/:driverId') @ApiOperation({ summary: 'Update avatar for driver' }) @ApiParam({ name: 'driverId', description: 'Driver ID' }) @ApiResponse({ status: 200, description: 'Avatar updated', type: UpdateAvatarOutputDTO }) async updateAvatar( @Param('driverId') driverId: string, @Body() input: UpdateAvatarInput, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Updating avatar', { driverId }); const dto: UpdateAvatarOutputDTO = await this.mediaService.updateAvatar(driverId, input); this.logger.info('[MediaController] Avatar update result', { driverId, success: dto.success }); res.status(HttpStatus.OK).json(dto); } @Post('avatar/validate-face') @ApiOperation({ summary: 'Validate face photo for avatar generation' }) @ApiResponse({ status: 200, description: 'Face validation result', type: ValidateFaceOutputDTO }) async validateFacePhoto( @Body() input: ValidateFaceInputDTO, @Res() res: Response, ): Promise { this.logger.debug('[MediaController] Validating face photo'); const dto: ValidateFaceOutputDTO = await this.mediaService.validateFacePhoto(input); if (dto.isValid) { this.logger.info('[MediaController] Face validation passed'); res.status(HttpStatus.OK).json(dto); } else { this.logger.warn('[MediaController] Face validation failed', { errorMessage: dto.errorMessage }); res.status(HttpStatus.BAD_REQUEST).json(dto); } } }