Skip to content

Commit b529621

Browse files
authored
Merge pull request #224 from dDevAhmed/feature/webhook-system
Implement Webhook System for Quest-Service
2 parents a49438d + ddc849e commit b529621

21 files changed

Lines changed: 941 additions & 1 deletion

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"@aws-sdk/s3-request-presigner": "^3.993.0",
2424
"@nestjs-modules/ioredis": "^2.0.2",
2525
"@nestjs/cache-manager": "^2.0.0",
26+
"@nestjs/bull": "^10.0.1",
27+
"@nestjs/bullmq": "^10.2.3",
2628
"@nestjs/common": "^10.0.0",
2729
"@nestjs/config": "^3.0.0",
2830
"@nestjs/core": "^10.0.0",
@@ -54,6 +56,8 @@
5456
"amqplib": "^0.10.3",
5557
"axios": "^1.13.5",
5658
"bcrypt": "^6.0.0",
59+
"bull": "^4.12.2",
60+
"bullmq": "^5.67.1",
5761
"cache-manager": "^5.0.0",
5862
"class-transformer": "^0.5.1",
5963
"class-validator": "^0.14.0",

src/app.module.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module } from '@nestjs/common';
2+
import { BullModule } from '@nestjs/bullmq';
23
import { ConfigModule, ConfigService } from '@nestjs/config';
34
import { TypeOrmModule } from '@nestjs/typeorm';
45
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@@ -50,6 +51,7 @@ import { EnergyModule } from './energy/energy.module';
5051
import { SkillRatingModule } from './skill-rating/skill-rating.module';
5152
import { WalletAuthModule } from './auth/wallet-auth.module';
5253
import { XpModule } from './xp/xp.module';
54+
import { WebhooksModule } from './webhooks/webhooks.module';
5355
import { PlayerEventsModule } from './player-events/player-events.module';
5456
import { AccountModule } from './account/account.module';
5557

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

92+
BullModule.forRootAsync({
93+
useFactory: (configService: ConfigService) => ({
94+
connection: {
95+
host: configService.get<string>('REDIS_HOST', 'localhost'),
96+
port: configService.get<number>('REDIS_PORT', 6379),
97+
password: configService.get<string>('REDIS_PASSWORD') || undefined,
98+
},
99+
}),
100+
inject: [ConfigService],
101+
}),
102+
90103
// Message broker
91104
RabbitMQModule,
92105

@@ -142,6 +155,7 @@ import { AccountModule } from './account/account.module';
142155
SkillRatingModule,
143156
WalletAuthModule,
144157
XpModule,
158+
WebhooksModule,
145159
AccountModule,
146160
],
147161
controllers: [AppController],

src/auth/auth.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, ConflictException, UnauthorizedException, BadRequestException } from "@nestjs/common"
2+
import { EventEmitter2 } from "@nestjs/event-emitter"
23
import { InjectRepository } from "@nestjs/typeorm"
34
import { Repository } from "typeorm"
45
import type { DeepPartial } from "typeorm"
@@ -18,6 +19,7 @@ import type { VerifyEmailDto } from "./dto/verify-email.dto"
1819
import type { JwtPayload } from "./interfaces/jwt-payload.interface"
1920
import { BCRYPT_SALT_ROUNDS, jwtConstants, UserRole } from "./constants"
2021
import { v4 as uuidv4 } from "uuid"
22+
import { WEBHOOK_INTERNAL_EVENTS } from "../webhooks/webhook.constants"
2123

2224
@Injectable()
2325
export class AuthService {
@@ -31,6 +33,7 @@ export class AuthService {
3133
@InjectRepository(TwoFactorBackupCode)
3234
private backupCodesRepository: Repository<TwoFactorBackupCode>,
3335
private jwtService: JwtService,
36+
private eventEmitter: EventEmitter2,
3437
) { }
3538

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

9598
await this.usersRepository.save(user)
9699

100+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.userRegistered, {
101+
userId: user.id,
102+
email: user.email,
103+
role: role.name,
104+
registeredAt: new Date().toISOString(),
105+
})
106+
97107
// TODO: Send verification email (mocked for now)
98108
console.log(`Verification email sent to ${user.email} with token: ${verificationToken}`)
99109

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// services/game-session.service.ts
2+
import { EventEmitter2 } from '@nestjs/event-emitter';
23
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
34
import { InjectRepository } from '@nestjs/typeorm';
45
import { Repository, LessThan } from 'typeorm';
56
import { GameSession } from '../entities/game-session.entity';
67
import { PlayerEventsService } from '../../player-events/player-events.service';
78
import { PuzzleVersionService } from '../../puzzles/services/puzzle-version.service';
9+
import { WEBHOOK_INTERNAL_EVENTS } from '../../webhooks/webhook.constants';
810
import { CacheService } from '../../cache/services/cache.service';
911

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

