From 129c63c3626810e38bcd0e0fc543aa23a69b31b8 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 15 Dec 2025 13:34:15 +0100 Subject: [PATCH] wip --- apps/api/.dockerignore | 10 + apps/api/Dockerfile.dev | 24 ++ apps/api/Dockerfile.prod | 44 +++ apps/api/index.ts | 1 + apps/api/jest.config.js | 17 ++ apps/api/package.json | 31 ++ apps/api/src/app.module.ts | 16 + .../application/analytics/analytics.module.ts | 34 +++ .../analytics/analytics.service.spec.ts | 91 ++++++ .../analytics/analytics.service.ts | 27 ++ .../record-engagement.use-case.spec.ts | 119 ++++++++ .../analytics/record-engagement.use-case.ts | 68 +++++ .../record-page-view.use-case.spec.ts | 80 +++++ .../analytics/record-page-view.use-case.ts | 60 ++++ .../application/hello/hello.service.spec.ts | 23 ++ .../src/application/hello/hello.service.ts | 9 + .../in-memory-engagement.repository.ts | 81 ++++++ .../in-memory-page-view.repository.ts | 81 ++++++ .../analytics/typeorm-page-view.entity.ts | 38 +++ .../typeorm-page-view.repository.spec.ts | 275 ++++++++++++++++++ .../analytics/typeorm-page-view.repository.ts | 104 +++++++ .../database/database.module.ts | 19 ++ apps/api/src/main.ts | 11 + .../src/presentation/analytics.controller.ts | 28 ++ apps/api/src/presentation/hello.controller.ts | 13 + apps/api/tsconfig.json | 49 ++++ 26 files changed, 1353 insertions(+) create mode 100644 apps/api/.dockerignore create mode 100644 apps/api/Dockerfile.dev create mode 100644 apps/api/Dockerfile.prod create mode 100644 apps/api/index.ts create mode 100644 apps/api/jest.config.js create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/application/analytics/analytics.module.ts create mode 100644 apps/api/src/application/analytics/analytics.service.spec.ts create mode 100644 apps/api/src/application/analytics/analytics.service.ts create mode 100644 apps/api/src/application/analytics/record-engagement.use-case.spec.ts create mode 100644 apps/api/src/application/analytics/record-engagement.use-case.ts create mode 100644 apps/api/src/application/analytics/record-page-view.use-case.spec.ts create mode 100644 apps/api/src/application/analytics/record-page-view.use-case.ts create mode 100644 apps/api/src/application/hello/hello.service.spec.ts create mode 100644 apps/api/src/application/hello/hello.service.ts create mode 100644 apps/api/src/infrastructure/analytics/in-memory-engagement.repository.ts create mode 100644 apps/api/src/infrastructure/analytics/in-memory-page-view.repository.ts create mode 100644 apps/api/src/infrastructure/analytics/typeorm-page-view.entity.ts create mode 100644 apps/api/src/infrastructure/analytics/typeorm-page-view.repository.spec.ts create mode 100644 apps/api/src/infrastructure/analytics/typeorm-page-view.repository.ts create mode 100644 apps/api/src/infrastructure/database/database.module.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/presentation/analytics.controller.ts create mode 100644 apps/api/src/presentation/hello.controller.ts create mode 100644 apps/api/tsconfig.json diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100644 index 000000000..878f85867 --- /dev/null +++ b/apps/api/.dockerignore @@ -0,0 +1,10 @@ + +node_modules +dist +.env +Dockerfile +docker-compose.* +.git +.gitignore +README.md +npm-debug.log diff --git a/apps/api/Dockerfile.dev b/apps/api/Dockerfile.dev new file mode 100644 index 000000000..49f37db62 --- /dev/null +++ b/apps/api/Dockerfile.dev @@ -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"] diff --git a/apps/api/Dockerfile.prod b/apps/api/Dockerfile.prod new file mode 100644 index 000000000..222d2e92c --- /dev/null +++ b/apps/api/Dockerfile.prod @@ -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"] diff --git a/apps/api/index.ts b/apps/api/index.ts new file mode 100644 index 000000000..aac925068 --- /dev/null +++ b/apps/api/index.ts @@ -0,0 +1 @@ +console.log('NestJS API service is running!'); diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 000000000..89fd719e2 --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,17 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + testEnvironment: 'node', + roots: ['/src'], + transform: { + '^.+\.(t|j)s$': ['ts-jest', { tsconfig: '/tsconfig.json' }], + }, + moduleFileExtensions: ['js', 'json', 'ts'], + collectCoverageFrom: [ + '**/*.(t|j)s' + ], + coverageDirectory: '../coverage', + testRegex: '.*\\.spec\\.ts$', + moduleNameMapper: { + '^@gridpilot/(.*)$': '/../../packages/$1', // Corrected path + }, +}; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..7a7fe8b1a --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 000000000..8958a0e4f --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/application/analytics/analytics.module.ts b/apps/api/src/application/analytics/analytics.module.ts new file mode 100644 index 000000000..a671ba532 --- /dev/null +++ b/apps/api/src/application/analytics/analytics.module.ts @@ -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 {} diff --git a/apps/api/src/application/analytics/analytics.service.spec.ts b/apps/api/src/application/analytics/analytics.service.spec.ts new file mode 100644 index 000000000..484bf4159 --- /dev/null +++ b/apps/api/src/application/analytics/analytics.service.spec.ts @@ -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); + recordPageViewUseCase = module.get(RecordPageViewUseCase); + recordEngagementUseCase = module.get(RecordEngagementUseCase); + logger = module.get('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 }); + }); +}); diff --git a/apps/api/src/application/analytics/analytics.service.ts b/apps/api/src/application/analytics/analytics.service.ts new file mode 100644 index 000000000..cf7fbf9ba --- /dev/null +++ b/apps/api/src/application/analytics/analytics.service.ts @@ -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 { + this.logger.debug('AnalyticsService: Recording engagement', { input }); + return this.recordEngagementUseCase.execute(input); + } +} diff --git a/apps/api/src/application/analytics/record-engagement.use-case.spec.ts b/apps/api/src/application/analytics/record-engagement.use-case.spec.ts new file mode 100644 index 000000000..f636031d9 --- /dev/null +++ b/apps/api/src/application/analytics/record-engagement.use-case.spec.ts @@ -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); + engagementRepository = module.get('IEngagementRepository'); + logger = module.get('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 }, + ); + }); +}); diff --git a/apps/api/src/application/analytics/record-engagement.use-case.ts b/apps/api/src/application/analytics/record-engagement.use-case.ts new file mode 100644 index 000000000..38d039838 --- /dev/null +++ b/apps/api/src/application/analytics/record-engagement.use-case.ts @@ -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; +} + +export interface RecordEngagementOutput { + eventId: string; + engagementWeight: number; +} + +export class RecordEngagementUseCase + implements AsyncUseCase { + constructor( + @Inject('IEngagementRepository') private readonly engagementRepository: IEngagementRepository, + @Inject('ILogger') private readonly logger: ILogger, + ) {} + + async execute(input: RecordEngagementInput): Promise { + this.logger.debug('Executing RecordEngagementUseCase', { input }); + try { + const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const baseProps: Omit[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; + } + } +} diff --git a/apps/api/src/application/analytics/record-page-view.use-case.spec.ts b/apps/api/src/application/analytics/record-page-view.use-case.spec.ts new file mode 100644 index 000000000..adbe72481 --- /dev/null +++ b/apps/api/src/application/analytics/record-page-view.use-case.spec.ts @@ -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); + pageViewRepository = module.get('IPageViewRepository'); + logger = module.get('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 }); + }); +}); diff --git a/apps/api/src/application/analytics/record-page-view.use-case.ts b/apps/api/src/application/analytics/record-page-view.use-case.ts new file mode 100644 index 000000000..0e870856a --- /dev/null +++ b/apps/api/src/application/analytics/record-page-view.use-case.ts @@ -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 { + constructor( + @Inject('IPageViewRepository') private readonly pageViewRepository: IPageViewRepository, + @Inject('ILogger') private readonly logger: ILogger, + ) {} + + async execute(input: RecordPageViewInput): Promise { + this.logger.debug('Executing RecordPageViewUseCase', { input }); + try { + const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const baseProps: Omit[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; + } + } +} diff --git a/apps/api/src/application/hello/hello.service.spec.ts b/apps/api/src/application/hello/hello.service.spec.ts new file mode 100644 index 000000000..e50a2c649 --- /dev/null +++ b/apps/api/src/application/hello/hello.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return "Hello World!"', () => { + expect(service.getHello()).toBe('Hello World!'); + }); +}); diff --git a/apps/api/src/application/hello/hello.service.ts b/apps/api/src/application/hello/hello.service.ts new file mode 100644 index 000000000..0da054abd --- /dev/null +++ b/apps/api/src/application/hello/hello.service.ts @@ -0,0 +1,9 @@ + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class HelloService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/apps/api/src/infrastructure/analytics/in-memory-engagement.repository.ts b/apps/api/src/infrastructure/analytics/in-memory-engagement.repository.ts new file mode 100644 index 000000000..bcccc75dd --- /dev/null +++ b/apps/api/src/infrastructure/analytics/in-memory-engagement.repository.ts @@ -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 = new Map(); + + async save(event: EngagementEvent): Promise { + this.engagements.set(event.id, event); + } + + async findById(id: string): Promise { + return this.engagements.get(id) || null; + } + + async findByEntityId( + entityType: EngagementEntityType, + entityId: string, + ): Promise { + return Array.from(this.engagements.values()).filter( + (e) => e.entityType === entityType && e.entityId === entityId, + ); + } + + async findByAction(action: EngagementAction): Promise { + return Array.from(this.engagements.values()).filter( + (e) => e.action === action, + ); + } + + async findByDateRange( + startDate: Date, + endDate: Date, + ): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/apps/api/src/infrastructure/analytics/in-memory-page-view.repository.ts b/apps/api/src/infrastructure/analytics/in-memory-page-view.repository.ts new file mode 100644 index 000000000..5527f66ee --- /dev/null +++ b/apps/api/src/infrastructure/analytics/in-memory-page-view.repository.ts @@ -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 = new Map(); + + async save(pageView: PageView): Promise { + this.pageViews.set(pageView.id, pageView); + } + + async findById(id: string): Promise { + return this.pageViews.get(id) || null; + } + + async findByEntityId( + entityType: EntityType, + entityId: string, + ): Promise { + return Array.from(this.pageViews.values()).filter( + (pv) => pv.entityType === entityType && pv.entityId === entityId, + ); + } + + async findByDateRange( + startDate: Date, + endDate: Date, + ): Promise { + return Array.from(this.pageViews.values()).filter((pv) => { + const pageViewDate = new Date(pv.timestamp); + return pageViewDate >= startDate && pageViewDate <= endDate; + }); + } + + async findBySession(sessionId: string): Promise { + return Array.from(this.pageViews.values()).filter( + (pv) => pv.sessionId === sessionId, + ); + } + + async countByEntityId( + entityType: EntityType, + entityId: string, + since?: Date, + ): Promise { + 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 { + const uniqueVisitorIds = new Set(); + 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(); + } +} diff --git a/apps/api/src/infrastructure/analytics/typeorm-page-view.entity.ts b/apps/api/src/infrastructure/analytics/typeorm-page-view.entity.ts new file mode 100644 index 000000000..f0ba34985 --- /dev/null +++ b/apps/api/src/infrastructure/analytics/typeorm-page-view.entity.ts @@ -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; +} diff --git a/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.spec.ts b/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.spec.ts new file mode 100644 index 000000000..353411b77 --- /dev/null +++ b/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.spec.ts @@ -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; + let module: TestingModule; + const mockPageViewEntities = new Map(); + + 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'); + pageViewTypeOrmRepository = module.get>( + 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); + }); +}); diff --git a/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.ts b/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.ts new file mode 100644 index 000000000..63d9ab300 --- /dev/null +++ b/apps/api/src/infrastructure/analytics/typeorm-page-view.repository.ts @@ -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, + ) {} + + async save(pageView: PageView): Promise { + const pageViewEntity = this.toPageViewEntity(pageView); + await this.pageViewRepository.save(pageViewEntity); + } + + async findById(id: string): Promise { + const entity = await this.pageViewRepository.findOne({ + where: { id }, + }); + return entity ? this.toPageView(entity) : null; + } + + async findByEntityId(entityType: EntityType, entityId: string): Promise { + const entities = await this.pageViewRepository.find({ + where: { entityType, entityId }, + order: { timestamp: 'DESC' }, + }); + return entities.map(this.toPageView); + } + + async findByDateRange(startDate: Date, endDate: Date): Promise { + const entities = await this.pageViewRepository.find({ + where: { timestamp: Between(startDate, endDate) }, + order: { timestamp: 'DESC' }, + }); + return entities.map(this.toPageView); + } + + async findBySession(sessionId: string): Promise { + 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 { + 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 { + 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, + }); + } +} diff --git a/apps/api/src/infrastructure/database/database.module.ts b/apps/api/src/infrastructure/database/database.module.ts new file mode 100644 index 000000000..f6fd50daa --- /dev/null +++ b/apps/api/src/infrastructure/database/database.module.ts @@ -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 {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 000000000..e868708c4 --- /dev/null +++ b/apps/api/src/main.ts @@ -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(); diff --git a/apps/api/src/presentation/analytics.controller.ts b/apps/api/src/presentation/analytics.controller.ts new file mode 100644 index 000000000..720f82e2c --- /dev/null +++ b/apps/api/src/presentation/analytics.controller.ts @@ -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 { + const { pageViewId } = await this.analyticsService.recordPageView(input); + res.status(HttpStatus.CREATED).json({ pageViewId }); + } + + @Post('engagement') + async recordEngagement( + @Body() input: RecordEngagementInput, + @Res() res: Response, + ): Promise { + const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input); + res.status(HttpStatus.CREATED).json(output); + } +} diff --git a/apps/api/src/presentation/hello.controller.ts b/apps/api/src/presentation/hello.controller.ts new file mode 100644 index 000000000..1084f4575 --- /dev/null +++ b/apps/api/src/presentation/hello.controller.ts @@ -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(); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..2bedf015e --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +}