refactor
This commit is contained in:
29
apps/api/src/domain/analytics/AnalyticsController.ts
Normal file
29
apps/api/src/domain/analytics/AnalyticsController.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { RecordPageViewInput, RecordPageViewOutput, RecordEngagementInput, RecordEngagementOutput } from './dto/AnalyticsDto';
|
||||
import { AnalyticsService } from './AnalyticsService';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
@Post('page-view')
|
||||
async recordPageView(
|
||||
@Body() input: RecordPageViewInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
}
|
||||
|
||||
@Post('engagement')
|
||||
async recordEngagement(
|
||||
@Body() input: RecordEngagementInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
|
||||
res.status(HttpStatus.CREATED).json(output);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/domain/analytics/AnalyticsModule.ts
Normal file
12
apps/api/src/domain/analytics/AnalyticsModule.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './AnalyticsController';
|
||||
import { AnalyticsService } from './AnalyticsService';
|
||||
import { AnalyticsProviders } from './AnalyticsProviders';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AnalyticsController],
|
||||
providers: AnalyticsProviders,
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
44
apps/api/src/domain/analytics/AnalyticsProviders.ts
Normal file
44
apps/api/src/domain/analytics/AnalyticsProviders.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { AnalyticsService } from './AnalyticsService';
|
||||
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
|
||||
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
|
||||
|
||||
const Logger_TOKEN = 'Logger_TOKEN';
|
||||
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
|
||||
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
|
||||
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
|
||||
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
|
||||
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
||||
|
||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
|
||||
import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
|
||||
|
||||
export const AnalyticsProviders: Provider[] = [
|
||||
AnalyticsService,
|
||||
RecordPageViewUseCase,
|
||||
RecordEngagementUseCase,
|
||||
{
|
||||
provide: Logger_TOKEN,
|
||||
useClass: ConsoleLogger,
|
||||
},
|
||||
{
|
||||
provide: IPAGE_VIEW_REPO_TOKEN,
|
||||
useClass: InMemoryPageViewRepository,
|
||||
},
|
||||
{
|
||||
provide: IENGAGEMENT_REPO_TOKEN,
|
||||
useClass: InMemoryEngagementRepository,
|
||||
},
|
||||
{
|
||||
provide: RECORD_PAGE_VIEW_USE_CASE_TOKEN,
|
||||
useClass: RecordPageViewUseCase,
|
||||
},
|
||||
{
|
||||
provide: RECORD_ENGAGEMENT_USE_CASE_TOKEN,
|
||||
useClass: RecordEngagementUseCase,
|
||||
},
|
||||
];
|
||||
26
apps/api/src/domain/analytics/AnalyticsService.ts
Normal file
26
apps/api/src/domain/analytics/AnalyticsService.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { RecordEngagementInput, RecordEngagementOutput, RecordPageViewInput, RecordPageViewOutput } from './dto/AnalyticsDto';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
|
||||
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
|
||||
|
||||
const Logger_TOKEN = 'Logger_TOKEN';
|
||||
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
|
||||
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@Inject(RECORD_PAGE_VIEW_USE_CASE_TOKEN) private readonly recordPageViewUseCase: RecordPageViewUseCase,
|
||||
@Inject(RECORD_ENGAGEMENT_USE_CASE_TOKEN) private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
||||
@Inject(Logger_TOKEN) private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
return await this.recordPageViewUseCase.execute(input);
|
||||
}
|
||||
|
||||
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
return await this.recordEngagementUseCase.execute(input);
|
||||
}
|
||||
}
|
||||
127
apps/api/src/domain/analytics/dto/AnalyticsDto.ts
Normal file
127
apps/api/src/domain/analytics/dto/AnalyticsDto.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsEnum, IsBoolean, IsNumber, IsObject } from 'class-validator';
|
||||
|
||||
// From core/analytics/domain/types/PageView.ts
|
||||
export enum EntityType {
|
||||
LEAGUE = 'league',
|
||||
DRIVER = 'driver',
|
||||
TEAM = 'team',
|
||||
RACE = 'race',
|
||||
SPONSOR = 'sponsor',
|
||||
}
|
||||
|
||||
// From core/analytics/domain/types/PageView.ts
|
||||
export enum VisitorType {
|
||||
ANONYMOUS = 'anonymous',
|
||||
DRIVER = 'driver',
|
||||
SPONSOR = 'sponsor',
|
||||
}
|
||||
|
||||
export class RecordPageViewInput {
|
||||
@ApiProperty({ enum: EntityType })
|
||||
@IsEnum(EntityType)
|
||||
entityType!: EntityType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
entityId!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
visitorId?: string;
|
||||
|
||||
@ApiProperty({ enum: VisitorType })
|
||||
@IsEnum(VisitorType)
|
||||
visitorType!: VisitorType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
sessionId!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
referrer?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userAgent?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewOutput {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
pageViewId!: string;
|
||||
}
|
||||
|
||||
// From core/analytics/domain/types/EngagementEvent.ts
|
||||
export enum EngagementAction {
|
||||
CLICK_SPONSOR_LOGO = 'click_sponsor_logo',
|
||||
CLICK_SPONSOR_URL = 'click_sponsor_url',
|
||||
DOWNLOAD_LIVERY_PACK = 'download_livery_pack',
|
||||
JOIN_LEAGUE = 'join_league',
|
||||
REGISTER_RACE = 'register_race',
|
||||
VIEW_STANDINGS = 'view_standings',
|
||||
VIEW_SCHEDULE = 'view_schedule',
|
||||
SHARE_SOCIAL = 'share_social',
|
||||
CONTACT_SPONSOR = 'contact_sponsor',
|
||||
}
|
||||
|
||||
// From core/analytics/domain/types/EngagementEvent.ts
|
||||
export enum EngagementEntityType {
|
||||
LEAGUE = 'league',
|
||||
DRIVER = 'driver',
|
||||
TEAM = 'team',
|
||||
RACE = 'race',
|
||||
SPONSOR = 'sponsor',
|
||||
SPONSORSHIP = 'sponsorship',
|
||||
}
|
||||
|
||||
export class RecordEngagementInput {
|
||||
@ApiProperty({ enum: EngagementAction })
|
||||
@IsEnum(EngagementAction)
|
||||
action!: EngagementAction;
|
||||
|
||||
@ApiProperty({ enum: EngagementEntityType })
|
||||
@IsEnum(EngagementEntityType)
|
||||
entityType!: EngagementEntityType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
entityId!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorId?: string;
|
||||
|
||||
@ApiProperty({ enum: ['anonymous', 'driver', 'sponsor'] })
|
||||
@IsEnum(['anonymous', 'driver', 'sponsor'])
|
||||
actorType!: 'anonymous' | 'driver' | 'sponsor';
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
sessionId!: string;
|
||||
|
||||
@ApiProperty({ required: false, type: Object })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export class RecordEngagementOutput {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
eventId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
engagementWeight!: number;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecordEngagementUseCase } from './RecordEngagementUseCase';
|
||||
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
describe('RecordEngagementUseCase', () => {
|
||||
let useCase: RecordEngagementUseCase;
|
||||
let engagementRepository: jest.Mocked<IEngagementRepository>;
|
||||
let logger: jest.Mocked<Logger>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RecordEngagementUseCase,
|
||||
{
|
||||
provide: 'IEngagementRepository_TOKEN',
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'Logger_TOKEN',
|
||||
useValue: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
|
||||
engagementRepository = module.get('IEngagementRepository_TOKEN');
|
||||
logger = module.get('Logger_TOKEN');
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should save the engagement event and return the eventId and engagementWeight', async () => {
|
||||
const input = {
|
||||
action: 'like' as any,
|
||||
entityType: 'race' as any,
|
||||
entityId: 'race-123',
|
||||
actorType: 'driver',
|
||||
sessionId: 'session-456',
|
||||
actorId: 'actor-789',
|
||||
metadata: { some: 'data' },
|
||||
};
|
||||
|
||||
const mockEvent = {
|
||||
getEngagementWeight: jest.fn().mockReturnValue(10),
|
||||
};
|
||||
|
||||
// Mock the create function to return the mock event
|
||||
const originalCreate = require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create;
|
||||
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = jest.fn().mockReturnValue(mockEvent);
|
||||
|
||||
engagementRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('Executing RecordEngagementUseCase', { input });
|
||||
expect(engagementRepository.save).toHaveBeenCalledWith(mockEvent);
|
||||
expect(logger.info).toHaveBeenCalledWith('Engagement recorded successfully', expect.objectContaining({ eventId: expect.any(String), input }));
|
||||
expect(result).toHaveProperty('eventId');
|
||||
expect(result).toHaveProperty('engagementWeight', 10);
|
||||
expect(typeof result.eventId).toBe('string');
|
||||
|
||||
// Restore original
|
||||
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = originalCreate;
|
||||
});
|
||||
|
||||
it('should handle errors and throw them', async () => {
|
||||
const input = {
|
||||
action: 'like' as any,
|
||||
entityType: 'race' as any,
|
||||
entityId: 'race-123',
|
||||
actorType: 'driver',
|
||||
sessionId: 'session-456',
|
||||
};
|
||||
|
||||
const error = new Error('Save failed');
|
||||
engagementRepository.save.mockRejectedValue(error);
|
||||
|
||||
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
|
||||
expect(logger.error).toHaveBeenCalledWith('Error recording engagement', error, { input });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { RecordEngagementInput, RecordEngagementOutput } from '../dto/AnalyticsDto';
|
||||
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
const Logger_TOKEN = 'Logger_TOKEN';
|
||||
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
|
||||
|
||||
@Injectable()
|
||||
export class RecordEngagementUseCase {
|
||||
constructor(
|
||||
@Inject(IENGAGEMENT_REPO_TOKEN) private readonly engagementRepository: IEngagementRepository,
|
||||
@Inject(Logger_TOKEN) private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
this.logger.debug('Executing RecordEngagementUseCase', { input });
|
||||
try {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
|
||||
id: eventId,
|
||||
action: input.action as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
|
||||
entityType: input.entityType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
|
||||
entityId: input.entityId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
};
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
...baseProps,
|
||||
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
|
||||
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
this.logger.info('Engagement recorded successfully', { eventId, input });
|
||||
|
||||
return {
|
||||
eventId,
|
||||
engagementWeight: event.getEngagementWeight(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Error recording engagement', error, { input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecordPageViewUseCase } from './RecordPageViewUseCase';
|
||||
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
describe('RecordPageViewUseCase', () => {
|
||||
let useCase: RecordPageViewUseCase;
|
||||
let pageViewRepository: jest.Mocked<IPageViewRepository>;
|
||||
let logger: jest.Mocked<Logger>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RecordPageViewUseCase,
|
||||
{
|
||||
provide: 'IPageViewRepository_TOKEN',
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'Logger_TOKEN',
|
||||
useValue: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
|
||||
pageViewRepository = module.get('IPageViewRepository_TOKEN');
|
||||
logger = module.get('Logger_TOKEN');
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should save the page view and return the pageViewId', async () => {
|
||||
const input = {
|
||||
entityType: 'race' as any,
|
||||
entityId: 'race-123',
|
||||
visitorType: 'anonymous' as any,
|
||||
sessionId: 'session-456',
|
||||
visitorId: 'visitor-789',
|
||||
referrer: 'https://example.com',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
country: 'US',
|
||||
};
|
||||
|
||||
pageViewRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('Executing RecordPageViewUseCase', { input });
|
||||
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('Page view recorded successfully', expect.objectContaining({ pageViewId: expect.any(String), input }));
|
||||
expect(result).toHaveProperty('pageViewId');
|
||||
expect(typeof result.pageViewId).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle errors and throw them', async () => {
|
||||
const input = {
|
||||
entityType: 'race' as any,
|
||||
entityId: 'race-123',
|
||||
visitorType: 'anonymous' as any,
|
||||
sessionId: 'session-456',
|
||||
};
|
||||
|
||||
const error = new Error('Save failed');
|
||||
pageViewRepository.save.mockRejectedValue(error);
|
||||
|
||||
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
|
||||
expect(logger.error).toHaveBeenCalledWith('Error recording page view', error, { input });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { RecordPageViewInput, RecordPageViewOutput } from '../dto/AnalyticsDto';
|
||||
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { PageView } from '@core/analytics/domain/entities/PageView';
|
||||
|
||||
const Logger_TOKEN = 'Logger_TOKEN';
|
||||
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
|
||||
|
||||
@Injectable()
|
||||
export class RecordPageViewUseCase {
|
||||
constructor(
|
||||
@Inject(IPAGE_VIEW_REPO_TOKEN) private readonly pageViewRepository: IPageViewRepository,
|
||||
@Inject(Logger_TOKEN) private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
this.logger.debug('Executing RecordPageViewUseCase', { input });
|
||||
try {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
|
||||
id: pageViewId,
|
||||
entityType: input.entityType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
|
||||
entityId: input.entityId,
|
||||
visitorType: input.visitorType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
|
||||
sessionId: input.sessionId,
|
||||
};
|
||||
|
||||
const pageView = PageView.create({
|
||||
...baseProps,
|
||||
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
|
||||
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
|
||||
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
|
||||
...(input.country !== undefined ? { country: input.country } : {}),
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
this.logger.info('Page view recorded successfully', { pageViewId, input });
|
||||
return { pageViewId };
|
||||
} catch (error) {
|
||||
this.logger.error('Error recording page view', error, { input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user