diff --git a/src/puzzle/session-event.entity.ts b/src/puzzle/session-event.entity.ts new file mode 100644 index 0000000..db7351a --- /dev/null +++ b/src/puzzle/session-event.entity.ts @@ -0,0 +1,26 @@ +// session-event.entity.ts +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('session_events') +export class SessionEvent { + @PrimaryGeneratedColumn() + id: number; + + @Column() + sessionId: string; + + @Column() + sequence: number; + + @Column() + eventType: 'move' | 'hint' | 'submit' | 'pause' | 'resume'; + + @Column('jsonb') + payload: any; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + timestamp: Date; + + @Column({ default: false }) + softDeleted: boolean; +} diff --git a/src/puzzle/session-event.queue.ts b/src/puzzle/session-event.queue.ts new file mode 100644 index 0000000..334c608 --- /dev/null +++ b/src/puzzle/session-event.queue.ts @@ -0,0 +1,10 @@ +// session-event.queue.ts +import { Queue } from 'bull'; +import { SessionEvent } from './session-event.entity'; + +const eventQueue = new Queue('session-events'); + +eventQueue.process(async (job) => { + // persist event asynchronously + await repo.save(job.data); +}); diff --git a/src/puzzle/session-replay.controller.ts b/src/puzzle/session-replay.controller.ts new file mode 100644 index 0000000..64272db --- /dev/null +++ b/src/puzzle/session-replay.controller.ts @@ -0,0 +1,23 @@ +// session-replay.controller.ts +import { Controller, Get, Param, Req } from '@nestjs/common'; +import { SessionReplayService } from './session-replay.service'; + +@Controller('sessions') +export class SessionReplayController { + constructor(private readonly service: SessionReplayService) {} + + @Get(':id/replay') + async getReplay(@Param('id') id: string, @Req() req) { + return this.service.getReplay(id, req.user.id, req.user.role === 'admin'); + } + + @Get(':id/replay/summary') + async getSummary(@Param('id') id: string) { + return this.service.getSummary(id); + } + + @Get('flagged') + async getFlagged() { + return this.service.flagSuspiciousSessions('hard'); // example + } +} diff --git a/src/puzzle/session-replay.service.ts b/src/puzzle/session-replay.service.ts new file mode 100644 index 0000000..4632be0 --- /dev/null +++ b/src/puzzle/session-replay.service.ts @@ -0,0 +1,55 @@ +// session-replay.service.ts +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { SessionEvent } from './session-event.entity'; + +const MIN_SOLVE_TIME = { + easy: 10, // seconds + medium: 30, + hard: 60, +}; + +@Injectable() +export class SessionReplayService { + constructor(private readonly repo: Repository) {} + + async recordEvent(event: Partial) { + // push to queue for async persistence + // queue worker will call repo.save(event) + } + + async getReplay(sessionId: string, userId: string, isAdmin: boolean) { + // check ownership or admin + const events = await this.repo.find({ + where: { sessionId, softDeleted: false }, + order: { sequence: 'ASC' }, + }); + return events; + } + + async getSummary(sessionId: string) { + const events = await this.repo.find({ where: { sessionId, softDeleted: false } }); + const totalMoves = events.filter(e => e.eventType === 'move').length; + const hintsUsed = events.filter(e => e.eventType === 'hint').length; + const pauses = events.filter(e => e.eventType === 'pause').length; + const totalTime = this.calculateTotalTime(events); + + return { totalMoves, hintsUsed, pauses, totalTime }; + } + + calculateTotalTime(events: SessionEvent[]): number { + const start = events[0]?.timestamp; + const end = events[events.length - 1]?.timestamp; + return start && end ? (end.getTime() - start.getTime()) / 1000 : 0; + } + + async flagSuspiciousSessions(difficulty: 'easy' | 'medium' | 'hard') { + const sessions = await this.repo.query(/* aggregate sessions */); + return sessions.filter(s => s.totalTime < MIN_SOLVE_TIME[difficulty]); + } + + async applyRetentionTTL() { + const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + await this.repo.update({ timestamp: cutoff }, { softDeleted: true }); + } +} diff --git a/test/session-replay.spec.ts b/test/session-replay.spec.ts new file mode 100644 index 0000000..e69de29