Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
22 changes: 22 additions & 0 deletions backend/src/analytics/entities/analytics-event.entity.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

@CreateDateColumn({ type: 'timestamptz' })
timestamp: Date;
}
76 changes: 76 additions & 0 deletions backend/src/analytics/listeners/users-analytics.listener.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<keyof Repository<AnalyticsEvent>, 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>(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();
});
});
63 changes: 63 additions & 0 deletions backend/src/analytics/listeners/users-analytics.listener.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsEvent>,
) {}

@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);
}
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -80,6 +81,7 @@ import { HealthModule } from './health/health.module';
}),
AuthModule,
UsersModule,
AnalyticsModule,
PuzzlesModule,
ProgressModule,
QuestsModule,
Expand Down
85 changes: 85 additions & 0 deletions backend/src/users/providers/xp-level.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<keyof Repository<User>, 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>(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<void>((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<void>((resolve) => setImmediate(resolve));

expect(eventEmitter.emit).toHaveBeenCalledWith(
'user_leveled_up',
expect.objectContaining({
userId: 'user-2',
entityId: 'user-2',
previousLevel: 1,
currentLevel: 2,
}),
);
});
});
39 changes: 39 additions & 0 deletions backend/src/users/providers/xp-level.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -10,6 +11,7 @@ export class XpLevelService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly eventEmitter: EventEmitter2,
) {}

/**
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 0 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading