diff --git a/core/identity/application/use-cases/GetCurrentSessionUseCase.ts b/core/identity/application/use-cases/GetCurrentSessionUseCase.ts index a6b74656c..4c210aa3d 100644 --- a/core/identity/application/use-cases/GetCurrentSessionUseCase.ts +++ b/core/identity/application/use-cases/GetCurrentSessionUseCase.ts @@ -1,6 +1,23 @@ import { User } from '../../domain/entities/User'; import { IUserRepository } from '../../domain/repositories/IUserRepository'; -// No direct import of apps/api DTOs in core module +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type GetCurrentSessionInput = { + userId: string; +}; + +export type GetCurrentSessionResult = { + user: User; +}; + +export type GetCurrentSessionErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export type GetCurrentSessionApplicationError = ApplicationErrorCode< + GetCurrentSessionErrorCode, + { message: string } +>; /** * Application Use Case: GetCurrentSessionUseCase @@ -8,13 +25,45 @@ import { IUserRepository } from '../../domain/repositories/IUserRepository'; * Retrieves the current user session information. */ export class GetCurrentSessionUseCase { - constructor(private userRepo: IUserRepository) {} + constructor( + private readonly userRepo: IUserRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} - async execute(userId: string): Promise { - const stored = await this.userRepo.findById(userId); - if (!stored) { - return null; + async execute(input: GetCurrentSessionInput): Promise< + Result + > { + try { + const stored = await this.userRepo.findById(input.userId); + if (!stored) { + return Result.err({ + code: 'USER_NOT_FOUND', + details: { message: 'User not found' }, + } as GetCurrentSessionApplicationError); + } + + const user = User.fromStored(stored); + const result: GetCurrentSessionResult = { user }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute GetCurrentSessionUseCase'; + + this.logger.error( + 'GetCurrentSessionUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as GetCurrentSessionApplicationError); } - return User.fromStored(stored); } } diff --git a/core/identity/application/use-cases/GetCurrentUserSessionUseCase.ts b/core/identity/application/use-cases/GetCurrentUserSessionUseCase.ts index fb7480e0e..9148b172c 100644 --- a/core/identity/application/use-cases/GetCurrentUserSessionUseCase.ts +++ b/core/identity/application/use-cases/GetCurrentUserSessionUseCase.ts @@ -1,14 +1,50 @@ import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type GetCurrentUserSessionInput = void; + +export type GetCurrentUserSessionResult = AuthSessionDTO | null; + +export type GetCurrentUserSessionErrorCode = 'REPOSITORY_ERROR'; + +export type GetCurrentUserSessionApplicationError = ApplicationErrorCode< + GetCurrentUserSessionErrorCode, + { message: string } +>; export class GetCurrentUserSessionUseCase { - private readonly sessionPort: IdentitySessionPort; + constructor( + private readonly sessionPort: IdentitySessionPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} - constructor(sessionPort: IdentitySessionPort) { - this.sessionPort = sessionPort; - } + async execute(): Promise> { + try { + const session = await this.sessionPort.getCurrentSession(); - async execute(): Promise { - return this.sessionPort.getCurrentSession(); + this.output.present(session); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute GetCurrentUserSessionUseCase'; + + this.logger.error( + 'GetCurrentUserSessionUseCase.execute failed', + error instanceof Error ? error : undefined, + {}, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as GetCurrentUserSessionApplicationError); + } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/GetUserUseCase.ts b/core/identity/application/use-cases/GetUserUseCase.ts index 44cc98b79..815274647 100644 --- a/core/identity/application/use-cases/GetUserUseCase.ts +++ b/core/identity/application/use-cases/GetUserUseCase.ts @@ -1,14 +1,58 @@ import { User } from '../../domain/entities/User'; import { IUserRepository } from '../../domain/repositories/IUserRepository'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type GetUserInput = { + userId: string; +}; + +export type GetUserResult = { + user: User; +}; + +export type GetUserErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export type GetUserApplicationError = ApplicationErrorCode< + GetUserErrorCode, + { message: string } +>; export class GetUserUseCase { - constructor(private userRepo: IUserRepository) {} + constructor( + private readonly userRepo: IUserRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} - async execute(userId: string): Promise { - const stored = await this.userRepo.findById(userId); - if (!stored) { - throw new Error('User not found'); + async execute(input: GetUserInput): Promise> { + try { + const stored = await this.userRepo.findById(input.userId); + if (!stored) { + return Result.err({ + code: 'USER_NOT_FOUND', + details: { message: 'User not found' }, + } as GetUserApplicationError); + } + + const user = User.fromStored(stored); + const result: GetUserResult = { user }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Failed to get user'; + + this.logger.error('GetUserUseCase.execute failed', error instanceof Error ? error : undefined, { + input, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as GetUserApplicationError); } - return User.fromStored(stored); } } \ No newline at end of file diff --git a/core/identity/application/use-cases/HandleAuthCallbackUseCase.ts b/core/identity/application/use-cases/HandleAuthCallbackUseCase.ts index c982aeaa1..b350ea040 100644 --- a/core/identity/application/use-cases/HandleAuthCallbackUseCase.ts +++ b/core/identity/application/use-cases/HandleAuthCallbackUseCase.ts @@ -3,19 +3,55 @@ import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type HandleAuthCallbackInput = AuthCallbackCommandDTO; + +export type HandleAuthCallbackResult = AuthSessionDTO; + +export type HandleAuthCallbackErrorCode = 'REPOSITORY_ERROR'; + +export type HandleAuthCallbackApplicationError = ApplicationErrorCode< + HandleAuthCallbackErrorCode, + { message: string } +>; export class HandleAuthCallbackUseCase { - private readonly provider: IdentityProviderPort; - private readonly sessionPort: IdentitySessionPort; + constructor( + private readonly provider: IdentityProviderPort, + private readonly sessionPort: IdentitySessionPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} - constructor(provider: IdentityProviderPort, sessionPort: IdentitySessionPort) { - this.provider = provider; - this.sessionPort = sessionPort; - } + async execute(input: HandleAuthCallbackInput): Promise< + Result + > { + try { + const user: AuthenticatedUserDTO = await this.provider.completeAuth(input); + const session = await this.sessionPort.createSession(user); - async execute(command: AuthCallbackCommandDTO): Promise { - const user: AuthenticatedUserDTO = await this.provider.completeAuth(command); - const session = await this.sessionPort.createSession(user); - return session; + this.output.present(session); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute HandleAuthCallbackUseCase'; + + this.logger.error( + 'HandleAuthCallbackUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as HandleAuthCallbackApplicationError); + } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/LoginUseCase.test.ts b/core/identity/application/use-cases/LoginUseCase.test.ts index 32ec47e7f..deea00b37 100644 --- a/core/identity/application/use-cases/LoginUseCase.test.ts +++ b/core/identity/application/use-cases/LoginUseCase.test.ts @@ -1,9 +1,17 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { LoginUseCase } from './LoginUseCase'; +import { + LoginUseCase, + type LoginInput, + type LoginResult, + type LoginErrorCode, +} from './LoginUseCase'; import { EmailAddress } from '../../domain/value-objects/EmailAddress'; import type { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; import { User } from '../../domain/entities/User'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('LoginUseCase', () => { let authRepo: { @@ -12,6 +20,8 @@ describe('LoginUseCase', () => { let passwordService: { verify: Mock; }; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: LoginUseCase; beforeEach(() => { @@ -21,16 +31,29 @@ describe('LoginUseCase', () => { passwordService = { verify: vi.fn(), }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new LoginUseCase( authRepo as unknown as IAuthRepository, passwordService as unknown as IPasswordHashingService, + logger, + output, ); }); - it('returns the user when credentials are valid', async () => { - const email = 'test@example.com'; - const password = 'password123'; - const emailVO = EmailAddress.create(email); + it('returns ok and presents user when credentials are valid', async () => { + const input: LoginInput = { + email: 'test@example.com', + password: 'password123', + }; + const emailVO = EmailAddress.create(input.email); const user = User.create({ id: { value: 'user-1' } as any, @@ -43,25 +66,45 @@ describe('LoginUseCase', () => { authRepo.findByEmail.mockResolvedValue(user); passwordService.verify.mockResolvedValue(true); - const result = await useCase.execute(email, password); + const result: Result> = + await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO); - expect(passwordService.verify).toHaveBeenCalledWith(password, 'stored-hash'); - expect(result).toBe(user); + expect(passwordService.verify).toHaveBeenCalledWith(input.password, 'stored-hash'); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as LoginResult; + expect(presented.user).toBe(user); }); - it('throws when user is not found', async () => { - const email = 'missing@example.com'; + it('returns INVALID_CREDENTIALS when user is not found', async () => { + const input: LoginInput = { + email: 'missing@example.com', + password: 'password123', + }; authRepo.findByEmail.mockResolvedValue(null); - await expect(useCase.execute(email, 'password')).rejects.toThrow('Invalid credentials'); + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + + expect(error.code).toBe('INVALID_CREDENTIALS'); + expect(error.details?.message).toBe('Invalid credentials'); + expect(output.present).not.toHaveBeenCalled(); }); - it('throws when password is invalid', async () => { - const email = 'test@example.com'; - const password = 'wrong-password'; - const emailVO = EmailAddress.create(email); + it('returns INVALID_CREDENTIALS when password is invalid', async () => { + const input: LoginInput = { + email: 'test@example.com', + password: 'wrong-password', + }; + const emailVO = EmailAddress.create(input.email); const user = User.create({ id: { value: 'user-1' } as any, @@ -74,8 +117,34 @@ describe('LoginUseCase', () => { authRepo.findByEmail.mockResolvedValue(user); passwordService.verify.mockResolvedValue(false); - await expect(useCase.execute(email, password)).rejects.toThrow('Invalid credentials'); - expect(authRepo.findByEmail).toHaveBeenCalled(); - expect(passwordService.verify).toHaveBeenCalled(); + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + + expect(error.code).toBe('INVALID_CREDENTIALS'); + expect(error.details?.message).toBe('Invalid credentials'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => { + const input: LoginInput = { + email: 'test@example.com', + password: 'password123', + }; + + authRepo.findByEmail.mockRejectedValue(new Error('DB failure')); + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); diff --git a/core/identity/application/use-cases/LoginUseCase.ts b/core/identity/application/use-cases/LoginUseCase.ts index 92e3ffb19..8a1e08dac 100644 --- a/core/identity/application/use-cases/LoginUseCase.ts +++ b/core/identity/application/use-cases/LoginUseCase.ts @@ -2,6 +2,22 @@ import { EmailAddress } from '../../domain/value-objects/EmailAddress'; import { User } from '../../domain/entities/User'; import { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type LoginInput = { + email: string; + password: string; +}; + +export type LoginResult = { + user: User; +}; + +export type LoginErrorCode = 'INVALID_CREDENTIALS' | 'REPOSITORY_ERROR'; + +export type LoginApplicationError = ApplicationErrorCode; /** * Application Use Case: LoginUseCase @@ -10,20 +26,52 @@ import { IPasswordHashingService } from '../../domain/services/PasswordHashingSe */ export class LoginUseCase { constructor( - private authRepo: IAuthRepository, - private passwordService: IPasswordHashingService + private readonly authRepo: IAuthRepository, + private readonly passwordService: IPasswordHashingService, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(email: string, password: string): Promise { - const emailVO = EmailAddress.create(email); - const user = await this.authRepo.findByEmail(emailVO); - if (!user || !user.getPasswordHash()) { - throw new Error('Invalid credentials'); + async execute(input: LoginInput): Promise> { + try { + const emailVO = EmailAddress.create(input.email); + const user = await this.authRepo.findByEmail(emailVO); + + if (!user || !user.getPasswordHash()) { + return Result.err({ + code: 'INVALID_CREDENTIALS', + details: { message: 'Invalid credentials' }, + } as LoginApplicationError); + } + + const passwordHash = user.getPasswordHash()!; + const isValid = await this.passwordService.verify(input.password, passwordHash.value); + + if (!isValid) { + return Result.err({ + code: 'INVALID_CREDENTIALS', + details: { message: 'Invalid credentials' }, + } as LoginApplicationError); + } + + const result: LoginResult = { user }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute LoginUseCase'; + + this.logger.error('LoginUseCase.execute failed', error instanceof Error ? error : undefined, { + input, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as LoginApplicationError); } - const isValid = await this.passwordService.verify(password, user.getPasswordHash()!.value); - if (!isValid) { - throw new Error('Invalid credentials'); - } - return user; } } \ No newline at end of file diff --git a/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts b/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts index f48bac921..7ddc58c63 100644 --- a/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts +++ b/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { LoginWithEmailUseCase, type LoginCommandDTO } from './LoginWithEmailUseCase'; +import { + LoginWithEmailUseCase, + type LoginWithEmailInput, + type LoginWithEmailResult, + type LoginWithEmailErrorCode, +} from './LoginWithEmailUseCase'; import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; -import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('LoginWithEmailUseCase', () => { let userRepository: { @@ -13,6 +20,8 @@ describe('LoginWithEmailUseCase', () => { getCurrentSession: Mock; clearSession: Mock; }; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: LoginWithEmailUseCase; beforeEach(() => { @@ -24,14 +33,26 @@ describe('LoginWithEmailUseCase', () => { getCurrentSession: vi.fn(), clearSession: vi.fn(), }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: Mock }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new LoginWithEmailUseCase( userRepository as unknown as IUserRepository, sessionPort as unknown as IdentitySessionPort, + logger, + output, ); }); - it('creates a session for valid credentials', async () => { - const command: LoginCommandDTO = { + it('returns ok and presents session result for valid credentials', async () => { + const input: LoginWithEmailInput = { email: 'Test@Example.com', password: 'password123', }; @@ -45,7 +66,7 @@ describe('LoginWithEmailUseCase', () => { createdAt: new Date(), }; - const session: AuthSessionDTO = { + const session = { user: { id: storedUser.id, email: storedUser.email, @@ -59,35 +80,59 @@ describe('LoginWithEmailUseCase', () => { userRepository.findByEmail.mockResolvedValue(storedUser); sessionPort.createSession.mockResolvedValue(session); - const result = await useCase.execute(command); + const result: Result> = + await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com'); expect(sessionPort.createSession).toHaveBeenCalledWith({ id: storedUser.id, email: storedUser.email, displayName: storedUser.displayName, + primaryDriverId: undefined, }); - expect(result).toEqual(session); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as LoginWithEmailResult; + expect(presented.sessionToken).toBe('token-123'); + expect(presented.userId).toBe(storedUser.id); + expect(presented.displayName).toBe(storedUser.displayName); + expect(presented.email).toBe(storedUser.email); }); - it('throws when email or password is missing', async () => { - await expect(useCase.execute({ email: '', password: 'x' })).rejects.toThrow('Email and password are required'); - await expect(useCase.execute({ email: 'a@example.com', password: '' })).rejects.toThrow('Email and password are required'); + it('returns INVALID_INPUT when email or password is missing', async () => { + const result1 = await useCase.execute({ email: '', password: 'x' }); + const result2 = await useCase.execute({ email: 'a@example.com', password: '' }); + + expect(result1.isErr()).toBe(true); + expect(result1.unwrapErr().code).toBe('INVALID_INPUT'); + expect(result2.isErr()).toBe(true); + expect(result2.unwrapErr().code).toBe('INVALID_INPUT'); + expect(output.present).not.toHaveBeenCalled(); }); - it('throws when user does not exist', async () => { - const command: LoginCommandDTO = { + it('returns INVALID_CREDENTIALS when user does not exist', async () => { + const input: LoginWithEmailInput = { email: 'missing@example.com', password: 'password', }; userRepository.findByEmail.mockResolvedValue(null); - await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('INVALID_CREDENTIALS'); + expect(error.details.message).toBe('Invalid email or password'); + expect(output.present).not.toHaveBeenCalled(); }); - it('throws when password is invalid', async () => { - const command: LoginCommandDTO = { + it('returns INVALID_CREDENTIALS when password is invalid', async () => { + const input: LoginWithEmailInput = { email: 'test@example.com', password: 'wrong', }; @@ -103,6 +148,33 @@ describe('LoginWithEmailUseCase', () => { userRepository.findByEmail.mockResolvedValue(storedUser); - await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('INVALID_CREDENTIALS'); + expect(error.details.message).toBe('Invalid email or password'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => { + const input: LoginWithEmailInput = { + email: 'test@example.com', + password: 'password123', + }; + + userRepository.findByEmail.mockRejectedValue(new Error('DB failure')); + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); diff --git a/core/identity/application/use-cases/LoginWithEmailUseCase.ts b/core/identity/application/use-cases/LoginWithEmailUseCase.ts index 390151465..7b2bc2b63 100644 --- a/core/identity/application/use-cases/LoginWithEmailUseCase.ts +++ b/core/identity/application/use-cases/LoginWithEmailUseCase.ts @@ -1,56 +1,112 @@ /** * Login with Email Use Case - * + * * Authenticates a user with email and password. */ import type { IUserRepository } from '../../domain/repositories/IUserRepository'; -import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; -import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; -export interface LoginCommandDTO { +export type LoginWithEmailInput = { email: string; password: string; -} +}; + +export type LoginWithEmailResult = { + sessionToken: string; + userId: string; + displayName: string; + email?: string; + primaryDriverId?: string; + issuedAt: number; + expiresAt: number; +}; + +export type LoginWithEmailErrorCode = + | 'INVALID_INPUT' + | 'INVALID_CREDENTIALS' + | 'REPOSITORY_ERROR'; + +export type LoginWithEmailApplicationError = ApplicationErrorCode< + LoginWithEmailErrorCode, + { message: string } +>; export class LoginWithEmailUseCase { constructor( private readonly userRepository: IUserRepository, private readonly sessionPort: IdentitySessionPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: LoginCommandDTO): Promise { - // Validate inputs - if (!command.email || !command.password) { - throw new Error('Email and password are required'); + async execute(input: LoginWithEmailInput): Promise> { + try { + if (!input.email || !input.password) { + return Result.err({ + code: 'INVALID_INPUT', + details: { message: 'Email and password are required' }, + } as LoginWithEmailApplicationError); + } + + const normalizedEmail = input.email.toLowerCase().trim(); + + const user = await this.userRepository.findByEmail(normalizedEmail); + if (!user) { + return Result.err({ + code: 'INVALID_CREDENTIALS', + details: { message: 'Invalid email or password' }, + } as LoginWithEmailApplicationError); + } + + const passwordHash = await this.hashPassword(input.password, user.salt); + if (passwordHash !== user.passwordHash) { + return Result.err({ + code: 'INVALID_CREDENTIALS', + details: { message: 'Invalid email or password' }, + } as LoginWithEmailApplicationError); + } + + const session = await this.sessionPort.createSession({ + id: user.id, + displayName: user.displayName, + email: user.email, + primaryDriverId: user.primaryDriverId, + } as any); + + const result: LoginWithEmailResult = { + sessionToken: (session as any).token, + userId: (session as any).user.id, + displayName: (session as any).user.displayName, + email: (session as any).user.email, + primaryDriverId: (session as any).user.primaryDriverId, + issuedAt: (session as any).issuedAt, + expiresAt: (session as any).expiresAt, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute LoginWithEmailUseCase'; + + this.logger.error( + 'LoginWithEmailUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as LoginWithEmailApplicationError); } - - // Find user by email - const user = await this.userRepository.findByEmail(command.email.toLowerCase().trim()); - if (!user) { - throw new Error('Invalid email or password'); - } - - // Verify password - const passwordHash = await this.hashPassword(command.password, user.salt); - if (passwordHash !== user.passwordHash) { - throw new Error('Invalid email or password'); - } - - // Create session - const authenticatedUserBase: AuthenticatedUserDTO = { - id: user.id, - displayName: user.displayName, - email: user.email, - }; - - const authenticatedUser: AuthenticatedUserDTO = - user.primaryDriverId !== undefined - ? { ...authenticatedUserBase, primaryDriverId: user.primaryDriverId } - : authenticatedUserBase; - - return this.sessionPort.createSession(authenticatedUser); } private async hashPassword(password: string, salt: string): Promise { diff --git a/core/identity/application/use-cases/LogoutUseCase.test.ts b/core/identity/application/use-cases/LogoutUseCase.test.ts index 4c45be675..96a54e672 100644 --- a/core/identity/application/use-cases/LogoutUseCase.test.ts +++ b/core/identity/application/use-cases/LogoutUseCase.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { LogoutUseCase } from './LogoutUseCase'; +import { LogoutUseCase, type LogoutResult, type LogoutErrorCode } from './LogoutUseCase'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('LogoutUseCase', () => { let sessionPort: { @@ -8,6 +11,8 @@ describe('LogoutUseCase', () => { getCurrentSession: Mock; createSession: Mock; }; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: LogoutUseCase; beforeEach(() => { @@ -17,12 +22,49 @@ describe('LogoutUseCase', () => { createSession: vi.fn(), }; - useCase = new LogoutUseCase(sessionPort as unknown as IdentitySessionPort); + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new LogoutUseCase( + sessionPort as unknown as IdentitySessionPort, + logger, + output, + ); }); - it('clears the current session', async () => { - await useCase.execute(); + it('clears the current session and presents success', async () => { + const result: Result> = + await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(sessionPort.clearSession).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ success: true }); + }); + + it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => { + const error = new Error('Session clear failed'); + sessionPort.clearSession.mockRejectedValue(error); + + const result: Result> = + await useCase.execute(); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Session clear failed'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); diff --git a/core/identity/application/use-cases/LogoutUseCase.ts b/core/identity/application/use-cases/LogoutUseCase.ts index 2be1932c2..fb966e6c3 100644 --- a/core/identity/application/use-cases/LogoutUseCase.ts +++ b/core/identity/application/use-cases/LogoutUseCase.ts @@ -1,13 +1,53 @@ import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type LogoutInput = {}; + +export type LogoutResult = { + success: boolean; +}; + +export type LogoutErrorCode = 'REPOSITORY_ERROR'; + +export type LogoutApplicationError = ApplicationErrorCode; export class LogoutUseCase { private readonly sessionPort: IdentitySessionPort; - constructor(sessionPort: IdentitySessionPort) { + constructor( + sessionPort: IdentitySessionPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) { this.sessionPort = sessionPort; } - async execute(): Promise { - await this.sessionPort.clearSession(); + async execute(): Promise> { + try { + await this.sessionPort.clearSession(); + + const result: LogoutResult = { success: true }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute LogoutUseCase'; + + this.logger.error( + 'LogoutUseCase.execute failed', + error instanceof Error ? error : undefined, + {}, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as LogoutApplicationError); + } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/SignupUseCase.ts b/core/identity/application/use-cases/SignupUseCase.ts index 6b330efec..dafba698b 100644 --- a/core/identity/application/use-cases/SignupUseCase.ts +++ b/core/identity/application/use-cases/SignupUseCase.ts @@ -3,6 +3,23 @@ import { UserId } from '../../domain/value-objects/UserId'; import { User } from '../../domain/entities/User'; import { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type SignupInput = { + email: string; + password: string; + displayName: string; +}; + +export type SignupResult = { + user: User; +}; + +export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'REPOSITORY_ERROR'; + +export type SignupApplicationError = ApplicationErrorCode; /** * Application Use Case: SignupUseCase @@ -11,31 +28,56 @@ import { IPasswordHashingService } from '../../domain/services/PasswordHashingSe */ export class SignupUseCase { constructor( - private authRepo: IAuthRepository, - private passwordService: IPasswordHashingService + private readonly authRepo: IAuthRepository, + private readonly passwordService: IPasswordHashingService, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(email: string, password: string, displayName: string): Promise { - const emailVO = EmailAddress.create(email); + async execute(input: SignupInput): Promise> { + try { + const emailVO = EmailAddress.create(input.email); - // Check if user already exists - const existingUser = await this.authRepo.findByEmail(emailVO); - if (existingUser) { - throw new Error('User already exists'); + const existingUser = await this.authRepo.findByEmail(emailVO); + if (existingUser) { + return Result.err({ + code: 'USER_ALREADY_EXISTS', + details: { message: 'User already exists' }, + } as SignupApplicationError); + } + + const hashedPassword = await this.passwordService.hash(input.password); + const passwordHashModule = await import('../../domain/value-objects/PasswordHash'); + const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword); + + const userId = UserId.create(); + const user = User.create({ + id: userId, + displayName: input.displayName, + email: emailVO.value, + passwordHash, + }); + + await this.authRepo.save(user); + + const result: SignupResult = { user }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute SignupUseCase'; + + this.logger.error('SignupUseCase.execute failed', error instanceof Error ? error : undefined, { + input, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as SignupApplicationError); } - - const hashedPassword = await this.passwordService.hash(password); - const passwordHash = await import('../../domain/value-objects/PasswordHash').then(m => m.PasswordHash.fromHash(hashedPassword)); - - const userId = UserId.create(); - const user = User.create({ - id: userId, - displayName, - email: emailVO.value, - passwordHash, - }); - - await this.authRepo.save(user); - return user; } } \ No newline at end of file diff --git a/core/identity/application/use-cases/SignupWithEmailUseCase.ts b/core/identity/application/use-cases/SignupWithEmailUseCase.ts index de1f45d34..f299e6ea3 100644 --- a/core/identity/application/use-cases/SignupWithEmailUseCase.ts +++ b/core/identity/application/use-cases/SignupWithEmailUseCase.ts @@ -1,84 +1,145 @@ /** * Signup with Email Use Case - * + * * Creates a new user account with email and password. */ import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; -import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; -export interface SignupCommandDTO { +export type SignupWithEmailInput = { email: string; password: string; displayName: string; -} +}; -export interface SignupResultDTO { - session: AuthSessionDTO; +export type SignupWithEmailResult = { + sessionToken: string; + userId: string; + displayName: string; + email: string; + createdAt: Date; isNewUser: boolean; -} +}; + +export type SignupWithEmailErrorCode = + | 'INVALID_EMAIL_FORMAT' + | 'WEAK_PASSWORD' + | 'INVALID_DISPLAY_NAME' + | 'EMAIL_ALREADY_EXISTS' + | 'REPOSITORY_ERROR'; + +export type SignupWithEmailApplicationError = ApplicationErrorCode< + SignupWithEmailErrorCode, + { message: string } +>; export class SignupWithEmailUseCase { constructor( private readonly userRepository: IUserRepository, private readonly sessionPort: IdentitySessionPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: SignupCommandDTO): Promise { + async execute(input: SignupWithEmailInput): Promise< + Result + > { // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(command.email)) { - throw new Error('Invalid email format'); + if (!emailRegex.test(input.email)) { + return Result.err({ + code: 'INVALID_EMAIL_FORMAT', + details: { message: 'Invalid email format' }, + } as SignupWithEmailApplicationError); } // Validate password strength - if (command.password.length < 8) { - throw new Error('Password must be at least 8 characters'); + if (input.password.length < 8) { + return Result.err({ + code: 'WEAK_PASSWORD', + details: { message: 'Password must be at least 8 characters' }, + } as SignupWithEmailApplicationError); } // Validate display name - if (!command.displayName || command.displayName.trim().length < 2) { - throw new Error('Display name must be at least 2 characters'); + if (!input.displayName || input.displayName.trim().length < 2) { + return Result.err({ + code: 'INVALID_DISPLAY_NAME', + details: { message: 'Display name must be at least 2 characters' }, + } as SignupWithEmailApplicationError); } // Check if email already exists - const existingUser = await this.userRepository.findByEmail(command.email); + const existingUser = await this.userRepository.findByEmail(input.email); if (existingUser) { - throw new Error('An account with this email already exists'); + return Result.err({ + code: 'EMAIL_ALREADY_EXISTS', + details: { message: 'An account with this email already exists' }, + } as SignupWithEmailApplicationError); } - // Hash password (simple hash for demo - in production use bcrypt) - const salt = this.generateSalt(); - const passwordHash = await this.hashPassword(command.password, salt); + try { + // Hash password (simple hash for demo - in production use bcrypt) + const salt = this.generateSalt(); + const passwordHash = await this.hashPassword(input.password, salt); - // Create user - const userId = this.generateUserId(); - const newUser: StoredUser = { - id: userId, - email: command.email.toLowerCase().trim(), - displayName: command.displayName.trim(), - passwordHash, - salt, - createdAt: new Date(), - }; + // Create user + const userId = this.generateUserId(); + const createdAt = new Date(); + const newUser: StoredUser = { + id: userId, + email: input.email.toLowerCase().trim(), + displayName: input.displayName.trim(), + passwordHash, + salt, + createdAt, + }; - await this.userRepository.create(newUser); + await this.userRepository.create(newUser); - // Create session - const authenticatedUser: AuthenticatedUserDTO = { - id: newUser.id, - displayName: newUser.displayName, - email: newUser.email, - }; + // Create session + const authenticatedUser: AuthenticatedUserDTO = { + id: newUser.id, + displayName: newUser.displayName, + email: newUser.email, + }; - const session = await this.sessionPort.createSession(authenticatedUser); + const session = await this.sessionPort.createSession(authenticatedUser); - return { - session, - isNewUser: true, - }; + const result: SignupWithEmailResult = { + sessionToken: session.token, + userId: session.user.id, + displayName: session.user.displayName, + email: session.user.email ?? newUser.email, + createdAt, + isNewUser: true, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute SignupWithEmailUseCase'; + + this.logger.error( + 'SignupWithEmailUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as SignupWithEmailApplicationError); + } } private generateSalt(): string { diff --git a/core/identity/application/use-cases/StartAuthUseCase.test.ts b/core/identity/application/use-cases/StartAuthUseCase.test.ts index 43d36e377..ee377bc2f 100644 --- a/core/identity/application/use-cases/StartAuthUseCase.test.ts +++ b/core/identity/application/use-cases/StartAuthUseCase.test.ts @@ -1,12 +1,21 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { StartAuthUseCase } from './StartAuthUseCase'; +import { + StartAuthUseCase, + type StartAuthInput, + type StartAuthResult, + type StartAuthErrorCode, +} from './StartAuthUseCase'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; -import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('StartAuthUseCase', () => { let provider: { startAuth: Mock; }; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: StartAuthUseCase; beforeEach(() => { @@ -14,22 +23,67 @@ describe('StartAuthUseCase', () => { startAuth: vi.fn(), }; - useCase = new StartAuthUseCase(provider as unknown as IdentityProviderPort); + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new StartAuthUseCase( + provider as unknown as IdentityProviderPort, + logger, + output, + ); }); - it('delegates to the identity provider to start auth', async () => { - const command: StartAuthCommandDTO = { - redirectUri: 'https://app/callback', - provider: 'demo', + it('returns ok and presents redirect when provider call succeeds', async () => { + const input: StartAuthInput = { + provider: 'IRACING_DEMO' as any, + returnTo: 'https://app/callback', }; const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' }; provider.startAuth.mockResolvedValue(expected); - const result = await useCase.execute(command); + const result: Result> = + await useCase.execute(input); - expect(provider.startAuth).toHaveBeenCalledWith(command); - expect(result).toEqual(expected); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(provider.startAuth).toHaveBeenCalledWith({ + provider: input.provider, + returnTo: input.returnTo, + }); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as StartAuthResult; + expect(presented).toEqual(expected); + }); + + it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => { + const input: StartAuthInput = { + provider: 'IRACING_DEMO' as any, + returnTo: 'https://app/callback', + }; + + provider.startAuth.mockRejectedValue(new Error('Provider failure')); + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Provider failure'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); diff --git a/core/identity/application/use-cases/StartAuthUseCase.ts b/core/identity/application/use-cases/StartAuthUseCase.ts index d9832f497..ef283daff 100644 --- a/core/identity/application/use-cases/StartAuthUseCase.ts +++ b/core/identity/application/use-cases/StartAuthUseCase.ts @@ -1,14 +1,63 @@ import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; + +export type StartAuthInput = { + provider: StartAuthCommandDTO['provider']; + returnTo?: StartAuthCommandDTO['returnTo']; +}; + +export type StartAuthResult = { + redirectUrl: string; + state: string; +}; + +export type StartAuthErrorCode = 'REPOSITORY_ERROR'; + +export type StartAuthApplicationError = ApplicationErrorCode; export class StartAuthUseCase { - private readonly provider: IdentityProviderPort; + constructor( + private readonly provider: IdentityProviderPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} - constructor(provider: IdentityProviderPort) { - this.provider = provider; - } + async execute(input: StartAuthInput): Promise> { + try { + const command: StartAuthCommandDTO = input.returnTo + ? { + provider: input.provider, + returnTo: input.returnTo, + } + : { + provider: input.provider, + }; - async execute(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> { - return this.provider.startAuth(command); + const { redirectUrl, state } = await this.provider.startAuth(command); + + const result: StartAuthResult = { redirectUrl, state }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute StartAuthUseCase'; + + this.logger.error( + 'StartAuthUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as StartAuthApplicationError); + } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/achievement/CreateAchievementUseCase.ts b/core/identity/application/use-cases/achievement/CreateAchievementUseCase.ts index 830384a50..3658144dc 100644 --- a/core/identity/application/use-cases/achievement/CreateAchievementUseCase.ts +++ b/core/identity/application/use-cases/achievement/CreateAchievementUseCase.ts @@ -1,16 +1,60 @@ import { Achievement, AchievementProps } from '@core/identity/domain/entities/Achievement'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; export interface IAchievementRepository { save(achievement: Achievement): Promise; findById(id: string): Promise; } -export class CreateAchievementUseCase { - constructor(private readonly achievementRepository: IAchievementRepository) {} +export type CreateAchievementInput = Omit; - async execute(props: Omit): Promise { - const achievement = Achievement.create(props); - await this.achievementRepository.save(achievement); - return achievement; +export type CreateAchievementResult = { + achievement: Achievement; +}; + +export type CreateAchievementErrorCode = 'REPOSITORY_ERROR'; + +export type CreateAchievementApplicationError = ApplicationErrorCode< + CreateAchievementErrorCode, + { message: string } +>; + +export class CreateAchievementUseCase { + constructor( + private readonly achievementRepository: IAchievementRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} + + async execute(input: CreateAchievementInput): Promise< + Result + > { + try { + const achievement = Achievement.create(input); + await this.achievementRepository.save(achievement); + + const result: CreateAchievementResult = { achievement }; + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to execute CreateAchievementUseCase'; + + this.logger.error( + 'CreateAchievementUseCase.execute failed', + error instanceof Error ? error : undefined, + { input }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as CreateAchievementApplicationError); + } } } diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCase.ts b/core/league/application/use-cases/GetLeagueStandingsUseCase.ts index c5193b81d..005301ad4 100644 --- a/core/league/application/use-cases/GetLeagueStandingsUseCase.ts +++ b/core/league/application/use-cases/GetLeagueStandingsUseCase.ts @@ -1,3 +1,5 @@ +// TODO is this even used? either remove or it must be within racing domain + export interface GetLeagueStandingsUseCase { execute(leagueId: string): Promise; } diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts index 5ec48a64d..a1027d5fb 100644 --- a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts +++ b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.ts @@ -1,5 +1,7 @@ -import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase'; import { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository'; +import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase'; + +// TODO is this even used? either remove or it must be within racing domain export class GetLeagueStandingsUseCaseImpl implements GetLeagueStandingsUseCase { constructor(private repository: ILeagueStandingsRepository) {} diff --git a/core/media/application/use-cases/DeleteMediaUseCase.test.ts b/core/media/application/use-cases/DeleteMediaUseCase.test.ts index d94684449..20b7dedc7 100644 --- a/core/media/application/use-cases/DeleteMediaUseCase.test.ts +++ b/core/media/application/use-cases/DeleteMediaUseCase.test.ts @@ -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 { + 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(); + }); }); diff --git a/core/media/application/use-cases/DeleteMediaUseCase.ts b/core/media/application/use-cases/DeleteMediaUseCase.ts index ca5ba0d8a..7f7e26b3f 100644 --- a/core/media/application/use-cases/DeleteMediaUseCase.ts +++ b/core/media/application/use-cases/DeleteMediaUseCase.ts @@ -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, private readonly logger: Logger, ) {} - async execute( - input: DeleteMediaInput, - presenter: IDeleteMediaPresenter, - ): Promise { - try { - this.logger.info('[DeleteMediaUseCase] Deleting media', { - mediaId: input.mediaId, - }); + async execute(input: DeleteMediaInput): Promise> { + 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' }, }); } } diff --git a/core/media/application/use-cases/GetAvatarUseCase.test.ts b/core/media/application/use-cases/GetAvatarUseCase.test.ts index cca8c914b..33c4e6fef 100644 --- a/core/media/application/use-cases/GetAvatarUseCase.test.ts +++ b/core/media/application/use-cases/GetAvatarUseCase.test.ts @@ -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 { + 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(); }); }); diff --git a/core/media/application/use-cases/GetAvatarUseCase.ts b/core/media/application/use-cases/GetAvatarUseCase.ts index 16376a804..7abb3e49f 100644 --- a/core/media/application/use-cases/GetAvatarUseCase.ts +++ b/core/media/application/use-cases/GetAvatarUseCase.ts @@ -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, private readonly logger: Logger, ) {} - async execute( - input: GetAvatarInput, - presenter: IGetAvatarPresenter, - ): Promise { - try { - this.logger.info('[GetAvatarUseCase] Getting avatar', { - driverId: input.driverId, - }); + async execute(input: GetAvatarInput): Promise> { + 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' }, }); } } diff --git a/core/media/application/use-cases/GetMediaUseCase.test.ts b/core/media/application/use-cases/GetMediaUseCase.test.ts index 6bfc858ed..61e68d6ac 100644 --- a/core/media/application/use-cases/GetMediaUseCase.test.ts +++ b/core/media/application/use-cases/GetMediaUseCase.test.ts @@ -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 { + 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; + 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; + expect(err.code).toBe('REPOSITORY_ERROR'); }); }); diff --git a/core/media/application/use-cases/GetMediaUseCase.ts b/core/media/application/use-cases/GetMediaUseCase.ts index 03ce4c4d2..b6c67c2bb 100644 --- a/core/media/application/use-cases/GetMediaUseCase.ts +++ b/core/media/application/use-cases/GetMediaUseCase.ts @@ -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; }; - 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, private readonly logger: Logger, ) {} async execute( input: GetMediaInput, - presenter: IGetMediaPresenter, - ): Promise { - try { - this.logger.info('[GetMediaUseCase] Getting media', { - mediaId: input.mediaId, - }); + ): Promise>> { + 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 }, }); } } diff --git a/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts b/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts index 9c1a6c3e4..202baf2eb 100644 --- a/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts +++ b/core/media/application/use-cases/RequestAvatarGenerationUseCase.ts @@ -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, private readonly logger: Logger, ) {} async execute( input: RequestAvatarGenerationInput, - presenter: IRequestAvatarGenerationPresenter, - ): Promise { - try { - this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', { - userId: input.userId, - suitColor: input.suitColor, - }); + ): Promise> { + 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' }, }); } } diff --git a/core/media/application/use-cases/SelectAvatarUseCase.ts b/core/media/application/use-cases/SelectAvatarUseCase.ts index a9aa182dd..aa93e05d8 100644 --- a/core/media/application/use-cases/SelectAvatarUseCase.ts +++ b/core/media/application/use-cases/SelectAvatarUseCase.ts @@ -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, private readonly logger: Logger, ) {} - async execute( - input: SelectAvatarInput, - presenter: ISelectAvatarPresenter, - ): Promise { - try { - this.logger.info('[SelectAvatarUseCase] Selecting avatar', { - requestId: input.requestId, - selectedIndex: input.selectedIndex, - }); + async execute(input: SelectAvatarInput): Promise> { + 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' }, }); } } diff --git a/core/media/application/use-cases/UpdateAvatarUseCase.ts b/core/media/application/use-cases/UpdateAvatarUseCase.ts index e316661b2..731cfa61b 100644 --- a/core/media/application/use-cases/UpdateAvatarUseCase.ts +++ b/core/media/application/use-cases/UpdateAvatarUseCase.ts @@ -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, private readonly logger: Logger, ) {} - async execute( - input: UpdateAvatarInput, - presenter: IUpdateAvatarPresenter, - ): Promise { - try { - this.logger.info('[UpdateAvatarUseCase] Updating avatar', { - driverId: input.driverId, - mediaUrl: input.mediaUrl, - }); + async execute(input: UpdateAvatarInput): Promise> { + 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' }, }); } } diff --git a/core/media/application/use-cases/UploadMediaUseCase.ts b/core/media/application/use-cases/UploadMediaUseCase.ts index 70cebd640..fc8464fb7 100644 --- a/core/media/application/use-cases/UploadMediaUseCase.ts +++ b/core/media/application/use-cases/UploadMediaUseCase.ts @@ -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, private readonly logger: Logger, ) {} async execute( input: UploadMediaInput, - presenter: IUploadMediaPresenter, - ): Promise { - try { - this.logger.info('[UploadMediaUseCase] Starting media upload', { - filename: input.file.originalname, - size: input.file.size, - uploadedBy: input.uploadedBy, - }); + ): Promise>> { + 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 }, }); } } diff --git a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts index 0f4497e09..dfccf844c 100644 --- a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts +++ b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts @@ -1,16 +1,27 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { GetUnreadNotificationsUseCase } from './GetUnreadNotificationsUseCase'; +import { + GetUnreadNotificationsUseCase, + type GetUnreadNotificationsInput, + type GetUnreadNotificationsResult, +} from './GetUnreadNotificationsUseCase'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; -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 { Notification } from '../../domain/entities/Notification'; interface NotificationRepositoryMock { findUnreadByRecipientId: Mock; } +interface OutputPortMock extends UseCaseOutputPort { + present: Mock; +} + describe('GetUnreadNotificationsUseCase', () => { let notificationRepository: NotificationRepositoryMock; let logger: Logger; + let output: OutputPortMock; let useCase: GetUnreadNotificationsUseCase; beforeEach(() => { @@ -25,8 +36,13 @@ describe('GetUnreadNotificationsUseCase', () => { error: vi.fn(), } as unknown as Logger; + output = { + present: vi.fn(), + } as unknown as OutputPortMock; + useCase = new GetUnreadNotificationsUseCase( notificationRepository as unknown as INotificationRepository, + output, logger, ); }); @@ -37,7 +53,7 @@ describe('GetUnreadNotificationsUseCase', () => { Notification.create({ id: 'n1', recipientId, - type: 'info', + type: 'system_announcement', title: 'Test', body: 'Body', channel: 'in_app', @@ -46,19 +62,33 @@ describe('GetUnreadNotificationsUseCase', () => { notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications); - const result = await useCase.execute(recipientId); + const input: GetUnreadNotificationsInput = { recipientId }; + + const result = await useCase.execute(input); expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId); - expect(result.notifications).toEqual(notifications); - expect(result.totalCount).toBe(1); + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ + notifications, + totalCount: 1, + }); }); - it('handles repository errors by logging and rethrowing', async () => { + it('handles repository errors by logging and returning error result', async () => { const recipientId = 'driver-1'; const error = new Error('DB error'); notificationRepository.findUnreadByRecipientId.mockRejectedValue(error); - await expect(useCase.execute(recipientId)).rejects.toThrow('DB error'); + const input: GetUnreadNotificationsInput = { recipientId }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); }); diff --git a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts index b7ca1c1b5..c92c24986 100644 --- a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts +++ b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts @@ -1,41 +1,72 @@ /** * Application Use Case: GetUnreadNotificationsUseCase - * + * * Retrieves unread notifications for a recipient. */ -import type { AsyncUseCase , 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 type { Notification } from '../../domain/entities/Notification'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; -export interface UnreadNotificationsResult { +export type GetUnreadNotificationsInput = { + recipientId: string; +}; + +export interface GetUnreadNotificationsResult { notifications: Notification[]; totalCount: number; } -export class GetUnreadNotificationsUseCase implements AsyncUseCase { +export type GetUnreadNotificationsErrorCode = 'REPOSITORY_ERROR'; + +export class GetUnreadNotificationsUseCase { constructor( private readonly notificationRepository: INotificationRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(recipientId: string): Promise { - this.logger.debug(`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`); + async execute( + input: GetUnreadNotificationsInput, + ): Promise>> { + const { recipientId } = input; + this.logger.debug( + `Attempting to retrieve unread notifications for recipient ID: ${recipientId}`, + ); + try { - const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId); - this.logger.info(`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`); - + const notifications = await this.notificationRepository.findUnreadByRecipientId( + recipientId, + ); + this.logger.info( + `Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`, + ); + if (notifications.length === 0) { this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`); } - return { + this.output.present({ notifications, totalCount: notifications.length, - }; + }); + + return Result.ok>( + undefined, + ); } catch (error) { - this.logger.error(`Failed to retrieve unread notifications for recipient ID: ${recipientId}`, error instanceof Error ? error : new Error(String(error))); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + `Failed to retrieve unread notifications for recipient ID: ${recipientId}`, + err, + ); + + return Result.err>({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } diff --git a/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts b/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts index 850349d73..5ff36bbf0 100644 --- a/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts +++ b/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; -import { MarkNotificationReadUseCase } from './MarkNotificationReadUseCase'; +import { + MarkNotificationReadUseCase, + type MarkNotificationReadCommand, + type MarkNotificationReadResult, +} from './MarkNotificationReadUseCase'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; -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 { Notification } from '../../domain/entities/Notification'; -import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; interface NotificationRepositoryMock { findById: Mock; @@ -11,9 +16,14 @@ interface NotificationRepositoryMock { markAllAsReadByRecipientId: Mock; } +interface OutputPortMock extends UseCaseOutputPort { + present: Mock; +} + describe('MarkNotificationReadUseCase', () => { let notificationRepository: NotificationRepositoryMock; let logger: Logger; + let output: OutputPortMock; let useCase: MarkNotificationReadUseCase; beforeEach(() => { @@ -30,27 +40,39 @@ describe('MarkNotificationReadUseCase', () => { error: vi.fn(), } as unknown as Logger; + output = { + present: vi.fn(), + } as unknown as OutputPortMock; + useCase = new MarkNotificationReadUseCase( notificationRepository as unknown as INotificationRepository, + output, logger, ); }); - it('throws when notification is not found', async () => { + it('returns NOTIFICATION_NOT_FOUND when notification is not found', async () => { notificationRepository.findById.mockResolvedValue(null); - await expect( - useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), - ).rejects.toThrow(NotificationDomainError); + const command: MarkNotificationReadCommand = { + notificationId: 'n1', + recipientId: 'driver-1', + }; - expect((logger.warn as unknown as Mock)).toHaveBeenCalled(); + const result = await useCase.execute(command); + + expect(result).toBeInstanceOf(Result); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode<'NOTIFICATION_NOT_FOUND', { message: string }>; + expect(err.code).toBe('NOTIFICATION_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); - it('throws when recipientId does not match', async () => { + it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => { const notification = Notification.create({ id: 'n1', recipientId: 'driver-2', - type: 'info', + type: 'system_announcement', title: 'Test', body: 'Body', channel: 'in_app', @@ -58,16 +80,24 @@ describe('MarkNotificationReadUseCase', () => { notificationRepository.findById.mockResolvedValue(notification); - await expect( - useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), - ).rejects.toThrow(NotificationDomainError); + const command: MarkNotificationReadCommand = { + notificationId: 'n1', + recipientId: 'driver-1', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode<'RECIPIENT_MISMATCH', { message: string }>; + expect(err.code).toBe('RECIPIENT_MISMATCH'); + expect(output.present).not.toHaveBeenCalled(); }); - it('marks notification as read when unread', async () => { + it('marks notification as read when unread and presents result', async () => { const notification = Notification.create({ id: 'n1', recipientId: 'driver-1', - type: 'info', + type: 'system_announcement', title: 'Test', body: 'Body', channel: 'in_app', @@ -75,9 +105,19 @@ describe('MarkNotificationReadUseCase', () => { notificationRepository.findById.mockResolvedValue(notification); - await useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }); + const command: MarkNotificationReadCommand = { + notificationId: 'n1', + recipientId: 'driver-1', + }; + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); expect(notificationRepository.update).toHaveBeenCalled(); - expect((logger.info as unknown as Mock)).toHaveBeenCalled(); + expect(output.present).toHaveBeenCalledWith({ + notificationId: 'n1', + recipientId: 'driver-1', + wasAlreadyRead: false, + }); }); }); diff --git a/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts b/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts index d9824fe0c..2b6d40818 100644 --- a/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts +++ b/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts @@ -4,7 +4,9 @@ * Marks a notification as read. */ -import type { AsyncUseCase , 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 type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; @@ -13,38 +15,86 @@ export interface MarkNotificationReadCommand { recipientId: string; // For validation } -export class MarkNotificationReadUseCase implements AsyncUseCase { +export interface MarkNotificationReadResult { + notificationId: string; + recipientId: string; + wasAlreadyRead: boolean; +} + +export type MarkNotificationReadErrorCode = + | 'NOTIFICATION_NOT_FOUND' + | 'RECIPIENT_MISMATCH' + | 'REPOSITORY_ERROR'; + +export class MarkNotificationReadUseCase { constructor( private readonly notificationRepository: INotificationRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(command: MarkNotificationReadCommand): Promise { - this.logger.debug(`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`); + async execute( + command: MarkNotificationReadCommand, + ): Promise>> { + this.logger.debug( + `Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`, + ); + try { const notification = await this.notificationRepository.findById(command.notificationId); - + if (!notification) { this.logger.warn(`Notification not found for ID: ${command.notificationId}`); - throw new NotificationDomainError('Notification not found'); + return Result.err({ + code: 'NOTIFICATION_NOT_FOUND', + details: { message: 'Notification not found' }, + }); } if (notification.recipientId !== command.recipientId) { - this.logger.warn(`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`); - throw new NotificationDomainError('Cannot mark another user\'s notification as read'); + this.logger.warn( + `Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`, + ); + return Result.err({ + code: 'RECIPIENT_MISMATCH', + details: { message: "Cannot mark another user's notification as read" }, + }); } if (!notification.isUnread()) { - this.logger.info(`Notification ${command.notificationId} is already read. Skipping update.`); - return; // Already read, nothing to do + this.logger.info( + `Notification ${command.notificationId} is already read. Skipping update.`, + ); + this.output.present({ + notificationId: command.notificationId, + recipientId: command.recipientId, + wasAlreadyRead: true, + }); + return Result.ok(undefined); } const updatedNotification = notification.markAsRead(); await this.notificationRepository.update(updatedNotification); - this.logger.info(`Notification ${command.notificationId} successfully marked as read.`); + this.logger.info( + `Notification ${command.notificationId} successfully marked as read.`, + ); + + this.output.present({ + notificationId: command.notificationId, + recipientId: command.recipientId, + wasAlreadyRead: false, + }); + + return Result.ok(undefined); } catch (error) { - this.logger.error(`Failed to mark notification ${command.notificationId} as read: ${error instanceof Error ? error.message : String(error)}`); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + `Failed to mark notification ${command.notificationId} as read: ${err.message}`, + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } @@ -54,13 +104,36 @@ export class MarkNotificationReadUseCase implements AsyncUseCase { +export interface MarkAllNotificationsReadInput { + recipientId: string; +} + +export interface MarkAllNotificationsReadResult { + recipientId: string; +} + +export type MarkAllNotificationsReadErrorCode = 'REPOSITORY_ERROR'; + +export class MarkAllNotificationsReadUseCase { constructor( private readonly notificationRepository: INotificationRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(recipientId: string): Promise { - await this.notificationRepository.markAllAsReadByRecipientId(recipientId); + async execute( + input: MarkAllNotificationsReadInput, + ): Promise>> { + try { + await this.notificationRepository.markAllAsReadByRecipientId(input.recipientId); + this.output.present({ recipientId: input.recipientId }); + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); + } } } @@ -74,27 +147,78 @@ export interface DismissNotificationCommand { recipientId: string; } -export class DismissNotificationUseCase implements AsyncUseCase { +export interface DismissNotificationResult { + notificationId: string; + recipientId: string; + wasAlreadyDismissed: boolean; +} + +export type DismissNotificationErrorCode = + | 'NOTIFICATION_NOT_FOUND' + | 'RECIPIENT_MISMATCH' + | 'CANNOT_DISMISS_REQUIRING_RESPONSE' + | 'REPOSITORY_ERROR'; + +export class DismissNotificationUseCase { constructor( private readonly notificationRepository: INotificationRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: DismissNotificationCommand): Promise { - const notification = await this.notificationRepository.findById(command.notificationId); - - if (!notification) { - throw new NotificationDomainError('Notification not found'); - } + async execute( + command: DismissNotificationCommand, + ): Promise>> { + try { + const notification = await this.notificationRepository.findById( + command.notificationId, + ); - if (notification.recipientId !== command.recipientId) { - throw new NotificationDomainError('Cannot dismiss another user\'s notification'); - } + if (!notification) { + return Result.err({ + code: 'NOTIFICATION_NOT_FOUND', + details: { message: 'Notification not found' }, + }); + } - if (notification.isDismissed()) { - return; // Already dismissed - } + if (notification.recipientId !== command.recipientId) { + return Result.err({ + code: 'RECIPIENT_MISMATCH', + details: { message: "Cannot dismiss another user's notification" }, + }); + } - const updatedNotification = notification.dismiss(); - await this.notificationRepository.update(updatedNotification); + if (notification.isDismissed()) { + this.output.present({ + notificationId: command.notificationId, + recipientId: command.recipientId, + wasAlreadyDismissed: true, + }); + return Result.ok(undefined); + } + + if (!notification.canDismiss()) { + return Result.err({ + code: 'CANNOT_DISMISS_REQUIRING_RESPONSE', + details: { message: 'Cannot dismiss notification that requires response' }, + }); + } + + const updatedNotification = notification.dismiss(); + await this.notificationRepository.update(updatedNotification); + + this.output.present({ + notificationId: command.notificationId, + recipientId: command.recipientId, + wasAlreadyDismissed: false, + }); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); + } } } \ No newline at end of file diff --git a/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts index 96749a59a..f131c65bc 100644 --- a/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts +++ b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts @@ -5,10 +5,22 @@ import { UpdateTypePreferenceUseCase, UpdateQuietHoursUseCase, SetDigestModeUseCase, + type GetNotificationPreferencesInput, + type GetNotificationPreferencesResult, + type UpdateChannelPreferenceCommand, + type UpdateChannelPreferenceResult, + type UpdateTypePreferenceCommand, + type UpdateTypePreferenceResult, + type UpdateQuietHoursCommand, + type UpdateQuietHoursResult, + type SetDigestModeCommand, + type SetDigestModeResult, } from './NotificationPreferencesUseCases'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; -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 type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; @@ -19,38 +31,46 @@ describe('NotificationPreferencesUseCases', () => { }; let logger: Logger; - beforeEach(() => { - preferenceRepository = { - getOrCreateDefault: vi.fn(), - save: vi.fn(), - } as unknown as INotificationPreferenceRepository as any; - - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as Logger; - }); - - it('GetNotificationPreferencesQuery returns preferences from repository', async () => { - const preference = { - id: 'pref-1', - } as unknown as NotificationPreference; - - preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); - - const useCase = new GetNotificationPreferencesQuery( - preferenceRepository as unknown as INotificationPreferenceRepository, - logger, - ); - - const result = await useCase.execute('driver-1'); - - expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); - expect(result).toBe(preference); - }); - + + beforeEach(() => { + preferenceRepository = { + getOrCreateDefault: vi.fn(), + save: vi.fn(), + } as unknown as INotificationPreferenceRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + }); + + it('GetNotificationPreferencesQuery returns preferences from repository', async () => { + const preference = { + id: 'pref-1', + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + + const useCase = new GetNotificationPreferencesQuery( + preferenceRepository as unknown as INotificationPreferenceRepository, + output, + logger, + ); + + const input: GetNotificationPreferencesInput = { driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ preference }); + }); it('UpdateChannelPreferenceUseCase updates channel preference', async () => { const preference = { updateChannel: vi.fn().mockReturnThis(), @@ -58,19 +78,28 @@ describe('NotificationPreferencesUseCases', () => { preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new UpdateChannelPreferenceUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, logger, ); - await useCase.execute({ + const command: UpdateChannelPreferenceCommand = { driverId: 'driver-1', channel: 'email' as NotificationChannel, - preference: 'enabled' as ChannelPreference, - }); + preference: { enabled: true } as ChannelPreference, + }; + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); expect(preference.updateChannel).toHaveBeenCalled(); expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', channel: 'email' }); }); it('UpdateTypePreferenceUseCase updates type preference', async () => { @@ -80,19 +109,28 @@ describe('NotificationPreferencesUseCases', () => { preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new UpdateTypePreferenceUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, logger, ); - await useCase.execute({ + const command: UpdateTypePreferenceCommand = { driverId: 'driver-1', - type: 'info' as NotificationType, - preference: 'enabled' as TypePreference, - }); + type: 'system_announcement' as NotificationType, + preference: { enabled: true } as TypePreference, + }; + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); expect(preference.updateTypePreference).toHaveBeenCalled(); expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', type: 'system_announcement' }); }); it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => { @@ -102,34 +140,56 @@ describe('NotificationPreferencesUseCases', () => { preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new UpdateQuietHoursUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, logger, ); - await useCase.execute({ + const command: UpdateQuietHoursCommand = { + driverId: 'driver-1', + startHour: 22, + endHour: 7, + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', startHour: 22, endHour: 7, }); - - expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7); - expect(preferenceRepository.save).toHaveBeenCalledWith(preference); }); - it('UpdateQuietHoursUseCase throws on invalid hours', async () => { + it('UpdateQuietHoursUseCase returns error on invalid hours', async () => { + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new UpdateQuietHoursUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, logger, ); - await expect( - useCase.execute({ driverId: 'd1', startHour: -1, endHour: 10 }), - ).rejects.toThrow(NotificationDomainError); + const badStart: UpdateQuietHoursCommand = { driverId: 'd1', startHour: -1, endHour: 10 }; + const result1 = await useCase.execute(badStart); + expect(result1.isErr()).toBe(true); + const err1 = result1.unwrapErr() as ApplicationErrorCode<'INVALID_START_HOUR', { message: string }>; + expect(err1.code).toBe('INVALID_START_HOUR'); - await expect( - useCase.execute({ driverId: 'd1', startHour: 10, endHour: 24 }), - ).rejects.toThrow(NotificationDomainError); + const badEnd: UpdateQuietHoursCommand = { driverId: 'd1', startHour: 10, endHour: 24 }; + const result2 = await useCase.execute(badEnd); + expect(result2.isErr()).toBe(true); + const err2 = result2.unwrapErr() as ApplicationErrorCode<'INVALID_END_HOUR', { message: string }>; + expect(err2.code).toBe('INVALID_END_HOUR'); }); it('SetDigestModeUseCase sets digest mode with valid frequency', async () => { @@ -139,27 +199,52 @@ describe('NotificationPreferencesUseCases', () => { preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new SetDigestModeUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, ); - await useCase.execute({ + const command: SetDigestModeCommand = { + driverId: 'driver-1', + enabled: true, + frequencyHours: 4, + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', enabled: true, frequencyHours: 4, }); - - expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4); - expect(preferenceRepository.save).toHaveBeenCalledWith(preference); }); - it('SetDigestModeUseCase throws on invalid frequency', async () => { + it('SetDigestModeUseCase returns error on invalid frequency', async () => { + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + } as any; + const useCase = new SetDigestModeUseCase( preferenceRepository as unknown as INotificationPreferenceRepository, + output, ); - await expect( - useCase.execute({ driverId: 'driver-1', enabled: true, frequencyHours: 0 }), - ).rejects.toThrow(NotificationDomainError); + const command: SetDigestModeCommand = { + driverId: 'driver-1', + enabled: true, + frequencyHours: 0, + }; + + const result = await useCase.execute(command); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode<'INVALID_FREQUENCY', { message: string }>; + expect(err.code).toBe('INVALID_FREQUENCY'); }); }); diff --git a/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts b/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts index 870772c79..80b8c2fa7 100644 --- a/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts +++ b/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts @@ -4,7 +4,9 @@ * Manages user notification preferences. */ -import type { AsyncUseCase , 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 { NotificationPreference } from '../../domain/entities/NotificationPreference'; import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; @@ -14,21 +16,40 @@ import { NotificationDomainError } from '../../domain/errors/NotificationDomainE /** * Query: GetNotificationPreferencesQuery */ -export class GetNotificationPreferencesQuery implements AsyncUseCase { +export interface GetNotificationPreferencesInput { + driverId: string; +} + +export interface GetNotificationPreferencesResult { + preference: NotificationPreference; +} + +export type GetNotificationPreferencesErrorCode = 'REPOSITORY_ERROR'; + +export class GetNotificationPreferencesQuery { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(driverId: string): Promise { + async execute( + input: GetNotificationPreferencesInput, + ): Promise>> { + const { driverId } = input; this.logger.debug(`Fetching notification preferences for driver: ${driverId}`); try { const preferences = await this.preferenceRepository.getOrCreateDefault(driverId); this.logger.info(`Successfully fetched preferences for driver: ${driverId}`); - return preferences; + this.output.present({ preference: preferences }); + return Result.ok(undefined); } catch (error) { - this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, error); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } @@ -42,22 +63,48 @@ export interface UpdateChannelPreferenceCommand { preference: ChannelPreference; } -export class UpdateChannelPreferenceUseCase implements AsyncUseCase { +export interface UpdateChannelPreferenceResult { + driverId: string; + channel: NotificationChannel; +} + +export type UpdateChannelPreferenceErrorCode = + | 'REPOSITORY_ERROR'; + +export class UpdateChannelPreferenceUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(command: UpdateChannelPreferenceCommand): Promise { - this.logger.debug(`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${command.preference}`); + async execute( + command: UpdateChannelPreferenceCommand, + ): Promise>> { + this.logger.debug( + `Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${JSON.stringify(command.preference)}`, + ); try { - const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); + const preferences = await this.preferenceRepository.getOrCreateDefault( + command.driverId, + ); const updated = preferences.updateChannel(command.channel, command.preference); await this.preferenceRepository.save(updated); - this.logger.info(`Successfully updated channel preference for driver: ${command.driverId}`); + this.logger.info( + `Successfully updated channel preference for driver: ${command.driverId}`, + ); + this.output.present({ driverId: command.driverId, channel: command.channel }); + return Result.ok(undefined); } catch (error) { - this.logger.error(`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, error); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + `Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, + err, + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } @@ -71,22 +118,47 @@ export interface UpdateTypePreferenceCommand { preference: TypePreference; } -export class UpdateTypePreferenceUseCase implements AsyncUseCase { +export interface UpdateTypePreferenceResult { + driverId: string; + type: NotificationType; +} + +export type UpdateTypePreferenceErrorCode = 'REPOSITORY_ERROR'; + +export class UpdateTypePreferenceUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(command: UpdateTypePreferenceCommand): Promise { - this.logger.debug(`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${command.preference}`); + async execute( + command: UpdateTypePreferenceCommand, + ): Promise>> { + this.logger.debug( + `Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${JSON.stringify(command.preference)}`, + ); try { - const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); + const preferences = await this.preferenceRepository.getOrCreateDefault( + command.driverId, + ); const updated = preferences.updateTypePreference(command.type, command.preference); await this.preferenceRepository.save(updated); - this.logger.info(`Successfully updated type preference for driver: ${command.driverId}`); + this.logger.info( + `Successfully updated type preference for driver: ${command.driverId}`, + ); + this.output.present({ driverId: command.driverId, type: command.type }); + return Result.ok(undefined); } catch (error) { - this.logger.error(`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, error); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + `Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, + err, + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } @@ -100,32 +172,73 @@ export interface UpdateQuietHoursCommand { endHour: number | undefined; } -export class UpdateQuietHoursUseCase implements AsyncUseCase { +export interface UpdateQuietHoursResult { + driverId: string; + startHour: number | undefined; + endHour: number | undefined; +} + +export type UpdateQuietHoursErrorCode = + | 'INVALID_START_HOUR' + | 'INVALID_END_HOUR' + | 'REPOSITORY_ERROR'; + +export class UpdateQuietHoursUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) {} - async execute(command: UpdateQuietHoursCommand): Promise { - this.logger.debug(`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`); + async execute( + command: UpdateQuietHoursCommand, + ): Promise>> { + this.logger.debug( + `Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`, + ); try { // Validate hours if provided if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) { - this.logger.warn(`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`); - throw new NotificationDomainError('Start hour must be between 0 and 23'); + this.logger.warn( + `Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`, + ); + return Result.err({ + code: 'INVALID_START_HOUR', + details: { message: 'Start hour must be between 0 and 23' }, + }); } if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) { - this.logger.warn(`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`); - throw new NotificationDomainError('End hour must be between 0 and 23'); + this.logger.warn( + `Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`, + ); + return Result.err({ + code: 'INVALID_END_HOUR', + details: { message: 'End hour must be between 0 and 23' }, + }); } - const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); - const updated = preferences.updateQuietHours(command.startHour, command.endHour); + const preferences = await this.preferenceRepository.getOrCreateDefault( + command.driverId, + ); + const updated = preferences.updateQuietHours( + command.startHour, + command.endHour, + ); await this.preferenceRepository.save(updated); this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`); + this.output.present({ + driverId: command.driverId, + startHour: command.startHour, + endHour: command.endHour, + }); + return Result.ok(undefined); } catch (error) { - this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, error); - throw error; + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); } } } @@ -139,18 +252,53 @@ export interface SetDigestModeCommand { frequencyHours?: number; } -export class SetDigestModeUseCase implements AsyncUseCase { +export interface SetDigestModeResult { + driverId: string; + enabled: boolean; + frequencyHours?: number; +} + +export type SetDigestModeErrorCode = + | 'INVALID_FREQUENCY' + | 'REPOSITORY_ERROR'; + +export class SetDigestModeUseCase { constructor( private readonly preferenceRepository: INotificationPreferenceRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: SetDigestModeCommand): Promise { + async execute( + command: SetDigestModeCommand, + ): Promise>> { if (command.frequencyHours !== undefined && command.frequencyHours < 1) { - throw new NotificationDomainError('Digest frequency must be at least 1 hour'); + return Result.err({ + code: 'INVALID_FREQUENCY', + details: { message: 'Digest frequency must be at least 1 hour' }, + }); } - const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); - const updated = preferences.setDigestMode(command.enabled, command.frequencyHours); - await this.preferenceRepository.save(updated); + try { + const preferences = await this.preferenceRepository.getOrCreateDefault( + command.driverId, + ); + const updated = preferences.setDigestMode( + command.enabled, + command.frequencyHours, + ); + await this.preferenceRepository.save(updated); + this.output.present({ + driverId: command.driverId, + enabled: command.enabled, + frequencyHours: command.frequencyHours, + }); + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message }, + }); + } } } \ No newline at end of file diff --git a/core/notifications/application/use-cases/SendNotificationUseCase.ts b/core/notifications/application/use-cases/SendNotificationUseCase.ts index 03dc096c4..ae6ebe3de 100644 --- a/core/notifications/application/use-cases/SendNotificationUseCase.ts +++ b/core/notifications/application/use-cases/SendNotificationUseCase.ts @@ -5,7 +5,9 @@ * based on their preferences. */ -import type { AsyncUseCase, 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 { v4 as uuid } from 'uuid'; import type { NotificationData } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification'; @@ -43,17 +45,22 @@ export interface SendNotificationResult { deliveryResults: NotificationDeliveryResult[]; } -export class SendNotificationUseCase implements AsyncUseCase { +export type SendNotificationErrorCode = 'REPOSITORY_ERROR'; + +export class SendNotificationUseCase { constructor( private readonly notificationRepository: INotificationRepository, private readonly preferenceRepository: INotificationPreferenceRepository, private readonly gatewayRegistry: NotificationGatewayRegistry, + private readonly output: UseCaseOutputPort, private readonly logger: Logger, ) { this.logger.debug('SendNotificationUseCase initialized.'); } - async execute(command: SendNotificationCommand): Promise { + async execute( + command: SendNotificationCommand, + ): Promise>> { this.logger.debug('Executing SendNotificationUseCase', { command }); try { // Get recipient's preferences @@ -84,7 +91,8 @@ export class SendNotificationUseCase implements AsyncUseCase { let useCase: CompleteDriverOnboardingUseCase; @@ -11,17 +17,25 @@ describe('CompleteDriverOnboardingUseCase', () => { findById: Mock; create: Mock; }; - let output: { present: Mock }; + let logger: Logger & { error: Mock }; + let output: { present: Mock } & UseCaseOutputPort; beforeEach(() => { driverRepository = { findById: vi.fn(), create: vi.fn(), }; - output = { present: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: Mock }; + output = { present: vi.fn() } as unknown as typeof output; useCase = new CompleteDriverOnboardingUseCase( driverRepository as unknown as IDriverRepository, - output as unknown as UseCaseOutputPort, + logger, + output, ); }); diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts index 5c6edf86a..c017818f8 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -3,6 +3,7 @@ import { Driver } from '../../domain/entities/Driver'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Logger } from '@core/shared/application/Logger'; export interface CompleteDriverOnboardingInput { userId: string; @@ -17,28 +18,43 @@ export type CompleteDriverOnboardingResult = { driver: Driver; }; +export type CompleteDriverOnboardingErrorCode = + | 'DRIVER_ALREADY_EXISTS' + | 'REPOSITORY_ERROR'; + +export type CompleteDriverOnboardingApplicationError = ApplicationErrorCode< + CompleteDriverOnboardingErrorCode, + { message: string } +>; + /** * Use Case for completing driver onboarding. */ export class CompleteDriverOnboardingUseCase { constructor( private readonly driverRepository: IDriverRepository, + private readonly logger: Logger, private readonly output: UseCaseOutputPort, ) {} - async execute(command: CompleteDriverOnboardingInput): Promise>> { + async execute( + input: CompleteDriverOnboardingInput, + ): Promise> { try { - const existing = await this.driverRepository.findById(command.userId); + const existing = await this.driverRepository.findById(input.userId); if (existing) { - return Result.err({ code: 'DRIVER_ALREADY_EXISTS' }); + return Result.err({ + code: 'DRIVER_ALREADY_EXISTS', + details: { message: 'Driver already exists' }, + }); } const driver = Driver.create({ - id: command.userId, - iracingId: command.userId, - name: command.displayName, - country: command.country, - ...(command.bio !== undefined ? { bio: command.bio } : {}), + id: input.userId, + iracingId: input.userId, + name: input.displayName, + country: input.country, + ...(input.bio !== undefined ? { bio: input.bio } : {}), }); await this.driverRepository.create(driver); @@ -47,10 +63,16 @@ export class CompleteDriverOnboardingUseCase { return Result.ok(undefined); } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + + this.logger.error('CompleteDriverOnboardingUseCase.execute failed', err, { + input, + }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { - message: error instanceof Error ? error.message : 'Unknown error', + message: err.message ?? 'Unexpected repository error', }, }); } diff --git a/docs/architecture/DOMAIN_OBJECTS.md b/docs/architecture/DOMAIN_OBJECTS.md new file mode 100644 index 000000000..597702c79 --- /dev/null +++ b/docs/architecture/DOMAIN_OBJECTS.md @@ -0,0 +1,264 @@ +Domain Objects Design Guide (Clean Architecture) + +This document defines all domain object types used in the Core and assigns strict responsibilities and boundaries. + +Its goal is to remove ambiguity between: + • Entities + • Value Objects + • Aggregate Roots + • Domain Services + • Domain Events + +The rules in this document are non-negotiable. + +⸻ + +Core Principle + +Domain objects represent business truth. + +They: + • outlive APIs and UIs + • must remain stable over time + • must not depend on technical details + +If a class answers a business question, it belongs here. + +⸻ + +1. Entities + +Definition + +An Entity is a domain object that: + • has a stable identity + • changes over time + • represents a business concept + +Identity matters more than attributes. + +⸻ + +Responsibilities + +Entities MUST: + • own their identity + • enforce invariants on state changes + • expose behavior, not setters + +Entities MUST NOT: + • depend on DTOs or transport models + • access repositories or services + • perform IO + • know about frameworks + +⸻ + +Creation Rules + • New entities are created via create() + • Existing entities are reconstructed via rehydrate() + +core//domain/entities/ + + +⸻ + +Example + • League + • Season + • Race + • Driver + +⸻ + +2. Value Objects + +Definition + +A Value Object is a domain object that: + • has no identity + • is immutable + • is defined by its value + +⸻ + +Responsibilities + +Value Objects MUST: + • validate their own invariants + • be immutable + • be comparable by value + +Value Objects MUST NOT: + • contain business workflows + • reference entities + • perform IO + +⸻ + +Creation Rules + • create() for new domain meaning + • fromX() for interpreting external formats + +core//domain/value-objects/ + + +⸻ + +Example + • Money + • LeagueName + • RaceTimeOfDay + • SeasonSchedule + +⸻ + +3. Aggregate Roots + +Definition + +An Aggregate Root is an entity that: + • acts as the consistency boundary + • protects invariants across related entities + +All access to the aggregate happens through the root. + +⸻ + +Responsibilities + +Aggregate Roots MUST: + • enforce consistency rules + • control modifications of child entities + +Aggregate Roots MUST NOT: + • expose internal collections directly + • allow partial updates bypassing rules + +⸻ + +Example + • League (root) + • Season (root) + +⸻ + +4. Domain Services + +Definition + +A Domain Service encapsulates domain logic that: + • does not naturally belong to a single entity + • involves multiple domain objects + +⸻ + +Responsibilities + +Domain Services MAY: + • coordinate entities + • calculate derived domain values + +Domain Services MUST: + • operate only on domain types + • remain stateless + +Domain Services MUST NOT: + • access repositories + • orchestrate use cases + • perform IO + +core//domain/services/ + + +⸻ + +Example + • SeasonConfigurationFactory + • ChampionshipAggregator + • StrengthOfFieldCalculator + +⸻ + +5. Domain Events + +Definition + +A Domain Event represents something that: + • has already happened + • is important to the business + +⸻ + +Responsibilities + +Domain Events MUST: + • be immutable + • carry minimal information + +Domain Events MUST NOT: + • contain behavior + • perform side effects + +core//domain/events/ + + +⸻ + +Example + • RaceCompleted + • SeasonActivated + +⸻ + +6. What Does NOT Belong in Domain Objects + +❌ DTOs +❌ API Models +❌ View Models +❌ Repositories +❌ Framework Types +❌ Logging +❌ Configuration + +If it depends on infrastructure, it does not belong here. + +⸻ + +7. Dependency Rules + +Entities → Value Objects +Entities → Domain Services +Domain Services → Entities + +Reverse dependencies are forbidden. + +⸻ + +8. Testing Requirements + +Domain Objects MUST: + • have unit tests for invariants + • be tested without mocks + +Domain Services MUST: + • have deterministic tests + +⸻ + +Mental Model (Final) + +Entities protect state. +Value Objects protect meaning. +Aggregate Roots protect consistency. +Domain Services protect cross-entity rules. +Domain Events describe facts. + +⸻ + +Final Summary + • Domain objects represent business truth + • They are pure and framework-free + • They form the most stable part of the system + +If domain objects are clean, everything else becomes easier. \ No newline at end of file diff --git a/docs/architecture/SERVICES.md b/docs/architecture/SERVICES.md new file mode 100644 index 000000000..45e95764b --- /dev/null +++ b/docs/architecture/SERVICES.md @@ -0,0 +1,252 @@ +Services Design Guide (Clean Architecture) + +This document defines all service types used across the system and assigns clear, non-overlapping responsibilities. + +It exists to remove ambiguity around the word “service”, which is heavily overloaded. + +The rules below are strict. + +⸻ + +Overview + +The system contains four distinct service categories, each in a different layer: + 1. Frontend Services + 2. API Services + 3. Core Application Services + 4. Core Domain Services + +They must never be mixed. + +⸻ + +1. Frontend Services + +Purpose + +Frontend services orchestrate UI-driven workflows. + +They answer the question: + +“How does the UI obtain and submit data?” + +⸻ + +Responsibilities + +Frontend services MAY: + • call API clients + • apply client-side guards (blockers, throttles) + • create View Models + • orchestrate multiple API calls + • handle recoverable UI errors + +Frontend services MUST NOT: + • contain business rules + • validate domain invariants + • modify domain state + • know about core domain objects + +⸻ + +Placement + +apps/website/lib/services/ + + +⸻ + +Example + • LeagueService + • RaceService + • AuthService + +Each service is UI-facing, not business-facing. + +⸻ + +2. API Services (Application Services) + +Purpose + +API services adapt HTTP-level concerns to core use cases. + +They answer the question: + +“How does an external client interact with the core?” + +⸻ + +Responsibilities + +API services MAY: + • orchestrate multiple use cases + • perform authorization checks + • map transport input to use-case input + • coordinate transactions + +API services MUST NOT: + • contain domain logic + • enforce business invariants + • build domain entities + • return domain objects + +⸻ + +Placement + +apps/api/**/ApplicationService.ts + + +⸻ + +Example + • LeagueApplicationService + • SeasonApplicationService + +API services are delivery-layer coordinators. + +⸻ + +3. Core Application Services (Use Cases) + +Purpose + +Core application services implement business use cases. + +They answer the question: + +“What does the system do?” + +⸻ + +Responsibilities + +Use Cases MUST: + • accept primitive input only + • create Value Objects + • create or modify Entities + • enforce business rules + • call repositories via ports + • communicate results via output ports + +Use Cases MUST NOT: + • know about HTTP, UI, or frameworks + • return DTOs + • perform persistence directly + +⸻ + +Placement + +core//application/commands/ +core//application/queries/ + + +⸻ + +Example + • CreateLeagueUseCase + • ApplyPenaltyUseCase + • GetLeagueStandingsQuery + +Use Cases define system behavior. + +⸻ + +4. Core Domain Services + +Purpose + +Domain services encapsulate domain logic that does not belong to a single entity. + +They answer the question: + +“What rules exist that span multiple domain objects?” + +⸻ + +Responsibilities + +Domain services MAY: + • coordinate multiple entities + • compute derived domain values + • enforce cross-aggregate rules + +Domain services MUST: + • use only domain concepts + • return domain objects or primitives + +Domain services MUST NOT: + • access repositories + • depend on application services + • perform IO + +⸻ + +Placement + +core//domain/services/ + + +⸻ + +Example + • SeasonConfigurationFactory + • ChampionshipAggregator + • StrengthOfFieldCalculator + +Domain services protect business integrity. + +⸻ + +Dependency Rules (Non-Negotiable) + +Frontend Service + → API Client + → API Service + → Core Use Case + → Domain Service / Entity + +Reverse dependencies are forbidden. + +⸻ + +Anti-Patterns (Forbidden) + +❌ Frontend calling core directly +❌ API service constructing domain entities +❌ Use case returning DTOs +❌ Domain service accessing repositories +❌ Single class acting as multiple service types + +⸻ + +Naming Conventions + +Layer Naming +Frontend *Service +API *ApplicationService +Core Application *UseCase, *Query +Core Domain *Service, *Factory, *Calculator + + +⸻ + +Mental Model (Final) + +Services coordinate. +Use Cases decide. +Domain enforces truth. +Adapters translate. + +If a class violates this mental model, it is in the wrong layer. + +⸻ + +Final Summary + • “Service” means different things in different layers + • Mixing service types causes architectural decay + • Clean Architecture remains simple when roles stay pure + +This document defines the only allowed service roles in the system. \ No newline at end of file