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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@aws-sdk/s3-request-presigner": "^3.993.0",
"@nestjs-modules/ioredis": "^2.0.2",
"@nestjs/cache-manager": "^2.0.0",
"@nestjs/bull": "^10.0.1",
"@nestjs/bullmq": "^10.2.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
Expand Down Expand Up @@ -54,6 +56,8 @@
"amqplib": "^0.10.3",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
"bull": "^4.12.2",
"bullmq": "^5.67.1",
"cache-manager": "^5.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
Expand Down
14 changes: 14 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
Expand Down Expand Up @@ -50,6 +51,7 @@ import { EnergyModule } from './energy/energy.module';
import { SkillRatingModule } from './skill-rating/skill-rating.module';
import { WalletAuthModule } from './auth/wallet-auth.module';
import { XpModule } from './xp/xp.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { PlayerEventsModule } from './player-events/player-events.module';
import { AccountModule } from './account/account.module';

Expand Down Expand Up @@ -87,6 +89,17 @@ import { AccountModule } from './account/account.module';
inject: [ConfigService],
}),

BullModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD') || undefined,
},
}),
inject: [ConfigService],
}),

// Message broker
RabbitMQModule,

Expand Down Expand Up @@ -142,6 +155,7 @@ import { AccountModule } from './account/account.module';
SkillRatingModule,
WalletAuthModule,
XpModule,
WebhooksModule,
AccountModule,
],
controllers: [AppController],
Expand Down
10 changes: 10 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, ConflictException, UnauthorizedException, BadRequestException } from "@nestjs/common"
import { EventEmitter2 } from "@nestjs/event-emitter"
import { InjectRepository } from "@nestjs/typeorm"
import { Repository } from "typeorm"
import type { DeepPartial } from "typeorm"
Expand All @@ -18,6 +19,7 @@ import type { VerifyEmailDto } from "./dto/verify-email.dto"
import type { JwtPayload } from "./interfaces/jwt-payload.interface"
import { BCRYPT_SALT_ROUNDS, jwtConstants, UserRole } from "./constants"
import { v4 as uuidv4 } from "uuid"
import { WEBHOOK_INTERNAL_EVENTS } from "../webhooks/webhook.constants"

@Injectable()
export class AuthService {
Expand All @@ -31,6 +33,7 @@ export class AuthService {
@InjectRepository(TwoFactorBackupCode)
private backupCodesRepository: Repository<TwoFactorBackupCode>,
private jwtService: JwtService,
private eventEmitter: EventEmitter2,
) { }

async hashPassword(password: string): Promise<string> {
Expand Down Expand Up @@ -94,6 +97,13 @@ export class AuthService {

await this.usersRepository.save(user)

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.userRegistered, {
userId: user.id,
email: user.email,
role: role.name,
registeredAt: new Date().toISOString(),
})

// TODO: Send verification email (mocked for now)
console.log(`Verification email sent to ${user.email} with token: ${verificationToken}`)

Expand Down
11 changes: 11 additions & 0 deletions src/game-session/services/game-session.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// services/game-session.service.ts
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { GameSession } from '../entities/game-session.entity';
import { PlayerEventsService } from '../../player-events/player-events.service';
import { PuzzleVersionService } from '../../puzzles/services/puzzle-version.service';
import { WEBHOOK_INTERNAL_EVENTS } from '../../webhooks/webhook.constants';
import { CacheService } from '../../cache/services/cache.service';

const SUSPENDED_KEY = (id: string) => `session:suspended:${id}`;
Expand All @@ -18,6 +20,7 @@ export class GameSessionService {
private readonly sessionRepo: Repository<GameSession>,
private readonly playerEventsService: PlayerEventsService,
private readonly puzzleVersionService: PuzzleVersionService,
private readonly eventEmitter: EventEmitter2,
private readonly cacheService: CacheService,
) {}

Expand Down Expand Up @@ -89,6 +92,14 @@ export class GameSessionService {
});
}

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.sessionEnded, {
sessionId: savedSession.id,
userId: savedSession.userId,
puzzleId: savedSession.puzzleId,
status: savedSession.status,
endedAt: savedSession.lastActiveAt.toISOString(),
});

return savedSession;
}

Expand Down
18 changes: 18 additions & 0 deletions src/leaderboard/leaderboard.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Leaderboard } from './entities/leaderboard.entity';
Expand All @@ -8,6 +9,7 @@ import { CreateLeaderboardDto } from './dto/create-leaderboard.dto';
import { CreateLeaderboardEntryDto } from './dto/create-leaderboard-entry.dto';
import { Cache } from 'cache-manager';
import { AchievementsService } from '../achievements/achievements.service';
import { WEBHOOK_INTERNAL_EVENTS } from '../webhooks/webhook.constants';

@Injectable()
export class LeaderboardService {
Expand All @@ -18,6 +20,7 @@ export class LeaderboardService {
private entryRepository: Repository<LeaderboardEntry>,
@Inject(CACHE_MANAGER) private cacheManager: any,
private achievementsService: AchievementsService,
private readonly eventEmitter: EventEmitter2,
) { }

async createLeaderboard(dto: CreateLeaderboardDto): Promise<Leaderboard> {
Expand All @@ -35,6 +38,15 @@ export class LeaderboardService {
await this.cacheManager.reset();
// Award leaderboard achievements if criteria met
await this.checkAndAwardLeaderboardAchievements(dto.leaderboardId, dto.userId);

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.leaderboardUpdated, {
leaderboardId: dto.leaderboardId,
entryId: saved.id,
userId: dto.userId,
score: saved.score,
updatedAt: new Date().toISOString(),
});

return saved;
}

Expand Down Expand Up @@ -131,6 +143,12 @@ export class LeaderboardService {
// (If you want to delete, use delete instead of update above)
// Invalidate cache
await this.cacheManager.reset();

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.leaderboardUpdated, {
leaderboardId,
archivedAt: now.toISOString(),
reset: true,
});
}

