refactor use cases

This commit is contained in:
2025-12-21 01:20:27 +01:00
parent c12656d671
commit 8ecd638396
39 changed files with 2523 additions and 686 deletions

View File

@@ -1,12 +1,23 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { DeleteMediaUseCase } from './DeleteMediaUseCase';
import {
DeleteMediaUseCase,
type DeleteMediaInput,
type DeleteMediaResult,
type DeleteMediaErrorCode,
} from './DeleteMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { IDeleteMediaPresenter } from '../presenters/IDeleteMediaPresenter';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> {
present: Mock;
result?: DeleteMediaResult;
}
describe('DeleteMediaUseCase', () => {
let mediaRepo: {
findById: Mock;
@@ -16,7 +27,7 @@ describe('DeleteMediaUseCase', () => {
deleteMedia: Mock;
};
let logger: Logger;
let presenter: IDeleteMediaPresenter & { result?: any };
let output: TestOutputPort;
let useCase: DeleteMediaUseCase;
beforeEach(() => {
@@ -36,29 +47,35 @@ describe('DeleteMediaUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
presenter = {
present: vi.fn((result) => {
(presenter as any).result = result;
output = {
present: vi.fn((result: DeleteMediaResult) => {
output.result = result;
}),
} as unknown as IDeleteMediaPresenter & { result?: any };
} as unknown as TestOutputPort;
useCase = new DeleteMediaUseCase(
mediaRepo as unknown as IMediaRepository,
mediaStorage as unknown as MediaStoragePort,
output,
logger,
);
});
it('returns error result when media is not found', async () => {
it('returns MEDIA_NOT_FOUND when media is not found', async () => {
mediaRepo.findById.mockResolvedValue(null);
await useCase.execute({ mediaId: 'missing' }, presenter);
const input: DeleteMediaInput = { mediaId: 'missing' };
const result = await useCase.execute(input);
expect(mediaRepo.findById).toHaveBeenCalledWith('missing');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Media not found',
});
expect(result).toBeInstanceOf(Result);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('deletes media from storage and repository on success', async () => {
@@ -68,30 +85,39 @@ describe('DeleteMediaUseCase', () => {
originalName: 'file.png',
mimeType: 'image/png',
size: 123,
url: MediaUrl.create('https://example.com/file.png'),
url: 'https://example.com/file.png',
type: 'image',
uploadedBy: 'user-1',
});
mediaRepo.findById.mockResolvedValue(media);
await useCase.execute({ mediaId: 'media-1' }, presenter);
const input: DeleteMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value);
expect(mediaRepo.delete).toHaveBeenCalledWith('media-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ success: true });
});
it('handles errors and presents failure result', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error'));
await useCase.execute({ mediaId: 'media-1' }, presenter);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Internal error occurred while deleting media',
expect(output.present).toHaveBeenCalledWith({
mediaId: 'media-1',
deleted: true,
});
});
it('handles repository errors by returning REPOSITORY_ERROR', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error'));
const input: DeleteMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -6,71 +6,73 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger } from '@core/shared/application';
import type { IDeleteMediaPresenter } from '../presenters/IDeleteMediaPresenter';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface DeleteMediaInput {
mediaId: string;
}
export interface DeleteMediaResult {
success: boolean;
errorMessage?: string;
mediaId: string;
deleted: boolean;
}
export interface IDeleteMediaPresenter {
present(result: DeleteMediaResult): void;
}
export type DeleteMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
export type DeleteMediaApplicationError = ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
export class DeleteMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<DeleteMediaResult>,
private readonly logger: Logger,
) {}
async execute(
input: DeleteMediaInput,
presenter: IDeleteMediaPresenter,
): Promise<void> {
try {
this.logger.info('[DeleteMediaUseCase] Deleting media', {
mediaId: input.mediaId,
});
async execute(input: DeleteMediaInput): Promise<Result<void, DeleteMediaApplicationError>> {
this.logger.info('[DeleteMediaUseCase] Deleting media', {
mediaId: input.mediaId,
});
try {
const media = await this.mediaRepo.findById(input.mediaId);
if (!media) {
presenter.present({
success: false,
errorMessage: 'Media not found',
return Result.err({
code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' },
});
return;
}
// Delete from storage
await this.mediaStorage.deleteMedia(media.url.value);
// Delete from repository
await this.mediaRepo.delete(input.mediaId);
presenter.present({
success: true,
this.output.present({
mediaId: input.mediaId,
deleted: true,
});
this.logger.info('[DeleteMediaUseCase] Media deleted successfully', {
mediaId: input.mediaId,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[DeleteMediaUseCase] Error deleting media', {
error: error instanceof Error ? error.message : 'Unknown error',
error: err.message,
mediaId: input.mediaId,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred while deleting media',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' },
});
}
}

View File

@@ -1,13 +1,19 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetAvatarUseCase } from './GetAvatarUseCase';
import {
GetAvatarUseCase,
type GetAvatarInput,
type GetAvatarResult,
type GetAvatarErrorCode,
} from './GetAvatarUseCase';
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { IGetAvatarPresenter } from '../presenters/IGetAvatarPresenter';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Avatar } from '../../domain/entities/Avatar';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestPresenter extends IGetAvatarPresenter {
result?: any;
interface TestOutputPort extends UseCaseOutputPort<GetAvatarResult> {
present: Mock;
result?: GetAvatarResult;
}
describe('GetAvatarUseCase', () => {
@@ -16,7 +22,7 @@ describe('GetAvatarUseCase', () => {
save: Mock;
};
let logger: Logger;
let presenter: TestPresenter;
let output: TestOutputPort;
let useCase: GetAvatarUseCase;
beforeEach(() => {
@@ -32,44 +38,51 @@ describe('GetAvatarUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
presenter = {
present: vi.fn((result) => {
presenter.result = result;
output = {
present: vi.fn((result: GetAvatarResult) => {
output.result = result;
}),
} as unknown as TestPresenter;
} as unknown as TestOutputPort;
useCase = new GetAvatarUseCase(
avatarRepo as unknown as IAvatarRepository,
output,
logger,
);
});
it('presents error when no avatar exists for driver', async () => {
it('returns AVATAR_NOT_FOUND when no avatar exists for driver', async () => {
avatarRepo.findActiveByDriverId.mockResolvedValue(null);
await useCase.execute({ driverId: 'driver-1' }, presenter);
const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Avatar not found',
});
expect(result).toBeInstanceOf(Result);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetAvatarErrorCode,
{ message: string }
>;
expect(err.code).toBe('AVATAR_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('presents avatar details when avatar exists', async () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: MediaUrl.create('https://example.com/avatar.png'),
mediaUrl: 'https://example.com/avatar.png',
});
avatarRepo.findActiveByDriverId.mockResolvedValue(avatar);
await useCase.execute({ driverId: 'driver-1' }, presenter);
const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: true,
expect(output.present).toHaveBeenCalledWith({
avatar: {
id: avatar.id,
driverId: avatar.driverId,
@@ -79,15 +92,19 @@ describe('GetAvatarUseCase', () => {
});
});
it('handles errors by logging and presenting failure', async () => {
it('handles repository errors by returning REPOSITORY_ERROR', async () => {
avatarRepo.findActiveByDriverId.mockRejectedValue(new Error('DB error'));
await useCase.execute({ driverId: 'driver-1' }, presenter);
const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Internal error occurred while retrieving avatar',
});
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetAvatarErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -5,55 +5,53 @@
*/
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { Logger } from '@core/shared/application';
import type { IGetAvatarPresenter } from '../presenters/IGetAvatarPresenter';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetAvatarInput {
driverId: string;
}
export interface GetAvatarResult {
success: boolean;
avatar?: {
avatar: {
id: string;
driverId: string;
mediaUrl: string;
selectedAt: Date;
};
errorMessage?: string;
}
export interface IGetAvatarPresenter {
present(result: GetAvatarResult): void;
}
export type GetAvatarErrorCode = 'AVATAR_NOT_FOUND' | 'REPOSITORY_ERROR';
export type GetAvatarApplicationError = ApplicationErrorCode<
GetAvatarErrorCode,
{ message: string }
>;
export class GetAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<GetAvatarResult>,
private readonly logger: Logger,
) {}
async execute(
input: GetAvatarInput,
presenter: IGetAvatarPresenter,
): Promise<void> {
try {
this.logger.info('[GetAvatarUseCase] Getting avatar', {
driverId: input.driverId,
});
async execute(input: GetAvatarInput): Promise<Result<void, GetAvatarApplicationError>> {
this.logger.info('[GetAvatarUseCase] Getting avatar', {
driverId: input.driverId,
});
try {
const avatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
if (!avatar) {
presenter.present({
success: false,
errorMessage: 'Avatar not found',
return Result.err({
code: 'AVATAR_NOT_FOUND',
details: { message: 'Avatar not found' },
});
return;
}
presenter.present({
success: true,
this.output.present({
avatar: {
id: avatar.id,
driverId: avatar.driverId,
@@ -62,15 +60,17 @@ export class GetAvatarUseCase {
},
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('[GetAvatarUseCase] Error getting avatar', {
error: error instanceof Error ? error.message : 'Unknown error',
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[GetAvatarUseCase] Error getting avatar', err, {
driverId: input.driverId,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred while retrieving avatar',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' },
});
}
}

View File

@@ -1,13 +1,20 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetMediaUseCase } from './GetMediaUseCase';
import {
GetMediaUseCase,
type GetMediaInput,
type GetMediaResult,
type GetMediaErrorCode,
} from './GetMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { IGetMediaPresenter } from '../presenters/IGetMediaPresenter';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestPresenter extends IGetMediaPresenter {
result?: any;
interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> {
present: Mock;
result?: GetMediaResult;
}
describe('GetMediaUseCase', () => {
@@ -15,7 +22,7 @@ describe('GetMediaUseCase', () => {
findById: Mock;
};
let logger: Logger;
let presenter: TestPresenter;
let output: TestOutputPort;
let useCase: GetMediaUseCase;
beforeEach(() => {
@@ -30,28 +37,31 @@ describe('GetMediaUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
presenter = {
present: vi.fn((result) => {
presenter.result = result;
output = {
present: vi.fn((result: GetMediaResult) => {
output.result = result;
}),
} as unknown as TestPresenter;
} as unknown as TestOutputPort;
useCase = new GetMediaUseCase(
mediaRepo as unknown as IMediaRepository,
output,
logger,
);
});
it('presents error when media is not found', async () => {
it('returns MEDIA_NOT_FOUND when media is not found', async () => {
mediaRepo.findById.mockResolvedValue(null);
await useCase.execute({ mediaId: 'missing' }, presenter);
const input: GetMediaInput = { mediaId: 'missing' };
const result = await useCase.execute(input);
expect(mediaRepo.findById).toHaveBeenCalledWith('missing');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Media not found',
});
expect(result).toBeInstanceOf(Result);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('presents media details when media exists', async () => {
@@ -68,11 +78,12 @@ describe('GetMediaUseCase', () => {
mediaRepo.findById.mockResolvedValue(media);
await useCase.execute({ mediaId: 'media-1' }, presenter);
const input: GetMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: true,
expect(output.present).toHaveBeenCalledWith({
media: {
id: media.id,
filename: media.filename,
@@ -88,15 +99,15 @@ describe('GetMediaUseCase', () => {
});
});
it('handles errors by logging and presenting failure', async () => {
it('handles repository errors by returning REPOSITORY_ERROR', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error'));
await useCase.execute({ mediaId: 'media-1' }, presenter);
const input: GetMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Internal error occurred while retrieving media',
});
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
});
});

View File

@@ -5,16 +5,16 @@
*/
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { Logger } from '@core/shared/application';
import type { IGetMediaPresenter } from '../presenters/IGetMediaPresenter';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetMediaInput {
mediaId: string;
}
export interface GetMediaResult {
success: boolean;
media?: {
media: {
id: string;
filename: string;
originalName: string;
@@ -26,40 +26,35 @@ export interface GetMediaResult {
uploadedAt: Date;
metadata?: Record<string, any>;
};
errorMessage?: string;
}
export interface IGetMediaPresenter {
present(result: GetMediaResult): void;
}
export type GetMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly output: UseCaseOutputPort<GetMediaResult>,
private readonly logger: Logger,
) {}
async execute(
input: GetMediaInput,
presenter: IGetMediaPresenter,
): Promise<void> {
try {
this.logger.info('[GetMediaUseCase] Getting media', {
mediaId: input.mediaId,
});
): Promise<Result<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
this.logger.info('[GetMediaUseCase] Getting media', {
mediaId: input.mediaId,
});
try {
const media = await this.mediaRepo.findById(input.mediaId);
if (!media) {
presenter.present({
success: false,
errorMessage: 'Media not found',
return Result.err({
code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' },
});
return;
}
presenter.present({
success: true,
this.output.present({
media: {
id: media.id,
filename: media.filename,
@@ -74,15 +69,16 @@ export class GetMediaUseCase {
},
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('[GetMediaUseCase] Error getting media', {
error: error instanceof Error ? error.message : 'Unknown error',
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[GetMediaUseCase] Error getting media', err, {
mediaId: input.mediaId,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred while retrieving media',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}

View File

@@ -8,10 +8,11 @@ import { v4 as uuidv4 } from 'uuid';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { FaceValidationPort } from '../ports/FaceValidationPort';
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
import type { IRequestAvatarGenerationPresenter } from '../presenters/IRequestAvatarGenerationPresenter';
import type { RacingSuitColor } from '../../domain/types/AvatarGenerationRequest';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface RequestAvatarGenerationInput {
userId: string;
@@ -20,63 +21,68 @@ export interface RequestAvatarGenerationInput {
style?: 'realistic' | 'cartoon' | 'pixel-art';
}
export interface RequestAvatarGenerationResult {
requestId: string;
status: 'validating' | 'generating' | 'completed';
avatarUrls?: string[];
}
export type RequestAvatarGenerationErrorCode =
| 'FACE_VALIDATION_FAILED'
| 'GENERATION_FAILED'
| 'REPOSITORY_ERROR';
export type RequestAvatarGenerationApplicationError = ApplicationErrorCode<
RequestAvatarGenerationErrorCode,
{ message: string }
>;
export class RequestAvatarGenerationUseCase {
constructor(
private readonly avatarRepo: IAvatarGenerationRepository,
private readonly faceValidation: FaceValidationPort,
private readonly avatarGeneration: AvatarGenerationPort,
private readonly output: UseCaseOutputPort<RequestAvatarGenerationResult>,
private readonly logger: Logger,
) {}
async execute(
input: RequestAvatarGenerationInput,
presenter: IRequestAvatarGenerationPresenter,
): Promise<void> {
try {
this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', {
userId: input.userId,
suitColor: input.suitColor,
});
): Promise<Result<void, RequestAvatarGenerationApplicationError>> {
this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', {
userId: input.userId,
suitColor: input.suitColor,
});
// Create the avatar generation request entity
try {
const requestId = uuidv4();
const request = AvatarGenerationRequest.create({
id: requestId,
userId: input.userId,
facePhotoUrl: input.facePhotoData, // Assuming facePhotoData is a URL or base64
facePhotoUrl: input.facePhotoData,
suitColor: input.suitColor,
style: input.style,
});
// Save initial request
await this.avatarRepo.save(request);
// Present initial status
presenter.present({
requestId,
status: 'validating',
});
// Validate face photo
request.markAsValidating();
await this.avatarRepo.save(request);
const validationResult = await this.faceValidation.validateFacePhoto(input.facePhotoData);
if (!validationResult.isValid || !validationResult.hasFace || validationResult.faceCount !== 1) {
const errorMessage = validationResult.errorMessage || 'Invalid face photo: must contain exactly one face';
const errorMessage =
validationResult.errorMessage || 'Invalid face photo: must contain exactly one face';
request.fail(errorMessage);
await this.avatarRepo.save(request);
presenter.present({
requestId,
status: 'failed',
errorMessage,
return Result.err({
code: 'FACE_VALIDATION_FAILED',
details: { message: errorMessage },
});
return;
}
// Generate avatars
request.markAsGenerating();
await this.avatarRepo.save(request);
@@ -85,7 +91,7 @@ export class RequestAvatarGenerationUseCase {
prompt: request.buildPrompt(),
suitColor: input.suitColor,
style: input.style || 'realistic',
count: 3, // Generate 3 avatar options
count: 3,
};
const generationResult = await this.avatarGeneration.generateAvatars(generationOptions);
@@ -95,20 +101,17 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage);
await this.avatarRepo.save(request);
presenter.present({
requestId,
status: 'failed',
errorMessage,
return Result.err({
code: 'GENERATION_FAILED',
details: { message: errorMessage },
});
return;
}
// Complete the request
const avatarUrls = generationResult.avatars.map(avatar => avatar.url);
request.completeWithAvatars(avatarUrls);
await this.avatarRepo.save(request);
presenter.present({
this.output.present({
requestId,
status: 'completed',
avatarUrls,
@@ -120,16 +123,17 @@ export class RequestAvatarGenerationUseCase {
avatarCount: avatarUrls.length,
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('[RequestAvatarGenerationUseCase] Error during avatar generation', {
error: error instanceof Error ? error.message : 'Unknown error',
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[RequestAvatarGenerationUseCase] Error during avatar generation', err, {
userId: input.userId,
});
presenter.present({
requestId: uuidv4(), // Fallback ID
status: 'failed',
errorMessage: 'Internal error occurred during avatar generation',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Internal error occurred during avatar generation' },
});
}
}

View File

@@ -5,8 +5,9 @@
*/
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { Logger } from '@core/shared/application';
import type { ISelectAvatarPresenter } from '../presenters/ISelectAvatarPresenter';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface SelectAvatarInput {
requestId: string;
@@ -14,47 +15,48 @@ export interface SelectAvatarInput {
}
export interface SelectAvatarResult {
success: boolean;
selectedAvatarUrl?: string;
errorMessage?: string;
requestId: string;
selectedAvatarUrl: string;
}
export interface ISelectAvatarPresenter {
present(result: SelectAvatarResult): void;
}
export type SelectAvatarErrorCode =
| 'REQUEST_NOT_FOUND'
| 'REQUEST_NOT_COMPLETED'
| 'REPOSITORY_ERROR';
export type SelectAvatarApplicationError = ApplicationErrorCode<
SelectAvatarErrorCode,
{ message: string }
>;
export class SelectAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarGenerationRepository,
private readonly output: UseCaseOutputPort<SelectAvatarResult>,
private readonly logger: Logger,
) {}
async execute(
input: SelectAvatarInput,
presenter: ISelectAvatarPresenter,
): Promise<void> {
try {
this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
requestId: input.requestId,
selectedIndex: input.selectedIndex,
});
async execute(input: SelectAvatarInput): Promise<Result<void, SelectAvatarApplicationError>> {
this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
requestId: input.requestId,
selectedIndex: input.selectedIndex,
});
try {
const request = await this.avatarRepo.findById(input.requestId);
if (!request) {
presenter.present({
success: false,
errorMessage: 'Avatar generation request not found',
return Result.err({
code: 'REQUEST_NOT_FOUND',
details: { message: 'Avatar generation request not found' },
});
return;
}
if (request.status !== 'completed') {
presenter.present({
success: false,
errorMessage: 'Avatar generation is not completed yet',
return Result.err({
code: 'REQUEST_NOT_COMPLETED',
details: { message: 'Avatar generation is not completed yet' },
});
return;
}
request.selectAvatar(input.selectedIndex);
@@ -62,8 +64,8 @@ export class SelectAvatarUseCase {
const selectedAvatarUrl = request.selectedAvatarUrl;
presenter.present({
success: true,
this.output.present({
requestId: input.requestId,
selectedAvatarUrl,
});
@@ -72,15 +74,17 @@ export class SelectAvatarUseCase {
selectedAvatarUrl,
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('[SelectAvatarUseCase] Error selecting avatar', {
error: error instanceof Error ? error.message : 'Unknown error',
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[SelectAvatarUseCase] Error selecting avatar', err, {
requestId: input.requestId,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred while selecting avatar',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' },
});
}
}

View File

@@ -4,11 +4,12 @@
* Handles the business logic for updating a driver's avatar.
*/
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { Logger } from '@core/shared/application';
import { Avatar } from '../../domain/entities/Avatar';
import type { IUpdateAvatarPresenter } from '../presenters/IUpdateAvatarPresenter';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { v4 as uuidv4 } from 'uuid';
import { Avatar } from '../../domain/entities/Avatar';
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
export interface UpdateAvatarInput {
driverId: string;
@@ -16,39 +17,38 @@ export interface UpdateAvatarInput {
}
export interface UpdateAvatarResult {
success: boolean;
errorMessage?: string;
avatarId: string;
driverId: string;
}
export interface IUpdateAvatarPresenter {
present(result: UpdateAvatarResult): void;
}
export type UpdateAvatarErrorCode = 'REPOSITORY_ERROR';
export type UpdateAvatarApplicationError = ApplicationErrorCode<
UpdateAvatarErrorCode,
{ message: string }
>;
export class UpdateAvatarUseCase {
constructor(
private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<UpdateAvatarResult>,
private readonly logger: Logger,
) {}
async execute(
input: UpdateAvatarInput,
presenter: IUpdateAvatarPresenter,
): Promise<void> {
try {
this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
driverId: input.driverId,
mediaUrl: input.mediaUrl,
});
async execute(input: UpdateAvatarInput): Promise<Result<void, UpdateAvatarApplicationError>> {
this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
driverId: input.driverId,
mediaUrl: input.mediaUrl,
});
// Deactivate current active avatar
try {
const currentAvatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
if (currentAvatar) {
currentAvatar.deactivate();
await this.avatarRepo.save(currentAvatar);
}
// Create new avatar
const avatarId = uuidv4();
const avatarId = uuidv4(); // TODO this ID should be a value object
const newAvatar = Avatar.create({
id: avatarId,
driverId: input.driverId,
@@ -57,8 +57,9 @@ export class UpdateAvatarUseCase {
await this.avatarRepo.save(newAvatar);
presenter.present({
success: true,
this.output.present({
avatarId,
driverId: input.driverId,
});
this.logger.info('[UpdateAvatarUseCase] Avatar updated successfully', {
@@ -66,15 +67,17 @@ export class UpdateAvatarUseCase {
avatarId,
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('[UpdateAvatarUseCase] Error updating avatar', {
error: error instanceof Error ? error.message : 'Unknown error',
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[UpdateAvatarUseCase] Error updating avatar', err, {
driverId: input.driverId,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred while updating avatar',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Internal error occurred while updating avatar' },
});
}
}

View File

@@ -6,9 +6,10 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media';
import type { IUploadMediaPresenter } from '../presenters/IUploadMediaPresenter';
import { v4 as uuidv4 } from 'uuid';
export interface UploadMediaInput {
@@ -18,34 +19,32 @@ export interface UploadMediaInput {
}
export interface UploadMediaResult {
success: boolean;
mediaId?: string;
url?: string;
errorMessage?: string;
mediaId: string;
url: string | undefined;
}
export interface IUploadMediaPresenter {
present(result: UploadMediaResult): void;
}
export type UploadMediaErrorCode =
| 'UPLOAD_FAILED'
| 'REPOSITORY_ERROR';
export class UploadMediaUseCase {
constructor(
private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<UploadMediaResult>,
private readonly logger: Logger,
) {}
async execute(
input: UploadMediaInput,
presenter: IUploadMediaPresenter,
): Promise<void> {
try {
this.logger.info('[UploadMediaUseCase] Starting media upload', {
filename: input.file.originalname,
size: input.file.size,
uploadedBy: input.uploadedBy,
});
): Promise<Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
this.logger.info('[UploadMediaUseCase] Starting media upload', {
filename: input.file.originalname,
size: input.file.size,
uploadedBy: input.uploadedBy,
});
try {
// Upload file to storage service
const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, {
filename: input.file.originalname,
@@ -54,11 +53,13 @@ export class UploadMediaUseCase {
});
if (!uploadResult.success) {
presenter.present({
success: false,
errorMessage: uploadResult.errorMessage || 'Failed to upload media',
return Result.err({
code: 'UPLOAD_FAILED',
details: {
message:
uploadResult.errorMessage ?? 'Failed to upload media',
},
});
return;
}
// Determine media type
@@ -85,8 +86,7 @@ export class UploadMediaUseCase {
// Save to repository
await this.mediaRepo.save(media);
presenter.present({
success: true,
this.output.present({
mediaId,
url: uploadResult.url,
});
@@ -96,15 +96,17 @@ export class UploadMediaUseCase {
url: uploadResult.url,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[UploadMediaUseCase] Error uploading media', {
error: error instanceof Error ? error.message : 'Unknown error',
error: err.message,
filename: input.file.originalname,
});
presenter.present({
success: false,
errorMessage: 'Internal error occurred during media upload',
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}