@@ -89,6 +92,14 @@ export class GameSessionService {
8992
});
9093
}
9194

95+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.sessionEnded, {
96+
sessionId: savedSession.id,
97+
userId: savedSession.userId,
98+
puzzleId: savedSession.puzzleId,
99+
status: savedSession.status,
100+
endedAt: savedSession.lastActiveAt.toISOString(),
101+
});
102+
92103
return savedSession;
93104
}
94105

src/leaderboard/leaderboard.service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable, Inject } from '@nestjs/common';
22
import { CACHE_MANAGER } from '@nestjs/cache-manager';
3+
import { EventEmitter2 } from '@nestjs/event-emitter';
34
import { InjectRepository } from '@nestjs/typeorm';
45
import { Repository } from 'typeorm';
56
import { Leaderboard } from './entities/leaderboard.entity';
@@ -8,6 +9,7 @@ import { CreateLeaderboardDto } from './dto/create-leaderboard.dto';
89
import { CreateLeaderboardEntryDto } from './dto/create-leaderboard-entry.dto';
910
import { Cache } from 'cache-manager';
1011
import { AchievementsService } from '../achievements/achievements.service';
12+
import { WEBHOOK_INTERNAL_EVENTS } from '../webhooks/webhook.constants';
1113

1214
@Injectable()
1315
export class LeaderboardService {
@@ -18,6 +20,7 @@ export class LeaderboardService {
1820
private entryRepository: Repository<LeaderboardEntry>,
1921
@Inject(CACHE_MANAGER) private cacheManager: any,
2022
private achievementsService: AchievementsService,
23+
private readonly eventEmitter: EventEmitter2,
2124
) { }
2225

2326
async createLeaderboard(dto: CreateLeaderboardDto): Promise<Leaderboard> {
@@ -35,6 +38,15 @@ export class LeaderboardService {
3538
await this.cacheManager.reset();
3639
// Award leaderboard achievements if criteria met
3740
await this.checkAndAwardLeaderboardAchievements(dto.leaderboardId, dto.userId);
41+
42+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.leaderboardUpdated, {
43+
leaderboardId: dto.leaderboardId,
44+
entryId: saved.id,
45+
userId: dto.userId,
46+
score: saved.score,
47+
updatedAt: new Date().toISOString(),
48+
});
49+
3850
return saved;
3951
}
4052

@@ -131,6 +143,12 @@ export class LeaderboardService {
131143
// (If you want to delete, use delete instead of update above)
132144
// Invalidate cache
133145
await this.cacheManager.reset();
146+
147+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.leaderboardUpdated, {
148+
leaderboardId,
149+
archivedAt: now.toISOString(),
150+
reset: true,
151+
});
134152
}
135153

136154
async getUserRankSummary(leaderboardId: number, userId: number) {

src/multiplayer/gateways/multiplayer.gateway.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@nestjs/websockets';
1111
import { Server, Socket } from 'socket.io';
1212
import { Logger } from '@nestjs/common';
13+
import { EventEmitter2 } from '@nestjs/event-emitter';
1314
import { MultiplayerService } from '../services/multiplayer.service';
1415
import { RoomType, Player, RoomStatus, Spectator } from '../interfaces/multiplayer.interface';
1516
import { ValidationService } from '../../game-engine/services/validation.service';
@@ -33,6 +34,7 @@ export class MultiplayerGateway implements OnGatewayInit, OnGatewayConnection, O
3334
private readonly validationService: ValidationService,
3435
private readonly leaderboardService: LeaderboardService,
3536
private readonly puzzlesService: PuzzlesService,
37+
private readonly eventEmitter: EventEmitter2,
3638
private readonly spectatorService: SpectatorService,
3739
) { }
3840

@@ -298,6 +300,14 @@ export class MultiplayerGateway implements OnGatewayInit, OnGatewayConnection, O
298300
puzzleCompleted: allPlayersSolved
299301
});
300302

