wip
This commit is contained in:
10
apps/api/.dockerignore
Normal file
10
apps/api/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
Dockerfile
|
||||
docker-compose.*
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
npm-debug.log
|
||||
24
apps/api/Dockerfile.dev
Normal file
24
apps/api/Dockerfile.dev
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install bash for better shell capabilities
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
# Copy root package.json and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN find ./node_modules -name "ts-node-dev" -print || true # Debugging line
|
||||
|
||||
# Copy apps/api and packages for development
|
||||
COPY apps/api apps/api/
|
||||
COPY packages packages/
|
||||
COPY apps/api/tsconfig.json apps/api/
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 9229
|
||||
|
||||
# Command to run the NestJS application in development with hot-reloading
|
||||
# Run from the correct workspace context
|
||||
CMD ["npm", "run", "start:dev", "--workspace=api"]
|
||||
44
apps/api/Dockerfile.prod
Normal file
44
apps/api/Dockerfile.prod
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package.json and install dependencies (for monorepo)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy apps/api and packages for building
|
||||
COPY apps/api apps/api/
|
||||
COPY packages packages/
|
||||
COPY apps/api/tsconfig.json apps/api/
|
||||
COPY tsconfig.base.json ./
|
||||
|
||||
# Build the NestJS application (ensuring correct workspace context)
|
||||
# Run from the root workspace context
|
||||
# RUN node ./node_modules/@nestjs/cli/bin/nest.js build --workspace=api # Not needed, npm run handles it
|
||||
RUN npm run build --workspace=api
|
||||
|
||||
|
||||
# Production stage: slim image with only production dependencies
|
||||
FROM node:20-alpine AS production_final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install wget for healthchecks
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
# Copy package files and install production dependencies only
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
|
||||
# Copy packages (needed for runtime dependencies)
|
||||
COPY --from=builder /app/packages ./packages
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Command to run the NestJS application
|
||||
CMD ["node", "./apps/api/dist/main"]
|
||||
1
apps/api/index.ts
Normal file
1
apps/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
console.log('NestJS API service is running!');
|
||||
17
apps/api/jest.config.js
Normal file
17
apps/api/jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
transform: {
|
||||
'^.+\.(t|j)s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
collectCoverageFrom: [
|
||||
'**/*.(t|j)s'
|
||||
],
|
||||
coverageDirectory: '../coverage',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
moduleNameMapper: {
|
||||
'^@gridpilot/(.*)$': '<rootDir>/../../packages/$1', // Corrected path
|
||||
},
|
||||
};
|
||||
31
apps/api/package.json
Normal file
31
apps/api/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "1.0.0",
|
||||
"description": "NestJS API service",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build --verbose",
|
||||
"start:dev": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/main.ts",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "npx jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"@nestjs/testing": "^10.4.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.20",
|
||||
"@nestjs/core": "^10.4.20",
|
||||
"@nestjs/platform-express": "^10.4.20",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"pg": "^8.12.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typeorm": "^0.3.20"
|
||||
}
|
||||
}
|
||||
16
apps/api/src/app.module.ts
Normal file
16
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HelloController } from './presentation/hello.controller';
|
||||
import { HelloService } from './application/hello/hello.service';
|
||||
import { AnalyticsModule } from './application/analytics/analytics.module';
|
||||
import { DatabaseModule } from './infrastructure/database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
AnalyticsModule
|
||||
],
|
||||
controllers: [HelloController],
|
||||
providers: [HelloService],
|
||||
})
|
||||
export class AppModule {}
|
||||
34
apps/api/src/application/analytics/analytics.module.ts
Normal file
34
apps/api/src/application/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module, ConsoleLogger } from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsController } from '../../presentation/analytics.controller';
|
||||
import { RecordPageViewUseCase } from './record-page-view.use-case';
|
||||
import { RecordEngagementUseCase } from './record-engagement.use-case';
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { InMemoryPageViewRepository } from '../../infrastructure/analytics/in-memory-page-view.repository';
|
||||
import { InMemoryEngagementRepository } from '../../infrastructure/analytics/in-memory-engagement.repository';
|
||||
|
||||
@Module({
|
||||
imports: [], // Removed TypeOrmModule as we are using in-memory repositories
|
||||
controllers: [AnalyticsController],
|
||||
providers: [
|
||||
AnalyticsService,
|
||||
RecordPageViewUseCase,
|
||||
RecordEngagementUseCase,
|
||||
{
|
||||
provide: 'IPageViewRepository',
|
||||
useClass: InMemoryPageViewRepository,
|
||||
},
|
||||
{
|
||||
provide: 'IEngagementRepository',
|
||||
useClass: InMemoryEngagementRepository,
|
||||
},
|
||||
{
|
||||
provide: 'ILogger',
|
||||
useClass: ConsoleLogger, // Using ConsoleLogger for now
|
||||
},
|
||||
],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
91
apps/api/src/application/analytics/analytics.service.spec.ts
Normal file
91
apps/api/src/application/analytics/analytics.service.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { RecordPageViewUseCase, RecordPageViewInput } from './record-page-view.use-case';
|
||||
import {
|
||||
RecordEngagementUseCase,
|
||||
RecordEngagementInput,
|
||||
RecordEngagementOutput,
|
||||
} from './record-engagement.use-case';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
|
||||
import { EngagementAction, EngagementEntityType } from '@gridpilot/analytics/domain/types/EngagementEvent';
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
let service: AnalyticsService;
|
||||
let recordPageViewUseCase: RecordPageViewUseCase;
|
||||
let recordEngagementUseCase: RecordEngagementUseCase;
|
||||
let logger: ILogger;
|
||||
|
||||
const mockRecordPageViewInput: RecordPageViewInput = {
|
||||
entityType: EntityType.LEAGUE,
|
||||
entityId: 'league-123',
|
||||
visitorType: VisitorType.ANONYMOUS,
|
||||
sessionId: 'session-abc',
|
||||
};
|
||||
|
||||
const mockRecordEngagementInput: RecordEngagementInput = {
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-456',
|
||||
actorType: 'driver',
|
||||
actorId: 'driver-789',
|
||||
sessionId: 'session-def',
|
||||
metadata: { campaign: 'summer-promo' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AnalyticsService,
|
||||
{
|
||||
provide: RecordPageViewUseCase,
|
||||
useValue: {
|
||||
execute: jest.fn().mockResolvedValue({ pageViewId: 'new-pv-123' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RecordEngagementUseCase,
|
||||
useValue: {
|
||||
execute: jest.fn().mockResolvedValue({ eventId: 'new-eng-456', engagementWeight: 10 }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'ILogger',
|
||||
useValue: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as ILogger,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AnalyticsService>(AnalyticsService);
|
||||
recordPageViewUseCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
|
||||
recordEngagementUseCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
|
||||
logger = module.get<ILogger>('ILogger');
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call recordPageViewUseCase.execute and return pageViewId', async () => {
|
||||
const result = await service.recordPageView(mockRecordPageViewInput);
|
||||
|
||||
expect(recordPageViewUseCase.execute).toHaveBeenCalledTimes(1);
|
||||
expect(recordPageViewUseCase.execute).toHaveBeenCalledWith(mockRecordPageViewInput);
|
||||
expect(result).toEqual({ pageViewId: 'new-pv-123' });
|
||||
expect(logger.debug).toHaveBeenCalledWith('AnalyticsService: Recording page view', { input: mockRecordPageViewInput });
|
||||
});
|
||||
|
||||
it('should call recordEngagementUseCase.execute and return engagement details', async () => {
|
||||
const result = await service.recordEngagement(mockRecordEngagementInput);
|
||||
|
||||
expect(recordEngagementUseCase.execute).toHaveBeenCalledTimes(1);
|
||||
expect(recordEngagementUseCase.execute).toHaveBeenCalledWith(mockRecordEngagementInput);
|
||||
expect(result).toEqual({ eventId: 'new-eng-456', engagementWeight: 10 });
|
||||
expect(logger.debug).toHaveBeenCalledWith('AnalyticsService: Recording engagement', { input: mockRecordEngagementInput });
|
||||
});
|
||||
});
|
||||
27
apps/api/src/application/analytics/analytics.service.ts
Normal file
27
apps/api/src/application/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { RecordPageViewUseCase, RecordPageViewInput } from './record-page-view.use-case';
|
||||
import {
|
||||
RecordEngagementUseCase,
|
||||
RecordEngagementInput,
|
||||
RecordEngagementOutput,
|
||||
} from './record-engagement.use-case';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
private readonly recordPageViewUseCase: RecordPageViewUseCase,
|
||||
private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
||||
@Inject('ILogger') private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async recordPageView(input: RecordPageViewInput): Promise<{ pageViewId: string }> {
|
||||
this.logger.debug('AnalyticsService: Recording page view', { input });
|
||||
return this.recordPageViewUseCase.execute(input);
|
||||
}
|
||||
|
||||
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
this.logger.debug('AnalyticsService: Recording engagement', { input });
|
||||
return this.recordEngagementUseCase.execute(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecordEngagementUseCase, RecordEngagementInput } from './record-engagement.use-case';
|
||||
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { EngagementEvent } from '@gridpilot/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
describe('RecordEngagementUseCase', () => {
|
||||
let useCase: RecordEngagementUseCase;
|
||||
let engagementRepository: IEngagementRepository;
|
||||
let logger: ILogger;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RecordEngagementUseCase,
|
||||
{
|
||||
provide: 'IEngagementRepository',
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByEntityId: jest.fn(),
|
||||
findByAction: jest.fn(),
|
||||
findByDateRange: jest.fn(),
|
||||
countByAction: jest.fn(),
|
||||
getSponsorClicksForEntity: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: 'ILogger',
|
||||
useValue: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
|
||||
engagementRepository = module.get<IEngagementRepository>('IEngagementRepository');
|
||||
logger = module.get<ILogger>('ILogger');
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(useCase).toBeDefined();
|
||||
});
|
||||
|
||||
it('should record an engagement event and save it to the repository', async () => {
|
||||
const input: RecordEngagementInput = {
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-123',
|
||||
actorType: 'driver',
|
||||
actorId: 'driver-456',
|
||||
sessionId: 'session-789',
|
||||
metadata: { campaign: 'spring-sale' },
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.eventId).toMatch(/^eng-\d{13}-[a-z0-9]{9}$/);
|
||||
expect(result.engagementWeight).toBeGreaterThan(0);
|
||||
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
|
||||
const savedEvent: EngagementEvent = (engagementRepository.save as jest.Mock).mock.calls[0][0];
|
||||
expect(savedEvent).toBeInstanceOf(EngagementEvent);
|
||||
expect(savedEvent.action).toBe(input.action);
|
||||
expect(savedEvent.entityType).toBe(input.entityType);
|
||||
expect(savedEvent.entityId).toBe(input.entityId);
|
||||
expect(savedEvent.actorType).toBe(input.actorType);
|
||||
expect(savedEvent.actorId).toBe(input.actorId);
|
||||
expect(savedEvent.sessionId).toBe(input.sessionId);
|
||||
expect(savedEvent.metadata).toEqual(input.metadata);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Engagement recorded successfully',
|
||||
{ eventId: expect.any(String), input },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle engagement events without actorId or metadata', async () => {
|
||||
const input: RecordEngagementInput = {
|
||||
action: 'view_standings',
|
||||
entityType: 'league',
|
||||
entityId: 'home-page',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'session-abc',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.eventId).toMatch(/^eng-\d{13}-[a-z0-9]{9}$/);
|
||||
expect(result.engagementWeight).toBeGreaterThan(0);
|
||||
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
|
||||
const savedEvent: EngagementEvent = (engagementRepository.save as jest.Mock).mock.calls[0][0];
|
||||
expect(savedEvent.actorId).toBeUndefined();
|
||||
expect(savedEvent.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log an error if saving to repository fails', async () => {
|
||||
const input: RecordEngagementInput = {
|
||||
action: 'click_sponsor_url',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'ad-001',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'session-xyz',
|
||||
};
|
||||
const error = new Error('Repository save failed');
|
||||
(engagementRepository.save as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(useCase.execute(input)).rejects.toThrow(error);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error recording engagement',
|
||||
error,
|
||||
{ input },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '@gridpilot/analytics/domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase
|
||||
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
|
||||
constructor(
|
||||
@Inject('IEngagementRepository') private readonly engagementRepository: IEngagementRepository,
|
||||
@Inject('ILogger') private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
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,
|
||||
entityType: input.entityType,
|
||||
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,80 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecordPageViewUseCase, RecordPageViewInput, RecordPageViewOutput } from './record-page-view.use-case';
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
import { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
|
||||
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
|
||||
|
||||
describe('RecordPageViewUseCase', () => {
|
||||
let useCase: RecordPageViewUseCase;
|
||||
let pageViewRepository: IPageViewRepository;
|
||||
let logger: ILogger;
|
||||
|
||||
const mockRecordPageViewInput: RecordPageViewInput = {
|
||||
entityType: EntityType.LEAGUE,
|
||||
entityId: 'league-123',
|
||||
visitorType: VisitorType.ANONYMOUS,
|
||||
sessionId: 'session-abc',
|
||||
referrer: 'https://example.com',
|
||||
userAgent: 'test-agent',
|
||||
country: 'US',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RecordPageViewUseCase,
|
||||
{
|
||||
provide: 'IPageViewRepository',
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByEntityId: jest.fn(),
|
||||
findByDateRange: jest.fn(),
|
||||
findBySession: jest.fn(),
|
||||
countByEntityId: jest.fn(),
|
||||
countUniqueVisitors: jest.fn(),
|
||||
} as IPageViewRepository,
|
||||
},
|
||||
{
|
||||
provide: 'ILogger',
|
||||
useValue: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as ILogger,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
|
||||
pageViewRepository = module.get<IPageViewRepository>('IPageViewRepository');
|
||||
logger = module.get<ILogger>('ILogger');
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(useCase).toBeDefined();
|
||||
});
|
||||
|
||||
it('should record a page view and return its ID', async () => {
|
||||
const result: RecordPageViewOutput = await useCase.execute(mockRecordPageViewInput);
|
||||
|
||||
expect(result).toHaveProperty('pageViewId');
|
||||
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
|
||||
const savedPageView: PageView = (pageViewRepository.save as jest.Mock).mock.calls[0][0];
|
||||
expect(savedPageView.entityId).toBe(mockRecordPageViewInput.entityId);
|
||||
expect(savedPageView.entityType).toBe(mockRecordPageViewInput.entityType);
|
||||
expect(savedPageView.sessionId).toBe(mockRecordPageViewInput.sessionId);
|
||||
expect(logger.debug).toHaveBeenCalledWith('Executing RecordPageViewUseCase', { input: mockRecordPageViewInput });
|
||||
expect(logger.info).toHaveBeenCalledWith('Page view recorded successfully', { pageViewId: result.pageViewId, input: mockRecordPageViewInput });
|
||||
});
|
||||
|
||||
it('should handle errors during page view recording', async () => {
|
||||
const error = new Error('Repository error');
|
||||
(pageViewRepository.save as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(useCase.execute(mockRecordPageViewInput)).rejects.toThrow(error);
|
||||
expect(logger.error).toHaveBeenCalledWith('Error recording page view', error, { input: mockRecordPageViewInput });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
|
||||
import type { EntityType, VisitorType } from '@gridpilot/analytics/domain/entities/PageView'; // Re-exported there
|
||||
import type { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RecordPageViewUseCase
|
||||
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
|
||||
constructor(
|
||||
@Inject('IPageViewRepository') private readonly pageViewRepository: IPageViewRepository,
|
||||
@Inject('ILogger') private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
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,
|
||||
entityId: input.entityId,
|
||||
visitorType: input.visitorType,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/api/src/application/hello/hello.service.spec.ts
Normal file
23
apps/api/src/application/hello/hello.service.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HelloService } from './hello.service';
|
||||
|
||||
describe('HelloService', () => {
|
||||
let service: HelloService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [HelloService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<HelloService>(HelloService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(service.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
9
apps/api/src/application/hello/hello.service.ts
Normal file
9
apps/api/src/application/hello/hello.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class HelloService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EngagementEvent, EngagementAction, EngagementEntityType } from '@gridpilot/analytics/domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryEngagementRepository implements IEngagementRepository {
|
||||
private readonly engagements: Map<string, EngagementEvent> = new Map();
|
||||
|
||||
async save(event: EngagementEvent): Promise<void> {
|
||||
this.engagements.set(event.id, event);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EngagementEvent | null> {
|
||||
return this.engagements.get(id) || null;
|
||||
}
|
||||
|
||||
async findByEntityId(
|
||||
entityType: EngagementEntityType,
|
||||
entityId: string,
|
||||
): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.engagements.values()).filter(
|
||||
(e) => e.entityType === entityType && e.entityId === entityId,
|
||||
);
|
||||
}
|
||||
|
||||
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.engagements.values()).filter(
|
||||
(e) => e.action === action,
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.engagements.values()).filter((e) => {
|
||||
const eventDate = new Date(e.timestamp);
|
||||
return eventDate >= startDate && eventDate <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
async countByAction(
|
||||
action: EngagementAction,
|
||||
entityId?: string,
|
||||
since?: Date,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const event of this.engagements.values()) {
|
||||
if (event.action === action) {
|
||||
if (entityId && event.entityId !== entityId) {
|
||||
continue;
|
||||
}
|
||||
if (since && new Date(event.timestamp) < since) {
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async getSponsorClicksForEntity(
|
||||
entityId: string,
|
||||
since?: Date,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const event of this.engagements.values()) {
|
||||
if (event.action === 'click_sponsor_url' && event.entityId === entityId) {
|
||||
if (since && new Date(event.timestamp) < since) {
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.engagements.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PageView, EntityType, VisitorType } from '@gridpilot/analytics/domain/entities/PageView';
|
||||
import type { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
|
||||
@Injectable()
|
||||
export class InMemoryPageViewRepository implements IPageViewRepository {
|
||||
private readonly pageViews: Map<string, PageView> = new Map();
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
this.pageViews.set(pageView.id, pageView);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
return this.pageViews.get(id) || null;
|
||||
}
|
||||
|
||||
async findByEntityId(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
(pv) => pv.entityType === entityType && pv.entityId === entityId,
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter((pv) => {
|
||||
const pageViewDate = new Date(pv.timestamp);
|
||||
return pageViewDate >= startDate && pageViewDate <= endDate;
|
||||
});
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
(pv) => pv.sessionId === sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async countByEntityId(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
since?: Date,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const pageView of this.pageViews.values()) {
|
||||
if (pageView.entityType === entityType && pageView.entityId === entityId) {
|
||||
if (since && new Date(pageView.timestamp) < since) {
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async countUniqueVisitors(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
since?: Date,
|
||||
): Promise<number> {
|
||||
const uniqueVisitorIds = new Set<string>();
|
||||
for (const pageView of this.pageViews.values()) {
|
||||
if (pageView.entityType === entityType && pageView.entityId === entityId) {
|
||||
if (since && new Date(pageView.timestamp) < since) {
|
||||
continue;
|
||||
}
|
||||
if (pageView.visitorId) {
|
||||
uniqueVisitorIds.add(pageView.visitorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueVisitorIds.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pageViews.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
|
||||
|
||||
@Entity('page_views')
|
||||
export class PageViewEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: EntityType })
|
||||
entityType: EntityType;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
entityId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
visitorId?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: VisitorType })
|
||||
visitorType: VisitorType;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
sessionId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 2048, nullable: true })
|
||||
referrer?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
userAgent?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 2, nullable: true })
|
||||
country?: string;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
|
||||
timestamp: Date;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
durationMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TypeOrmPageViewRepository } from './typeorm-page-view.repository';
|
||||
import { PageViewEntity } from './typeorm-page-view.entity';
|
||||
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
|
||||
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
describe('TypeOrmPageViewRepository (Integration)', () => {
|
||||
jest.setTimeout(30000); // Increase timeout for integration tests
|
||||
let repository: IPageViewRepository;
|
||||
let pageViewTypeOrmRepository: Repository<PageViewEntity>;
|
||||
let module: TestingModule;
|
||||
const mockPageViewEntities = new Map<string, PageViewEntity>();
|
||||
|
||||
const mockPageViewRepository = {
|
||||
save: jest.fn(entity => {
|
||||
mockPageViewEntities.set(entity.id, entity);
|
||||
return entity;
|
||||
}),
|
||||
findOne: jest.fn(({ where: { id } }) => {
|
||||
return Promise.resolve(mockPageViewEntities.get(id) || null);
|
||||
}),
|
||||
find: jest.fn((query: any) => {
|
||||
const { where, order } = query;
|
||||
let results = Array.from(mockPageViewEntities.values());
|
||||
|
||||
if (where) {
|
||||
if (where.entityId) {
|
||||
results = results.filter(pv => pv.entityId === where.entityId);
|
||||
}
|
||||
if (where.entityType) {
|
||||
results = results.filter(pv => pv.entityType === where.entityType);
|
||||
}
|
||||
if (where.sessionId) {
|
||||
results = results.filter(pv => pv.sessionId === where.sessionId);
|
||||
}
|
||||
// Handle Between operator for timestamp
|
||||
if (where.timestamp && typeof where.timestamp === 'object') {
|
||||
// TypeORM's Between operator passes an object like { type: 'between', value: [date1, date2] }
|
||||
const timestampCondition = where.timestamp;
|
||||
if (timestampCondition.type === 'between' && timestampCondition.value.length === 2) {
|
||||
const [startDate, endDate] = timestampCondition.value;
|
||||
results = results.filter(pv => pv.timestamp >= startDate && pv.timestamp <= endDate);
|
||||
} else if (timestampCondition.type === 'moreThanOrEqual') {
|
||||
results = results.filter(pv => pv.timestamp >= timestampCondition.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (order && order.timestamp) {
|
||||
results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // DESC order
|
||||
}
|
||||
return Promise.resolve(results);
|
||||
}),
|
||||
count: jest.fn((query: any) => {
|
||||
const { where } = query;
|
||||
let results = Array.from(mockPageViewEntities.values());
|
||||
if (where) {
|
||||
if (where.entityId) {
|
||||
results = results.filter(pv => pv.entityId === where.entityId);
|
||||
}
|
||||
if (where.entityType) {
|
||||
results = results.filter(pv => pv.entityType === where.entityType);
|
||||
}
|
||||
if (where.timestamp && where.timestamp.type === 'moreThanOrEqual') {
|
||||
results = results.filter(pv => pv.timestamp >= where.timestamp.value);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(results.length);
|
||||
}),
|
||||
createQueryBuilder: jest.fn(() => {
|
||||
let queryResult = Array.from(mockPageViewEntities.values());
|
||||
let conditions: Array<(pv: PageViewEntity) => boolean> = [];
|
||||
|
||||
const queryBuilder: any = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn((condition, parameters) => {
|
||||
if (parameters.entityType) {
|
||||
conditions.push(pv => pv.entityType === parameters.entityType);
|
||||
}
|
||||
if (parameters.entityId) {
|
||||
conditions.push(pv => pv.entityId === parameters.entityId);
|
||||
}
|
||||
if (parameters.since) {
|
||||
conditions.push(pv => pv.timestamp >= parameters.since);
|
||||
}
|
||||
return queryBuilder;
|
||||
}),
|
||||
andWhere: jest.fn((condition, parameters) => {
|
||||
if (parameters.entityId) {
|
||||
conditions.push(pv => pv.entityId === parameters.entityId);
|
||||
}
|
||||
if (parameters.until) {
|
||||
conditions.push(pv => pv.timestamp <= parameters.until);
|
||||
}
|
||||
if (parameters.since) { // For countUniqueVisitors's second andWhere
|
||||
conditions.push(pv => pv.timestamp >= parameters.since);
|
||||
}
|
||||
return queryBuilder;
|
||||
}),
|
||||
getRawOne: jest.fn(() => {
|
||||
const filteredResult = queryResult.filter(pv => conditions.every(cond => cond(pv)));
|
||||
return Promise.resolve({ count: new Set(filteredResult.map(pv => pv.visitorId)).size });
|
||||
}),
|
||||
getMany: jest.fn(() => {
|
||||
const filteredResult = queryResult.filter(pv => conditions.every(cond => cond(pv)));
|
||||
return Promise.resolve(filteredResult);
|
||||
}),
|
||||
};
|
||||
return queryBuilder;
|
||||
}),
|
||||
query: jest.fn(() => Promise.resolve([])),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
TypeOrmPageViewRepository,
|
||||
{
|
||||
provide: getRepositoryToken(PageViewEntity),
|
||||
useValue: mockPageViewRepository,
|
||||
},
|
||||
{ provide: 'IPageViewRepository', useClass: TypeOrmPageViewRepository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repository = module.get<IPageViewRepository>('IPageViewRepository');
|
||||
pageViewTypeOrmRepository = module.get<Repository<PageViewEntity>>(
|
||||
getRepositoryToken(PageViewEntity),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear the database before each test
|
||||
mockPageViewEntities.clear(); // Clear the mock in-memory database
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// await module.close();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(repository).toBeDefined();
|
||||
});
|
||||
|
||||
it('should save a page view', async () => {
|
||||
const pageView = PageView.create({
|
||||
id: uuid(),
|
||||
entityType: EntityType.LEAGUE,
|
||||
entityId: uuid(),
|
||||
visitorType: VisitorType.ANONYMOUS,
|
||||
sessionId: uuid(),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
await repository.save(pageView);
|
||||
|
||||
const foundPageView = await repository.findById(pageView.id);
|
||||
expect(foundPageView).toBeDefined();
|
||||
expect(foundPageView?.id).toBe(pageView.id);
|
||||
expect(foundPageView?.entityType).toBe(pageView.entityType);
|
||||
});
|
||||
|
||||
it('should find a page view by ID', async () => {
|
||||
const pageView = PageView.create({
|
||||
id: uuid(),
|
||||
entityType: EntityType.DRIVER,
|
||||
entityId: uuid(),
|
||||
visitorType: VisitorType.DRIVER,
|
||||
sessionId: uuid(),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
await repository.save(pageView);
|
||||
|
||||
const foundPageView = await repository.findById(pageView.id);
|
||||
expect(foundPageView).toBeDefined();
|
||||
expect(foundPageView?.id).toBe(pageView.id);
|
||||
});
|
||||
|
||||
it('should return null if page view not found by ID', async () => {
|
||||
const foundPageView = await repository.findById(uuid());
|
||||
expect(foundPageView).toBeNull();
|
||||
});
|
||||
|
||||
it('should find page views by entity ID', async () => {
|
||||
const entityId = uuid();
|
||||
const pageView1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
|
||||
const pageView2 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date(Date.now() - 1000) });
|
||||
await repository.save(pageView1);
|
||||
await repository.save(pageView2);
|
||||
|
||||
const foundViews = await repository.findByEntityId(EntityType.LEAGUE, entityId);
|
||||
expect(foundViews).toHaveLength(2);
|
||||
expect(foundViews[0]?.id).toEqual(pageView1.id); // Should be ordered by timestamp DESC
|
||||
});
|
||||
|
||||
it('should count page views by entity ID', async () => {
|
||||
const entityId = uuid();
|
||||
const pageView1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
|
||||
const pageView2 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date(Date.now() - 1000 * 60 * 60) });
|
||||
await repository.save(pageView1);
|
||||
await repository.save(pageView2);
|
||||
|
||||
const count = await repository.countByEntityId(EntityType.LEAGUE, entityId);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should count unique visitors by entity ID', async () => {
|
||||
const entityId = uuid();
|
||||
const visitorId1 = uuid();
|
||||
const visitorId2 = uuid();
|
||||
|
||||
// Two views from visitor1
|
||||
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId1, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
|
||||
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId1, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
|
||||
// One view from visitor2
|
||||
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId2, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
|
||||
|
||||
const count = await repository.countUniqueVisitors(EntityType.LEAGUE, entityId);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for count unique visitors if no visitors', async () => {
|
||||
const count = await repository.countUniqueVisitors(EntityType.LEAGUE, uuid());
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should find page views by date range', async () => {
|
||||
const entityId = uuid();
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const pvToday = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: today });
|
||||
const pvYesterday = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: yesterday });
|
||||
const pvTwoDaysAgo = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: twoDaysAgo });
|
||||
|
||||
await repository.save(pvToday);
|
||||
await repository.save(pvYesterday);
|
||||
await repository.save(pvTwoDaysAgo);
|
||||
|
||||
const result = await repository.findByDateRange(twoDaysAgo, today);
|
||||
expect(result.length).toBe(3);
|
||||
expect(result.map(p => p?.id)).toEqual(expect.arrayContaining([pvToday.id, pvYesterday.id, pvTwoDaysAgo.id]));
|
||||
|
||||
const resultToday = await repository.findByDateRange(today, today);
|
||||
expect(resultToday.length).toBe(1);
|
||||
expect(resultToday[0]?.id).toBe(pvToday.id);
|
||||
});
|
||||
|
||||
it('should find page views by session ID', async () => {
|
||||
const sessionId = uuid();
|
||||
const entityId1 = uuid();
|
||||
const entityId2 = uuid();
|
||||
|
||||
const pv1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId: entityId1, visitorType: VisitorType.ANONYMOUS, sessionId, timestamp: new Date() });
|
||||
const pv2 = PageView.create({ id: uuid(), entityType: EntityType.DRIVER, entityId: entityId2, visitorType: VisitorType.ANONYMOUS, sessionId, timestamp: new Date(Date.now() - 100) });
|
||||
const pvOtherSession = PageView.create({ id: uuid(), entityType: EntityType.TEAM, entityId: uuid(), visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
|
||||
|
||||
await repository.save(pv1);
|
||||
await repository.save(pv2);
|
||||
await repository.save(pvOtherSession);
|
||||
|
||||
const foundViews = await repository.findBySession(sessionId);
|
||||
expect(foundViews).toHaveLength(2);
|
||||
expect(foundViews.map(p => p.id)).toEqual(expect.arrayContaining([pv1.id, pv2.id]));
|
||||
// Should be ordered by timestamp DESC
|
||||
expect(foundViews[0]?.id).toBe(pv1.id);
|
||||
expect(foundViews[1]?.id).toBe(pv2.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThanOrEqual, Between } from 'typeorm';
|
||||
import { PageView, EntityType } from '@gridpilot/analytics/domain/entities/PageView';
|
||||
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
|
||||
import { PageViewEntity } from './typeorm-page-view.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmPageViewRepository implements IPageViewRepository {
|
||||
constructor(
|
||||
@InjectRepository(PageViewEntity)
|
||||
private readonly pageViewRepository: Repository<PageViewEntity>,
|
||||
) {}
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
const pageViewEntity = this.toPageViewEntity(pageView);
|
||||
await this.pageViewRepository.save(pageViewEntity);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
const entity = await this.pageViewRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
return entity ? this.toPageView(entity) : null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
|
||||
const entities = await this.pageViewRepository.find({
|
||||
where: { entityType, entityId },
|
||||
order: { timestamp: 'DESC' },
|
||||
});
|
||||
return entities.map(this.toPageView);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
|
||||
const entities = await this.pageViewRepository.find({
|
||||
where: { timestamp: Between(startDate, endDate) },
|
||||
order: { timestamp: 'DESC' },
|
||||
});
|
||||
return entities.map(this.toPageView);
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
const entities = await this.pageViewRepository.find({
|
||||
where: { sessionId },
|
||||
order: { timestamp: 'DESC' },
|
||||
});
|
||||
return entities.map(this.toPageView);
|
||||
}
|
||||
|
||||
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
const where: any = { entityType, entityId };
|
||||
if (since) {
|
||||
where.timestamp = MoreThanOrEqual(since);
|
||||
}
|
||||
return this.pageViewRepository.count({ where });
|
||||
}
|
||||
|
||||
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
const query = this.pageViewRepository.createQueryBuilder('page_view')
|
||||
.select('COUNT(DISTINCT "visitorId")', 'count')
|
||||
.where('page_view.entityType = :entityType', { entityType })
|
||||
.andWhere('page_view.entityId = :entityId', { entityId });
|
||||
|
||||
if (since) {
|
||||
query.andWhere('page_view.timestamp >= :since', { since });
|
||||
}
|
||||
|
||||
const result = await query.getRawOne();
|
||||
return parseInt(result.count, 10) || 0;
|
||||
}
|
||||
|
||||
public toPageViewEntity(pageView: PageView): PageViewEntity {
|
||||
const entity = new PageViewEntity();
|
||||
entity.id = pageView.id;
|
||||
entity.entityType = pageView.entityType;
|
||||
entity.entityId = pageView.entityId;
|
||||
entity.visitorId = pageView.visitorId;
|
||||
entity.visitorType = pageView.visitorType;
|
||||
entity.sessionId = pageView.sessionId;
|
||||
entity.referrer = pageView.referrer;
|
||||
entity.userAgent = pageView.userAgent;
|
||||
entity.country = pageView.country;
|
||||
entity.timestamp = pageView.timestamp;
|
||||
entity.durationMs = pageView.durationMs;
|
||||
return entity;
|
||||
}
|
||||
|
||||
private toPageView(entity: PageViewEntity): PageView {
|
||||
return PageView.create({
|
||||
id: entity.id,
|
||||
entityType: entity.entityType,
|
||||
entityId: entity.entityId,
|
||||
visitorType: entity.visitorType,
|
||||
sessionId: entity.sessionId,
|
||||
timestamp: entity.timestamp,
|
||||
visitorId: entity.visitorId,
|
||||
referrer: entity.referrer,
|
||||
userAgent: entity.userAgent,
|
||||
country: entity.country,
|
||||
durationMs: entity.durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
19
apps/api/src/infrastructure/database/database.module.ts
Normal file
19
apps/api/src/infrastructure/database/database.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PageViewEntity } from '../analytics/typeorm-page-view.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||
username: process.env.DATABASE_USER || 'user',
|
||||
password: process.env.DATABASE_PASSWORD || 'password',
|
||||
database: process.env.DATABASE_NAME || 'gridpilot',
|
||||
entities: [PageViewEntity],
|
||||
synchronize: true, // Use carefully in production
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
11
apps/api/src/main.ts
Normal file
11
apps/api/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import 'reflect-metadata'; // For NestJS DI (before any other imports)
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
}
|
||||
bootstrap();
|
||||
28
apps/api/src/presentation/analytics.controller.ts
Normal file
28
apps/api/src/presentation/analytics.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Post, Body, Res, HttpStatus } from '@nestjs/common';
|
||||
import { AnalyticsService } from '../application/analytics/analytics.service';
|
||||
import { RecordPageViewInput } from '../application/analytics/record-page-view.use-case';
|
||||
import { RecordEngagementInput, RecordEngagementOutput } from '../application/analytics/record-engagement.use-case';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Post('page-view')
|
||||
async recordPageView(
|
||||
@Body() input: RecordPageViewInput,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { pageViewId } = await this.analyticsService.recordPageView(input);
|
||||
res.status(HttpStatus.CREATED).json({ pageViewId });
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/presentation/hello.controller.ts
Normal file
13
apps/api/src/presentation/hello.controller.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HelloService } from '../application/hello/hello.service';
|
||||
|
||||
@Controller()
|
||||
export class HelloController {
|
||||
constructor(private readonly helloService: HelloService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.helloService.getHello();
|
||||
}
|
||||
}
|
||||
49
apps/api/tsconfig.json
Normal file
49
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"lib": ["es2022", "dom"],
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitThis": false,
|
||||
"strictNullChecks": false,
|
||||
"alwaysStrict": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"assumeChangesOnlyAffectDirectDependencies": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "express", "jest"],
|
||||
"strictPropertyInitialization": false,
|
||||
"paths": {
|
||||
"@gridpilot/shared/*": [
|
||||
"../../packages/shared/*"
|
||||
],
|
||||
"@gridpilot/analytics/*": [
|
||||
"../../packages/analytics/*"
|
||||
],
|
||||
"@gridpilot/analytics/domain/repositories/*": [
|
||||
"../../packages/analytics/domain/repositories/*"
|
||||
],
|
||||
"@gridpilot/analytics/domain/entities/*": [
|
||||
"../../packages/analytics/domain/entities/*"
|
||||
],
|
||||
"@nestjs/testing": [
|
||||
"./node_modules/@nestjs/testing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.mock.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user