Skip to content

Commit 2c5f79a

Browse files
authored
Merge pull request #227 from OthmanImam/feat/EventSource
feat(player-events): event sourcing + audit log for player actions
2 parents f918d8a + 07c2ee5 commit 2c5f79a

15 files changed

+508
-12
lines changed

src/achievements/achievements.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33

44
import { AchievementsService } from './achievements.service';
55
import { AchievementsController } from './achievements.controller';
6+
import { PlayerEventsModule } from '../player-events/player-events.module';
67
import { Achievement } from './entities/achievement.entity';
78
import { UserAchievement } from './entities/user-achievement.entity';
89
import { NotificationsModule } from '../notifications/notifications.module';
910
import { AchievementConditionEngine } from './achievement-condition.engine';
1011

1112
@Module({
12-
imports: [TypeOrmModule.forFeature([Achievement, UserAchievement]), NotificationsModule],
13+
imports: [TypeOrmModule.forFeature([Achievement, UserAchievement]), NotificationsModule, PlayerEventsModule],
1314
controllers: [AchievementsController],
1415
providers: [AchievementsService, AchievementConditionEngine],
1516
exports: [AchievementsService],

src/app.module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { EnergyModule } from './energy/energy.module';
5050
import { SkillRatingModule } from './skill-rating/skill-rating.module';
5151
import { WalletAuthModule } from './auth/wallet-auth.module';
5252
import { XpModule } from './xp/xp.module';
53+
import { PlayerEventsModule } from './player-events/player-events.module';
5354

5455
@Module({
5556
imports: [
@@ -128,8 +129,7 @@ import { XpModule } from './xp/xp.module';
128129
PuzzleModule,
129130
EventModule,
130131
SeasonalEventsModule,
131-
MultiplayerModule,
132-
RecommendationsModule,
132+
MultiplayerModule, PlayerEventsModule, RecommendationsModule,
133133
AntiCheatModule,
134134
QuestsModule,
135135
IntegrationsModule,

src/game-session/game-session.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
import { Module } from '@nestjs/common';
1+
import { Module, forwardRef } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { GameSession } from './entities/game-session.entity';
44
import { Spectator } from './entities/spectator.entity';
5+
import { PlayerEventsModule } from '../player-events/player-events.module';
56
import { GameSessionService } from './services/game-session.service';
67
import { SpectatorService } from './services/spectator.service';
78
import { CleanupSessionJob } from './services/cleanup-session.job';
89
import { AutosaveSessionJob } from './services/autosave-session.job';
910
import { GameSessionController } from './controllers/game-session.controller';
1011

1112
@Module({
12-
imports: [TypeOrmModule.forFeature([GameSession, Spectator])],
13+
imports: [TypeOrmModule.forFeature([GameSession, Spectator]), forwardRef(() => PlayerEventsModule)],
1314
controllers: [GameSessionController],
1415
providers: [
1516
GameSessionService,

src/game-session/services/game-session.service.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { Injectable, NotFoundException } from '@nestjs/common';
33
import { InjectRepository } from '@nestjs/typeorm';
44
import { Repository, LessThan } from 'typeorm';
55
import { GameSession } from '../entities/game-session.entity';
6+
import { PlayerEventsService } from '../../player-events/player-events.service';
67

78
@Injectable()
89
export class GameSessionService {
910
constructor(
1011
@InjectRepository(GameSession)
1112
private readonly sessionRepo: Repository<GameSession>,
13+
private readonly playerEventsService: PlayerEventsService,
1214
) {}
1315

1416
async create(userId: string) {
@@ -18,7 +20,19 @@ export class GameSessionService {
1820
state: {},
1921
lastActiveAt: new Date(),
2022
});
21-
return this.sessionRepo.save(session);
23+
const savedSession = await this.sessionRepo.save(session);
24+
25+
await this.playerEventsService.emitPlayerEvent({
26+
userId,
27+
sessionId: savedSession.id,
28+
eventType: 'puzzle.started',
29+
payload: {
30+
sessionId: savedSession.id,
31+
startedAt: savedSession.createdAt || new Date(),
32+
},
33+
});
34+
35+
return savedSession;
2236
}
2337

2438
async updateState(sessionId: string, partialState: Record<string, any>) {
@@ -44,10 +58,29 @@ export class GameSessionService {
4458
session.status = status;
4559
session.lastActiveAt = new Date();
4660

47-
return this.sessionRepo.save(session);
61+
const savedSession = await this.sessionRepo.save(session);
62+
63+
if (status === 'ABANDONED') {
64+
await this.playerEventsService.emitPlayerEvent({
65+
userId: savedSession.userId,
66+
sessionId: savedSession.id,
67+
eventType: 'puzzle.abandoned',
68+
payload: {
69+
sessionId: savedSession.id,
70+
reason: 'session ended as abandoned',
71+
endedAt: savedSession.lastActiveAt,
72+
},
73+
});
74+
}
75+
76+
return savedSession;
4877
}
4978

5079
async getActiveSessions() {
5180
return this.sessionRepo.find({ where: { status: 'IN_PROGRESS' } });
5281
}
82+
83+
async getById(sessionId: string) {
84+
return this.sessionRepo.findOne({ where: { id: sessionId } });
85+
}
5386
}

src/hints/hints.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
33
import { HintsService } from './hints.service';
44
import { HintsController } from './hints.controller';
5+
import { PlayerEventsModule } from '../player-events/player-events.module';
56
import { Hint } from './entities/hint.entity';
67
import { HintUsage } from './entities/hint-usage.entity';
78
import { HintTemplate } from './entities/hint-template.entity';
@@ -11,6 +12,7 @@ import { PuzzlesModule } from '../puzzles/puzzles.module';
1112
imports: [
1213
TypeOrmModule.forFeature([Hint, HintUsage, HintTemplate]),
1314
PuzzlesModule,
15+
PlayerEventsModule,
1416
],
1517
controllers: [HintsController],
1618
providers: [HintsService],

src/hints/hints.service.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
33
import { Repository, FindOptionsWhere, MoreThanOrEqual } from 'typeorm';
4+
import { PlayerEventsService } from '../player-events/player-events.service';
45
import { Hint } from './entities/hint.entity';
56
import { HintUsage } from './entities/hint-usage.entity';
67
import { HintTemplate } from './entities/hint-template.entity';
@@ -18,6 +19,7 @@ export class HintsService {
1819
private readonly usageRepo: Repository<HintUsage>,
1920
@InjectRepository(HintTemplate)
2021
private readonly templateRepo: Repository<HintTemplate>,
22+
private readonly playerEventsService: PlayerEventsService,
2123
) {}
2224

2325
async createHint(dto: CreateHintDto): Promise<Hint> {
@@ -92,6 +94,21 @@ export class HintsService {
9294
},
9395
});
9496

97+
// Emit audit event for hint usage
98+
void this.playerEventsService.emitPlayerEvent({
99+
userId: dto.userId,
100+
sessionId: null,
101+
eventType: 'hint.used',
102+
payload: {
103+
puzzleId: dto.puzzleId,
104+
hintId: selected.id,
105+
attemptNumber: dto.attemptNumber,
106+
timeSpent: dto.timeSpent,
107+
puzzleState: dto.puzzleState,
108+
playerState: dto.playerState,
109+
},
110+
});
111+
95112
return selected;
96113
}
97114

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { IsUUID, IsEnum, IsObject, IsOptional, IsNumber, Min, Max, IsString } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { PlayerActionEventType } from '../entities/player-action-event.entity';
4+
5+
export class CreatePlayerActionEventDto {
6+
@IsUUID()
7+
userId: string;
8+
9+
@IsUUID()
10+
@IsOptional()
11+
sessionId?: string;
12+
13+
@IsEnum(['puzzle.started', 'puzzle.solved', 'puzzle.abandoned', 'hint.used', 'answer.submitted', 'achievement.unlocked'])
14+
eventType: PlayerActionEventType;
15+
16+
@IsObject()
17+
payload: Record<string, any>;
18+
}
19+
20+
export class PagedQueryDto {
21+
@IsOptional()
22+
@Type(() => Number)
23+
@IsNumber()
24+
@Min(1)
25+
page?: number = 1;
26+
27+
@IsOptional()
28+
@Type(() => Number)
29+
@IsNumber()
30+
@Min(1)
31+
@Max(100)
32+
limit?: number = 20;
33+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
2+
3+
export type PlayerActionEventType =
4+
| 'puzzle.started'
5+
| 'puzzle.solved'
6+
| 'puzzle.abandoned'
7+
| 'hint.used'
8+
| 'answer.submitted'
9+
| 'achievement.unlocked';
10+
11+
@Entity('player_action_events')
12+
@Index(['userId', 'eventType', 'timestamp'])
13+
export class PlayerActionEvent {
14+
@PrimaryGeneratedColumn('uuid')
15+
id: string;
16+
17+
@Column({ type: 'uuid' })
18+
@Index()
19+
userId: string;
20+
21+
@Column({ type: 'uuid', nullable: true })
22+
@Index()
23+
sessionId: string;
24+
25+
@Column({ type: 'varchar', length: 50 })
26+
@Index()
27+
eventType: PlayerActionEventType;
28+
29+
@Column({ type: 'jsonb', default: {} })
30+
payload: Record<string, any>;
31+
32+
@CreateDateColumn({ type: 'timestamptz' })
33+
@Index()
34+
timestamp: Date;
35+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Controller, Get, Param, Query, Request, UseGuards, ForbiddenException } from '@nestjs/common';
2+
import { PlayerEventsService } from './player-events.service';
3+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4+
import { RolesGuard } from '../auth/guards/roles.guard';
5+
import { Roles } from '../auth/decorators/roles.decorator';
6+
import { UserRole } from '../auth/constants';
7+
import { GameSessionService } from '../game-session/services/game-session.service';
8+
import { PagedQueryDto } from './dto/player-action-event.dto';
9+
10+
@Controller()
11+
@UseGuards(JwtAuthGuard, RolesGuard)
12+
export class PlayerEventsController {
13+
constructor(
14+
private readonly playerEventsService: PlayerEventsService,
15+
private readonly gameSessionService: GameSessionService,
16+
) {}
17+
18+
@Get('players/:id/events')
19+
@Roles(UserRole.ADMIN)
20+
async getPlayerEvents(@Param('id') userId: string, @Query() query: PagedQueryDto) {
21+
const page = query.page || 1;
22+
const limit = query.limit || 20;
23+
return this.playerEventsService.getEventsByPlayer(userId, page, limit);
24+
}
25+
26+
@Get('sessions/:id/events')
27+
async getSessionEvents(@Param('id') sessionId: string, @Query() query: PagedQueryDto, @Request() req: any) {
28+
const page = query.page || 1;
29+
const limit = query.limit || 50;
30+
31+
// Only admins or the session owner can fetch session events
32+
const session = await this.gameSessionService.getById(sessionId);
33+
if (!session) {
34+
throw new ForbiddenException('Session not found or access denied');
35+
}
36+
37+
const requester = req.user;
38+
const isAdmin = requester?.role?.name === UserRole.ADMIN;
39+
if (!isAdmin && session.userId !== requester.id) {
40+
throw new ForbiddenException('Access denied');
41+
}
42+
43+
return this.playerEventsService.getEventsBySession(sessionId, page, limit);
44+
}
45+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module, forwardRef } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { PlayerEventsService } from './player-events.service';
4+
import { PlayerEventsController } from './player-events.controller';
5+
import { PlayerActionEvent } from './entities/player-action-event.entity';
6+
import { GameSession } from '../game-session/entities/game-session.entity';
7+
import { GameSessionModule } from '../game-session/game-session.module';
8+
9+
@Module({
10+
imports: [TypeOrmModule.forFeature([PlayerActionEvent, GameSession]), forwardRef(() => GameSessionModule)],
11+
controllers: [PlayerEventsController],
12+
providers: [PlayerEventsService],
13+
exports: [PlayerEventsService],
14+
})
15+
export class PlayerEventsModule {}

0 commit comments

Comments
 (0)