This commit is contained in:
2025-12-21 22:35:38 +01:00
parent 3c64f328e2
commit 9bd2e630e6
38 changed files with 736 additions and 684 deletions

View File

@@ -12,24 +12,14 @@ describe('AnalyticsController', () => {
let controller: AnalyticsController;
let service: ReturnType<typeof vi.mocked<AnalyticsService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AnalyticsController],
providers: [
{
provide: AnalyticsService,
useValue: {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
},
},
],
}).compile();
controller = module.get<AnalyticsController>(AnalyticsController);
service = vi.mocked(module.get(AnalyticsService));
beforeEach(() => {
service = {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
} as any;
controller = new AnalyticsController(service);
});
describe('recordPageView', () => {

View File

@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AnalyticsModule } from './AnalyticsModule';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
describe('AnalyticsModule', () => {
let module: TestingModule;
@@ -9,7 +10,10 @@ describe('AnalyticsModule', () => {
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [AnalyticsModule],
}).compile();
})
.overrideProvider('Logger_TOKEN')
.useClass(ConsoleLogger)
.compile();
});
it('should compile the module', () => {

View File

@@ -1,7 +1,8 @@
import { vi } from 'vitest';
import { Mock, vi } from 'vitest';
import { AuthController } from './AuthController';
import { AuthService } from './AuthService';
import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto';
import { AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
describe('AuthController', () => {
let controller: AuthController;
@@ -36,7 +37,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
(service.signupWithEmail as jest.Mock).mockResolvedValue(session);
(service.signupWithEmail as Mock).mockResolvedValue(session);
const result = await controller.signup(params);
@@ -59,7 +60,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
(service.loginWithEmail as jest.Mock).mockResolvedValue(session);
(service.loginWithEmail as Mock).mockResolvedValue(session);
const result = await controller.login(params);
@@ -78,7 +79,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
(service.getCurrentSession as jest.Mock).mockResolvedValue(session);
(service.getCurrentSession as Mock).mockResolvedValue(session);
const result = await controller.getSession();
@@ -87,7 +88,7 @@ describe('AuthController', () => {
});
it('should return null if no session', async () => {
(service.getCurrentSession as jest.Mock).mockResolvedValue(null);
(service.getCurrentSession as Mock).mockResolvedValue(null);
const result = await controller.getSession();
@@ -97,8 +98,8 @@ describe('AuthController', () => {
describe('logout', () => {
it('should call service.logout and return DTO', async () => {
const dto = { success: true };
(service.logout as jest.Mock).mockResolvedValue(dto);
const dto: CommandResultDTO = { success: true };
(service.logout as Mock).mockResolvedValue(dto);
const result = await controller.logout();

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { AuthService } from './AuthService';
import { LoginParams, SignupParams, AuthSessionDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
@Controller('auth')
export class AuthController {
@@ -22,7 +23,7 @@ export class AuthController {
}
@Post('logout')
async logout(): Promise<{ success: boolean }> {
async logout(): Promise<CommandResultDTO> {
return this.authService.logout();
}
}

View File

@@ -31,6 +31,8 @@ export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase';
export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase';
export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase';
export const AUTH_SESSION_PRESENTER_TOKEN = 'AuthSessionPresenter';
export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter';
export const AuthProviders: Provider[] = [
{
@@ -73,20 +75,28 @@ export const AuthProviders: Provider[] = [
},
{
provide: LOGIN_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) =>
new LoginUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new LoginUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) =>
new SignupUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new SignupUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger) =>
new LogoutUseCase(sessionPort, logger),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, presenter: CommandResultPresenter) =>
new LogoutUseCase(sessionPort, logger, presenter),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
},
{
provide: AUTH_SESSION_PRESENTER_TOKEN,
useClass: AuthSessionPresenter,
},
{
provide: COMMAND_RESULT_PRESENTER_TOKEN,
useClass: CommandResultPresenter,
},
];

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject } from '@nestjs/common';
// Core Use Cases
import { LoginUseCase, type LoginInput } from '@core/identity/application/use-cases/LoginUseCase';
@@ -11,12 +11,10 @@ import { User } from '@core/identity/domain/entities/User';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { Logger } from '@core/shared/application';
import { IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, LOGIN_USE_CASE_TOKEN, LOGOUT_USE_CASE_TOKEN, SIGNUP_USE_CASE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthenticatedUserDTO, AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
import { AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
import { CommandResultPresenter, type CommandResultDTO } from './presenters/CommandResultPresenter';
@Injectable()
export class AuthService {
constructor(
@Inject(LOGGER_TOKEN) private logger: Logger,
@@ -25,31 +23,10 @@ export class AuthService {
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
private readonly authSessionPresenter: AuthSessionPresenter,
private readonly commandResultPresenter: CommandResultPresenter,
) {}
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
return {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName() ?? '',
};
}
private buildAuthSessionDTO(token: string, user: AuthenticatedUserDTO): AuthSessionDTO {
return {
token,
user: {
userId: user.userId,
email: user.email,
displayName: user.displayName,
},
};
}
async getCurrentSession(): Promise<AuthSessionDTO | null> {
// TODO this must call a use case
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) {
@@ -87,7 +64,8 @@ export class AuthService {
throw new Error(error.details?.message ?? 'Signup failed');
}
const userDTO = this.authSessionPresenter.getResponseModel();
const authSessionPresenter = new AuthSessionPresenter();
const userDTO = authSessionPresenter.getResponseModel();
const coreUserDTO = {
id: userDTO.userId,
displayName: userDTO.displayName,
@@ -116,7 +94,8 @@ export class AuthService {
throw new Error(error.details?.message ?? 'Login failed');
}
const userDTO = this.authSessionPresenter.getResponseModel();
const authSessionPresenter = new AuthSessionPresenter();
const userDTO = authSessionPresenter.getResponseModel();
const coreUserDTO = {
id: userDTO.userId,
displayName: userDTO.displayName,
@@ -133,6 +112,7 @@ export class AuthService {
async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.');
const commandResultPresenter = new CommandResultPresenter();
const result = await this.logoutUseCase.execute();
if (result.isErr()) {
@@ -140,6 +120,6 @@ export class AuthService {
throw new Error(error.details?.message ?? 'Logout failed');
}
return this.commandResultPresenter.getResponseModel();
return commandResultPresenter.getResponseModel();
}
}

View File

@@ -5,16 +5,12 @@ import { UserId } from '@core/identity/domain/value-objects/UserId';
describe('AuthSessionPresenter', () => {
let presenter: AuthSessionPresenter;
let mockIdentitySessionPort: any;
beforeEach(() => {
mockIdentitySessionPort = {
createSession: vi.fn(),
};
presenter = new AuthSessionPresenter(mockIdentitySessionPort);
presenter = new AuthSessionPresenter();
});
it('maps successful result into response model', async () => {
it('maps successful result into response model', () => {
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Test User',
@@ -22,20 +18,15 @@ describe('AuthSessionPresenter', () => {
passwordHash: { value: 'hash' } as any,
});
const expectedSession = {
token: 'token-123',
user: {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
},
const expectedUser = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
mockIdentitySessionPort.createSession.mockResolvedValue(expectedSession);
presenter.present({ user });
await presenter.present({ user });
expect(presenter.getResponseModel()).toEqual(expectedSession);
expect(presenter.getResponseModel()).toEqual(expectedUser);
});
it('getResponseModel throws when not presented', () => {

View File

@@ -2,6 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing';
import { DashboardModule } from './DashboardModule';
import { DashboardController } from './DashboardController';
import { DashboardService } from './DashboardService';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
import { DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN } from './DashboardProviders';
describe('DashboardModule', () => {
let module: TestingModule;
@@ -27,4 +29,10 @@ describe('DashboardModule', () => {
expect(service).toBeDefined();
expect(service).toBeInstanceOf(DashboardService);
});
it('should bind DashboardOverviewPresenter as the output port for the use case', () => {
const presenter = module.get<DashboardOverviewPresenter>(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN);
expect(presenter).toBeDefined();
expect(presenter).toBeInstanceOf(DashboardOverviewPresenter);
});
});

View File

@@ -7,14 +7,14 @@ import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresen
import type { Logger } from '@core/shared/application/Logger';
// Tokens
import { LOGGER_TOKEN, DASHBOARD_OVERVIEW_USE_CASE_TOKEN } from './DashboardProviders';
import { LOGGER_TOKEN, DASHBOARD_OVERVIEW_USE_CASE_TOKEN, DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN } from './DashboardProviders';
@Injectable()
export class DashboardService {
constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
private readonly dashboardOverviewPresenter: DashboardOverviewPresenter,
@Inject(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN) private readonly dashboardOverviewPresenter: DashboardOverviewPresenter,
) {}
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Req, Param } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, Req, Param } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
@@ -25,16 +25,14 @@ export class DriverController {
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO })
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
const presenter = await this.driverService.getDriversLeaderboard();
return presenter.viewModel;
return await this.driverService.getDriversLeaderboard();
}
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO })
async getTotalDrivers(): Promise<DriverStatsDTO> {
const presenter = await this.driverService.getTotalDrivers();
return presenter.viewModel;
return await this.driverService.getTotalDrivers();
}
@Get('current')
@@ -47,8 +45,7 @@ export class DriverController {
return null;
}
const presenter = await this.driverService.getCurrentDriver(userId);
return presenter.viewModel;
return await this.driverService.getCurrentDriver(userId);
}
@Post('complete-onboarding')
@@ -59,8 +56,7 @@ export class DriverController {
@Req() req: AuthenticatedRequest,
): Promise<CompleteOnboardingOutputDTO> {
const userId = req.user!.userId;
const presenter = await this.driverService.completeOnboarding(userId, input);
return presenter.viewModel;
return await this.driverService.completeOnboarding(userId, input);
}
@Get(':driverId/races/:raceId/registration-status')
@@ -70,8 +66,7 @@ export class DriverController {
@Param('driverId') driverId: string,
@Param('raceId') raceId: string,
): Promise<DriverRegistrationStatusDTO> {
const presenter = await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
return presenter.viewModel;
return await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
}
@Get(':driverId')
@@ -79,8 +74,7 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriver(@Param('driverId') driverId: string): Promise<GetDriverOutputDTO | null> {
const presenter = await this.driverService.getDriver(driverId);
return presenter.viewModel;
return await this.driverService.getDriver(driverId);
}
@Get(':driverId/profile')
@@ -88,8 +82,7 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getDriverProfile(@Param('driverId') driverId: string): Promise<GetDriverProfileOutputDTO> {
const presenter = await this.driverService.getDriverProfile(driverId);
return presenter.viewModel;
return await this.driverService.getDriverProfile(driverId);
}
@Put(':driverId/profile')
@@ -99,8 +92,7 @@ export class DriverController {
@Param('driverId') driverId: string,
@Body() body: { bio?: string; country?: string },
): Promise<GetDriverOutputDTO | null> {
const presenter = await this.driverService.updateDriverProfile(driverId, body.bio, body.country);
return presenter.viewModel;
return await this.driverService.updateDriverProfile(driverId, body.bio, body.country);
}
// Add other Driver endpoints here based on other presenters

View File

@@ -69,6 +69,13 @@ export const GET_PROFILE_OVERVIEW_USE_CASE_TOKEN = 'GetProfileOverviewUseCase';
export const DriverProviders: Provider[] = [
DriverService, // Provide the service itself
// Presenters
DriversLeaderboardPresenter,
DriverStatsPresenter,
CompleteOnboardingPresenter,
DriverRegistrationStatusPresenter,
DriverPresenter,
DriverProfilePresenter,
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), // Factory for InMemoryDriverRepository
@@ -138,8 +145,9 @@ export const DriverProviders: Provider[] = [
driverStatsService: IDriverStatsService,
imageService: IImageServicePort,
logger: Logger,
) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN],
presenter: DriversLeaderboardPresenter,
) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger, presenter),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN, DriversLeaderboardPresenter.name],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
@@ -148,19 +156,19 @@ export const DriverProviders: Provider[] = [
},
{
provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new CompleteDriverOnboardingUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
useFactory: (driverRepo: IDriverRepository, logger: Logger, presenter: CompleteOnboardingPresenter) => new CompleteDriverOnboardingUseCase(driverRepo, logger, presenter),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, CompleteOnboardingPresenter.name],
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger) =>
new IsDriverRegisteredForRaceUseCase(registrationRepo, logger),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger, presenter: DriverRegistrationStatusPresenter) =>
new IsDriverRegisteredForRaceUseCase(registrationRepo, logger, presenter),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN, DriverRegistrationStatusPresenter.name],
},
{
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new UpdateDriverProfileUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
useFactory: (driverRepo: IDriverRepository, presenter: DriverPresenter, logger: Logger) => new UpdateDriverProfileUseCase(driverRepo, presenter, logger),
inject: [DRIVER_REPOSITORY_TOKEN, DriverPresenter.name, LOGGER_TOKEN],
},
{
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
@@ -173,6 +181,7 @@ export const DriverProviders: Provider[] = [
driverExtendedProfileProvider: DriverExtendedProfileProvider,
driverStatsService: IDriverStatsService,
rankingService: IRankingService,
presenter: DriverProfilePresenter,
) =>
new GetProfileOverviewUseCase(
driverRepo,
@@ -207,6 +216,7 @@ export const DriverProviders: Provider[] = [
rating: ranking.rating,
overallRank: ranking.overallRank,
})),
presenter,
),
inject: [
DRIVER_REPOSITORY_TOKEN,
@@ -217,6 +227,7 @@ export const DriverProviders: Provider[] = [
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
RANKING_SERVICE_TOKEN,
DriverProfilePresenter.name,
],
},
];

View File

@@ -14,7 +14,7 @@ import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTo
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
import { UpdateDriverProfileUseCase, type UpdateDriverProfileInput } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
// Presenters
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
@@ -70,10 +70,12 @@ export class DriverService {
const result = await this.getDriversLeaderboardUseCase.execute({});
const presenter = new DriversLeaderboardPresenter();
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load drivers leaderboard');
}
return presenter.getResponseModel();
return this.driversLeaderboardPresenter.getResponseModel();
}
async getTotalDrivers(): Promise<DriverStatsDTO> {
@@ -101,14 +103,15 @@ export class DriverService {
lastName: input.lastName,
displayName: input.displayName,
country: input.country,
timezone: input.timezone,
bio: input.bio,
...(input.bio !== undefined ? { bio: input.bio } : {}),
});
const presenter = new CompleteOnboardingPresenter();
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to complete onboarding');
}
return presenter.responseModel;
return this.completeOnboardingPresenter.getResponseModel();
}
async getDriverRegistrationStatus(
@@ -121,10 +124,12 @@ export class DriverService {
driverId: query.driverId,
});
const presenter = new DriverRegistrationStatusPresenter();
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to check registration status');
}
return presenter.responseModel;
return this.driverRegistrationStatusPresenter.getResponseModel();
}
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
@@ -132,10 +137,9 @@ export class DriverService {
const driver = await this.driverRepository.findById(userId);
const presenter = new DriverPresenter();
presenter.present(driver ?? null);
this.driverPresenter.present(driver ?? null);
return presenter.responseModel;
return this.driverPresenter.getResponseModel();
}
async updateDriverProfile(
@@ -145,19 +149,21 @@ export class DriverService {
): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Updating driver profile for driverId: ${driverId}`);
const result = await this.updateDriverProfileUseCase.execute({ driverId, bio, country });
const input: UpdateDriverProfileInput = { driverId };
if (bio !== undefined) input.bio = bio;
if (country !== undefined) input.country = country;
const presenter = new DriverPresenter();
const result = await this.updateDriverProfileUseCase.execute(input);
if (result.isErr()) {
this.logger.error(`Failed to update driver profile: ${result.error.code}`);
presenter.present(null);
return presenter.responseModel;
this.logger.error(`Failed to update driver profile: ${result.unwrapErr().code}`);
this.driverPresenter.present(null);
return this.driverPresenter.getResponseModel();
}
const updatedDriver = await this.driverRepository.findById(driverId);
presenter.present(updatedDriver ?? null);
return presenter.responseModel;
this.driverPresenter.present(updatedDriver ?? null);
return this.driverPresenter.getResponseModel();
}
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
@@ -165,10 +171,9 @@ export class DriverService {
const driver = await this.driverRepository.findById(driverId);
const presenter = new DriverPresenter();
presenter.present(driver ?? null);
this.driverPresenter.present(driver ?? null);
return presenter.responseModel;
return this.driverPresenter.getResponseModel();
}
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
@@ -176,9 +181,11 @@ export class DriverService {
const result = await this.getProfileOverviewUseCase.execute({ driverId });
const presenter = new DriverProfilePresenter();
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load driver profile');
}
return presenter.responseModel;
return this.driverProfilePresenter.getResponseModel();
}
}

View File

@@ -9,10 +9,10 @@ describe('DriverRegistrationStatusPresenter', () => {
});
describe('present', () => {
it('should map parameters to view model for registered driver', () => {
presenter.present(true, 'race-123', 'driver-456');
it('should map parameters to response model for registered driver', () => {
presenter.present({ isRegistered: true, raceId: 'race-123', driverId: 'driver-456' });
const result = presenter.viewModel;
const result = presenter.getResponseModel();
expect(result).toEqual({
isRegistered: true,
@@ -21,10 +21,10 @@ describe('DriverRegistrationStatusPresenter', () => {
});
});
it('should map parameters to view model for unregistered driver', () => {
presenter.present(false, 'race-789', 'driver-101');
it('should map parameters to response model for unregistered driver', () => {
presenter.present({ isRegistered: false, raceId: 'race-789', driverId: 'driver-101' });
const result = presenter.viewModel;
const result = presenter.getResponseModel();
expect(result).toEqual({
isRegistered: false,
@@ -36,11 +36,11 @@ describe('DriverRegistrationStatusPresenter', () => {
describe('reset', () => {
it('should reset the result', () => {
presenter.present(true, 'race-123', 'driver-456');
expect(presenter.viewModel).toBeDefined();
presenter.present({ isRegistered: true, raceId: 'race-123', driverId: 'driver-456' });
expect(presenter.getResponseModel()).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
});
});
});

View File

@@ -9,6 +9,10 @@ export class DriverRegistrationStatusPresenter
{
private responseModel: DriverRegistrationStatusDTO | null = null;
reset(): void {
this.responseModel = null;
}
present(result: IsDriverRegisteredForRaceResult): void {
this.responseModel = {
isRegistered: result.isRegistered,

View File

@@ -1,29 +1,15 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import type {
GetDriversLeaderboardResult,
GetDriversLeaderboardErrorCode,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export type DriversLeaderboardApplicationError = ApplicationErrorCode<
GetDriversLeaderboardErrorCode,
{ message: string }
>;
export class DriversLeaderboardPresenter implements UseCaseOutputPort<GetDriversLeaderboardResult> {
private responseModel: DriversLeaderboardDTO | null = null;
export class DriversLeaderboardPresenter {
present(
result: Result<GetDriversLeaderboardResult, DriversLeaderboardApplicationError>,
): DriversLeaderboardDTO {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load drivers leaderboard');
}
const output = result.unwrap();
return {
drivers: output.items.map(item => ({
present(result: GetDriversLeaderboardResult): void {
this.responseModel = {
drivers: result.items.map(item => ({
id: item.driver.id,
name: item.driver.name.toString(),
rating: item.rating,
@@ -36,9 +22,14 @@ export class DriversLeaderboardPresenter {
rank: item.rank,
avatarUrl: item.avatarUrl,
})),
totalRaces: output.items.reduce((sum, d) => sum + d.racesCompleted, 0),
totalWins: output.items.reduce((sum, d) => sum + d.wins, 0),
activeCount: output.items.filter(d => d.isActive).length,
totalRaces: result.totalRaces,
totalWins: result.totalWins,
activeCount: result.activeCount,
};
}
getResponseModel(): DriversLeaderboardDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -8,7 +8,7 @@ import { IAvatarRepository } from '@core/media/domain/repositories/IAvatarReposi
import { FaceValidationPort } from '@core/media/application/ports/FaceValidationPort';
import { AvatarGenerationPort } from '@core/media/application/ports/AvatarGenerationPort';
import { MediaStoragePort } from '@core/media/application/ports/MediaStoragePort';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
// Import use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
@@ -18,6 +18,22 @@ import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMedi
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Import result types
import type { RequestAvatarGenerationResult } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import type { UploadMediaResult } from '@core/media/application/use-cases/UploadMediaUseCase';
import type { GetMediaResult } from '@core/media/application/use-cases/GetMediaUseCase';
import type { DeleteMediaResult } from '@core/media/application/use-cases/DeleteMediaUseCase';
import type { GetAvatarResult } from '@core/media/application/use-cases/GetAvatarUseCase';
import type { UpdateAvatarResult } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Import presenters
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
import { UploadMediaPresenter } from './presenters/UploadMediaPresenter';
import { GetMediaPresenter } from './presenters/GetMediaPresenter';
import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
// Define injection tokens
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
@@ -35,6 +51,14 @@ export const DELETE_MEDIA_USE_CASE_TOKEN = 'DeleteMediaUseCase';
export const GET_AVATAR_USE_CASE_TOKEN = 'GetAvatarUseCase';
export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase';
// Output port tokens
export const REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN = 'RequestAvatarGenerationOutputPort';
export const UPLOAD_MEDIA_OUTPUT_PORT_TOKEN = 'UploadMediaOutputPort';
export const GET_MEDIA_OUTPUT_PORT_TOKEN = 'GetMediaOutputPort';
export const DELETE_MEDIA_OUTPUT_PORT_TOKEN = 'DeleteMediaOutputPort';
export const GET_AVATAR_OUTPUT_PORT_TOKEN = 'GetAvatarOutputPort';
export const UPDATE_AVATAR_OUTPUT_PORT_TOKEN = 'UpdateAvatarOutputPort';
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import type { Media } from '@core/media/domain/entities/Media';
import type { Avatar } from '@core/media/domain/entities/Avatar';
@@ -110,6 +134,12 @@ class MockLogger implements Logger {
export const MediaProviders: Provider[] = [
MediaService, // Provide the service itself
RequestAvatarGenerationPresenter,
UploadMediaPresenter,
GetMediaPresenter,
DeleteMediaPresenter,
GetAvatarPresenter,
UpdateAvatarPresenter,
{
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
useClass: MockAvatarGenerationRepository,
@@ -138,41 +168,66 @@ export const MediaProviders: Provider[] = [
provide: LOGGER_TOKEN,
useClass: MockLogger,
},
// Output ports
{
provide: REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN,
useExisting: RequestAvatarGenerationPresenter,
},
{
provide: UPLOAD_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: UploadMediaPresenter,
},
{
provide: GET_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: GetMediaPresenter,
},
{
provide: DELETE_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: DeleteMediaPresenter,
},
{
provide: GET_AVATAR_OUTPUT_PORT_TOKEN,
useExisting: GetAvatarPresenter,
},
{
provide: UPDATE_AVATAR_OUTPUT_PORT_TOKEN,
useExisting: UpdateAvatarPresenter,
},
// Use cases
{
provide: REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, logger: Logger) =>
new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, logger),
inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, output: UseCaseOutputPort<RequestAvatarGenerationResult>, logger: Logger) =>
new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, output, logger),
inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: UPLOAD_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, logger: Logger) =>
new UploadMediaUseCase(mediaRepo, mediaStorage, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, output: UseCaseOutputPort<UploadMediaResult>, logger: Logger) =>
new UploadMediaUseCase(mediaRepo, mediaStorage, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, UPLOAD_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, logger: Logger) =>
new GetMediaUseCase(mediaRepo, logger),
inject: [MEDIA_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (mediaRepo: IMediaRepository, output: UseCaseOutputPort<GetMediaResult>, logger: Logger) =>
new GetMediaUseCase(mediaRepo, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, GET_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: DELETE_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, logger: Logger) =>
new DeleteMediaUseCase(mediaRepo, mediaStorage, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, output: UseCaseOutputPort<DeleteMediaResult>, logger: Logger) =>
new DeleteMediaUseCase(mediaRepo, mediaStorage, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, DELETE_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_AVATAR_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarRepository, logger: Logger) =>
new GetAvatarUseCase(avatarRepo, logger),
inject: [AVATAR_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (avatarRepo: IAvatarRepository, output: UseCaseOutputPort<GetAvatarResult>, logger: Logger) =>
new GetAvatarUseCase(avatarRepo, output, logger),
inject: [AVATAR_REPOSITORY_TOKEN, GET_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: UPDATE_AVATAR_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarRepository, logger: Logger) =>
new UpdateAvatarUseCase(avatarRepo, logger),
inject: [AVATAR_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (avatarRepo: IAvatarRepository, output: UseCaseOutputPort<UpdateAvatarResult>, logger: Logger) =>
new UpdateAvatarUseCase(avatarRepo, output, logger),
inject: [AVATAR_REPOSITORY_TOKEN, UPDATE_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -59,6 +59,12 @@ export class MediaService {
private readonly updateAvatarUseCase: UpdateAvatarUseCase,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
private readonly requestAvatarGenerationPresenter: RequestAvatarGenerationPresenter,
private readonly uploadMediaPresenter: UploadMediaPresenter,
private readonly getMediaPresenter: GetMediaPresenter,
private readonly deleteMediaPresenter: DeleteMediaPresenter,
private readonly getAvatarPresenter: GetAvatarPresenter,
private readonly updateAvatarPresenter: UpdateAvatarPresenter,
) {}
async requestAvatarGeneration(
@@ -66,18 +72,23 @@ export class MediaService {
): Promise<RequestAvatarGenerationOutputDTO> {
this.logger.debug('[MediaService] Requesting avatar generation.');
const presenter = new RequestAvatarGenerationPresenter();
presenter.reset();
const result = await this.requestAvatarGenerationUseCase.execute({
userId: input.userId,
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as RacingSuitColor,
});
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
requestId: '',
avatarUrls: [],
errorMessage: error.details?.message ?? 'Failed to request avatar generation',
};
}
return presenter.responseModel;
return this.requestAvatarGenerationPresenter.responseModel;
}
async uploadMedia(
@@ -85,69 +96,87 @@ export class MediaService {
): Promise<UploadMediaOutputDTO> {
this.logger.debug('[MediaService] Uploading media.');
const presenter = new UploadMediaPresenter();
presenter.reset();
const result = await this.uploadMediaUseCase.execute({
file: input.file,
uploadedBy: input.userId ?? '',
metadata: input.metadata,
metadata: input.metadata || {},
});
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Upload failed',
};
}
return presenter.responseModel;
return this.uploadMediaPresenter.responseModel;
}
async getMedia(mediaId: string): Promise<GetMediaOutputDTO | null> {
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
const presenter = new GetMediaPresenter();
presenter.reset();
const result = await this.getMediaUseCase.execute({ mediaId });
presenter.present(result);
return presenter.responseModel;
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'MEDIA_NOT_FOUND') {
return null;
}
throw new Error(error.details?.message ?? 'Failed to get media');
}
return this.getMediaPresenter.responseModel;
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutputDTO> {
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
const presenter = new DeleteMediaPresenter();
presenter.reset();
const result = await this.deleteMediaUseCase.execute({ mediaId });
presenter.present(result);
return presenter.responseModel;
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Failed to delete media',
};
}
return this.deleteMediaPresenter.responseModel;
}
async getAvatar(driverId: string): Promise<GetAvatarOutputDTO | null> {
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
const presenter = new GetAvatarPresenter();
presenter.reset();
const result = await this.getAvatarUseCase.execute({ driverId });
presenter.present(result);
return presenter.responseModel;
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'AVATAR_NOT_FOUND') {
return null;
}
throw new Error(error.details?.message ?? 'Failed to get avatar');
}
return this.getAvatarPresenter.responseModel;
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
const presenter = new UpdateAvatarPresenter();
presenter.reset();
const result = await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.mediaUrl,
mediaUrl: input.avatarUrl,
});
presenter.present(result);
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: error.details?.message ?? 'Failed to update avatar',
};
}
return presenter.responseModel;
return this.updateAvatarPresenter.responseModel;
}
}

View File

@@ -1,41 +1,19 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
DeleteMediaResult,
DeleteMediaErrorCode,
} from '@core/media/application/use-cases/DeleteMediaUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { DeleteMediaResult } from '@core/media/application/use-cases/DeleteMediaUseCase';
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
type DeleteMediaResponseModel = DeleteMediaOutputDTO;
export type DeleteMediaApplicationError = ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
export class DeleteMediaPresenter {
export class DeleteMediaPresenter implements UseCaseOutputPort<DeleteMediaResult> {
private model: DeleteMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<DeleteMediaResult, DeleteMediaApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
this.model = {
success: false,
error: error.details?.message ?? 'Failed to delete media',
};
return;
}
const output = result.unwrap();
present(result: DeleteMediaResult): void {
this.model = {
success: output.deleted,
error: undefined,
success: result.deleted,
};
}

View File

@@ -1,41 +1,19 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetAvatarResult,
GetAvatarErrorCode,
} from '@core/media/application/use-cases/GetAvatarUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetAvatarResult } from '@core/media/application/use-cases/GetAvatarUseCase';
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
export type GetAvatarResponseModel = GetAvatarOutputDTO | null;
export type GetAvatarApplicationError = ApplicationErrorCode<
GetAvatarErrorCode,
{ message: string }
>;
export class GetAvatarPresenter {
export class GetAvatarPresenter implements UseCaseOutputPort<GetAvatarResult> {
private model: GetAvatarResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<GetAvatarResult, GetAvatarApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'AVATAR_NOT_FOUND') {
this.model = null;
return;
}
throw new Error(error.details?.message ?? 'Failed to get avatar');
}
const output = result.unwrap();
present(result: GetAvatarResult): void {
this.model = {
avatarUrl: output.avatar.mediaUrl,
avatarUrl: result.avatar.mediaUrl,
};
}

View File

@@ -1,37 +1,18 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetMediaResult, GetMediaErrorCode } from '@core/media/application/use-cases/GetMediaUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetMediaResult } from '@core/media/application/use-cases/GetMediaUseCase';
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
export type GetMediaResponseModel = GetMediaOutputDTO | null;
export type GetMediaApplicationError = ApplicationErrorCode<
GetMediaErrorCode,
{ message: string }
>;
export class GetMediaPresenter {
export class GetMediaPresenter implements UseCaseOutputPort<GetMediaResult> {
private model: GetMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<GetMediaResult, GetMediaApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'MEDIA_NOT_FOUND') {
this.model = null;
return;
}
throw new Error(error.details?.message ?? 'Failed to get media');
}
const output = result.unwrap();
const media = output.media;
present(result: GetMediaResult): void {
const media = result.media;
this.model = {
id: media.id,

View File

@@ -1,50 +1,21 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
RequestAvatarGenerationResult,
RequestAvatarGenerationErrorCode,
} from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { RequestAvatarGenerationResult } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import type { RequestAvatarGenerationOutputDTO } from '../dtos/RequestAvatarGenerationOutputDTO';
type RequestAvatarGenerationResponseModel = RequestAvatarGenerationOutputDTO;
export type RequestAvatarGenerationApplicationError = ApplicationErrorCode<
RequestAvatarGenerationErrorCode,
{ message: string }
>;
export class RequestAvatarGenerationPresenter {
export class RequestAvatarGenerationPresenter implements UseCaseOutputPort<RequestAvatarGenerationResult> {
private model: RequestAvatarGenerationResponseModel | null = null;
reset() {
this.model = null;
}
present(
result: Result<
RequestAvatarGenerationResult,
RequestAvatarGenerationApplicationError
>,
): void {
if (result.isErr()) {
const error = result.unwrapErr();
this.model = {
success: false,
requestId: '',
avatarUrls: [],
errorMessage: error.details?.message ?? 'Failed to request avatar generation',
};
return;
}
const output = result.unwrap();
present(result: RequestAvatarGenerationResult): void {
this.model = {
success: output.status === 'completed',
requestId: output.requestId,
avatarUrls: output.avatarUrls,
errorMessage: undefined,
success: result.status === 'completed',
requestId: result.requestId,
avatarUrls: result.avatarUrls || [],
};
}

View File

@@ -1,36 +1,19 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
UpdateAvatarResult,
UpdateAvatarErrorCode,
} from '@core/media/application/use-cases/UpdateAvatarUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { UpdateAvatarResult } from '@core/media/application/use-cases/UpdateAvatarUseCase';
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
type UpdateAvatarResponseModel = UpdateAvatarOutputDTO;
export type UpdateAvatarApplicationError = ApplicationErrorCode<
UpdateAvatarErrorCode,
{ message: string }
>;
export class UpdateAvatarPresenter {
export class UpdateAvatarPresenter implements UseCaseOutputPort<UpdateAvatarResult> {
private model: UpdateAvatarResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<UpdateAvatarResult, UpdateAvatarApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to update avatar');
}
const output = result.unwrap();
present(result: UpdateAvatarResult): void {
this.model = {
success: true,
error: undefined,
};
}

View File

@@ -1,43 +1,21 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
UploadMediaResult,
UploadMediaErrorCode,
} from '@core/media/application/use-cases/UploadMediaUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { UploadMediaResult } from '@core/media/application/use-cases/UploadMediaUseCase';
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
type UploadMediaResponseModel = UploadMediaOutputDTO;
export type UploadMediaApplicationError = ApplicationErrorCode<
UploadMediaErrorCode,
{ message: string }
>;
export class UploadMediaPresenter {
export class UploadMediaPresenter implements UseCaseOutputPort<UploadMediaResult> {
private model: UploadMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<UploadMediaResult, UploadMediaApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
this.model = {
success: false,
error: error.details?.message ?? 'Upload failed',
};
return;
}
const output = result.unwrap();
present(result: UploadMediaResult): void {
this.model = {
success: true,
mediaId: output.mediaId,
url: output.url,
error: undefined,
mediaId: result.mediaId,
url: result.url,
};
}

View File

@@ -12,8 +12,7 @@ export class PaymentsController {
@ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
const presenter = await this.paymentsService.getPayments(query);
return presenter.viewModel;
return this.paymentsService.getPayments(query);
}
@Post()
@@ -21,16 +20,14 @@ export class PaymentsController {
@ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
async createPayment(@Body() input: CreatePaymentInput): Promise<CreatePaymentOutput> {
const presenter = await this.paymentsService.createPayment(input);
return presenter.viewModel;
return this.paymentsService.createPayment(input);
}
@Patch('status')
@ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
const presenter = await this.paymentsService.updatePaymentStatus(input);
return presenter.viewModel;
return this.paymentsService.updatePaymentStatus(input);
}
@Get('membership-fees')

View File

@@ -6,7 +6,7 @@ import type { IPaymentRepository } from '@core/payments/domain/repositories/IPay
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository';
import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository';
import type { IWalletRepository, ITransactionRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application/Logger';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
// Import use cases
import { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
@@ -29,6 +29,20 @@ import { InMemoryPrizeRepository } from '@adapters/payments/persistence/inmemory
import { InMemoryWalletRepository, InMemoryTransactionRepository } from '@adapters/payments/persistence/inmemory/InMemoryWalletRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Presenters
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { AwardPrizePresenter } from './presenters/AwardPrizePresenter';
import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
// Repository injection tokens
export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository';
export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository';
@@ -52,9 +66,87 @@ export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase';
export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';
// Output port tokens
export const GET_PAYMENTS_OUTPUT_PORT_TOKEN = 'GetPaymentsOutputPort_TOKEN';
export const CREATE_PAYMENT_OUTPUT_PORT_TOKEN = 'CreatePaymentOutputPort_TOKEN';
export const UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN = 'UpdatePaymentStatusOutputPort_TOKEN';
export const GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN = 'GetMembershipFeesOutputPort_TOKEN';
export const UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN = 'UpsertMembershipFeeOutputPort_TOKEN';
export const UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN = 'UpdateMemberPaymentOutputPort_TOKEN';
export const GET_PRIZES_OUTPUT_PORT_TOKEN = 'GetPrizesOutputPort_TOKEN';
export const CREATE_PRIZE_OUTPUT_PORT_TOKEN = 'CreatePrizeOutputPort_TOKEN';
export const AWARD_PRIZE_OUTPUT_PORT_TOKEN = 'AwardPrizeOutputPort_TOKEN';
export const DELETE_PRIZE_OUTPUT_PORT_TOKEN = 'DeletePrizeOutputPort_TOKEN';
export const GET_WALLET_OUTPUT_PORT_TOKEN = 'GetWalletOutputPort_TOKEN';
export const PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN = 'ProcessWalletTransactionOutputPort_TOKEN';
export const PaymentsProviders: Provider[] = [
PaymentsService,
// Presenters
GetPaymentsPresenter,
CreatePaymentPresenter,
UpdatePaymentStatusPresenter,
GetMembershipFeesPresenter,
UpsertMembershipFeePresenter,
UpdateMemberPaymentPresenter,
GetPrizesPresenter,
CreatePrizePresenter,
AwardPrizePresenter,
DeletePrizePresenter,
GetWalletPresenter,
ProcessWalletTransactionPresenter,
// Output ports
{
provide: GET_PAYMENTS_OUTPUT_PORT_TOKEN,
useExisting: GetPaymentsPresenter,
},
{
provide: CREATE_PAYMENT_OUTPUT_PORT_TOKEN,
useExisting: CreatePaymentPresenter,
},
{
provide: UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN,
useExisting: UpdatePaymentStatusPresenter,
},
{
provide: GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN,
useExisting: GetMembershipFeesPresenter,
},
{
provide: UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN,
useExisting: UpsertMembershipFeePresenter,
},
{
provide: UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN,
useExisting: UpdateMemberPaymentPresenter,
},
{
provide: GET_PRIZES_OUTPUT_PORT_TOKEN,
useExisting: GetPrizesPresenter,
},
{
provide: CREATE_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: CreatePrizePresenter,
},
{
provide: AWARD_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: AwardPrizePresenter,
},
{
provide: DELETE_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: DeletePrizePresenter,
},
{
provide: GET_WALLET_OUTPUT_PORT_TOKEN,
useExisting: GetWalletPresenter,
},
{
provide: PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN,
useExisting: ProcessWalletTransactionPresenter,
},
// Logger
{
provide: LOGGER_TOKEN,
@@ -96,66 +188,66 @@ export const PaymentsProviders: Provider[] = [
// Use cases (use cases receive repositories, services receive use cases)
{
provide: GET_PAYMENTS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new GetPaymentsUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<any>) => new GetPaymentsUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, GET_PAYMENTS_OUTPUT_PORT_TOKEN],
},
{
provide: CREATE_PAYMENT_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new CreatePaymentUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<any>) => new CreatePaymentUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, CREATE_PAYMENT_OUTPUT_PORT_TOKEN],
},
{
provide: UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository) => new UpdatePaymentStatusUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<any>) => new UpdatePaymentStatusUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN],
},
{
provide: GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository, output: UseCaseOutputPort<any>) =>
new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN, GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN],
},
{
provide: UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository) => new UpsertMembershipFeeUseCase(membershipFeeRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository, output: UseCaseOutputPort<any>) => new UpsertMembershipFeeUseCase(membershipFeeRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN],
},
{
provide: UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository, output: UseCaseOutputPort<any>) =>
new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN, UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN],
},
{
provide: GET_PRIZES_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new GetPrizesUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<any>) => new GetPrizesUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, GET_PRIZES_OUTPUT_PORT_TOKEN],
},
{
provide: CREATE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new CreatePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<any>) => new CreatePrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, CREATE_PRIZE_OUTPUT_PORT_TOKEN],
},
{
provide: AWARD_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new AwardPrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<any>) => new AwardPrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, AWARD_PRIZE_OUTPUT_PORT_TOKEN],
},
{
provide: DELETE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository) => new DeletePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<any>) => new DeletePrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, DELETE_PRIZE_OUTPUT_PORT_TOKEN],
},
{
provide: GET_WALLET_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new GetWalletUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository, output: UseCaseOutputPort<any>) =>
new GetWalletUseCase(walletRepo, transactionRepo, output),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN, GET_WALLET_OUTPUT_PORT_TOKEN],
},
{
provide: PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new ProcessWalletTransactionUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository, output: UseCaseOutputPort<any>) =>
new ProcessWalletTransactionUseCase(walletRepo, transactionRepo, output),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN, PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN],
},
];

View File

@@ -90,54 +90,78 @@ export class PaymentsService {
@Inject(GET_WALLET_USE_CASE_TOKEN) private readonly getWalletUseCase: GetWalletUseCase,
@Inject(PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN) private readonly processWalletTransactionUseCase: ProcessWalletTransactionUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
private readonly getPaymentsPresenter: GetPaymentsPresenter,
private readonly createPaymentPresenter: CreatePaymentPresenter,
private readonly updatePaymentStatusPresenter: UpdatePaymentStatusPresenter,
private readonly getMembershipFeesPresenter: GetMembershipFeesPresenter,
private readonly upsertMembershipFeePresenter: UpsertMembershipFeePresenter,
private readonly updateMemberPaymentPresenter: UpdateMemberPaymentPresenter,
private readonly getPrizesPresenter: GetPrizesPresenter,
private readonly createPrizePresenter: CreatePrizePresenter,
private readonly awardPrizePresenter: AwardPrizePresenter,
private readonly deletePrizePresenter: DeletePrizePresenter,
private readonly getWalletPresenter: GetWalletPresenter,
private readonly processWalletTransactionPresenter: ProcessWalletTransactionPresenter,
) {}
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsPresenter> {
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
this.logger.debug('[PaymentsService] Getting payments', { query });
const presenter = new GetPaymentsPresenter();
await this.getPaymentsUseCase.execute(query, presenter);
return presenter;
const result = await this.getPaymentsUseCase.execute(query);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to get payments');
}
return this.getPaymentsPresenter.getResponseModel();
}
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentPresenter> {
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
this.logger.debug('[PaymentsService] Creating payment', { input });
const presenter = new CreatePaymentPresenter();
await this.createPaymentUseCase.execute(input, presenter);
return presenter;
const result = await this.createPaymentUseCase.execute(input);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to create payment');
}
return this.createPaymentPresenter.getResponseModel();
}
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusPresenter> {
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
this.logger.debug('[PaymentsService] Updating payment status', { input });
const presenter = new UpdatePaymentStatusPresenter();
await this.updatePaymentStatusUseCase.execute(input, presenter);
return presenter;
const result = await this.updatePaymentStatusUseCase.execute(input);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to update payment status');
}
return this.updatePaymentStatusPresenter.getResponseModel();
}
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesPresenter> {
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
this.logger.debug('[PaymentsService] Getting membership fees', { query });
const presenter = new GetMembershipFeesPresenter();
await this.getMembershipFeesUseCase.execute(query, presenter);
return presenter;
const result = await this.getMembershipFeesUseCase.execute(query);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to get membership fees');
}
return this.getMembershipFeesPresenter.getResponseModel();
}
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeePresenter> {
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
this.logger.debug('[PaymentsService] Upserting membership fee', { input });
const presenter = new UpsertMembershipFeePresenter();
await this.upsertMembershipFeeUseCase.execute(input, presenter);
return presenter;
const result = await this.upsertMembershipFeeUseCase.execute(input);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to upsert membership fee');
}
return this.upsertMembershipFeePresenter.getResponseModel();
}
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentPresenter> {
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
this.logger.debug('[PaymentsService] Updating member payment', { input });
const presenter = new UpdateMemberPaymentPresenter();
await this.updateMemberPaymentUseCase.execute(input, presenter);
return presenter;
const result = await this.updateMemberPaymentUseCase.execute(input);
if (result.isErr()) {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to update member payment');
}
return this.updateMemberPaymentPresenter.getResponseModel();
}
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesPresenter> {

View File

@@ -1,25 +1,34 @@
import type {
ICreatePaymentPresenter,
CreatePaymentResultDTO,
CreatePaymentViewModel,
} from '@core/payments/application/presenters/ICreatePaymentPresenter';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { CreatePaymentResult } from '@core/payments/application/use-cases/CreatePaymentUseCase';
import type { CreatePaymentOutput } from '../dtos/PaymentsDto';
export class CreatePaymentPresenter implements ICreatePaymentPresenter {
private responseModel: CreatePaymentViewModel | null = null;
export class CreatePaymentPresenter implements UseCaseOutputPort<CreatePaymentResult> {
private responseModel: CreatePaymentOutput | null = null;
reset() {
this.responseModel = null;
}
present(dto: CreatePaymentResultDTO) {
this.responseModel = dto;
present(result: CreatePaymentResult): void {
this.responseModel = {
payment: {
id: result.payment.id,
type: result.payment.type,
amount: result.payment.amount,
platformFee: result.payment.platformFee,
netAmount: result.payment.netAmount,
payerId: result.payment.payerId,
payerType: result.payment.payerType,
leagueId: result.payment.leagueId,
...(result.payment.seasonId !== undefined ? { seasonId: result.payment.seasonId } : {}),
status: result.payment.status,
createdAt: result.payment.createdAt,
...(result.payment.completedAt !== undefined ? { completedAt: result.payment.completedAt } : {}),
},
};
}
getResponseModel(): CreatePaymentViewModel | null {
return this.responseModel;
}
get responseModel(): CreatePaymentViewModel {
getResponseModel(): CreatePaymentOutput {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}

View File

@@ -1,26 +1,35 @@
import type {
IGetPaymentsPresenter,
GetPaymentsResultDTO,
GetPaymentsViewModel,
} from '@core/payments/application/presenters/IGetPaymentsPresenter';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetPaymentsResult } from '@core/payments/application/use-cases/GetPaymentsUseCase';
import type { GetPaymentsOutput } from '../dtos/PaymentsDto';
export class GetPaymentsPresenter implements IGetPaymentsPresenter {
private result: GetPaymentsViewModel | null = null;
export class GetPaymentsPresenter implements UseCaseOutputPort<GetPaymentsResult> {
private responseModel: GetPaymentsOutput | null = null;
reset() {
this.result = null;
this.responseModel = null;
}
present(dto: GetPaymentsResultDTO) {
this.result = dto;
present(result: GetPaymentsResult): void {
this.responseModel = {
payments: result.payments.map(payment => ({
id: payment.id,
type: payment.type,
amount: payment.amount,
platformFee: payment.platformFee,
netAmount: payment.netAmount,
payerId: payment.payerId,
payerType: payment.payerType,
leagueId: payment.leagueId,
...(payment.seasonId !== undefined ? { seasonId: payment.seasonId } : {}),
status: payment.status,
createdAt: payment.createdAt,
...(payment.completedAt !== undefined ? { completedAt: payment.completedAt } : {}),
})),
};
}
getViewModel(): GetPaymentsViewModel | null {
return this.result;
}
get viewModel(): GetPaymentsViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): GetPaymentsOutput {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,26 +1,35 @@
import type {
IUpdatePaymentStatusPresenter,
UpdatePaymentStatusResultDTO,
UpdatePaymentStatusViewModel,
} from '@core/payments/application/presenters/IUpdatePaymentStatusPresenter';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { UpdatePaymentStatusResult } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import type { UpdatePaymentStatusOutput } from '../dtos/PaymentsDto';
export class UpdatePaymentStatusPresenter implements IUpdatePaymentStatusPresenter {
private result: UpdatePaymentStatusViewModel | null = null;
export class UpdatePaymentStatusPresenter implements UseCaseOutputPort<UpdatePaymentStatusResult> {
private responseModel: UpdatePaymentStatusOutput | null = null;
reset() {
this.result = null;
this.responseModel = null;
}
present(dto: UpdatePaymentStatusResultDTO) {
this.result = dto;
present(result: UpdatePaymentStatusResult): void {
this.responseModel = {
payment: {
id: result.payment.id,
type: result.payment.type,
amount: result.payment.amount,
platformFee: result.payment.platformFee,
netAmount: result.payment.netAmount,
payerId: result.payment.payerId,
payerType: result.payment.payerType,
leagueId: result.payment.leagueId,
...(result.payment.seasonId !== undefined ? { seasonId: result.payment.seasonId } : {}),
status: result.payment.status,
createdAt: result.payment.createdAt,
...(result.payment.completedAt !== undefined ? { completedAt: result.payment.completedAt } : {}),
},
};
}
getViewModel(): UpdatePaymentStatusViewModel | null {
return this.result;
}
get viewModel(): UpdatePaymentStatusViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): UpdatePaymentStatusOutput {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,8 +1,5 @@
import type { Result } from '@core/shared/application/Result';
import type {
ReviewProtestResult,
ReviewProtestApplicationError,
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ReviewProtestResult } from '@core/racing/application/use-cases/ReviewProtestUseCase';
export interface ReviewProtestResponseDTO {
success: boolean;
@@ -13,34 +10,18 @@ export interface ReviewProtestResponseDTO {
decision?: 'uphold' | 'dismiss';
}
export class ReviewProtestPresenter {
export class ReviewProtestPresenter implements UseCaseOutputPort<ReviewProtestResult> {
private model: ReviewProtestResponseDTO | null = null;
reset(): void {
this.model = null;
}
present(
result: Result<ReviewProtestResult, ReviewProtestApplicationError>,
): void {
if (result.isErr()) {
const error = result.unwrapErr();
this.model = {
success: false,
errorCode: error.code,
message: error.details?.message,
};
return;
}
const value = result.unwrap();
present(result: ReviewProtestResult): void {
this.model = {
success: true,
protestId: value.protestId,
stewardId: value.stewardId,
decision: value.decision,
protestId: result.protestId,
decision: result.status === 'upheld' ? 'uphold' : 'dismiss',
};
}

View File

@@ -96,11 +96,14 @@ export class RaceService {
async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.');
const presenter = new GetAllRacesPresenter();
this.getAllRacesUseCase.setOutput(presenter);
const result = await this.getAllRacesUseCase.execute({});
const presenter = new GetAllRacesPresenter();
presenter.reset();
presenter.present(result);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return presenter;
}

View File

@@ -1,37 +1,17 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetAllRacesResult,
GetAllRacesErrorCode,
} from '@core/racing/application/use-cases/GetAllRacesUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetAllRacesResult } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
export type GetAllRacesResponseModel = AllRacesPageDTO;
export type GetAllRacesApplicationError = ApplicationErrorCode<
GetAllRacesErrorCode,
{ message: string }
>;
export class GetAllRacesPresenter {
export class GetAllRacesPresenter implements UseCaseOutputPort<GetAllRacesResult> {
private model: GetAllRacesResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: Result<GetAllRacesResult, GetAllRacesApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get all races');
}
const output = result.unwrap();
present(result: GetAllRacesResult): void {
const leagueMap = new Map<string, string>();
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const league of output.leagues) {
for (const league of result.leagues) {
const id = league.id.toString();
const name = league.name.toString();
leagueMap.set(id, name);
@@ -39,7 +19,7 @@ export class GetAllRacesPresenter {
}
this.model = {
races: output.races.map(race => ({
races: result.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,