diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts new file mode 100644 index 00000000..8addb378 --- /dev/null +++ b/backend/src/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UsersAnalyticsListener } from './listeners/users-analytics.listener'; +import { AnalyticsEvent } from './entities/analytics-event.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AnalyticsEvent])], + providers: [UsersAnalyticsListener], +}) +export class AnalyticsModule {} diff --git a/backend/src/analytics/entities/analytics-event.entity.ts b/backend/src/analytics/entities/analytics-event.entity.ts new file mode 100644 index 00000000..aefa39a9 --- /dev/null +++ b/backend/src/analytics/entities/analytics-event.entity.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('analytics_events') +export class AnalyticsEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + eventType: string; + + @Column({ type: 'varchar', length: 100 }) + userId: string; + + @Column({ type: 'varchar', length: 100 }) + entityId: string; + + @Column({ type: 'json', nullable: true }) + payload?: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + timestamp: Date; +} diff --git a/backend/src/analytics/listeners/users-analytics.listener.spec.ts b/backend/src/analytics/listeners/users-analytics.listener.spec.ts new file mode 100644 index 00000000..7934a00c --- /dev/null +++ b/backend/src/analytics/listeners/users-analytics.listener.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UsersAnalyticsListener } from './users-analytics.listener'; +import { AnalyticsEvent } from '../entities/analytics-event.entity'; + +describe('UsersAnalyticsListener', () => { + let listener: UsersAnalyticsListener; + let analyticsRepository: Partial, jest.Mock>>; + + beforeEach(async () => { + analyticsRepository = { + create: jest.fn((entity) => entity), + save: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersAnalyticsListener, + { provide: getRepositoryToken(AnalyticsEvent), useValue: analyticsRepository }, + ], + }).compile(); + + listener = module.get(UsersAnalyticsListener); + }); + + it('persists xp_awarded analytics events', async () => { + const event = { + userId: 'user-1', + entityId: 'user-1', + xpAmount: 150, + previousLevel: 1, + currentLevel: 1, + timestamp: new Date('2026-07-20T00:00:00Z'), + }; + + await listener.handleXpAwarded(event); + + expect(analyticsRepository.create).toHaveBeenCalledWith({ + eventType: 'xp_awarded', + userId: event.userId, + entityId: event.entityId, + payload: { + xpAmount: event.xpAmount, + previousLevel: event.previousLevel, + currentLevel: event.currentLevel, + }, + timestamp: event.timestamp, + }); + expect(analyticsRepository.save).toHaveBeenCalled(); + }); + + it('persists user_leveled_up analytics events', async () => { + const event = { + userId: 'user-2', + entityId: 'user-2', + previousLevel: 1, + currentLevel: 2, + timestamp: new Date('2026-07-20T00:00:00Z'), + }; + + await listener.handleUserLeveledUp(event); + + expect(analyticsRepository.create).toHaveBeenCalledWith({ + eventType: 'user_leveled_up', + userId: event.userId, + entityId: event.entityId, + payload: { + previousLevel: event.previousLevel, + currentLevel: event.currentLevel, + }, + timestamp: event.timestamp, + }); + expect(analyticsRepository.save).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/analytics/listeners/users-analytics.listener.ts b/backend/src/analytics/listeners/users-analytics.listener.ts new file mode 100644 index 00000000..40fa7678 --- /dev/null +++ b/backend/src/analytics/listeners/users-analytics.listener.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsEvent } from '../entities/analytics-event.entity'; + +export interface XpAwardedEvent { + userId: string; + entityId: string; + xpAmount: number; + previousLevel: number; + currentLevel: number; + timestamp: Date; +} + +export interface UserLeveledUpEvent { + userId: string; + entityId: string; + previousLevel: number; + currentLevel: number; + timestamp: Date; +} + +@Injectable() +export class UsersAnalyticsListener { + constructor( + @InjectRepository(AnalyticsEvent) + private readonly analyticsEventRepository: Repository, + ) {} + + @OnEvent('xp_awarded') + async handleXpAwarded(event: XpAwardedEvent) { + const analyticsEvent = this.analyticsEventRepository.create({ + eventType: 'xp_awarded', + userId: event.userId, + entityId: event.entityId, + payload: { + xpAmount: event.xpAmount, + previousLevel: event.previousLevel, + currentLevel: event.currentLevel, + }, + timestamp: event.timestamp, + }); + + await this.analyticsEventRepository.save(analyticsEvent); + } + + @OnEvent('user_leveled_up') + async handleUserLeveledUp(event: UserLeveledUpEvent) { + const analyticsEvent = this.analyticsEventRepository.create({ + eventType: 'user_leveled_up', + userId: event.userId, + entityId: event.entityId, + payload: { + previousLevel: event.previousLevel, + currentLevel: event.currentLevel, + }, + timestamp: event.timestamp, + }); + + await this.analyticsEventRepository.save(analyticsEvent); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5da1b312..ad3b8ff0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import jwtConfig from './auth/authConfig/jwt.config'; import { UsersService } from './users/providers/users.service'; import { GeolocationMiddleware } from './common/middleware/geolocation.middleware'; import { HealthModule } from './health/health.module'; +import { AnalyticsModule } from './analytics/analytics.module'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -80,6 +81,7 @@ import { HealthModule } from './health/health.module'; }), AuthModule, UsersModule, + AnalyticsModule, PuzzlesModule, ProgressModule, QuestsModule, diff --git a/backend/src/users/providers/xp-level.service.spec.ts b/backend/src/users/providers/xp-level.service.spec.ts new file mode 100644 index 00000000..e04816ef --- /dev/null +++ b/backend/src/users/providers/xp-level.service.spec.ts @@ -0,0 +1,85 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Repository } from 'typeorm'; +import { XpLevelService } from './xp-level.service'; +import { User } from '../user.entity'; + +describe('XpLevelService', () => { + let service: XpLevelService; + let userRepository: Partial, jest.Mock>>; + let eventEmitter: { emit: jest.Mock }; + + beforeEach(async () => { + userRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + eventEmitter = { + emit: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + XpLevelService, + { provide: getRepositoryToken(User), useValue: userRepository }, + { provide: EventEmitter2, useValue: eventEmitter }, + ], + }).compile(); + + service = module.get(XpLevelService); + }); + + it('emits xp_awarded with the expected payload shape', async () => { + const user = { id: 'user-1', xp: 0, level: 1 } as User; + userRepository.findOne!.mockResolvedValue(user); + userRepository.save!.mockResolvedValue({ ...user, xp: 100, level: 1 }); + + const result = await service.addXp('user-1', 100); + + expect(result).toEqual({ + levelUp: false, + currentLevel: 1, + currentXp: 100, + previousLevel: 1, + }); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'xp_awarded', + expect.objectContaining({ + userId: 'user-1', + entityId: 'user-1', + xpAmount: 100, + currentLevel: 1, + previousLevel: 1, + }), + ); + }); + + it('emits user_leveled_up when the user levels up', async () => { + const user = { id: 'user-2', xp: 450, level: 1 } as User; + userRepository.findOne!.mockResolvedValue(user); + userRepository.save!.mockResolvedValue({ ...user, xp: 550, level: 2 }); + + const result = await service.addXp('user-2', 100); + + expect(result.levelUp).toBe(true); + expect(result.currentLevel).toBe(2); + expect(result.previousLevel).toBe(1); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'user_leveled_up', + expect.objectContaining({ + userId: 'user-2', + entityId: 'user-2', + previousLevel: 1, + currentLevel: 2, + }), + ); + }); +}); diff --git a/backend/src/users/providers/xp-level.service.ts b/backend/src/users/providers/xp-level.service.ts index 0ef048f0..c15d0ca4 100644 --- a/backend/src/users/providers/xp-level.service.ts +++ b/backend/src/users/providers/xp-level.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { User } from '../user.entity'; @Injectable() @@ -10,6 +11,7 @@ export class XpLevelService { constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly eventEmitter: EventEmitter2, ) {} /** @@ -48,6 +50,43 @@ export class XpLevelService { await this.userRepository.save(user); + const eventTimestamp = new Date(); + const xpAwardedEvent = { + userId: user.id, + entityId: user.id, + xpAmount, + previousLevel, + currentLevel: user.level, + timestamp: eventTimestamp, + }; + + // Fire-and-forget analytics event emission. Do not await to avoid blocking request path. + setImmediate(() => { + try { + this.eventEmitter.emit('xp_awarded', xpAwardedEvent); + } catch { + // swallow listener errors to keep request path safe + } + }); + + if (levelUp) { + const leveledUpEvent = { + userId: user.id, + entityId: user.id, + previousLevel, + currentLevel: user.level, + timestamp: eventTimestamp, + }; + + setImmediate(() => { + try { + this.eventEmitter.emit('user_leveled_up', leveledUpEvent); + } catch { + // swallow listener errors to keep request path safe + } + }); + } + return { levelUp, currentLevel: user.level, diff --git a/package-lock.json b/package-lock.json index 49a2167c..1498063e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -761,34 +761,6 @@ "undici-types": "~6.21.0" } }, - "middleware": { - "name": "@mindblock/middleware", - "version": "0.1.0", - "extraneous": true, - "dependencies": { - "@nestjs/common": "^11.0.12", - "bcrypt": "^6.0.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" - }, - "devDependencies": { - "@types/express": "^5.0.0", - "@types/jest": "^29.5.14", - "@types/node": "^22.10.7", - "@typescript-eslint/eslint-plugin": "^8.20.0", - "@typescript-eslint/parser": "^8.20.0", - "eslint": "^9.18.0", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", - "jest": "^29.7.0", - "prettier": "^3.4.2", - "ts-jest": "^29.2.5", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",