303+
this.eventEmitter.emit('puzzle.solved', {
304+
userId: data.userId,
305+
puzzleId: data.puzzleId,
306+
score: result.score,
307+
totalScore: player?.score,
308+
});
309+
310+
if (room.type === RoomType.COMPETITIVE) {
301311
// Broadcast to spectators
302312
this.server.to(`${data.roomId}-spectators`).emit('collaborativeSolutionVerified', {
303313
userId: data.userId,

src/puzzles/services/solution-submission.service.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import {
22
Injectable,
33
Logger,
44
ConflictException,
5-
BadRequestException,
5+
// BadRequestException,
66
NotFoundException,
77
HttpException,
88
HttpStatus,
99
} from '@nestjs/common';
10+
import { EventEmitter2 } from '@nestjs/event-emitter';
1011
import { InjectRepository } from '@nestjs/typeorm';
1112
import { Repository, DataSource, MoreThan } from 'typeorm';
1213
import * as crypto from 'crypto';
@@ -26,6 +27,7 @@ import { ViolationType } from '../../anti-cheat/constants';
2627
import { XpService } from '../../xp/xp.service';
2728
import { PlayerEventsService } from '../../player-events/player-events.service';
2829
import { PuzzleVersionService } from './puzzle-version.service';
30+
import { WEBHOOK_INTERNAL_EVENTS } from '../../webhooks/webhook.constants';
2931

3032
// ────────────────────────────────────────────────────────────────────────────
3133
// Constants
@@ -56,6 +58,7 @@ export class SolutionSubmissionService {
5658
private readonly xpService: XpService,
5759
private readonly playerEventsService: PlayerEventsService,
5860
private readonly puzzleVersionService: PuzzleVersionService,
61+
private readonly eventEmitter: EventEmitter2,
5962
) { }
6063

6164
// ──────────────────────────────────────────────────────────────────────────
@@ -251,6 +254,15 @@ export class SolutionSubmissionService {
251254
},
252255
});
253256

257+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.puzzleSolved, {
258+
userId,
259+
puzzleId,
260+
solveTimeSeconds: timeTakenSeconds,
261+
hintsUsed: dto.hintsUsed ?? 0,
262+
scoreAwarded,
263+
solvedAt: now.toISOString(),
264+
});
265+
254266
// Each achievement unlocked should produce an audit event
255267
for (const achievement of result.rewards?.achievements ?? []) {
256268
void this.playerEventsService.emitPlayerEvent({
@@ -264,6 +276,15 @@ export class SolutionSubmissionService {
264276
hintsUsed: dto.hintsUsed ?? 0,
265277
},
266278
});
279+
280+
this.eventEmitter.emit(WEBHOOK_INTERNAL_EVENTS.achievementUnlocked, {
281+
userId,
282+
puzzleId,
283+
achievementId: achievement,
284+
unlockedAt: now.toISOString(),
285+
hintsUsed: dto.hintsUsed ?? 0,
286+
solveTimeSeconds: timeTakenSeconds,
287+
});
267288
}
268289
}
269290

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
IsUrl,
3+
IsArray,
4+
IsString,
5+
ArrayNotEmpty,
6+
IsOptional,
7+
IsIn,
8+
MinLength,
9+
} from 'class-validator';
10+
import { SUPPORTED_WEBHOOK_EVENTS, WebhookEvent } from '../webhook.constants';
11+
12+
export class CreateWebhookDto {
13+
@IsUrl({ protocols: ['https'], require_protocol: true }, { message: 'URL must be a valid HTTPS URL' })
14+
url: string;
15+
16+
@IsString()
17+
@MinLength(1)
18+
secret: string;
19+
20+
@IsArray()
21+
@ArrayNotEmpty()
22+
@IsIn(SUPPORTED_WEBHOOK_EVENTS, { each: true })
23+
events: WebhookEvent[];
24+
25+
@IsOptional()
26+
@IsString()
27+
appId?: string;
28+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
CreateDateColumn,
6+
ManyToOne,
7+
JoinColumn,
8+
Index,
9+
} from 'typeorm';
10+
import { Webhook } from './webhook.entity';
11+
12+
export type DeliveryStatus = 'pending' | 'success' | 'failed' | 'retry';
13+
14+
@Entity('webhook_deliveries')
15+
@Index(['webhookId', 'createdAt'], { order: { createdAt: 'DESC' } })
16+
export class WebhookDelivery {
17+
@PrimaryGeneratedColumn('uuid')
18+
id: string;
19+
20+
@Column('uuid')
21+
webhookId: string;
22+
23+
@ManyToOne(() => Webhook, { onDelete: 'CASCADE' })
24+
@JoinColumn({ name: 'webhookId' })
25+
webhook: Webhook;
26+
27+
@Column()
28+
event: string;
29+
30+
@Column('jsonb')
31+
payload: any;
32+
33+
@Column({ type: 'text', nullable: true })
34+
signature: string;
35+
36+
@Column({ default: 'pending' })
37+
status: DeliveryStatus;
38+
39+
@Column({ nullable: true })
40+
responseCode?: number;
41+
42+
@Column({ type: 'text', nullable: true })
43+
responseBody?: string;
44+
45+
@Column({ type: 'text', nullable: true })
46+
error?: string;
47+
48+
@Column({ default: 0 })
49+
retryCount: number;
50+
51+
@Column({ type: 'timestamptz', nullable: true })
52+
nextRetryAt?: Date;
53+
54+
@CreateDateColumn()
55+
createdAt: Date;
56+
57+
@Column({ type: 'timestamptz', nullable: true })
58+
deliveredAt?: Date;
59+
}

0 commit comments

Comments
 (0)