Files
gridpilot.gg/apps/api/src/domain/media/MediaService.ts
2026-01-16 15:20:25 +01:00

240 lines
9.3 KiB
TypeScript

import type { MediaReference } from '@core/domain/media/MediaReference';
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
import { Inject, Injectable } from '@nestjs/common';
import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import type { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
import type { ValidateFaceInputDTO } from './dtos/ValidateFaceInputDTO';
import type { ValidateFaceOutputDTO } from './dtos/ValidateFaceOutputDTO';
import type { MulterFile } from './types/MulterFile';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO;
// Use cases
import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMediaUseCase';
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { GetMediaUseCase } from '@core/media/application/use-cases/GetMediaUseCase';
import { GetUploadedMediaUseCase, type GetUploadedMediaResult } from '@core/media/application/use-cases/GetUploadedMediaUseCase';
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import { ResolveMediaReferenceUseCase } from '@core/media/application/use-cases/ResolveMediaReferenceUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
import { UploadMediaUseCase } from '@core/media/application/use-cases/UploadMediaUseCase';
// Presenters (now transformers)
import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
import { GetMediaPresenter } from './presenters/GetMediaPresenter';
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
import { UploadMediaPresenter } from './presenters/UploadMediaPresenter';
import type { Logger } from '@core/shared/domain/Logger';
import {
DELETE_MEDIA_USE_CASE_TOKEN,
GET_AVATAR_USE_CASE_TOKEN,
GET_MEDIA_USE_CASE_TOKEN,
GET_UPLOADED_MEDIA_USE_CASE_TOKEN,
LOGGER_TOKEN,
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
RESOLVE_MEDIA_REFERENCE_USE_CASE_TOKEN,
UPDATE_AVATAR_USE_CASE_TOKEN,
UPLOAD_MEDIA_USE_CASE_TOKEN,
} from './MediaTokens';
@Injectable()
export class MediaService {
constructor(
@Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN)
private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase,
@Inject(UPLOAD_MEDIA_USE_CASE_TOKEN)
private readonly uploadMediaUseCase: UploadMediaUseCase,
@Inject(GET_MEDIA_USE_CASE_TOKEN)
private readonly getMediaUseCase: GetMediaUseCase,
@Inject(DELETE_MEDIA_USE_CASE_TOKEN)
private readonly deleteMediaUseCase: DeleteMediaUseCase,
@Inject(GET_AVATAR_USE_CASE_TOKEN)
private readonly getAvatarUseCase: GetAvatarUseCase,
@Inject(UPDATE_AVATAR_USE_CASE_TOKEN)
private readonly updateAvatarUseCase: UpdateAvatarUseCase,
@Inject(RESOLVE_MEDIA_REFERENCE_USE_CASE_TOKEN)
private readonly resolveMediaReferenceUseCase: ResolveMediaReferenceUseCase,
@Inject(GET_UPLOADED_MEDIA_USE_CASE_TOKEN)
private readonly getUploadedMediaUseCase: GetUploadedMediaUseCase,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
private readonly requestAvatarGenerationPresenter: RequestAvatarGenerationPresenter,
private readonly uploadMediaPresenter: UploadMediaPresenter,
private readonly getMediaPresenter: GetMediaPresenter,
private readonly deleteMediaPresenter: DeleteMediaPresenter,
private readonly getAvatarPresenter: GetAvatarPresenter,
private readonly updateAvatarPresenter: UpdateAvatarPresenter,
) {}
async requestAvatarGeneration(
input: RequestAvatarGenerationInput,
): Promise<RequestAvatarGenerationOutputDTO> {
this.logger.debug('[MediaService] Requesting avatar generation.');
const result = await this.requestAvatarGenerationUseCase.execute({
userId: input.userId,
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as RacingSuitColor,
});
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
requestId: '',
avatarUrls: [],
errorMessage: error.details?.message ?? 'Failed to request avatar generation',
};
}
return this.requestAvatarGenerationPresenter.transform(result.unwrap());
}
async uploadMedia(
input: UploadMediaInput & { file: MulterFile } & { userId?: string; metadata?: Record<string, unknown> },
): Promise<UploadMediaOutputDTO> {
this.logger.debug('[MediaService] Uploading media.');
const result = await this.uploadMediaUseCase.execute({
file: input.file,
uploadedBy: input.userId ?? '',
metadata: input.metadata || {},
});
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Upload failed',
};
}
return this.uploadMediaPresenter.transform(result.unwrap());
}
async getMedia(mediaId: string): Promise<GetMediaOutputDTO | null> {
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
const result = await this.getMediaUseCase.execute({ mediaId });
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'MEDIA_NOT_FOUND') {
return null;
}
throw new Error(error.details?.message ?? 'Failed to get media');
}
return this.getMediaPresenter.transform(result.unwrap());
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutputDTO> {
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
const result = await this.deleteMediaUseCase.execute({ mediaId });
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Failed to delete media',
};
}
return this.deleteMediaPresenter.transform(result.unwrap());
}
async getAvatar(driverId: string): Promise<GetAvatarOutputDTO | null> {
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
const result = await this.getAvatarUseCase.execute({ driverId });
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'AVATAR_NOT_FOUND') {
return null;
}
throw new Error(error.details?.message ?? 'Failed to get avatar');
}
return this.getAvatarPresenter.transform(result.unwrap());
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
// Handle null avatarUrl - this would mean removing the avatar
if (input.avatarUrl === null) {
// For now, we'll treat null as an error since the use case requires a URL
// In a complete implementation, this would trigger avatar removal
return {
success: false,
error: 'Avatar URL cannot be null',
};
}
const result = await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.avatarUrl,
});
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Failed to update avatar',
};
}
return this.updateAvatarPresenter.transform(result.unwrap());
}
async validateFacePhoto(input: ValidateFaceInputDTO): Promise<ValidateFaceOutputDTO> {
this.logger.debug('[MediaService] Validating face photo.');
// Simple validation: check if it's a valid base64 image
if (!input.imageData || !input.imageData.startsWith('data:image/')) {
return { isValid: false, errorMessage: 'Invalid image data' };
}
// Check file size (rough estimate from base64 length)
const base64Length = input.imageData.length;
const fileSizeInBytes = (base64Length * 3) / 4; // Rough estimate
const maxSize = 5 * 1024 * 1024; // 5MB
if (fileSizeInBytes > maxSize) {
return { isValid: false, errorMessage: 'Image too large (max 5MB)' };
}
return { isValid: true };
}
async resolveMediaReference(reference: MediaReference): Promise<string | null> {
const result = await this.resolveMediaReferenceUseCase.execute({ reference });
if (result.isErr()) {
throw new Error(result.unwrapErr().message);
}
return result.unwrap();
}
async getUploadedMedia(storageKey: string): Promise<GetUploadedMediaResult | null> {
const result = await this.getUploadedMediaUseCase.execute({ storageKey });
if (result.isErr()) {
throw new Error(result.unwrapErr().message);
}
return result.unwrap();
}
}