From f2d8a235830d420570c337b50d04b5f2fb80118f Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 21 Dec 2025 17:05:36 +0100 Subject: [PATCH] refactor --- .../GetAnalyticsMetricsUseCase.test.ts | 30 +-- .../use-cases/GetAnalyticsMetricsUseCase.ts | 16 +- .../use-cases/GetDashboardDataUseCase.test.ts | 22 +- .../use-cases/GetDashboardDataUseCase.ts | 24 +- .../use-cases/GetEntityAnalyticsQuery.ts | 151 +++++------ .../use-cases/RecordEngagementUseCase.test.ts | 21 +- .../use-cases/RecordEngagementUseCase.ts | 24 +- .../use-cases/RecordPageViewUseCase.test.ts | 20 +- .../use-cases/RecordPageViewUseCase.ts | 24 +- .../application/use-cases/GetUserUseCase.ts | 25 +- .../application/use-cases/LoginUseCase.ts | 31 ++- .../application/use-cases/SignupUseCase.ts | 25 +- .../presenters/IDeleteMediaPresenter.ts | 8 - .../presenters/IGetAvatarPresenter.ts | 14 - .../presenters/IGetMediaPresenter.ts | 20 -- .../IRequestAvatarGenerationPresenter.ts | 13 - .../presenters/ISelectAvatarPresenter.ts | 9 - .../presenters/IUpdateAvatarPresenter.ts | 8 - .../presenters/IUploadMediaPresenter.ts | 10 - .../RequestAvatarGenerationUseCase.test.ts | 126 --------- .../use-cases/SelectAvatarUseCase.test.ts | 76 ------ .../value-objects}/MediaUrl.test.ts | 0 core/media/index.ts | 38 --- core/payments/application/index.ts | 1 - .../presenters/IAwardPrizePresenter.ts | 16 -- .../presenters/ICreatePaymentPresenter.ts | 16 -- .../presenters/ICreatePrizePresenter.ts | 16 -- .../presenters/IDeletePrizePresenter.ts | 15 -- .../presenters/IGetMembershipFeesPresenter.ts | 42 --- .../presenters/IGetPaymentsPresenter.ts | 31 --- .../presenters/IGetPrizesPresenter.ts | 31 --- .../presenters/IGetWalletPresenter.ts | 40 --- .../IProcessWalletTransactionPresenter.ts | 18 -- .../IUpdateMemberPaymentPresenter.ts | 16 -- .../IUpdatePaymentStatusPresenter.ts | 16 -- .../IUpsertMembershipFeePresenter.ts | 16 -- core/payments/application/presenters/index.ts | 12 - .../use-cases/AwardPrizeUseCase.ts | 52 ++-- .../use-cases/CreatePaymentUseCase.test.ts | 76 ++++++ .../use-cases/CreatePaymentUseCase.ts | 55 ++-- .../use-cases/CreatePrizeUseCase.ts | 51 ++-- .../use-cases/DeletePrizeUseCase.ts | 38 +-- .../GetMembershipFeesUseCase.test.ts | 70 ++--- .../use-cases/GetMembershipFeesUseCase.ts | 62 ++--- .../use-cases/GetPaymentsUseCase.test.ts | 67 +++++ .../use-cases/GetPaymentsUseCase.ts | 57 ++-- .../application/use-cases/GetPrizesUseCase.ts | 45 ++-- .../GetSponsorBillingUseCase.ts} | 38 ++- .../application/use-cases/GetWalletUseCase.ts | 56 ++-- .../ProcessWalletTransactionUseCase.test.ts | 114 ++++++++ .../ProcessWalletTransactionUseCase.ts | 71 ++--- .../use-cases/UpdateMemberPaymentUseCase.ts | 49 ++-- .../use-cases/UpdatePaymentStatusUseCase.ts | 57 ++-- .../use-cases/UpsertMembershipFeeUseCase.ts | 48 ++-- core/payments/application/use-cases/index.ts | 12 - .../services/SeasonApplicationService.ts | 2 +- .../use-cases/DashboardOverviewUseCase.ts | 8 +- .../GetAllLeaguesWithCapacityUseCase.ts | 20 +- .../use-cases/GetDriversLeaderboardUseCase.ts | 6 +- core/shared/application/UseCase.ts | 18 +- core/shared/application/UseCaseOutputPort.ts | 10 +- core/shared/presentation/Presenter.ts | 5 +- core/social/application/index.ts | 16 -- .../presenters/ISocialPresenters.ts | 20 -- docs/architecture/LOGGING.md | 245 ++++++++++++++++++ docs/architecture/USECASES.md | 84 ++++++ 66 files changed, 1131 insertions(+), 1342 deletions(-) delete mode 100644 core/media/application/presenters/IDeleteMediaPresenter.ts delete mode 100644 core/media/application/presenters/IGetAvatarPresenter.ts delete mode 100644 core/media/application/presenters/IGetMediaPresenter.ts delete mode 100644 core/media/application/presenters/IRequestAvatarGenerationPresenter.ts delete mode 100644 core/media/application/presenters/ISelectAvatarPresenter.ts delete mode 100644 core/media/application/presenters/IUpdateAvatarPresenter.ts delete mode 100644 core/media/application/presenters/IUploadMediaPresenter.ts delete mode 100644 core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts delete mode 100644 core/media/application/use-cases/SelectAvatarUseCase.test.ts rename core/media/{ => domain/value-objects}/MediaUrl.test.ts (100%) delete mode 100644 core/media/index.ts delete mode 100644 core/payments/application/presenters/IAwardPrizePresenter.ts delete mode 100644 core/payments/application/presenters/ICreatePaymentPresenter.ts delete mode 100644 core/payments/application/presenters/ICreatePrizePresenter.ts delete mode 100644 core/payments/application/presenters/IDeletePrizePresenter.ts delete mode 100644 core/payments/application/presenters/IGetMembershipFeesPresenter.ts delete mode 100644 core/payments/application/presenters/IGetPaymentsPresenter.ts delete mode 100644 core/payments/application/presenters/IGetPrizesPresenter.ts delete mode 100644 core/payments/application/presenters/IGetWalletPresenter.ts delete mode 100644 core/payments/application/presenters/IProcessWalletTransactionPresenter.ts delete mode 100644 core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts delete mode 100644 core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts delete mode 100644 core/payments/application/presenters/IUpsertMembershipFeePresenter.ts delete mode 100644 core/payments/application/presenters/index.ts create mode 100644 core/payments/application/use-cases/CreatePaymentUseCase.test.ts create mode 100644 core/payments/application/use-cases/GetPaymentsUseCase.test.ts rename core/payments/application/{services/SponsorBillingService.ts => use-cases/GetSponsorBillingUseCase.ts} (81%) create mode 100644 core/payments/application/use-cases/ProcessWalletTransactionUseCase.test.ts delete mode 100644 core/payments/application/use-cases/index.ts delete mode 100644 core/social/application/index.ts delete mode 100644 core/social/application/presenters/ISocialPresenters.ts create mode 100644 docs/architecture/LOGGING.md diff --git a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts index 9e6f44e64..d29883419 100644 --- a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts +++ b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput } from './GetAnalyticsMetricsUseCase'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; describe('GetAnalyticsMetricsUseCase', () => { let pageViewRepository: { @@ -15,6 +16,7 @@ describe('GetAnalyticsMetricsUseCase', () => { getBounceRate: Mock; }; let logger: Logger; + let output: UseCaseOutputPort> & { present: Mock }; let useCase: GetAnalyticsMetricsUseCase; beforeEach(() => { @@ -36,42 +38,36 @@ describe('GetAnalyticsMetricsUseCase', () => { error: vi.fn(), } as unknown as Logger; + output = { + present: vi.fn(), + }; + useCase = new GetAnalyticsMetricsUseCase( pageViewRepository as unknown as IPageViewRepository, + output, logger, ); }); - it('returns default metrics and logs retrieval when no input is provided', async () => { - const result = await useCase.execute(); - - expect(result).toEqual({ - pageViews: 0, - uniqueVisitors: 0, - averageSessionDuration: 0, - bounceRate: 0, - }); + it('presents default metrics and logs retrieval when no input is provided', async () => { + await useCase.execute(); expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); - it('uses provided date range and logs error when execute throws', async () => { + it('uses provided date range and presents error when execute throws', async () => { const input: GetAnalyticsMetricsInput = { startDate: new Date('2024-01-01'), endDate: new Date('2024-01-31'), }; - const erroringUseCase = new GetAnalyticsMetricsUseCase( - pageViewRepository as unknown as IPageViewRepository, - logger, - ); - // Simulate an error by temporarily spying on logger.info to throw (logger.info as unknown as Mock).mockImplementation(() => { throw new Error('Logging failed'); }); - await expect(erroringUseCase.execute(input)).rejects.toThrow('Logging failed'); + await useCase.execute(input); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts index 9d1fb50e6..2da28c353 100644 --- a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts +++ b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; @@ -17,15 +17,13 @@ export interface GetAnalyticsMetricsOutput { export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR'; -export class GetAnalyticsMetricsUseCase { +export class GetAnalyticsMetricsUseCase implements UseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly logger: Logger, ) {} - async execute(input: GetAnalyticsMetricsInput = {}): Promise< - Result> - > { + async execute(input: GetAnalyticsMetricsInput = {}): Promise>> { try { const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago const endDate = input.endDate ?? new Date(); @@ -45,19 +43,21 @@ export class GetAnalyticsMetricsUseCase { uniqueVisitors, }); - return Result.ok({ + const result = Result.ok>({ pageViews, uniqueVisitors, averageSessionDuration, bounceRate, }); + return result; } catch (error) { const err = error as Error; - this.logger.error('Failed to get analytics metrics', { error, input }); - return Result.err({ + this.logger.error('Failed to get analytics metrics', err, { input }); + const result = Result.err>({ code: 'REPOSITORY_ERROR', details: { message: err.message ?? 'Failed to get analytics metrics' }, }); + return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts index ad58572ab..91068b98c 100644 --- a/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, type Mock } from 'vitest'; import { GetDashboardDataUseCase } from './GetDashboardDataUseCase'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; describe('GetDashboardDataUseCase', () => { let logger: Logger; + let output: UseCaseOutputPort> & { present: Mock }; let useCase: GetDashboardDataUseCase; beforeEach(() => { @@ -14,19 +16,23 @@ describe('GetDashboardDataUseCase', () => { error: vi.fn(), } as unknown as Logger; - useCase = new GetDashboardDataUseCase(logger); + output = { + present: vi.fn(), + }; + + useCase = new GetDashboardDataUseCase(output, logger); }); - it('returns placeholder dashboard metrics and logs retrieval', async () => { - const result = await useCase.execute(); + it('presents placeholder dashboard metrics and logs retrieval', async () => { + await useCase.execute(); - expect(result).toEqual({ + expect(output.present).toHaveBeenCalledWith(Result.ok({ totalUsers: 0, activeUsers: 0, totalRaces: 0, totalLeagues: 0, - }); + })); - expect((logger.info as unknown as ReturnType)).toHaveBeenCalled(); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts index b3fe6d0c3..831ae61f7 100644 --- a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts @@ -1,4 +1,6 @@ -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetDashboardDataInput {} @@ -9,12 +11,14 @@ export interface GetDashboardDataOutput { totalLeagues: number; } -export class GetDashboardDataUseCase { +export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR'; + +export class GetDashboardDataUseCase implements UseCase { constructor( private readonly logger: Logger, ) {} - async execute(): Promise { + async execute(input: GetDashboardDataInput = {}): Promise>> { try { // Placeholder implementation - would need repositories from identity and racing domains const totalUsers = 0; @@ -29,15 +33,21 @@ export class GetDashboardDataUseCase { totalLeagues, }); - return { + const result = Result.ok>({ totalUsers, activeUsers, totalRaces, totalLeagues, - }; + }); + return result; } catch (error) { - this.logger.error('Failed to get dashboard data', { error }); - throw error; + const err = error as Error; + this.logger.error('Failed to get dashboard data', err); + const result = Result.err>({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to get dashboard data' }, + }); + return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts index f74f1599a..37b5b9422 100644 --- a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts +++ b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts @@ -5,12 +5,14 @@ * Returns metrics formatted for display to sponsors and admins. */ -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger, UseCaseOutputPort } from '@core/shared/application'; import type { IPageViewRepository } from '../repositories/IPageViewRepository'; import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository'; import type { EntityType } from '../../domain/types/PageView'; import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetEntityAnalyticsInput { entityType: EntityType; @@ -42,89 +44,68 @@ export interface EntityAnalyticsOutput { }; } +export type GetEntityAnalyticsErrorCode = 'REPOSITORY_ERROR'; + export class GetEntityAnalyticsQuery - implements AsyncUseCase { + implements AsyncUseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly engagementRepository: IEngagementRepository, private readonly snapshotRepository: IAnalyticsSnapshotRepository, + private readonly output: UseCaseOutputPort>>, private readonly logger: Logger ) {} - async execute(input: GetEntityAnalyticsInput): Promise { - this.logger.debug(`Executing GetEntityAnalyticsQuery with input: ${JSON.stringify(input)}`); - const period = input.period ?? 'weekly'; - const now = new Date(); - const since = input.since ?? this.getPeriodStartDate(now, period); - this.logger.debug(`Calculated period: ${period}, now: ${now.toISOString()}, since: ${since.toISOString()}`); - - // Get current metrics - let totalPageViews = 0; + async execute(input: GetEntityAnalyticsInput): Promise>> { try { + this.logger.debug(`Executing GetEntityAnalyticsQuery with input: ${JSON.stringify(input)}`); + const period = input.period ?? 'weekly'; + const now = new Date(); + const since = input.since ?? this.getPeriodStartDate(now, period); + this.logger.debug(`Calculated period: ${period}, now: ${now.toISOString()}, since: ${since.toISOString()}`); + + // Get current metrics + let totalPageViews = 0; totalPageViews = await this.pageViewRepository.countByEntityId( input.entityType, input.entityId, since ); this.logger.debug(`Total page views for entity ${input.entityId}: ${totalPageViews}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error counting total page views for entity ${input.entityId}: ${err.message}`); - throw error; - } - let uniqueVisitors = 0; - try { + let uniqueVisitors = 0; uniqueVisitors = await this.pageViewRepository.countUniqueVisitors( input.entityType, input.entityId, since ); this.logger.debug(`Unique visitors for entity ${input.entityId}: ${uniqueVisitors}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error counting unique visitors for entity ${input.entityId}: ${err.message}`); - throw error; - } - let sponsorClicks = 0; - try { + let sponsorClicks = 0; sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity( input.entityId, since ); this.logger.debug(`Sponsor clicks for entity ${input.entityId}: ${sponsorClicks}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error getting sponsor clicks for entity ${input.entityId}: ${err.message}`); - throw error; - } - // Calculate engagement score (weighted sum of actions) - let engagementScore = 0; - try { + // Calculate engagement score (weighted sum of actions) + let engagementScore = 0; engagementScore = await this.calculateEngagementScore(input.entityId, since); this.logger.debug(`Engagement score for entity ${input.entityId}: ${engagementScore}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error calculating engagement score for entity ${input.entityId}: ${err.message}`); - throw error; - } - - // Determine trust indicator - const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore); - this.logger.debug(`Trust indicator for entity ${input.entityId}: ${trustIndicator}`); - // Calculate exposure value (for sponsor ROI) - const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks); - this.logger.debug(`Exposure value for entity ${input.entityId}: ${exposureValue}`); + // Determine trust indicator + const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore); + this.logger.debug(`Trust indicator for entity ${input.entityId}: ${trustIndicator}`); - // Get previous period for trends - const previousPeriodStart = this.getPreviousPeriodStart(since, period); - this.logger.debug(`Previous period start: ${previousPeriodStart.toISOString()}`); + // Calculate exposure value (for sponsor ROI) + const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks); + this.logger.debug(`Exposure value for entity ${input.entityId}: ${exposureValue}`); - let previousPageViews = 0; - try { + // Get previous period for trends + const previousPeriodStart = this.getPreviousPeriodStart(since, period); + this.logger.debug(`Previous period start: ${previousPeriodStart.toISOString()}`); + + let previousPageViews = 0; const fullPreviousPageViews = await this.pageViewRepository.countByEntityId( input.entityType, input.entityId, @@ -132,14 +113,8 @@ export class GetEntityAnalyticsQuery ); previousPageViews = fullPreviousPageViews - totalPageViews; // This calculates change, not just previous period's total this.logger.debug(`Previous period full page views: ${fullPreviousPageViews}, change: ${previousPageViews}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error counting previous period page views for entity ${input.entityId}: ${err.message}`); - throw error; - } - let previousUniqueVisitors = 0; - try { + let previousUniqueVisitors = 0; const fullPreviousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors( input.entityType, input.entityId, @@ -148,36 +123,42 @@ export class GetEntityAnalyticsQuery previousUniqueVisitors = fullPreviousUniqueVisitors - uniqueVisitors; // This calculates change, not just previous period's total this.logger.debug(`Previous period full unique visitors: ${fullPreviousUniqueVisitors}, change: ${previousUniqueVisitors}`); + const resultData: EntityAnalyticsOutput = { + entityType: input.entityType, + entityId: input.entityId, + summary: { + totalPageViews, + uniqueVisitors, + sponsorClicks, + engagementScore, + trustIndicator, + exposureValue, + }, + trends: { + pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews), + uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors), + engagementChange: 0, // Would need historical engagement data + }, + period: { + start: since, + end: now, + label: this.formatPeriodLabel(since, now), + }, + }; + const result = Result.ok>(resultData); + this.output.present(result); + this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`); + return result; } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Error counting previous period unique visitors for entity ${input.entityId}: ${err.message}`); - throw error; + const err = error as Error; + this.logger.error(`Failed to get entity analytics for ${input.entityId}: ${err.message}`, err); + const result = Result.err>({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to get entity analytics' }, + }); + this.output.present(result); + return result; } - - const result: EntityAnalyticsOutput = { - entityType: input.entityType, - entityId: input.entityId, - summary: { - totalPageViews, - uniqueVisitors, - sponsorClicks, - engagementScore, - trustIndicator, - exposureValue, - }, - trends: { - pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews), - uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors), - engagementChange: 0, // Would need historical engagement data - }, - period: { - start: since, - end: now, - label: this.formatPeriodLabel(since, now), - }, - }; - this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`); - return result; } private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date { diff --git a/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts index d52cd808e..6bf7ee9ec 100644 --- a/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts +++ b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts @@ -2,14 +2,16 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { RecordEngagementUseCase, type RecordEngagementInput } from './RecordEngagementUseCase'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import { EngagementEvent } from '../../domain/entities/EngagementEvent'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent'; +import { Result } from '@core/shared/application/Result'; describe('RecordEngagementUseCase', () => { let engagementRepository: { save: Mock; }; let logger: Logger; + let output: UseCaseOutputPort> & { present: Mock }; let useCase: RecordEngagementUseCase; beforeEach(() => { @@ -24,13 +26,18 @@ describe('RecordEngagementUseCase', () => { error: vi.fn(), } as unknown as Logger; + output = { + present: vi.fn(), + }; + useCase = new RecordEngagementUseCase( engagementRepository as unknown as IEngagementRepository, + output, logger, ); }); - it('creates and saves an EngagementEvent and returns its id and weight', async () => { + it('creates and saves an EngagementEvent and presents its id and weight', async () => { const input: RecordEngagementInput = { action: 'view' as EngagementAction, entityType: 'league' as EngagementEntityType, @@ -43,7 +50,7 @@ describe('RecordEngagementUseCase', () => { engagementRepository.save.mockResolvedValue(undefined); - const result = await useCase.execute(input); + await useCase.execute(input); expect(engagementRepository.save).toHaveBeenCalledTimes(1); const saved = (engagementRepository.save as unknown as Mock).mock.calls[0][0] as EngagementEvent; @@ -52,12 +59,11 @@ describe('RecordEngagementUseCase', () => { expect(saved.id).toBeDefined(); expect(saved.entityId).toBe(input.entityId); expect(saved.entityType).toBe(input.entityType); - expect(result.eventId).toBe(saved.id); - expect(typeof result.engagementWeight).toBe('number'); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); - it('logs and rethrows when repository save fails', async () => { + it('logs and presents error when repository save fails', async () => { const input: RecordEngagementInput = { action: 'view' as EngagementAction, entityType: 'league' as EngagementEntityType, @@ -69,7 +75,8 @@ describe('RecordEngagementUseCase', () => { const error = new Error('DB error'); engagementRepository.save.mockRejectedValue(error); - await expect(useCase.execute(input)).rejects.toThrow('DB error'); + await useCase.execute(input); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/RecordEngagementUseCase.ts b/core/analytics/application/use-cases/RecordEngagementUseCase.ts index 9d6d9e212..b7b62c9fe 100644 --- a/core/analytics/application/use-cases/RecordEngagementUseCase.ts +++ b/core/analytics/application/use-cases/RecordEngagementUseCase.ts @@ -1,7 +1,9 @@ -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; import { EngagementEvent } from '../../domain/entities/EngagementEvent'; import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface RecordEngagementInput { action: EngagementAction; @@ -18,13 +20,15 @@ export interface RecordEngagementOutput { engagementWeight: number; } -export class RecordEngagementUseCase { +export type RecordEngagementErrorCode = 'REPOSITORY_ERROR'; + +export class RecordEngagementUseCase implements UseCase { constructor( private readonly engagementRepository: IEngagementRepository, private readonly logger: Logger, ) {} - async execute(input: RecordEngagementInput): Promise { + async execute(input: RecordEngagementInput): Promise>> { try { const engagementEvent = EngagementEvent.create({ id: crypto.randomUUID(), @@ -46,13 +50,19 @@ export class RecordEngagementUseCase { entityType: input.entityType, }); - return { + const result = Result.ok>({ eventId: engagementEvent.id, engagementWeight: engagementEvent.getEngagementWeight(), - }; + }); + return result; } catch (error) { - this.logger.error('Failed to record engagement event', { error: error as Error, input }); - throw error; + const err = error as Error; + this.logger.error('Failed to record engagement event', err, { input }); + const result = Result.err>({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to record engagement event' }, + }); + return result; } } } \ No newline at end of file diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts index 82e1be216..f1d83e0fb 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts @@ -2,14 +2,16 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { RecordPageViewUseCase, type RecordPageViewInput } from './RecordPageViewUseCase'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import { PageView } from '../../domain/entities/PageView'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { EntityType, VisitorType } from '../../domain/types/PageView'; +import { Result } from '@core/shared/application/Result'; describe('RecordPageViewUseCase', () => { let pageViewRepository: { save: Mock; }; let logger: Logger; + let output: UseCaseOutputPort> & { present: Mock }; let useCase: RecordPageViewUseCase; beforeEach(() => { @@ -24,13 +26,18 @@ describe('RecordPageViewUseCase', () => { error: vi.fn(), } as unknown as Logger; + output = { + present: vi.fn(), + }; + useCase = new RecordPageViewUseCase( pageViewRepository as unknown as IPageViewRepository, + output, logger, ); }); - it('creates and saves a PageView and returns its id', async () => { + it('creates and saves a PageView and presents its id', async () => { const input: RecordPageViewInput = { entityType: 'league' as EntityType, entityId: 'league-1', @@ -44,7 +51,7 @@ describe('RecordPageViewUseCase', () => { pageViewRepository.save.mockResolvedValue(undefined); - const result = await useCase.execute(input); + await useCase.execute(input); expect(pageViewRepository.save).toHaveBeenCalledTimes(1); const saved = (pageViewRepository.save as unknown as Mock).mock.calls[0][0] as PageView; @@ -53,11 +60,11 @@ describe('RecordPageViewUseCase', () => { expect(saved.id).toBeDefined(); expect(saved.entityId).toBe(input.entityId); expect(saved.entityType).toBe(input.entityType); - expect(result.pageViewId).toBe(saved.id); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); }); - it('logs and rethrows when repository save fails', async () => { + it('logs and presents error when repository save fails', async () => { const input: RecordPageViewInput = { entityType: 'league' as EntityType, entityId: 'league-1', @@ -68,7 +75,8 @@ describe('RecordPageViewUseCase', () => { const error = new Error('DB error'); pageViewRepository.save.mockRejectedValue(error); - await expect(useCase.execute(input)).rejects.toThrow('DB error'); + await useCase.execute(input); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); }); }); diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.ts index 26a70f81a..a6c926a52 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.ts @@ -1,7 +1,9 @@ -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import { PageView } from '../../domain/entities/PageView'; import type { EntityType, VisitorType } from '../../domain/types/PageView'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface RecordPageViewInput { entityType: EntityType; @@ -18,13 +20,15 @@ export interface RecordPageViewOutput { pageViewId: string; } -export class RecordPageViewUseCase { +export type RecordPageViewErrorCode = 'REPOSITORY_ERROR'; + +export class RecordPageViewUseCase implements UseCase { constructor( private readonly pageViewRepository: IPageViewRepository, private readonly logger: Logger, ) {} - async execute(input: RecordPageViewInput): Promise { + async execute(input: RecordPageViewInput): Promise>> { try { const pageView = PageView.create({ id: crypto.randomUUID(), @@ -46,12 +50,18 @@ export class RecordPageViewUseCase { entityType: input.entityType, }); - return { + const result = Result.ok>({ pageViewId: pageView.id, - }; + }); + return result; } catch (error) { - this.logger.error('Failed to record page view', { error, input }); - throw error; + const err = error as Error; + this.logger.error('Failed to record page view', err, { input }); + const result = Result.err>({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to record page view' }, + }); + return result; } } } \ 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 815274647..e5ebcf0e1 100644 --- a/core/identity/application/use-cases/GetUserUseCase.ts +++ b/core/identity/application/use-cases/GetUserUseCase.ts @@ -2,7 +2,7 @@ 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'; +import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application'; export type GetUserInput = { userId: string; @@ -19,28 +19,29 @@ export type GetUserApplicationError = ApplicationErrorCode< { message: string } >; -export class GetUserUseCase { +export class GetUserUseCase implements UseCase { constructor( private readonly userRepo: IUserRepository, private readonly logger: Logger, - private readonly output: UseCaseOutputPort, + private readonly output: UseCaseOutputPort>, ) {} - async execute(input: GetUserInput): Promise> { + async execute(input: GetUserInput): Promise> { try { const stored = await this.userRepo.findById(input.userId); if (!stored) { - return Result.err({ + const result = Result.err({ code: 'USER_NOT_FOUND', details: { message: 'User not found' }, - } as GetUserApplicationError); + }); + this.output.present(result); + return result; } const user = User.fromStored(stored); - const result: GetUserResult = { user }; + const result = Result.ok({ user }); this.output.present(result); - - return Result.ok(undefined); + return result; } catch (error) { const message = error instanceof Error && error.message ? error.message : 'Failed to get user'; @@ -49,10 +50,12 @@ export class GetUserUseCase { input, }); - return Result.err({ + const result = Result.err({ code: 'REPOSITORY_ERROR', details: { message }, - } as GetUserApplicationError); + }); + this.output.present(result); + return result; } } } \ No newline at end of file diff --git a/core/identity/application/use-cases/LoginUseCase.ts b/core/identity/application/use-cases/LoginUseCase.ts index 8a1e08dac..3a18285bd 100644 --- a/core/identity/application/use-cases/LoginUseCase.ts +++ b/core/identity/application/use-cases/LoginUseCase.ts @@ -4,7 +4,7 @@ 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'; +import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application'; export type LoginInput = { email: string; @@ -24,40 +24,43 @@ export type LoginApplicationError = ApplicationErrorCode { constructor( private readonly authRepo: IAuthRepository, private readonly passwordService: IPasswordHashingService, private readonly logger: Logger, - private readonly output: UseCaseOutputPort, + private readonly output: UseCaseOutputPort>, ) {} - async execute(input: LoginInput): Promise> { + 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({ + const result = Result.err({ code: 'INVALID_CREDENTIALS', details: { message: 'Invalid credentials' }, - } as LoginApplicationError); + }); + this.output.present(result); + return result; } const passwordHash = user.getPasswordHash()!; const isValid = await this.passwordService.verify(input.password, passwordHash.value); if (!isValid) { - return Result.err({ + const result = Result.err({ code: 'INVALID_CREDENTIALS', details: { message: 'Invalid credentials' }, - } as LoginApplicationError); + }); + this.output.present(result); + return result; } - const result: LoginResult = { user }; + const result = Result.ok({ user }); this.output.present(result); - - return Result.ok(undefined); + return result; } catch (error) { const message = error instanceof Error && error.message @@ -68,10 +71,12 @@ export class LoginUseCase { input, }); - return Result.err({ + const result = Result.err({ code: 'REPOSITORY_ERROR', details: { message }, - } as LoginApplicationError); + }); + this.output.present(result); + return result; } } } \ 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 dafba698b..1b2aa8329 100644 --- a/core/identity/application/use-cases/SignupUseCase.ts +++ b/core/identity/application/use-cases/SignupUseCase.ts @@ -5,7 +5,7 @@ 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'; +import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application'; export type SignupInput = { email: string; @@ -26,24 +26,26 @@ export type SignupApplicationError = ApplicationErrorCode { constructor( private readonly authRepo: IAuthRepository, private readonly passwordService: IPasswordHashingService, private readonly logger: Logger, - private readonly output: UseCaseOutputPort, + private readonly output: UseCaseOutputPort>, ) {} - async execute(input: SignupInput): Promise> { + async execute(input: SignupInput): Promise> { try { const emailVO = EmailAddress.create(input.email); const existingUser = await this.authRepo.findByEmail(emailVO); if (existingUser) { - return Result.err({ + const result = Result.err({ code: 'USER_ALREADY_EXISTS', details: { message: 'User already exists' }, - } as SignupApplicationError); + }); + this.output.present(result); + return result; } const hashedPassword = await this.passwordService.hash(input.password); @@ -60,10 +62,9 @@ export class SignupUseCase { await this.authRepo.save(user); - const result: SignupResult = { user }; + const result = Result.ok({ user }); this.output.present(result); - - return Result.ok(undefined); + return result; } catch (error) { const message = error instanceof Error && error.message @@ -74,10 +75,12 @@ export class SignupUseCase { input, }); - return Result.err({ + const result = Result.err({ code: 'REPOSITORY_ERROR', details: { message }, - } as SignupApplicationError); + }); + this.output.present(result); + return result; } } } \ No newline at end of file diff --git a/core/media/application/presenters/IDeleteMediaPresenter.ts b/core/media/application/presenters/IDeleteMediaPresenter.ts deleted file mode 100644 index 7066decad..000000000 --- a/core/media/application/presenters/IDeleteMediaPresenter.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DeleteMediaResult { - success: boolean; - errorMessage?: string; -} - -export interface IDeleteMediaPresenter { - present(result: DeleteMediaResult): void; -} \ No newline at end of file diff --git a/core/media/application/presenters/IGetAvatarPresenter.ts b/core/media/application/presenters/IGetAvatarPresenter.ts deleted file mode 100644 index c5ac24a5f..000000000 --- a/core/media/application/presenters/IGetAvatarPresenter.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface GetAvatarResult { - success: boolean; - avatar?: { - id: string; - driverId: string; - mediaUrl: string; - selectedAt: Date; - }; - errorMessage?: string; -} - -export interface IGetAvatarPresenter { - present(result: GetAvatarResult): void; -} \ No newline at end of file diff --git a/core/media/application/presenters/IGetMediaPresenter.ts b/core/media/application/presenters/IGetMediaPresenter.ts deleted file mode 100644 index 83630d6d8..000000000 --- a/core/media/application/presenters/IGetMediaPresenter.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface GetMediaResult { - success: boolean; - media?: { - id: string; - filename: string; - originalName: string; - mimeType: string; - size: number; - url: string; - type: string; - uploadedBy: string; - uploadedAt: Date; - metadata?: Record; - }; - errorMessage?: string; -} - -export interface IGetMediaPresenter { - present(result: GetMediaResult): void; -} \ No newline at end of file diff --git a/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts b/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts deleted file mode 100644 index a2d356b81..000000000 --- a/core/media/application/presenters/IRequestAvatarGenerationPresenter.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface RequestAvatarGenerationResultDTO { - requestId: string; - status: 'validating' | 'generating' | 'completed' | 'failed'; - avatarUrls?: string[]; - errorMessage?: string; -} - -export interface IRequestAvatarGenerationPresenter { - reset(): void; - present(dto: RequestAvatarGenerationResultDTO): void; - get viewModel(): RequestAvatarGenerationResultDTO; - getViewModel(): RequestAvatarGenerationResultDTO; -} \ No newline at end of file diff --git a/core/media/application/presenters/ISelectAvatarPresenter.ts b/core/media/application/presenters/ISelectAvatarPresenter.ts deleted file mode 100644 index 5f6ccf180..000000000 --- a/core/media/application/presenters/ISelectAvatarPresenter.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface SelectAvatarResult { - success: boolean; - selectedAvatarUrl?: string; - errorMessage?: string; -} - -export interface ISelectAvatarPresenter { - present(result: SelectAvatarResult): void; -} \ No newline at end of file diff --git a/core/media/application/presenters/IUpdateAvatarPresenter.ts b/core/media/application/presenters/IUpdateAvatarPresenter.ts deleted file mode 100644 index 2192ec0f3..000000000 --- a/core/media/application/presenters/IUpdateAvatarPresenter.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface UpdateAvatarResult { - success: boolean; - errorMessage?: string; -} - -export interface IUpdateAvatarPresenter { - present(result: UpdateAvatarResult): void; -} \ No newline at end of file diff --git a/core/media/application/presenters/IUploadMediaPresenter.ts b/core/media/application/presenters/IUploadMediaPresenter.ts deleted file mode 100644 index fdcb099f4..000000000 --- a/core/media/application/presenters/IUploadMediaPresenter.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface UploadMediaResult { - success: boolean; - mediaId?: string; - url?: string; - errorMessage?: string; -} - -export interface IUploadMediaPresenter { - present(result: UploadMediaResult): void; -} \ No newline at end of file diff --git a/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts b/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts deleted file mode 100644 index 193a9ed43..000000000 --- a/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { RequestAvatarGenerationUseCase } from './RequestAvatarGenerationUseCase'; -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 { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; -import type { RequestAvatarGenerationInput } from './RequestAvatarGenerationUseCase'; - -describe('RequestAvatarGenerationUseCase', () => { - let avatarRepo: { - save: Mock; - findById: Mock; - }; - let faceValidation: { - validateFacePhoto: Mock; - }; - let avatarGeneration: { - generateAvatars: Mock; - }; - let logger: Logger; - let useCase: RequestAvatarGenerationUseCase; - - beforeEach(() => { - avatarRepo = { - save: vi.fn(), - findById: vi.fn(), - } as unknown as IAvatarGenerationRepository as any; - - faceValidation = { - validateFacePhoto: vi.fn(), - } as unknown as FaceValidationPort as any; - - avatarGeneration = { - generateAvatars: vi.fn(), - } as unknown as AvatarGenerationPort as any; - - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as Logger; - - useCase = new RequestAvatarGenerationUseCase( - avatarRepo as unknown as IAvatarGenerationRepository, - faceValidation as unknown as FaceValidationPort, - avatarGeneration as unknown as AvatarGenerationPort, - logger, - ); - }); - - const createPresenter = () => { - const presenter: { present: Mock; result?: any } = { - present: vi.fn((result) => { - presenter.result = result; - }), - result: undefined, - }; - return presenter; - }; - - it('fails when face validation fails', async () => { - const input: RequestAvatarGenerationInput = { - userId: 'user-1', - facePhotoData: 'photo-data', - suitColor: 'red', - style: 'realistic', - }; - - const presenter = createPresenter(); - - faceValidation.validateFacePhoto.mockResolvedValue({ - isValid: false, - hasFace: false, - faceCount: 0, - errorMessage: 'No face detected', - }); - - await useCase.execute(input, presenter as any); - - expect((presenter.present as Mock)).toHaveBeenCalledWith({ - requestId: expect.any(String), - status: 'failed', - errorMessage: 'No face detected', - }); - }); - - it('completes request and returns avatar URLs on success', async () => { - const input: RequestAvatarGenerationInput = { - userId: 'user-1', - facePhotoData: 'photo-data', - suitColor: 'red', - style: 'realistic', - }; - - const presenter = createPresenter(); - - faceValidation.validateFacePhoto.mockResolvedValue({ - isValid: true, - hasFace: true, - faceCount: 1, - }); - - avatarGeneration.generateAvatars.mockResolvedValue({ - success: true, - avatars: [ - { url: 'https://example.com/avatar1.png' }, - { url: 'https://example.com/avatar2.png' }, - ], - }); - - await useCase.execute(input, presenter as any); - - expect(faceValidation.validateFacePhoto).toHaveBeenCalled(); - expect(avatarGeneration.generateAvatars).toHaveBeenCalled(); - expect((presenter.present as Mock)).toHaveBeenCalledWith({ - requestId: expect.any(String), - status: 'completed', - avatarUrls: [ - 'https://example.com/avatar1.png', - 'https://example.com/avatar2.png', - ], - }); - }); -}); diff --git a/core/media/application/use-cases/SelectAvatarUseCase.test.ts b/core/media/application/use-cases/SelectAvatarUseCase.test.ts deleted file mode 100644 index 59dc1e72b..000000000 --- a/core/media/application/use-cases/SelectAvatarUseCase.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { SelectAvatarUseCase } from './SelectAvatarUseCase'; -import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; -import type { ISelectAvatarPresenter } from '../presenters/ISelectAvatarPresenter'; -import type { Logger } from '@core/shared/application'; -import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; - -interface TestPresenter extends ISelectAvatarPresenter { - result?: any; -} - -describe('SelectAvatarUseCase', () => { - let avatarRepo: { - findById: Mock; - save: Mock; - }; - let logger: Logger; - let presenter: TestPresenter; - let useCase: SelectAvatarUseCase; - - beforeEach(() => { - avatarRepo = { - findById: vi.fn(), - save: vi.fn(), - } as unknown as IAvatarGenerationRepository as any; - - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as Logger; - - presenter = { - present: vi.fn((result) => { - presenter.result = result; - }), - } as unknown as TestPresenter; - - useCase = new SelectAvatarUseCase( - avatarRepo as unknown as IAvatarGenerationRepository, - logger, - ); - }); - - it('returns error when request is not found', async () => { - avatarRepo.findById.mockResolvedValue(null); - - await useCase.execute({ requestId: 'req-1', selectedIndex: 0 }, presenter); - - expect(avatarRepo.findById).toHaveBeenCalledWith('req-1'); - expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ - success: false, - errorMessage: 'Avatar generation request not found', - }); - }); - - it('returns error when request is not completed', async () => { - const request = AvatarGenerationRequest.create({ - id: 'req-1', - userId: 'user-1', - facePhotoUrl: 'photo', - suitColor: 'red', - style: 'realistic', - }); - - avatarRepo.findById.mockResolvedValue(request); - - await useCase.execute({ requestId: 'req-1', selectedIndex: 0 }, presenter); - - expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ - success: false, - errorMessage: 'Avatar generation is not completed yet', - }); - }); -}); diff --git a/core/media/MediaUrl.test.ts b/core/media/domain/value-objects/MediaUrl.test.ts similarity index 100% rename from core/media/MediaUrl.test.ts rename to core/media/domain/value-objects/MediaUrl.test.ts diff --git a/core/media/index.ts b/core/media/index.ts deleted file mode 100644 index db53964e1..000000000 --- a/core/media/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Ports -export * from './application/ports/ImageServicePort'; -export * from './application/ports/FaceValidationPort'; -export * from './application/ports/AvatarGenerationPort'; - -// Ports -export * from './application/ports/ImageServicePort'; -export * from './application/ports/FaceValidationPort'; -export * from './application/ports/AvatarGenerationPort'; -export * from './application/ports/MediaStoragePort'; - -// Presenters -export * from './application/presenters/IRequestAvatarGenerationPresenter'; -export * from './application/presenters/ISelectAvatarPresenter'; -export * from './application/presenters/IUploadMediaPresenter'; -export * from './application/presenters/IGetMediaPresenter'; -export * from './application/presenters/IDeleteMediaPresenter'; -export * from './application/presenters/IGetAvatarPresenter'; -export * from './application/presenters/IUpdateAvatarPresenter'; - -// Use Cases -export * from './application/use-cases/RequestAvatarGenerationUseCase'; -export * from './application/use-cases/SelectAvatarUseCase'; -export * from './application/use-cases/UploadMediaUseCase'; -export * from './application/use-cases/GetMediaUseCase'; -export * from './application/use-cases/DeleteMediaUseCase'; -export * from './application/use-cases/GetAvatarUseCase'; -export * from './application/use-cases/UpdateAvatarUseCase'; - -// Domain -export * from './domain/entities/AvatarGenerationRequest'; -export * from './domain/entities/Media'; -export * from './domain/entities/Avatar'; -export * from './domain/repositories/IAvatarGenerationRepository'; -export * from './domain/repositories/IMediaRepository'; -export * from './domain/repositories/IAvatarRepository'; -export type { AvatarGenerationRequestProps } from './domain/types/AvatarGenerationRequest'; -export type { MediaType } from './domain/entities/Media'; \ No newline at end of file diff --git a/core/payments/application/index.ts b/core/payments/application/index.ts index c105aa325..d5ddacaaf 100644 --- a/core/payments/application/index.ts +++ b/core/payments/application/index.ts @@ -1,2 +1 @@ -export * from './presenters'; export * from './use-cases'; \ No newline at end of file diff --git a/core/payments/application/presenters/IAwardPrizePresenter.ts b/core/payments/application/presenters/IAwardPrizePresenter.ts deleted file mode 100644 index 52dd36b73..000000000 --- a/core/payments/application/presenters/IAwardPrizePresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: IAwardPrizePresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PrizeDto } from './IGetPrizesPresenter'; - -export interface AwardPrizeResultDTO { - prize: PrizeDto; -} - -export interface AwardPrizeViewModel { - prize: PrizeDto; -} - -export interface IAwardPrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/ICreatePaymentPresenter.ts b/core/payments/application/presenters/ICreatePaymentPresenter.ts deleted file mode 100644 index 478805bb1..000000000 --- a/core/payments/application/presenters/ICreatePaymentPresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: ICreatePaymentPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PaymentDto } from './IGetPaymentsPresenter'; - -export interface CreatePaymentResultDTO { - payment: PaymentDto; -} - -export interface CreatePaymentViewModel { - payment: PaymentDto; -} - -export interface ICreatePaymentPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/ICreatePrizePresenter.ts b/core/payments/application/presenters/ICreatePrizePresenter.ts deleted file mode 100644 index d4c2a6cc5..000000000 --- a/core/payments/application/presenters/ICreatePrizePresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: ICreatePrizePresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PrizeDto } from './IGetPrizesPresenter'; - -export interface CreatePrizeResultDTO { - prize: PrizeDto; -} - -export interface CreatePrizeViewModel { - prize: PrizeDto; -} - -export interface ICreatePrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IDeletePrizePresenter.ts b/core/payments/application/presenters/IDeletePrizePresenter.ts deleted file mode 100644 index 93ba2cdaa..000000000 --- a/core/payments/application/presenters/IDeletePrizePresenter.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Presenter Interface: IDeletePrizePresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; - -export interface DeletePrizeResultDTO { - success: boolean; -} - -export interface DeletePrizeViewModel { - success: boolean; -} - -export interface IDeletePrizePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetMembershipFeesPresenter.ts b/core/payments/application/presenters/IGetMembershipFeesPresenter.ts deleted file mode 100644 index 4a8451138..000000000 --- a/core/payments/application/presenters/IGetMembershipFeesPresenter.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Presenter Interface: IGetMembershipFeesPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { MembershipFeeType } from '../../domain/entities/MembershipFee'; -import type { MemberPaymentStatus } from '../../domain/entities/MemberPayment'; - -export interface MembershipFeeDto { - id: string; - leagueId: string; - seasonId?: string; - type: MembershipFeeType; - amount: number; - enabled: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface MemberPaymentDto { - id: string; - feeId: string; - driverId: string; - amount: number; - platformFee: number; - netAmount: number; - status: MemberPaymentStatus; - dueDate: Date; - paidAt?: Date; -} - -export interface GetMembershipFeesResultDTO { - fee: MembershipFeeDto | null; - payments: MemberPaymentDto[]; -} - -export interface GetMembershipFeesViewModel { - fee: MembershipFeeDto | null; - payments: MemberPaymentDto[]; -} - -export interface IGetMembershipFeesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetPaymentsPresenter.ts b/core/payments/application/presenters/IGetPaymentsPresenter.ts deleted file mode 100644 index 84065791a..000000000 --- a/core/payments/application/presenters/IGetPaymentsPresenter.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Presenter Interface: IGetPaymentsPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment'; - -export interface PaymentDto { - id: string; - type: PaymentType; - amount: number; - platformFee: number; - netAmount: number; - payerId: string; - payerType: PayerType; - leagueId: string; - seasonId?: string; - status: PaymentStatus; - createdAt: Date; - completedAt?: Date; -} - -export interface GetPaymentsResultDTO { - payments: PaymentDto[]; -} - -export interface GetPaymentsViewModel { - payments: PaymentDto[]; -} - -export interface IGetPaymentsPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetPrizesPresenter.ts b/core/payments/application/presenters/IGetPrizesPresenter.ts deleted file mode 100644 index 47c018e17..000000000 --- a/core/payments/application/presenters/IGetPrizesPresenter.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Presenter Interface: IGetPrizesPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PrizeType } from '../../domain/entities/Prize'; - -export interface PrizeDto { - id: string; - leagueId: string; - seasonId: string; - position: number; - name: string; - amount: number; - type: PrizeType; - description?: string; - awarded: boolean; - awardedTo?: string; - awardedAt?: Date; - createdAt: Date; -} - -export interface GetPrizesResultDTO { - prizes: PrizeDto[]; -} - -export interface GetPrizesViewModel { - prizes: PrizeDto[]; -} - -export interface IGetPrizesPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IGetWalletPresenter.ts b/core/payments/application/presenters/IGetWalletPresenter.ts deleted file mode 100644 index 041f31bcc..000000000 --- a/core/payments/application/presenters/IGetWalletPresenter.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Presenter Interface: IGetWalletPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { TransactionType, ReferenceType } from '../../domain/entities/Wallet'; - -export interface WalletDto { - id: string; - leagueId: string; - balance: number; - totalRevenue: number; - totalPlatformFees: number; - totalWithdrawn: number; - currency: string; - createdAt: Date; -} - -export interface TransactionDto { - id: string; - walletId: string; - type: TransactionType; - amount: number; - description: string; - referenceId?: string; - referenceType?: ReferenceType; - createdAt: Date; -} - -export interface GetWalletResultDTO { - wallet: WalletDto; - transactions: TransactionDto[]; -} - -export interface GetWalletViewModel { - wallet: WalletDto; - transactions: TransactionDto[]; -} - -export interface IGetWalletPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts b/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts deleted file mode 100644 index 9f0c2ad92..000000000 --- a/core/payments/application/presenters/IProcessWalletTransactionPresenter.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Presenter Interface: IProcessWalletTransactionPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { WalletDto, TransactionDto } from './IGetWalletPresenter'; - -export interface ProcessWalletTransactionResultDTO { - wallet: WalletDto; - transaction: TransactionDto; -} - -export interface ProcessWalletTransactionViewModel { - wallet: WalletDto; - transaction: TransactionDto; -} - -export interface IProcessWalletTransactionPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts b/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts deleted file mode 100644 index 8c66f398b..000000000 --- a/core/payments/application/presenters/IUpdateMemberPaymentPresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: IUpdateMemberPaymentPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { MemberPaymentDto } from './IGetMembershipFeesPresenter'; - -export interface UpdateMemberPaymentResultDTO { - payment: MemberPaymentDto; -} - -export interface UpdateMemberPaymentViewModel { - payment: MemberPaymentDto; -} - -export interface IUpdateMemberPaymentPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts b/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts deleted file mode 100644 index b0e0f3c3b..000000000 --- a/core/payments/application/presenters/IUpdatePaymentStatusPresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: IUpdatePaymentStatusPresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { PaymentDto } from './IGetPaymentsPresenter'; - -export interface UpdatePaymentStatusResultDTO { - payment: PaymentDto; -} - -export interface UpdatePaymentStatusViewModel { - payment: PaymentDto; -} - -export interface IUpdatePaymentStatusPresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts b/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts deleted file mode 100644 index 77a328c07..000000000 --- a/core/payments/application/presenters/IUpsertMembershipFeePresenter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Presenter Interface: IUpsertMembershipFeePresenter - */ - -import type { Presenter } from '@core/shared/presentation/Presenter'; -import type { MembershipFeeDto } from './IGetMembershipFeesPresenter'; - -export interface UpsertMembershipFeeResultDTO { - fee: MembershipFeeDto; -} - -export interface UpsertMembershipFeeViewModel { - fee: MembershipFeeDto; -} - -export interface IUpsertMembershipFeePresenter extends Presenter {} \ No newline at end of file diff --git a/core/payments/application/presenters/index.ts b/core/payments/application/presenters/index.ts deleted file mode 100644 index 005849452..000000000 --- a/core/payments/application/presenters/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './IGetPaymentsPresenter'; -export * from './ICreatePaymentPresenter'; -export * from './IUpdatePaymentStatusPresenter'; -export * from './IGetMembershipFeesPresenter'; -export * from './IUpsertMembershipFeePresenter'; -export * from './IUpdateMemberPaymentPresenter'; -export * from './IGetPrizesPresenter'; -export * from './ICreatePrizePresenter'; -export * from './IAwardPrizePresenter'; -export * from './IDeletePrizePresenter'; -export * from './IGetWalletPresenter'; -export * from './IProcessWalletTransactionPresenter'; \ No newline at end of file diff --git a/core/payments/application/use-cases/AwardPrizeUseCase.ts b/core/payments/application/use-cases/AwardPrizeUseCase.ts index 9b1131d5d..f841a1a79 100644 --- a/core/payments/application/use-cases/AwardPrizeUseCase.ts +++ b/core/payments/application/use-cases/AwardPrizeUseCase.ts @@ -5,38 +5,41 @@ */ import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; -import type { - IAwardPrizePresenter, - AwardPrizeResultDTO, - AwardPrizeViewModel, -} from '../presenters/IAwardPrizePresenter'; +import type { Prize } from '../../domain/entities/Prize'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface AwardPrizeInput { prizeId: string; driverId: string; } +export interface AwardPrizeResult { + prize: Prize; +} + +export type AwardPrizeErrorCode = 'PRIZE_NOT_FOUND' | 'PRIZE_ALREADY_AWARDED'; + export class AwardPrizeUseCase - implements UseCase + implements UseCase { - constructor(private readonly prizeRepository: IPrizeRepository) {} - - async execute( - input: AwardPrizeInput, - presenter: IAwardPrizePresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly prizeRepository: IPrizeRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: AwardPrizeInput): Promise>> { const { prizeId, driverId } = input; const prize = await this.prizeRepository.findById(prizeId); if (!prize) { - throw new Error('Prize not found'); + return Result.err({ code: 'PRIZE_NOT_FOUND' as const }); } if (prize.awarded) { - throw new Error('Prize has already been awarded'); + return Result.err({ code: 'PRIZE_ALREADY_AWARDED' as const }); } prize.awarded = true; @@ -45,23 +48,8 @@ export class AwardPrizeUseCase const updatedPrize = await this.prizeRepository.update(prize); - const dto: AwardPrizeResultDTO = { - prize: { - id: updatedPrize.id, - leagueId: updatedPrize.leagueId, - seasonId: updatedPrize.seasonId, - position: updatedPrize.position, - name: updatedPrize.name, - amount: updatedPrize.amount, - type: updatedPrize.type, - description: updatedPrize.description, - awarded: updatedPrize.awarded, - awardedTo: updatedPrize.awardedTo, - awardedAt: updatedPrize.awardedAt, - createdAt: updatedPrize.createdAt, - }, - }; + this.output.present({ prize: updatedPrize }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePaymentUseCase.test.ts b/core/payments/application/use-cases/CreatePaymentUseCase.test.ts new file mode 100644 index 000000000..6fdf6ac3e --- /dev/null +++ b/core/payments/application/use-cases/CreatePaymentUseCase.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { CreatePaymentUseCase, type CreatePaymentInput } from './CreatePaymentUseCase'; +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import { PaymentType, PayerType } from '../../domain/entities/Payment'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('CreatePaymentUseCase', () => { + let paymentRepository: { + create: Mock; + }; + let output: { + present: Mock; + }; + let useCase: CreatePaymentUseCase; + + beforeEach(() => { + paymentRepository = { + create: vi.fn(), + } as unknown as IPaymentRepository as any; + + output = { + present: vi.fn(), + }; + + useCase = new CreatePaymentUseCase( + paymentRepository as unknown as IPaymentRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('creates a payment and presents the result', async () => { + const input: CreatePaymentInput = { + type: PaymentType.SPONSORSHIP, + amount: 100, + payerId: 'payer-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const createdPayment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'payer-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + seasonId: 'season-1', + status: 'pending', + createdAt: new Date(), + completedAt: undefined, + }; + + paymentRepository.create.mockResolvedValue(createdPayment); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(paymentRepository.create).toHaveBeenCalledWith({ + id: expect.stringContaining('payment-'), + type: 'sponsorship', + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'payer-1', + payerType: 'sponsor', + leagueId: 'league-1', + seasonId: 'season-1', + status: 'pending', + createdAt: expect.any(Date), + }); + expect(output.present).toHaveBeenCalledWith({ payment: createdPayment }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePaymentUseCase.ts b/core/payments/application/use-cases/CreatePaymentUseCase.ts index c81469473..b18ce7950 100644 --- a/core/payments/application/use-cases/CreatePaymentUseCase.ts +++ b/core/payments/application/use-cases/CreatePaymentUseCase.ts @@ -5,14 +5,12 @@ */ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; -import type { Payment, PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment'; -import type { - ICreatePaymentPresenter, - CreatePaymentResultDTO, - CreatePaymentViewModel, - PaymentDto, -} from '../presenters/ICreatePaymentPresenter'; +import type { Payment, PaymentType, PayerType } from '../../domain/entities/Payment'; +import { PaymentStatus } from '../../domain/entities/Payment'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface CreatePaymentInput { type: PaymentType; @@ -23,17 +21,21 @@ export interface CreatePaymentInput { seasonId?: string; } +export interface CreatePaymentResult { + payment: Payment; +} + +export type CreatePaymentErrorCode = never; + export class CreatePaymentUseCase - implements UseCase + implements UseCase { - constructor(private readonly paymentRepository: IPaymentRepository) {} - - async execute( - input: CreatePaymentInput, - presenter: ICreatePaymentPresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly paymentRepository: IPaymentRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: CreatePaymentInput): Promise>> { const { type, amount, payerId, payerType, leagueId, seasonId } = input; // Calculate platform fee (assume 5% for now) @@ -50,32 +52,15 @@ export class CreatePaymentUseCase payerId, payerType, leagueId, - seasonId, status: PaymentStatus.PENDING, createdAt: new Date(), + ...(seasonId !== undefined ? { seasonId } : {}), }; const createdPayment = await this.paymentRepository.create(payment); - const dto: PaymentDto = { - id: createdPayment.id, - type: createdPayment.type, - amount: createdPayment.amount, - platformFee: createdPayment.platformFee, - netAmount: createdPayment.netAmount, - payerId: createdPayment.payerId, - payerType: createdPayment.payerType, - leagueId: createdPayment.leagueId, - seasonId: createdPayment.seasonId, - status: createdPayment.status, - createdAt: createdPayment.createdAt, - completedAt: createdPayment.completedAt, - }; + this.output.present({ payment: createdPayment }); - const result: CreatePaymentResultDTO = { - payment: dto, - }; - - presenter.present(result); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePrizeUseCase.ts b/core/payments/application/use-cases/CreatePrizeUseCase.ts index 444fb7bd8..91d72fd60 100644 --- a/core/payments/application/use-cases/CreatePrizeUseCase.ts +++ b/core/payments/application/use-cases/CreatePrizeUseCase.ts @@ -6,12 +6,10 @@ import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; import type { PrizeType, Prize } from '../../domain/entities/Prize'; -import type { - ICreatePrizePresenter, - CreatePrizeResultDTO, - CreatePrizeViewModel, -} from '../presenters/ICreatePrizePresenter'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface CreatePrizeInput { leagueId: string; @@ -23,22 +21,26 @@ export interface CreatePrizeInput { description?: string; } +export interface CreatePrizeResult { + prize: Prize; +} + +export type CreatePrizeErrorCode = 'PRIZE_ALREADY_EXISTS'; + export class CreatePrizeUseCase - implements UseCase + implements UseCase { - constructor(private readonly prizeRepository: IPrizeRepository) {} - - async execute( - input: CreatePrizeInput, - presenter: ICreatePrizePresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly prizeRepository: IPrizeRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: CreatePrizeInput): Promise>> { const { leagueId, seasonId, position, name, amount, type, description } = input; const existingPrize = await this.prizeRepository.findByPosition(leagueId, seasonId, position); if (existingPrize) { - throw new Error(`Prize for position ${position} already exists`); + return Result.err({ code: 'PRIZE_ALREADY_EXISTS' as const }); } const id = `prize-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -50,30 +52,15 @@ export class CreatePrizeUseCase name, amount, type, - description, awarded: false, createdAt: new Date(), + ...(description !== undefined ? { description } : {}), }; const createdPrize = await this.prizeRepository.create(prize); - const dto: CreatePrizeResultDTO = { - prize: { - id: createdPrize.id, - leagueId: createdPrize.leagueId, - seasonId: createdPrize.seasonId, - position: createdPrize.position, - name: createdPrize.name, - amount: createdPrize.amount, - type: createdPrize.type, - description: createdPrize.description, - awarded: createdPrize.awarded, - awardedTo: createdPrize.awardedTo, - awardedAt: createdPrize.awardedAt, - createdAt: createdPrize.createdAt, - }, - }; + this.output.present({ prize: createdPrize }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/DeletePrizeUseCase.ts b/core/payments/application/use-cases/DeletePrizeUseCase.ts index 236ac5366..9dea9fdd9 100644 --- a/core/payments/application/use-cases/DeletePrizeUseCase.ts +++ b/core/payments/application/use-cases/DeletePrizeUseCase.ts @@ -5,45 +5,45 @@ */ import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; -import type { - IDeletePrizePresenter, - DeletePrizeResultDTO, - DeletePrizeViewModel, -} from '../presenters/IDeletePrizePresenter'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface DeletePrizeInput { prizeId: string; } +export interface DeletePrizeResult { + success: boolean; +} + +export type DeletePrizeErrorCode = 'PRIZE_NOT_FOUND' | 'CANNOT_DELETE_AWARDED_PRIZE'; + export class DeletePrizeUseCase - implements UseCase + implements UseCase { - constructor(private readonly prizeRepository: IPrizeRepository) {} - - async execute( - input: DeletePrizeInput, - presenter: IDeletePrizePresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly prizeRepository: IPrizeRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: DeletePrizeInput): Promise>> { const { prizeId } = input; const prize = await this.prizeRepository.findById(prizeId); if (!prize) { - throw new Error('Prize not found'); + return Result.err({ code: 'PRIZE_NOT_FOUND' as const }); } if (prize.awarded) { - throw new Error('Cannot delete an awarded prize'); + return Result.err({ code: 'CANNOT_DELETE_AWARDED_PRIZE' as const }); } await this.prizeRepository.delete(prizeId); - const dto: DeletePrizeResultDTO = { - success: true, - }; + this.output.present({ success: true }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts b/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts index fb8a954fc..b768c82e5 100644 --- a/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts +++ b/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts @@ -1,14 +1,7 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { GetMembershipFeesUseCase, type GetMembershipFeesInput } from './GetMembershipFeesUseCase'; import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; -import type { IGetMembershipFeesPresenter, GetMembershipFeesResultDTO, GetMembershipFeesViewModel } from '../presenters/IGetMembershipFeesPresenter'; - -interface TestPresenter extends IGetMembershipFeesPresenter { - reset: Mock; - present: Mock; - lastDto?: GetMembershipFeesResultDTO; - viewModel?: GetMembershipFeesViewModel; -} +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetMembershipFeesUseCase', () => { let membershipFeeRepository: { @@ -17,7 +10,9 @@ describe('GetMembershipFeesUseCase', () => { let memberPaymentRepository: { findByLeagueIdAndDriverId: Mock; }; - let presenter: TestPresenter; + let output: { + present: Mock; + }; let useCase: GetMembershipFeesUseCase; beforeEach(() => { @@ -29,28 +24,24 @@ describe('GetMembershipFeesUseCase', () => { findByLeagueIdAndDriverId: vi.fn(), } as unknown as IMemberPaymentRepository as any; - presenter = { - reset: vi.fn(), - present: vi.fn((dto: GetMembershipFeesResultDTO) => { - presenter.lastDto = dto; - }), - toViewModel: vi.fn((dto: GetMembershipFeesResultDTO) => ({ - fee: dto.fee, - payments: dto.payments, - })), - } as unknown as TestPresenter; + output = { + present: vi.fn(), + }; useCase = new GetMembershipFeesUseCase( membershipFeeRepository as unknown as IMembershipFeeRepository, memberPaymentRepository as unknown as IMemberPaymentRepository, + output as unknown as UseCaseOutputPort, ); }); - it('throws when leagueId is missing', async () => { + it('returns error when leagueId is missing', async () => { const input = { leagueId: '' } as GetMembershipFeesInput; - await expect(useCase.execute(input, presenter)).rejects.toThrow('leagueId is required'); - expect(presenter.reset).toHaveBeenCalled(); + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('INVALID_INPUT'); }); it('returns null fee and empty payments when no fee exists', async () => { @@ -58,11 +49,12 @@ describe('GetMembershipFeesUseCase', () => { membershipFeeRepository.findByLeagueId.mockResolvedValue(null); - await useCase.execute(input, presenter); + const result = await useCase.execute(input); + expect(result.isOk()).toBe(true); expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1'); expect(memberPaymentRepository.findByLeagueIdAndDriverId).not.toHaveBeenCalled(); - expect(presenter.present).toHaveBeenCalledWith({ + expect(output.present).toHaveBeenCalledWith({ fee: null, payments: [], }); @@ -99,35 +91,15 @@ describe('GetMembershipFeesUseCase', () => { membershipFeeRepository.findByLeagueId.mockResolvedValue(fee); memberPaymentRepository.findByLeagueIdAndDriverId.mockResolvedValue(payments); - await useCase.execute(input, presenter); + const result = await useCase.execute(input); + expect(result.isOk()).toBe(true); expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1'); expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository as unknown as IMembershipFeeRepository); - expect(presenter.present).toHaveBeenCalledWith({ - fee: { - id: fee.id, - leagueId: fee.leagueId, - seasonId: fee.seasonId, - type: fee.type, - amount: fee.amount, - enabled: fee.enabled, - createdAt: fee.createdAt, - updatedAt: fee.updatedAt, - }, - payments: [ - { - id: 'pay-1', - feeId: 'fee-1', - driverId: 'driver-1', - amount: 100, - platformFee: 5, - netAmount: 95, - status: 'paid', - dueDate: payments[0].dueDate, - paidAt: payments[0].paidAt, - }, - ], + expect(output.present).toHaveBeenCalledWith({ + fee, + payments, }); }); }); diff --git a/core/payments/application/use-cases/GetMembershipFeesUseCase.ts b/core/payments/application/use-cases/GetMembershipFeesUseCase.ts index 9e23964b6..cb9abe7b1 100644 --- a/core/payments/application/use-cases/GetMembershipFeesUseCase.ts +++ b/core/payments/application/use-cases/GetMembershipFeesUseCase.ts @@ -5,70 +5,50 @@ */ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; -import type { - IGetMembershipFeesPresenter, - GetMembershipFeesResultDTO, - GetMembershipFeesViewModel, -} from '../presenters/IGetMembershipFeesPresenter'; +import type { MembershipFee } from '../../domain/entities/MembershipFee'; +import type { MemberPayment } from '../../domain/entities/MemberPayment'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export type GetMembershipFeesErrorCode = 'INVALID_INPUT'; export interface GetMembershipFeesInput { leagueId: string; driverId?: string; } +export interface GetMembershipFeesResult { + fee: MembershipFee | null; + payments: MemberPayment[]; +} + export class GetMembershipFeesUseCase - implements UseCase + implements UseCase { constructor( private readonly membershipFeeRepository: IMembershipFeeRepository, private readonly memberPaymentRepository: IMemberPaymentRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute( - input: GetMembershipFeesInput, - presenter: IGetMembershipFeesPresenter, - ): Promise { - presenter.reset(); - + async execute(input: GetMembershipFeesInput): Promise>> { const { leagueId, driverId } = input; if (!leagueId) { - throw new Error('leagueId is required'); + return Result.err({ code: 'INVALID_INPUT' as const }); } const fee = await this.membershipFeeRepository.findByLeagueId(leagueId); - - let payments: unknown[] = []; + + let payments: MemberPayment[] = []; if (driverId && fee) { - const memberPayments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository); - payments = memberPayments.map(p => ({ - id: p.id, - feeId: p.feeId, - driverId: p.driverId, - amount: p.amount, - platformFee: p.platformFee, - netAmount: p.netAmount, - status: p.status, - dueDate: p.dueDate, - paidAt: p.paidAt, - })); + payments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository); } - const dto: GetMembershipFeesResultDTO = { - fee: fee ? { - id: fee.id, - leagueId: fee.leagueId, - seasonId: fee.seasonId, - type: fee.type, - amount: fee.amount, - enabled: fee.enabled, - createdAt: fee.createdAt, - updatedAt: fee.updatedAt, - } : null, - payments, - }; + this.output.present({ fee, payments }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPaymentsUseCase.test.ts b/core/payments/application/use-cases/GetPaymentsUseCase.test.ts new file mode 100644 index 000000000..c2e5a640d --- /dev/null +++ b/core/payments/application/use-cases/GetPaymentsUseCase.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetPaymentsUseCase, type GetPaymentsInput } from './GetPaymentsUseCase'; +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import { PaymentType, PayerType } from '../../domain/entities/Payment'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('GetPaymentsUseCase', () => { + let paymentRepository: { + findByFilters: Mock; + }; + let output: { + present: Mock; + }; + let useCase: GetPaymentsUseCase; + + beforeEach(() => { + paymentRepository = { + findByFilters: vi.fn(), + } as unknown as IPaymentRepository as any; + + output = { + present: vi.fn(), + }; + + useCase = new GetPaymentsUseCase( + paymentRepository as unknown as IPaymentRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('retrieves payments and presents the result', async () => { + const input: GetPaymentsInput = { + leagueId: 'league-1', + payerId: 'payer-1', + type: PaymentType.SPONSORSHIP, + }; + + const payments = [ + { + id: 'payment-1', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'payer-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + seasonId: 'season-1', + status: 'completed', + createdAt: new Date(), + completedAt: new Date(), + }, + ]; + + paymentRepository.findByFilters.mockResolvedValue(payments); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(paymentRepository.findByFilters).toHaveBeenCalledWith({ + leagueId: 'league-1', + payerId: 'payer-1', + type: PaymentType.SPONSORSHIP, + }); + expect(output.present).toHaveBeenCalledWith({ payments }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPaymentsUseCase.ts b/core/payments/application/use-cases/GetPaymentsUseCase.ts index f57ffca01..660d3959f 100644 --- a/core/payments/application/use-cases/GetPaymentsUseCase.ts +++ b/core/payments/application/use-cases/GetPaymentsUseCase.ts @@ -5,14 +5,11 @@ */ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; -import type { PaymentType } from '../../domain/entities/Payment'; -import type { - IGetPaymentsPresenter, - GetPaymentsResultDTO, - GetPaymentsViewModel, - PaymentDto, -} from '../presenters/IGetPaymentsPresenter'; +import type { Payment, PaymentType } from '../../domain/entities/Payment'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetPaymentsInput { leagueId?: string; @@ -20,40 +17,32 @@ export interface GetPaymentsInput { type?: PaymentType; } +export interface GetPaymentsResult { + payments: Payment[]; +} + +export type GetPaymentsErrorCode = never; + export class GetPaymentsUseCase - implements UseCase + implements UseCase { - constructor(private readonly paymentRepository: IPaymentRepository) {} - - async execute( - input: GetPaymentsInput, - presenter: IGetPaymentsPresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly paymentRepository: IPaymentRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: GetPaymentsInput): Promise>> { const { leagueId, payerId, type } = input; - const payments = await this.paymentRepository.findByFilters({ leagueId, payerId, type }); + const filters: { leagueId?: string; payerId?: string; type?: PaymentType } = {}; + if (leagueId !== undefined) filters.leagueId = leagueId; + if (payerId !== undefined) filters.payerId = payerId; + if (type !== undefined) filters.type = type; - const dtos: PaymentDto[] = 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, - seasonId: payment.seasonId, - status: payment.status, - createdAt: payment.createdAt, - completedAt: payment.completedAt, - })); + const payments = await this.paymentRepository.findByFilters(filters); - const dto: GetPaymentsResultDTO = { - payments: dtos, - }; + this.output.present({ payments }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPrizesUseCase.ts b/core/payments/application/use-cases/GetPrizesUseCase.ts index b183f947c..239c8b241 100644 --- a/core/payments/application/use-cases/GetPrizesUseCase.ts +++ b/core/payments/application/use-cases/GetPrizesUseCase.ts @@ -5,29 +5,29 @@ */ import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; -import type { - IGetPrizesPresenter, - GetPrizesResultDTO, - GetPrizesViewModel, -} from '../presenters/IGetPrizesPresenter'; +import type { Prize } from '../../domain/entities/Prize'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; export interface GetPrizesInput { leagueId: string; seasonId?: string; } +export interface GetPrizesResult { + prizes: Prize[]; +} + export class GetPrizesUseCase - implements UseCase + implements UseCase { - constructor(private readonly prizeRepository: IPrizeRepository) {} - - async execute( - input: GetPrizesInput, - presenter: IGetPrizesPresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly prizeRepository: IPrizeRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: GetPrizesInput): Promise> { const { leagueId, seasonId } = input; let prizes; @@ -39,23 +39,8 @@ export class GetPrizesUseCase prizes.sort((a, b) => a.position - b.position); - const dto: GetPrizesResultDTO = { - prizes: prizes.map(prize => ({ - id: prize.id, - leagueId: prize.leagueId, - seasonId: prize.seasonId, - position: prize.position, - name: prize.name, - amount: prize.amount, - type: prize.type, - description: prize.description, - awarded: prize.awarded, - awardedTo: prize.awardedTo, - awardedAt: prize.awardedAt, - createdAt: prize.createdAt, - })), - }; + this.output.present({ prizes }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/services/SponsorBillingService.ts b/core/payments/application/use-cases/GetSponsorBillingUseCase.ts similarity index 81% rename from core/payments/application/services/SponsorBillingService.ts rename to core/payments/application/use-cases/GetSponsorBillingUseCase.ts index 601fdbefc..043a4d400 100644 --- a/core/payments/application/services/SponsorBillingService.ts +++ b/core/payments/application/use-cases/GetSponsorBillingUseCase.ts @@ -1,6 +1,9 @@ -import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository'; -import { PaymentStatus, PaymentType, PayerType } from '@core/payments/domain/entities/Payment'; +import { PaymentStatus, PaymentType } from '../../domain/entities/Payment'; import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface SponsorBillingStats { totalSpent: number; @@ -42,18 +45,29 @@ export interface SponsorBillingSummary { stats: SponsorBillingStats; } -/** - * Application Service: SponsorBillingService - * - * Aggregates sponsor-facing billing information from payments and season sponsorships. - */ -export class SponsorBillingService { +export interface GetSponsorBillingInput { + sponsorId: string; +} + +export interface GetSponsorBillingResult { + paymentMethods: SponsorPaymentMethodSummary[]; + invoices: SponsorInvoiceSummary[]; + stats: SponsorBillingStats; +} + +export type GetSponsorBillingErrorCode = never; + +export class GetSponsorBillingUseCase + implements UseCase +{ constructor( private readonly paymentRepository: IPaymentRepository, private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, ) {} - async getSponsorBilling(sponsorId: string): Promise { + async execute(input: GetSponsorBillingInput): Promise>> { + const { sponsorId } = input; + // In this in-memory implementation we derive billing data from payments // where the sponsor is the payer. const payments = await this.paymentRepository.findByFilters({ @@ -122,11 +136,13 @@ export class SponsorBillingService { // payment-methods port can be added later when the concept exists in core. const paymentMethods: SponsorPaymentMethodSummary[] = []; - return { + const result: GetSponsorBillingResult = { paymentMethods, invoices, stats, }; + + return Result.ok(result); } private calculateAverageMonthlySpend(invoices: SponsorInvoiceSummary[]): number { @@ -147,4 +163,4 @@ export class SponsorBillingService { const months = d2.getMonth() - d1.getMonth(); return years * 12 + months + 1; } -} +} \ No newline at end of file diff --git a/core/payments/application/use-cases/GetWalletUseCase.ts b/core/payments/application/use-cases/GetWalletUseCase.ts index f6890f25a..baf368cb0 100644 --- a/core/payments/application/use-cases/GetWalletUseCase.ts +++ b/core/payments/application/use-cases/GetWalletUseCase.ts @@ -5,40 +5,41 @@ */ import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository'; -import type { Wallet } from '../../domain/entities/Wallet'; -import type { - IGetWalletPresenter, - GetWalletResultDTO, - GetWalletViewModel, -} from '../presenters/IGetWalletPresenter'; +import type { Wallet, Transaction } from '../../domain/entities/Wallet'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export type GetWalletErrorCode = 'INVALID_INPUT'; export interface GetWalletInput { leagueId: string; } +export interface GetWalletResult { + wallet: Wallet; + transactions: Transaction[]; +} + export class GetWalletUseCase - implements UseCase + implements UseCase { constructor( private readonly walletRepository: IWalletRepository, private readonly transactionRepository: ITransactionRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute( - input: GetWalletInput, - presenter: IGetWalletPresenter, - ): Promise { - presenter.reset(); - + async execute(input: GetWalletInput): Promise>> { const { leagueId } = input; if (!leagueId) { - throw new Error('LeagueId is required'); + return Result.err({ code: 'INVALID_INPUT' as const }); } let wallet = await this.walletRepository.findByLeagueId(leagueId); - + if (!wallet) { const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newWallet: Wallet = { @@ -57,29 +58,8 @@ export class GetWalletUseCase const transactions = await this.transactionRepository.findByWalletId(wallet.id); transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - const dto: GetWalletResultDTO = { - wallet: { - id: wallet.id, - leagueId: wallet.leagueId, - balance: wallet.balance, - totalRevenue: wallet.totalRevenue, - totalPlatformFees: wallet.totalPlatformFees, - totalWithdrawn: wallet.totalWithdrawn, - currency: wallet.currency, - createdAt: wallet.createdAt, - }, - transactions: transactions.map(t => ({ - id: t.id, - walletId: t.walletId, - type: t.type, - amount: t.amount, - description: t.description, - referenceId: t.referenceId, - referenceType: t.referenceType, - createdAt: t.createdAt, - })), - }; + this.output.present({ wallet, transactions }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/ProcessWalletTransactionUseCase.test.ts b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.test.ts new file mode 100644 index 000000000..eb3b96145 --- /dev/null +++ b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { ProcessWalletTransactionUseCase, type ProcessWalletTransactionInput } from './ProcessWalletTransactionUseCase'; +import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository'; +import { TransactionType, ReferenceType } from '../../domain/entities/Wallet'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('ProcessWalletTransactionUseCase', () => { + let walletRepository: { + findByLeagueId: Mock; + create: Mock; + update: Mock; + }; + let transactionRepository: { + create: Mock; + }; + let output: { + present: Mock; + }; + let useCase: ProcessWalletTransactionUseCase; + + beforeEach(() => { + walletRepository = { + findByLeagueId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + } as unknown as IWalletRepository as any; + + transactionRepository = { + create: vi.fn(), + } as unknown as ITransactionRepository as any; + + output = { + present: vi.fn(), + }; + + useCase = new ProcessWalletTransactionUseCase( + walletRepository as unknown as IWalletRepository, + transactionRepository as unknown as ITransactionRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('processes a deposit transaction and presents the result', async () => { + const input: ProcessWalletTransactionInput = { + leagueId: 'league-1', + type: TransactionType.DEPOSIT, + amount: 100, + description: 'Test deposit', + referenceId: 'ref-1', + referenceType: ReferenceType.SPONSORSHIP, + }; + + const wallet = { + id: 'wallet-1', + leagueId: 'league-1', + balance: 50, + totalRevenue: 50, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date(), + }; + + const transaction = { + id: 'txn-1', + walletId: 'wallet-1', + type: TransactionType.DEPOSIT, + amount: 100, + description: 'Test deposit', + referenceId: 'ref-1', + referenceType: ReferenceType.SPONSORSHIP, + createdAt: new Date(), + }; + + walletRepository.findByLeagueId.mockResolvedValue(wallet); + transactionRepository.create.mockResolvedValue(transaction); + walletRepository.update.mockResolvedValue({ ...wallet, balance: 150, totalRevenue: 150 }); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ + wallet: { ...wallet, balance: 150, totalRevenue: 150 }, + transaction, + }); + }); + + it('returns error for insufficient balance on withdrawal', async () => { + const input: ProcessWalletTransactionInput = { + leagueId: 'league-1', + type: TransactionType.WITHDRAWAL, + amount: 100, + description: 'Test withdrawal', + }; + + const wallet = { + id: 'wallet-1', + leagueId: 'league-1', + balance: 50, + totalRevenue: 50, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date(), + }; + + walletRepository.findByLeagueId.mockResolvedValue(wallet); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('INSUFFICIENT_BALANCE'); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts index f8f06b2f5..4453e155d 100644 --- a/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts +++ b/core/payments/application/use-cases/ProcessWalletTransactionUseCase.ts @@ -5,13 +5,12 @@ */ import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository'; -import type { Wallet, Transaction, TransactionType, ReferenceType } from '../../domain/entities/Wallet'; -import type { - IProcessWalletTransactionPresenter, - ProcessWalletTransactionResultDTO, - ProcessWalletTransactionViewModel, -} from '../presenters/IProcessWalletTransactionPresenter'; +import type { Wallet, Transaction } from '../../domain/entities/Wallet'; +import { TransactionType, ReferenceType } from '../../domain/entities/Wallet'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface ProcessWalletTransactionInput { leagueId: string; @@ -22,32 +21,35 @@ export interface ProcessWalletTransactionInput { referenceType?: ReferenceType; } +export interface ProcessWalletTransactionResult { + wallet: Wallet; + transaction: Transaction; +} + +export type ProcessWalletTransactionErrorCode = 'MISSING_REQUIRED_FIELDS' | 'INVALID_TYPE' | 'INSUFFICIENT_BALANCE'; + export class ProcessWalletTransactionUseCase - implements UseCase + implements UseCase { constructor( private readonly walletRepository: IWalletRepository, private readonly transactionRepository: ITransactionRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute( - input: ProcessWalletTransactionInput, - presenter: IProcessWalletTransactionPresenter, - ): Promise { - presenter.reset(); - + async execute(input: ProcessWalletTransactionInput): Promise>> { const { leagueId, type, amount, description, referenceId, referenceType } = input; if (!leagueId || !type || amount === undefined || !description) { - throw new Error('Missing required fields: leagueId, type, amount, description'); + return Result.err({ code: 'MISSING_REQUIRED_FIELDS' as const }); } - if (type !== ('deposit' as TransactionType) && type !== ('withdrawal' as TransactionType)) { - throw new Error('Type must be "deposit" or "withdrawal"'); + if (type !== TransactionType.DEPOSIT && type !== TransactionType.WITHDRAWAL) { + return Result.err({ code: 'INVALID_TYPE' as const }); } let wallet = await this.walletRepository.findByLeagueId(leagueId); - + if (!wallet) { const id = `wallet-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newWallet: Wallet = { @@ -63,9 +65,9 @@ export class ProcessWalletTransactionUseCase wallet = await this.walletRepository.create(newWallet); } - if (type === ('withdrawal' as TransactionType)) { + if (type === TransactionType.WITHDRAWAL) { if (amount > wallet.balance) { - throw new Error('Insufficient balance'); + return Result.err({ code: 'INSUFFICIENT_BALANCE' as const }); } } @@ -76,14 +78,14 @@ export class ProcessWalletTransactionUseCase type, amount, description, - referenceId, - referenceType, createdAt: new Date(), + ...(referenceId !== undefined ? { referenceId } : {}), + ...(referenceType !== undefined ? { referenceType } : {}), }; const createdTransaction = await this.transactionRepository.create(transaction); - if (type === ('deposit' as TransactionType)) { + if (type === TransactionType.DEPOSIT) { wallet.balance += amount; wallet.totalRevenue += amount; } else { @@ -93,29 +95,8 @@ export class ProcessWalletTransactionUseCase const updatedWallet = await this.walletRepository.update(wallet); - const dto: ProcessWalletTransactionResultDTO = { - wallet: { - id: updatedWallet.id, - leagueId: updatedWallet.leagueId, - balance: updatedWallet.balance, - totalRevenue: updatedWallet.totalRevenue, - totalPlatformFees: updatedWallet.totalPlatformFees, - totalWithdrawn: updatedWallet.totalWithdrawn, - currency: updatedWallet.currency, - createdAt: updatedWallet.createdAt, - }, - transaction: { - id: createdTransaction.id, - walletId: createdTransaction.walletId, - type: createdTransaction.type, - amount: createdTransaction.amount, - description: createdTransaction.description, - referenceId: createdTransaction.referenceId, - referenceType: createdTransaction.referenceType, - createdAt: createdTransaction.createdAt, - }, - }; + this.output.present({ wallet: updatedWallet, transaction: createdTransaction }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts index 08ec9d767..d9907ea56 100644 --- a/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts +++ b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.ts @@ -5,13 +5,12 @@ */ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; -import type { MemberPaymentStatus, MemberPayment } from '../../domain/entities/MemberPayment'; -import type { - IUpdateMemberPaymentPresenter, - UpdateMemberPaymentResultDTO, - UpdateMemberPaymentViewModel, -} from '../presenters/IUpdateMemberPaymentPresenter'; +import type { MemberPayment } from '../../domain/entities/MemberPayment'; +import { MemberPaymentStatus } from '../../domain/entities/MemberPayment'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; const PLATFORM_FEE_RATE = 0.10; @@ -22,25 +21,27 @@ export interface UpdateMemberPaymentInput { paidAt?: Date | string; } +export interface UpdateMemberPaymentResult { + payment: MemberPayment; +} + +export type UpdateMemberPaymentErrorCode = 'MEMBERSHIP_FEE_NOT_FOUND'; + export class UpdateMemberPaymentUseCase - implements UseCase + implements UseCase { constructor( private readonly membershipFeeRepository: IMembershipFeeRepository, private readonly memberPaymentRepository: IMemberPaymentRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute( - input: UpdateMemberPaymentInput, - presenter: IUpdateMemberPaymentPresenter, - ): Promise { - presenter.reset(); - + async execute(input: UpdateMemberPaymentInput): Promise>> { const { feeId, driverId, status, paidAt } = input; const fee = await this.membershipFeeRepository.findById(feeId); if (!fee) { - throw new Error('Membership fee configuration not found'); + return Result.err({ code: 'MEMBERSHIP_FEE_NOT_FOUND' as const }); } let payment = await this.memberPaymentRepository.findByFeeIdAndDriverId(feeId, driverId); @@ -57,7 +58,7 @@ export class UpdateMemberPaymentUseCase amount: fee.amount, platformFee, netAmount, - status: 'pending' as MemberPaymentStatus, + status: MemberPaymentStatus.PENDING, dueDate: new Date(), }; payment = await this.memberPaymentRepository.create(newPayment); @@ -66,26 +67,14 @@ export class UpdateMemberPaymentUseCase if (status) { payment.status = status; } - if (paidAt || status === ('paid' as MemberPaymentStatus)) { + if (paidAt || status === MemberPaymentStatus.PAID) { payment.paidAt = paidAt ? new Date(paidAt as string) : new Date(); } const updatedPayment = await this.memberPaymentRepository.update(payment); - const dto: UpdateMemberPaymentResultDTO = { - payment: { - id: updatedPayment.id, - feeId: updatedPayment.feeId, - driverId: updatedPayment.driverId, - amount: updatedPayment.amount, - platformFee: updatedPayment.platformFee, - netAmount: updatedPayment.netAmount, - status: updatedPayment.status, - dueDate: updatedPayment.dueDate, - paidAt: updatedPayment.paidAt, - }, - }; + this.output.present({ payment: updatedPayment }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts index fe8aa0eda..80ead480a 100644 --- a/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts +++ b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.ts @@ -5,36 +5,38 @@ */ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; -import type { PaymentStatus } from '../../domain/entities/Payment'; -import type { - IUpdatePaymentStatusPresenter, - UpdatePaymentStatusResultDTO, - UpdatePaymentStatusViewModel, - PaymentDto, -} from '../presenters/IUpdatePaymentStatusPresenter'; +import type { Payment } from '../../domain/entities/Payment'; +import { PaymentStatus } from '../../domain/entities/Payment'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export type UpdatePaymentStatusErrorCode = 'PAYMENT_NOT_FOUND'; export interface UpdatePaymentStatusInput { paymentId: string; status: PaymentStatus; } +export interface UpdatePaymentStatusResult { + payment: Payment; +} + export class UpdatePaymentStatusUseCase - implements UseCase + implements UseCase { - constructor(private readonly paymentRepository: IPaymentRepository) {} - - async execute( - input: UpdatePaymentStatusInput, - presenter: IUpdatePaymentStatusPresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly paymentRepository: IPaymentRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: UpdatePaymentStatusInput): Promise>> { const { paymentId, status } = input; const existingPayment = await this.paymentRepository.findById(paymentId); if (!existingPayment) { - throw new Error(`Payment with id ${paymentId} not found`); + return Result.err({ code: 'PAYMENT_NOT_FOUND' as const }); } const updatedPayment = { @@ -43,27 +45,10 @@ export class UpdatePaymentStatusUseCase completedAt: status === PaymentStatus.COMPLETED ? new Date() : existingPayment.completedAt, }; - const savedPayment = await this.paymentRepository.update(updatedPayment); + const savedPayment = await this.paymentRepository.update(updatedPayment as Payment); - const dto: PaymentDto = { - id: savedPayment.id, - type: savedPayment.type, - amount: savedPayment.amount, - platformFee: savedPayment.platformFee, - netAmount: savedPayment.netAmount, - payerId: savedPayment.payerId, - payerType: savedPayment.payerType, - leagueId: savedPayment.leagueId, - seasonId: savedPayment.seasonId, - status: savedPayment.status, - createdAt: savedPayment.createdAt, - completedAt: savedPayment.completedAt, - }; + this.output.present({ payment: savedPayment }); - const result: UpdatePaymentStatusResultDTO = { - payment: dto, - }; - - presenter.present(result); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts index 3b046781f..e12ac33cf 100644 --- a/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts +++ b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.ts @@ -6,12 +6,9 @@ import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository'; import type { MembershipFeeType, MembershipFee } from '../../domain/entities/MembershipFee'; -import type { - IUpsertMembershipFeePresenter, - UpsertMembershipFeeResultDTO, - UpsertMembershipFeeViewModel, -} from '../presenters/IUpsertMembershipFeePresenter'; import type { UseCase } from '@core/shared/application/UseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Result } from '@core/shared/application/Result'; export interface UpsertMembershipFeeInput { leagueId: string; @@ -20,26 +17,30 @@ export interface UpsertMembershipFeeInput { amount: number; } +export interface UpsertMembershipFeeResult { + fee: MembershipFee; +} + +export type UpsertMembershipFeeErrorCode = never; + export class UpsertMembershipFeeUseCase - implements UseCase + implements UseCase { - constructor(private readonly membershipFeeRepository: IMembershipFeeRepository) {} - - async execute( - input: UpsertMembershipFeeInput, - presenter: IUpsertMembershipFeePresenter, - ): Promise { - presenter.reset(); + constructor( + private readonly membershipFeeRepository: IMembershipFeeRepository, + private readonly output: UseCaseOutputPort, + ) {} + async execute(input: UpsertMembershipFeeInput): Promise> { const { leagueId, seasonId, type, amount } = input; let existingFee = await this.membershipFeeRepository.findByLeagueId(leagueId); - + let fee: MembershipFee; if (existingFee) { existingFee.type = type; existingFee.amount = amount; - existingFee.seasonId = seasonId; + if (seasonId !== undefined) existingFee.seasonId = seasonId; existingFee.enabled = amount > 0; existingFee.updatedAt = new Date(); fee = await this.membershipFeeRepository.update(existingFee); @@ -48,29 +49,18 @@ export class UpsertMembershipFeeUseCase const newFee: MembershipFee = { id, leagueId, - seasonId, type, amount, enabled: amount > 0, createdAt: new Date(), updatedAt: new Date(), + ...(seasonId !== undefined ? { seasonId } : {}), }; fee = await this.membershipFeeRepository.create(newFee); } - const dto: UpsertMembershipFeeResultDTO = { - fee: { - id: fee.id, - leagueId: fee.leagueId, - seasonId: fee.seasonId, - type: fee.type, - amount: fee.amount, - enabled: fee.enabled, - createdAt: fee.createdAt, - updatedAt: fee.updatedAt, - }, - }; + this.output.present({ fee }); - presenter.present(dto); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/payments/application/use-cases/index.ts b/core/payments/application/use-cases/index.ts deleted file mode 100644 index ef3af2637..000000000 --- a/core/payments/application/use-cases/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './GetPaymentsUseCase'; -export * from './CreatePaymentUseCase'; -export * from './UpdatePaymentStatusUseCase'; -export * from './GetMembershipFeesUseCase'; -export * from './UpsertMembershipFeeUseCase'; -export * from './UpdateMemberPaymentUseCase'; -export * from './GetPrizesUseCase'; -export * from './CreatePrizeUseCase'; -export * from './AwardPrizeUseCase'; -export * from './DeletePrizeUseCase'; -export * from './GetWalletUseCase'; -export * from './ProcessWalletTransactionUseCase'; \ No newline at end of file diff --git a/core/racing/application/services/SeasonApplicationService.ts b/core/racing/application/services/SeasonApplicationService.ts index 9c0327ded..90e6844ee 100644 --- a/core/racing/application/services/SeasonApplicationService.ts +++ b/core/racing/application/services/SeasonApplicationService.ts @@ -14,7 +14,7 @@ import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonSteward import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; -// TODO The whole file mixes a lot of concerns... +// TODO The whole file mixes a lot of concerns...should be resolved into use cases or is it obsolet? export interface CreateSeasonForLeagueCommand { leagueId: string; diff --git a/core/racing/application/use-cases/DashboardOverviewUseCase.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.ts index a04009bf2..40e95a164 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.ts @@ -9,7 +9,6 @@ import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepo import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { League } from '../../domain/entities/League'; import { Race } from '../../domain/entities/Race'; import { Result as RaceResult } from '../../domain/entities/Result'; @@ -97,14 +96,13 @@ export class DashboardOverviewUseCase { private readonly getDriverStats: ( driverId: string, ) => DashboardDriverStatsAdapter | null, - private readonly output: UseCaseOutputPort, ) {} async execute( input: DashboardOverviewInput, ): Promise< Result< - void, + DashboardOverviewResult, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> > > { @@ -209,9 +207,7 @@ export class DashboardOverviewUseCase { friends: friendsSummary, }; - this.output.present(result); - - return Result.ok(undefined); + return Result.ok(result); } catch (error) { return Result.err({ code: 'REPOSITORY_ERROR', diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts index 8cad39553..9bbddf689 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts @@ -27,14 +27,14 @@ export class GetAllLeaguesWithCapacityUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, - private readonly output: UseCaseOutputPort, + private readonly outputPort: UseCaseOutputPort, ) {} async execute( _input: GetAllLeaguesWithCapacityInput = {}, ): Promise< Result< - void, + GetAllLeaguesWithCapacityResult, ApplicationErrorCode > > { @@ -44,15 +44,15 @@ export class GetAllLeaguesWithCapacityUseCase { const summaries: LeagueCapacitySummary[] = []; for (const league of leagues) { - const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); + const members = await this.leagueMembershipRepository.getLeagueMembers(league.id.toString()); const currentDrivers = members.filter( (m) => - m.status === 'active' && - (m.role === 'owner' || - m.role === 'admin' || - m.role === 'steward' || - m.role === 'member'), + m.status.toString() === 'active' && + (m.role.toString() === 'owner' || + m.role.toString() === 'admin' || + m.role.toString() === 'steward' || + m.role.toString() === 'member'), ).length; const maxDrivers = league.settings.maxDrivers ?? 0; @@ -60,9 +60,7 @@ export class GetAllLeaguesWithCapacityUseCase { summaries.push({ league, currentDrivers, maxDrivers }); } - this.output.present({ leagues: summaries }); - - return Result.ok(undefined); + return Result.ok({ leagues: summaries }); } catch (error: unknown) { const message = error instanceof Error && error.message diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts index bbab57830..c534c3890 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -52,7 +52,7 @@ export class GetDriversLeaderboardUseCase { async execute( _input: GetDriversLeaderboardInput, - ): Promise>> { + ): Promise>> { this.logger.debug('Executing GetDriversLeaderboardUseCase'); try { const drivers = await this.driverRepository.findAll(); @@ -90,14 +90,12 @@ export class GetDriversLeaderboardUseCase { this.logger.debug('Successfully retrieved drivers leaderboard.'); - this.output.present({ + return Result.ok({ items: items.sort((a, b) => b.rating - a.rating), totalRaces, totalWins, activeCount, }); - - return Result.ok(undefined); } catch (error) { this.logger.error( 'Error executing GetDriversLeaderboardUseCase', diff --git a/core/shared/application/UseCase.ts b/core/shared/application/UseCase.ts index 942f09858..3c6dd7652 100644 --- a/core/shared/application/UseCase.ts +++ b/core/shared/application/UseCase.ts @@ -1,4 +1,3 @@ -import type { Presenter } from '../presentation'; import type { Result } from './Result'; import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; @@ -8,9 +7,8 @@ import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; * Use cases represent application-level business logic. They coordinate domain objects and repositories, * but contain no infrastructure or framework concerns. * - * Commands change system state and return nothing on success. They use a presenter to handle the output. - * If a business rejection is possible, the output must be a Result> handled by the presenter. - * Use cases do not throw errors; they use error codes in Result. + * Commands change system state and return a Result on execution. If a business rejection is possible, + * the result contains an ApplicationErrorCode. Use cases do not throw errors; they use error codes in Result. * * Example: * ```typescript @@ -19,19 +17,17 @@ import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; * | 'RACE_ALREADY_EXISTS' * | 'INVALID_RACE_CONFIG'; * - * export class CreateRaceUseCase implements UseCase>, ViewModel>> { - * execute(input: CreateRaceInput, presenter: Presenter>, ViewModel>): Promise { + * export class CreateRaceUseCase implements UseCase { + * execute(input: CreateRaceInput): Promise>> { * // implementation * } * } * ``` * * @template Input - The input type for the use case - * @template Success - The success type in the Result passed to the presenter + * @template Success - The success type in the Result * @template ErrorCode - The error code type for ApplicationErrorCode - * @template ViewModel - The view model type - * @template P - The presenter type, extending Presenter>, ViewModel> */ -export interface UseCase>, ViewModel>> { - execute(input: Input, presenter: P): Promise | void; +export interface UseCase { + execute(input: Input): Promise>>; } \ No newline at end of file diff --git a/core/shared/application/UseCaseOutputPort.ts b/core/shared/application/UseCaseOutputPort.ts index 61e252bfb..8140e3d22 100644 --- a/core/shared/application/UseCaseOutputPort.ts +++ b/core/shared/application/UseCaseOutputPort.ts @@ -1,11 +1,15 @@ +import type { Result } from './Result'; +import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; + /** * Output Port interface for use cases. * * Defines how the core communicates outward. A behavioral boundary that allows * the use case to present results without knowing the presentation details. * - * @template T - The result type to present + * @template T - The success type in the Result + * @template E - The error code type */ -export interface UseCaseOutputPort { - present(data: T): void; +export interface UseCaseOutputPort { + present(result: Result>): any; } \ No newline at end of file diff --git a/core/shared/presentation/Presenter.ts b/core/shared/presentation/Presenter.ts index 4288694cd..37877dac2 100644 --- a/core/shared/presentation/Presenter.ts +++ b/core/shared/presentation/Presenter.ts @@ -1,5 +1,6 @@ -export interface Presenter { +// This must not be used within core. It's in presentation layer, e.g. to be used in an API. +export interface Presenter { present(input: InputDTO): void; - getViewModel(): ViewModel | null; + getResponseModel(): ResponseModel | null; reset(): void; } \ No newline at end of file diff --git a/core/social/application/index.ts b/core/social/application/index.ts deleted file mode 100644 index 59e4a98dc..000000000 --- a/core/social/application/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { GetCurrentUserSocialUseCase } from './use-cases/GetCurrentUserSocialUseCase'; -export type { GetCurrentUserSocialParams } from './use-cases/GetCurrentUserSocialUseCase'; - -export { GetUserFeedUseCase } from './use-cases/GetUserFeedUseCase'; -export type { GetUserFeedParams } from './use-cases/GetUserFeedUseCase'; - -export type { CurrentUserSocialDTO } from './dto/CurrentUserSocialDTO'; -export type { FriendDTO } from './dto/FriendDTO'; -export type { FeedItemDTO } from './dto/FeedItemDTO'; - -export type { - CurrentUserSocialViewModel, - ICurrentUserSocialPresenter, - UserFeedViewModel, - IUserFeedPresenter, -} from './presenters/ISocialPresenters'; \ No newline at end of file diff --git a/core/social/application/presenters/ISocialPresenters.ts b/core/social/application/presenters/ISocialPresenters.ts deleted file mode 100644 index 5ce7415d2..000000000 --- a/core/social/application/presenters/ISocialPresenters.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO'; -import type { FriendDTO } from '../dto/FriendDTO'; -import type { FeedItemDTO } from '../dto/FeedItemDTO'; - -export interface CurrentUserSocialViewModel { - currentUser: CurrentUserSocialDTO; - friends: FriendDTO[]; -} - -export interface ICurrentUserSocialPresenter { - present(viewModel: CurrentUserSocialViewModel): void; -} - -export interface UserFeedViewModel { - items: FeedItemDTO[]; -} - -export interface IUserFeedPresenter { - present(viewModel: UserFeedViewModel): void; -} \ No newline at end of file diff --git a/docs/architecture/LOGGING.md b/docs/architecture/LOGGING.md new file mode 100644 index 000000000..381d4231b --- /dev/null +++ b/docs/architecture/LOGGING.md @@ -0,0 +1,245 @@ +Logging & Correlation ID Design Guide (Clean Architecture) + +This document defines a clean, strict, and production-ready logging architecture with correlation IDs. + +It removes ambiguity around: + • where logging belongs + • how context is attached + • how logs stay machine-readable + • how Clean Architecture boundaries are preserved + +The rules below are non-negotiable. + +⸻ + +Core Principles + +Logs are data, not text. +Context is injected, never constructed in the Core. + +Logging must: + • be machine-readable + • be framework-agnostic in the Core + • support correlation across requests + • be safe for parallel execution + +⸻ + +Architectural Responsibilities + +Core + • describes intent + • never knows about correlation IDs + • never knows about log destinations + +App Layer (API) + • defines runtime context (request, user) + • binds correlation IDs + +Adapters + • implement concrete loggers (console, file, structured) + • decide formatting and transport + +⸻ + +1. Logger Port (Core) + +Purpose + +Defines what logging means, without defining how logging works. + +⸻ + +Rules + • exactly one logging interface + • no framework imports + • no correlation or runtime context + +⸻ + +Location + +core/shared/application/LoggerPort.ts + + +⸻ + +Contract + • debug(message, meta?) + • info(message, meta?) + • warn(message, meta?) + • error(message, meta?) + +Messages are semantic. +Metadata is optional and structured. + +⸻ + +2. Request Context (App Layer) + +Purpose + +Represents runtime execution context. + +⸻ + +Contains + • correlationId + • optional userId + +⸻ + +Rules + • never visible to Core + • created per request + +⸻ + +Location + +apps/api/context/RequestContext.ts + + +⸻ + +3. Logger Implementations (Adapters) + +Purpose + +Provide concrete logging behavior. + +⸻ + +Rules + • implement LoggerPort + • accept context via constructor + • produce structured logs + • no business logic + +⸻ + +Examples + • ConsoleLogger + • FileLogger + • PinoLogger + • LokiLogger + +⸻ + +Location + +adapters/logging/ + + +⸻ + +4. Logger Factory + +Purpose + +Creates context-bound logger instances. + +⸻ + +Rules + • factory is injected + • logger instances are short-lived + • component name is bound here + +⸻ + +Location + +adapters/logging/LoggerFactory.ts +adapters/logging/LoggerFactoryImpl.ts + + +⸻ + +5. Correlation ID Handling + +Where it lives + • API middleware + • message envelopes + • background job contexts + +⸻ + +Rules + • generated once per request + • propagated across async boundaries + • never generated in the Core + +⸻ + +6. Usage Rules by Layer + +Layer Logging Allowed Notes +Domain ❌ No Throw domain errors instead +Use Cases ⚠️ Minimal Business milestones only +API Services ✅ Yes Main logging location +Adapters ✅ Yes IO, integration, failures +Frontend ⚠️ Limited Errors + analytics only + + +⸻ + +7. Forbidden Patterns + +❌ Manual string prefixes ([ServiceName]) +❌ Global/singleton loggers with mutable state +❌ any in logger abstractions +❌ Correlation IDs in Core +❌ Logging inside domain entities + +⸻ + +8. File Structure (Final) + +core/ +└── shared/ + └── application/ + └── LoggerPort.ts # * required + +apps/api/ +├── context/ +│ └── RequestContext.ts # * required +├── middleware/ +│ └── CorrelationMiddleware.ts +└── modules/ + └── */ + └── *Service.ts + +adapters/ +└── logging/ + ├── LoggerFactory.ts # * required + ├── LoggerFactoryImpl.ts # * required + ├── ConsoleLogger.ts # optional + ├── FileLogger.ts # optional + └── PinoLogger.ts # optional + + +⸻ + +Mental Model (Final) + +The Core describes events. +The App provides context. +Adapters deliver telemetry. + +If any layer violates this, the architecture is broken. + +⸻ + +Summary + • one LoggerPort in the Core + • context bound outside the Core + • adapters implement logging destinations + • correlation IDs are runtime concerns + • logs are structured, searchable, and safe + +This setup is: + • Clean Architecture compliant + • production-ready + • scalable + • refactor-safe \ No newline at end of file diff --git a/docs/architecture/USECASES.md b/docs/architecture/USECASES.md index 099c082e5..c9a544344 100644 --- a/docs/architecture/USECASES.md +++ b/docs/architecture/USECASES.md @@ -168,6 +168,90 @@ export class SponsorsController { } +⸻ + +Payments Example + +Application Layer + +Use Case + +@Injectable() +export class CreatePaymentUseCase { + constructor( + private readonly paymentRepository: IPaymentRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute(input: CreatePaymentInput): Promise>> { + // business logic + const payment = await this.paymentRepository.create(payment); + + this.output.present({ payment }); + + return Result.ok(undefined); + } +} + +Result Model + +type CreatePaymentResult = { + payment: Payment; +}; + +API Layer + +Presenter + +@Injectable() +export class CreatePaymentPresenter + implements UseCaseOutputPort +{ + private viewModel: CreatePaymentViewModel | null = null; + + present(result: CreatePaymentResult): void { + this.viewModel = { + payment: this.mapPaymentToDto(result.payment), + }; + } + + getViewModel(): CreatePaymentViewModel | null { + return this.viewModel; + } + + reset(): void { + this.viewModel = null; + } + + private mapPaymentToDto(payment: Payment): PaymentDto { + return { + id: payment.id, + // ... other fields + }; + } +} + +Controller + +@Controller('/payments') +export class PaymentsController { + constructor( + private readonly useCase: CreatePaymentUseCase, + private readonly presenter: CreatePaymentPresenter, + ) {} + + @Post() + async createPayment(@Body() input: CreatePaymentInput) { + const result = await this.useCase.execute(input); + + if (result.isErr()) { + throw mapApplicationError(result.unwrapErr()); + } + + return this.presenter.getViewModel(); + } +} + ⸻ 4. Module Wiring (Composition Root)