refactor
This commit is contained in:
@@ -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<Result<any, any>> & { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GetAnalyticsMetricsInput, GetAnalyticsMetricsOutput, GetAnalyticsMetricsErrorCode> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: GetAnalyticsMetricsInput = {}): Promise<
|
||||
Result<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>
|
||||
> {
|
||||
async execute(input: GetAnalyticsMetricsInput = {}): Promise<Result<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
|
||||
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<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>({
|
||||
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<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Failed to get analytics metrics' },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Result<any, any>> & { 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<typeof vi.fn>)).toHaveBeenCalled();
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GetDashboardDataInput, GetDashboardDataOutput, GetDashboardDataErrorCode> {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<GetDashboardDataOutput> {
|
||||
async execute(input: GetDashboardDataInput = {}): Promise<Result<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
|
||||
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<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
|
||||
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<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Failed to get dashboard data' },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
|
||||
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput, GetEntityAnalyticsErrorCode> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
|
||||
private readonly output: UseCaseOutputPort<Result<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>>,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
|
||||
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<Result<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>> {
|
||||
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<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>(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<EntityAnalyticsOutput, ApplicationErrorCode<GetEntityAnalyticsErrorCode, { message: string }>>({
|
||||
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 {
|
||||
|
||||
@@ -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<Result<any, any>> & { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<RecordEngagementInput, RecordEngagementOutput, RecordEngagementErrorCode> {
|
||||
constructor(
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
async execute(input: RecordEngagementInput): Promise<Result<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const engagementEvent = EngagementEvent.create({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -46,13 +50,19 @@ export class RecordEngagementUseCase {
|
||||
entityType: input.entityType,
|
||||
});
|
||||
|
||||
return {
|
||||
const result = Result.ok<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>({
|
||||
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<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Failed to record engagement event' },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Result<any, any>> & { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<RecordPageViewInput, RecordPageViewOutput, RecordPageViewErrorCode> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
async execute(input: RecordPageViewInput): Promise<Result<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const pageView = PageView.create({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -46,12 +50,18 @@ export class RecordPageViewUseCase {
|
||||
entityType: input.entityType,
|
||||
});
|
||||
|
||||
return {
|
||||
const result = Result.ok<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>({
|
||||
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<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Failed to record page view' },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GetUserInput, GetUserResult, GetUserErrorCode> {
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetUserResult>,
|
||||
private readonly output: UseCaseOutputPort<Result<GetUserResult, GetUserApplicationError>>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetUserInput): Promise<Result<void, GetUserApplicationError>> {
|
||||
async execute(input: GetUserInput): Promise<Result<GetUserResult, GetUserApplicationError>> {
|
||||
try {
|
||||
const stored = await this.userRepo.findById(input.userId);
|
||||
if (!stored) {
|
||||
return Result.err({
|
||||
const result = Result.err<GetUserResult, GetUserApplicationError>({
|
||||
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<GetUserResult, GetUserApplicationError>({ 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<GetUserResult, GetUserApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
} as GetUserApplicationError);
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<LoginErrorCode, { messa
|
||||
*
|
||||
* Handles user login by verifying credentials.
|
||||
*/
|
||||
export class LoginUseCase {
|
||||
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<LoginResult>,
|
||||
private readonly output: UseCaseOutputPort<Result<LoginResult, LoginApplicationError>>,
|
||||
) {}
|
||||
|
||||
async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
|
||||
async execute(input: LoginInput): Promise<Result<LoginResult, LoginApplicationError>> {
|
||||
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<LoginResult, LoginApplicationError>({
|
||||
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<LoginResult, LoginApplicationError>({
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
details: { message: 'Invalid credentials' },
|
||||
} as LoginApplicationError);
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: LoginResult = { user };
|
||||
const result = Result.ok<LoginResult, LoginApplicationError>({ 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<LoginResult, LoginApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
} as LoginApplicationError);
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SignupErrorCode, { mes
|
||||
*
|
||||
* Handles user registration.
|
||||
*/
|
||||
export class SignupUseCase {
|
||||
export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<SignupResult>,
|
||||
private readonly output: UseCaseOutputPort<Result<SignupResult, SignupApplicationError>>,
|
||||
) {}
|
||||
|
||||
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
|
||||
async execute(input: SignupInput): Promise<Result<SignupResult, SignupApplicationError>> {
|
||||
try {
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
|
||||
const existingUser = await this.authRepo.findByEmail(emailVO);
|
||||
if (existingUser) {
|
||||
return Result.err({
|
||||
const result = Result.err<SignupResult, SignupApplicationError>({
|
||||
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<SignupResult, SignupApplicationError>({ 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<SignupResult, SignupApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
} as SignupApplicationError);
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface DeleteMediaResult {
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IDeleteMediaPresenter {
|
||||
present(result: DeleteMediaResult): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
};
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IGetMediaPresenter {
|
||||
present(result: GetMediaResult): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface SelectAvatarResult {
|
||||
success: boolean;
|
||||
selectedAvatarUrl?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ISelectAvatarPresenter {
|
||||
present(result: SelectAvatarResult): void;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface UpdateAvatarResult {
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IUpdateAvatarPresenter {
|
||||
present(result: UpdateAvatarResult): void;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface UploadMediaResult {
|
||||
success: boolean;
|
||||
mediaId?: string;
|
||||
url?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IUploadMediaPresenter {
|
||||
present(result: UploadMediaResult): void;
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -1,2 +1 @@
|
||||
export * from './presenters';
|
||||
export * from './use-cases';
|
||||
@@ -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<AwardPrizeResultDTO, AwardPrizeViewModel> {}
|
||||
@@ -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<CreatePaymentResultDTO, CreatePaymentViewModel> {}
|
||||
@@ -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<CreatePrizeResultDTO, CreatePrizeViewModel> {}
|
||||
@@ -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<DeletePrizeResultDTO, DeletePrizeViewModel> {}
|
||||
@@ -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<GetMembershipFeesResultDTO, GetMembershipFeesViewModel> {}
|
||||
@@ -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<GetPaymentsResultDTO, GetPaymentsViewModel> {}
|
||||
@@ -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<GetPrizesResultDTO, GetPrizesViewModel> {}
|
||||
@@ -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<GetWalletResultDTO, GetWalletViewModel> {}
|
||||
@@ -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<ProcessWalletTransactionResultDTO, ProcessWalletTransactionViewModel> {}
|
||||
@@ -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<UpdateMemberPaymentResultDTO, UpdateMemberPaymentViewModel> {}
|
||||
@@ -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<UpdatePaymentStatusResultDTO, UpdatePaymentStatusViewModel> {}
|
||||
@@ -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<UpsertMembershipFeeResultDTO, UpsertMembershipFeeViewModel> {}
|
||||
@@ -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';
|
||||
@@ -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<AwardPrizeInput, AwardPrizeResultDTO, AwardPrizeViewModel, IAwardPrizePresenter>
|
||||
implements UseCase<AwardPrizeInput, void, AwardPrizeErrorCode>
|
||||
{
|
||||
constructor(private readonly prizeRepository: IPrizeRepository) {}
|
||||
|
||||
async execute(
|
||||
input: AwardPrizeInput,
|
||||
presenter: IAwardPrizePresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<AwardPrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: AwardPrizeInput): Promise<Result<void, ApplicationErrorCode<AwardPrizeErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<any>,
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<CreatePaymentInput, CreatePaymentResultDTO, CreatePaymentViewModel, ICreatePaymentPresenter>
|
||||
implements UseCase<CreatePaymentInput, void, CreatePaymentErrorCode>
|
||||
{
|
||||
constructor(private readonly paymentRepository: IPaymentRepository) {}
|
||||
|
||||
async execute(
|
||||
input: CreatePaymentInput,
|
||||
presenter: ICreatePaymentPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<CreatePrizeInput, CreatePrizeResultDTO, CreatePrizeViewModel, ICreatePrizePresenter>
|
||||
implements UseCase<CreatePrizeInput, void, CreatePrizeErrorCode>
|
||||
{
|
||||
constructor(private readonly prizeRepository: IPrizeRepository) {}
|
||||
|
||||
async execute(
|
||||
input: CreatePrizeInput,
|
||||
presenter: ICreatePrizePresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<CreatePrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePrizeInput): Promise<Result<void, ApplicationErrorCode<CreatePrizeErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<DeletePrizeInput, DeletePrizeResultDTO, DeletePrizeViewModel, IDeletePrizePresenter>
|
||||
implements UseCase<DeletePrizeInput, void, DeletePrizeErrorCode>
|
||||
{
|
||||
constructor(private readonly prizeRepository: IPrizeRepository) {}
|
||||
|
||||
async execute(
|
||||
input: DeletePrizeInput,
|
||||
presenter: IDeletePrizePresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<DeletePrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: DeletePrizeInput): Promise<Result<void, ApplicationErrorCode<DeletePrizeErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<any>,
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GetMembershipFeesInput, GetMembershipFeesResultDTO, GetMembershipFeesViewModel, IGetMembershipFeesPresenter>
|
||||
implements UseCase<GetMembershipFeesInput, void, GetMembershipFeesErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly memberPaymentRepository: IMemberPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<GetMembershipFeesResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetMembershipFeesInput,
|
||||
presenter: IGetMembershipFeesPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
|
||||
async execute(input: GetMembershipFeesInput): Promise<Result<void, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<any>,
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<GetPaymentsInput, GetPaymentsResultDTO, GetPaymentsViewModel, IGetPaymentsPresenter>
|
||||
implements UseCase<GetPaymentsInput, void, GetPaymentsErrorCode>
|
||||
{
|
||||
constructor(private readonly paymentRepository: IPaymentRepository) {}
|
||||
|
||||
async execute(
|
||||
input: GetPaymentsInput,
|
||||
presenter: IGetPaymentsPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<GetPaymentsResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetPaymentsInput): Promise<Result<void, ApplicationErrorCode<GetPaymentsErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<GetPrizesInput, GetPrizesResultDTO, GetPrizesViewModel, IGetPrizesPresenter>
|
||||
implements UseCase<GetPrizesInput, void, never>
|
||||
{
|
||||
constructor(private readonly prizeRepository: IPrizeRepository) {}
|
||||
|
||||
async execute(
|
||||
input: GetPrizesInput,
|
||||
presenter: IGetPrizesPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<GetPrizesResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetPrizesInput): Promise<Result<void, never>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
|
||||
) {}
|
||||
|
||||
async getSponsorBilling(sponsorId: string): Promise<SponsorBillingSummary> {
|
||||
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GetWalletInput, GetWalletResultDTO, GetWalletViewModel, IGetWalletPresenter>
|
||||
implements UseCase<GetWalletInput, void, GetWalletErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly transactionRepository: ITransactionRepository,
|
||||
private readonly output: UseCaseOutputPort<GetWalletResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetWalletInput,
|
||||
presenter: IGetWalletPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
|
||||
async execute(input: GetWalletInput): Promise<Result<void, ApplicationErrorCode<GetWalletErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<any>,
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<ProcessWalletTransactionInput, ProcessWalletTransactionResultDTO, ProcessWalletTransactionViewModel, IProcessWalletTransactionPresenter>
|
||||
implements UseCase<ProcessWalletTransactionInput, void, ProcessWalletTransactionErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly transactionRepository: ITransactionRepository,
|
||||
private readonly output: UseCaseOutputPort<ProcessWalletTransactionResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: ProcessWalletTransactionInput,
|
||||
presenter: IProcessWalletTransactionPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
|
||||
async execute(input: ProcessWalletTransactionInput): Promise<Result<void, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateMemberPaymentInput, UpdateMemberPaymentResultDTO, UpdateMemberPaymentViewModel, IUpdateMemberPaymentPresenter>
|
||||
implements UseCase<UpdateMemberPaymentInput, void, UpdateMemberPaymentErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly memberPaymentRepository: IMemberPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateMemberPaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: UpdateMemberPaymentInput,
|
||||
presenter: IUpdateMemberPaymentPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
|
||||
async execute(input: UpdateMemberPaymentInput): Promise<Result<void, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdatePaymentStatusInput, UpdatePaymentStatusResultDTO, UpdatePaymentStatusViewModel, IUpdatePaymentStatusPresenter>
|
||||
implements UseCase<UpdatePaymentStatusInput, void, UpdatePaymentStatusErrorCode>
|
||||
{
|
||||
constructor(private readonly paymentRepository: IPaymentRepository) {}
|
||||
|
||||
async execute(
|
||||
input: UpdatePaymentStatusInput,
|
||||
presenter: IUpdatePaymentStatusPresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdatePaymentStatusResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: UpdatePaymentStatusInput): Promise<Result<void, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpsertMembershipFeeInput, UpsertMembershipFeeResultDTO, UpsertMembershipFeeViewModel, IUpsertMembershipFeePresenter>
|
||||
implements UseCase<UpsertMembershipFeeInput, void, UpsertMembershipFeeErrorCode>
|
||||
{
|
||||
constructor(private readonly membershipFeeRepository: IMembershipFeeRepository) {}
|
||||
|
||||
async execute(
|
||||
input: UpsertMembershipFeeInput,
|
||||
presenter: IUpsertMembershipFeePresenter,
|
||||
): Promise<void> {
|
||||
presenter.reset();
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly output: UseCaseOutputPort<UpsertMembershipFeeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: UpsertMembershipFeeInput): Promise<Result<void, never>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DashboardOverviewResult>,
|
||||
) {}
|
||||
|
||||
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',
|
||||
|
||||
@@ -27,14 +27,14 @@ export class GetAllLeaguesWithCapacityUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly output: UseCaseOutputPort<GetAllLeaguesWithCapacityResult>,
|
||||
private readonly outputPort: UseCaseOutputPort<GetAllLeaguesWithCapacityResult, GetAllLeaguesWithCapacityErrorCode>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
_input: GetAllLeaguesWithCapacityInput = {},
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
GetAllLeaguesWithCapacityResult,
|
||||
ApplicationErrorCode<GetAllLeaguesWithCapacityErrorCode, { message: string }>
|
||||
>
|
||||
> {
|
||||
@@ -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
|
||||
|
||||
@@ -52,7 +52,7 @@ export class GetDriversLeaderboardUseCase {
|
||||
|
||||
async execute(
|
||||
_input: GetDriversLeaderboardInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>>> {
|
||||
): Promise<Result<GetDriversLeaderboardResult, ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>>> {
|
||||
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',
|
||||
|
||||
@@ -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<T, ApplicationErrorCode<E>> 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<E>. 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<CreateRaceInput, void, CreateRaceErrorCode, ViewModel, Presenter<Result<void, ApplicationErrorCode<CreateRaceErrorCode>>, ViewModel>> {
|
||||
* execute(input: CreateRaceInput, presenter: Presenter<Result<void, ApplicationErrorCode<CreateRaceErrorCode>>, ViewModel>): Promise<void> {
|
||||
* export class CreateRaceUseCase implements UseCase<CreateRaceInput, void, CreateRaceErrorCode> {
|
||||
* execute(input: CreateRaceInput): Promise<Result<void, ApplicationErrorCode<CreateRaceErrorCode>>> {
|
||||
* // 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<Result<Success, ApplicationErrorCode<ErrorCode>>, ViewModel>
|
||||
*/
|
||||
export interface UseCase<Input, Success, ErrorCode extends string, ViewModel, P extends Presenter<Result<Success, ApplicationErrorCode<ErrorCode>>, ViewModel>> {
|
||||
execute(input: Input, presenter: P): Promise<void> | void;
|
||||
export interface UseCase<Input, Success, ErrorCode extends string> {
|
||||
execute(input: Input): Promise<Result<Success, ApplicationErrorCode<ErrorCode>>>;
|
||||
}
|
||||
@@ -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<T> {
|
||||
present(data: T): void;
|
||||
export interface UseCaseOutputPort<T, E extends string = string> {
|
||||
present(result: Result<T, ApplicationErrorCode<E>>): any;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Presenter<InputDTO, ViewModel> {
|
||||
// This must not be used within core. It's in presentation layer, e.g. to be used in an API.
|
||||
export interface Presenter<InputDTO, ResponseModel> {
|
||||
present(input: InputDTO): void;
|
||||
getViewModel(): ViewModel | null;
|
||||
getResponseModel(): ResponseModel | null;
|
||||
reset(): void;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
245
docs/architecture/LOGGING.md
Normal file
245
docs/architecture/LOGGING.md
Normal file
@@ -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
|
||||
@@ -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<CreatePaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||||
// 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<CreatePaymentResult>
|
||||
{
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user