async getUserRankSummary(leaderboardId: number, userId: number) {
Expand Down
10 changes: 10 additions & 0 deletions src/multiplayer/gateways/multiplayer.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MultiplayerService } from '../services/multiplayer.service';
import { RoomType, Player, RoomStatus, Spectator } from '../interfaces/multiplayer.interface';
import { ValidationService } from '../../game-engine/services/validation.service';
Expand All @@ -33,6 +34,7 @@ export class MultiplayerGateway implements OnGatewayInit, OnGatewayConnection, O
private readonly validationService: ValidationService,
private readonly leaderboardService: LeaderboardService,
private readonly puzzlesService: PuzzlesService,
private readonly eventEmitter: EventEmitter2,
private readonly spectatorService: SpectatorService,
) { }

Expand Down Expand Up @@ -298,6 +300,14 @@ export class MultiplayerGateway implements OnGatewayInit, OnGatewayConnection, O
puzzleCompleted: allPlayersSolved
});

this.eventEmitter.emit('puzzle.solved', {
userId: data.userId,
puzzleId: data.puzzleId,
score: result.score,
totalScore: player?.score,
});

if (room.type === RoomType.COMPETITIVE) {
// Broadcast to spectators
this.server.to(`${data.roomId}-spectators`).emit('collaborativeSolutionVerified', {
userId: data.userId,
Expand Down
23 changes: 22 additions & 1 deletion src/puzzles/services/solution-submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {
Injectable,
Logger,
ConflictException,
BadRequestException,
// BadRequestException,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, MoreThan } from 'typeorm';
import * as crypto from 'crypto';
Expand All @@ -26,6 +27,7 @@ import { ViolationType } from '../../anti-cheat/constants';
import { XpService } from '../../xp/xp.service';
import { PlayerEventsService } from '../../player-events/player-events.service';
import { PuzzleVersionService } from './puzzle-version.service';
import { WEBHOOK_INTERNAL_EVENTS } from '../../webhooks/webhook.constants';

// ────────────────────────────────────────────────────────────────────────────
// Constants
Expand Down Expand Up @@ -56,6 +58,7 @@ export class SolutionSubmissionService {
private readonly xpService: XpService,
private readonly playerEventsService: PlayerEventsService,
private readonly puzzleVersionService: PuzzleVersionService,
private readonly eventEmitter: EventEmitter2,
) { }

// ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -251,6 +254,15 @@ export class SolutionSubmissionService {
},
});

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.puzzleSolved, {
userId,
puzzleId,
solveTimeSeconds: timeTakenSeconds,
hintsUsed: dto.hintsUsed ?? 0,
scoreAwarded,
solvedAt: now.toISOString(),
});

// Each achievement unlocked should produce an audit event
for (const achievement of result.rewards?.achievements ?? []) {
void this.playerEventsService.emitPlayerEvent({
Expand All @@ -264,6 +276,15 @@ export class SolutionSubmissionService {
hintsUsed: dto.hintsUsed ?? 0,
},
});

this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.achievementUnlocked, {
userId,
puzzleId,
achievementId: achievement,
unlockedAt: now.toISOString(),
hintsUsed: dto.hintsUsed ?? 0,
solveTimeSeconds: timeTakenSeconds,
});
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/webhooks/dto/create-webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
IsUrl,
IsArray,
IsString,
ArrayNotEmpty,
IsOptional,
IsIn,
MinLength,
} from 'class-validator';
import { SUPPORTED_WEBHOOK_EVENTS, WebhookEvent } from '../webhook.constants';

export class CreateWebhookDto {
@IsUrl({ protocols: ['https'], require_protocol: true }, { message: 'URL must be a valid HTTPS URL' })
url: string;

@IsString()
@MinLength(1)
secret: string;

@IsArray()
@ArrayNotEmpty()
@IsIn(SUPPORTED_WEBHOOK_EVENTS, { each: true })
events: WebhookEvent[];

@IsOptional()
@IsString()
appId?: string;
}
59 changes: 59 additions & 0 deletions src/webhooks/entities/webhook-delivery.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Webhook } from './webhook.entity';

export type DeliveryStatus = 'pending' | 'success' | 'failed' | 'retry';

@Entity('webhook_deliveries')
@Index(['webhookId', 'createdAt'], { order: { createdAt: 'DESC' } })
export class WebhookDelivery {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column('uuid')
webhookId: string;

@ManyToOne(() => Webhook, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'webhookId' })
webhook: Webhook;

@Column()
event: string;

@Column('jsonb')
payload: any;

@Column({ type: 'text', nullable: true })
signature: string;

@Column({ default: 'pending' })
status: DeliveryStatus;

@Column({ nullable: true })
responseCode?: number;

@Column({ type: 'text', nullable: true })
responseBody?: string;

@Column({ type: 'text', nullable: true })
error?: string;

@Column({ default: 0 })
retryCount: number;

@Column({ type: 'timestamptz', nullable: true })
nextRetryAt?: Date;

@CreateDateColumn()
createdAt: Date;

@Column({ type: 'timestamptz', nullable: true })
deliveredAt?: Date;
}
Loading
Loading