This commit is contained in:
2025-12-15 13:34:15 +01:00
parent 217337862c
commit 129c63c362
26 changed files with 1353 additions and 0 deletions

10
apps/api/.dockerignore Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
console.log('NestJS API service is running!');

17
apps/api/jest.config.js Normal file
View 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
View 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"
}
}

View 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 {}

View 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 {}

View 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 });
});
});

View 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);
}
}

View File

@@ -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 },
);
});
});

View File

@@ -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;
}
}
}

View File

@@ -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 });
});
});

View File

@@ -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;
}
}
}

View 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!');
});
});

View File

@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HelloService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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,
});
}
}

View 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
View 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();

View 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);
}
}

View 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
View 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"]
}