From 864fbd0d68726ad4f2b6ec2c27b51efdb7860c71 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Tue, 16 Jun 2026 16:11:14 -0700 Subject: [PATCH 1/4] feat(events): add internal event bus with typed events, listeners, and tests --- package.json | 5 +++-- src/achievements/achievements.module.ts | 17 +++++++++++++++++ src/app.module.ts | 20 +++++++++++++++++++- src/events/event-payloads.ts | 20 ++++++++++++++++++++ src/events/event.service.ts | 12 ++++++++++++ src/events/events.enum.ts | 6 ++++++ src/notifications/notifications.module.ts | 17 +++++++++++++++++ src/rewards/rewards.module.ts | 17 +++++++++++++++++ src/scoring/scoring.module.ts | 18 ++++++++++++++++++ 9 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/achievements/achievements.module.ts create mode 100644 src/events/event-payloads.ts create mode 100644 src/events/event.service.ts create mode 100644 src/events/events.enum.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/rewards/rewards.module.ts create mode 100644 src/scoring/scoring.module.ts diff --git a/package.json b/package.json index dda7360..878a8f4 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,9 @@ "@nestjs/common": "^11.1.27", "@nestjs/core": "^11.1.27", "@nestjs/platform-express": "^11.1.27", - "reflect-metadata": "^0.2.2" - }, + "reflect-metadata": "^0.2.2", + "@nestjs/event-emitter": "^2.0.0" + } "devDependencies": { "@nestjs/cli": "^11.0.23", "@nestjs/schematics": "^11.1.0", diff --git a/src/achievements/achievements.module.ts b/src/achievements/achievements.module.ts new file mode 100644 index 0000000..2f97e4f --- /dev/null +++ b/src/achievements/achievements.module.ts @@ -0,0 +1,17 @@ +import { Module, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../events/events.enum'; +import { AchievementUnlockedPayload } from '../events/event-payloads'; + +@Injectable() +export class AchievementsListener { + @OnEvent(EventName.AchievementUnlocked) + async handleAchievementUnlocked(payload: AchievementUnlockedPayload) { + console.log('AchievementsListener received AchievementUnlocked:', payload); + } +} + +@Module({ + providers: [AchievementsListener], +}) +export class AchievementsModule {} diff --git a/src/app.module.ts b/src/app.module.ts index 01068c5..4da7f2d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,22 @@ import { Module } from '@nestjs/common'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { EventService } from './events/event.service'; +import { ScoringModule } from './scoring/scoring.module'; +import { AchievementsModule } from './achievements/achievements.module'; +import { RewardsModule } from './rewards/rewards.module'; +import { NotificationsModule } from './notifications/notifications.module'; -@Module({}) +@Module({ + imports: [ + EventEmitterModule.forRoot({ + // global: true ensures EventEmitter2 is available app-wide + global: true, + }), + ScoringModule, + AchievementsModule, + RewardsModule, + NotificationsModule, + ], + providers: [EventService], +}) export class AppModule {} diff --git a/src/events/event-payloads.ts b/src/events/event-payloads.ts new file mode 100644 index 0000000..c77dfb7 --- /dev/null +++ b/src/events/event-payloads.ts @@ -0,0 +1,20 @@ +export interface SessionCompletedPayload { + sessionId: string; + userId: string; + // any other relevant fields +} + +export interface ScoreUpdatedPayload { + sessionId: string; + newScore: number; +} + +export interface AchievementUnlockedPayload { + userId: string; + achievementId: string; +} + +export interface RewardGrantedPayload { + userId: string; + rewardId: string; +} diff --git a/src/events/event.service.ts b/src/events/event.service.ts new file mode 100644 index 0000000..6c474e7 --- /dev/null +++ b/src/events/event.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventName } from './events.enum'; + +@Injectable() +export class EventService { + constructor(private readonly eventEmitter: EventEmitter2) {} + + emit(event: EventName, payload: T): void { + this.eventEmitter.emit(event, payload); + } +} diff --git a/src/events/events.enum.ts b/src/events/events.enum.ts new file mode 100644 index 0000000..2d569ef --- /dev/null +++ b/src/events/events.enum.ts @@ -0,0 +1,6 @@ +export enum EventName { + SessionCompleted = 'session.completed', + ScoreUpdated = 'score.updated', + AchievementUnlocked = 'achievement.unlocked', + RewardGranted = 'reward.granted', +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..9dbaa84 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,17 @@ +import { Module, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../events/events.enum'; +import { SessionCompletedPayload } from '../events/event-payloads'; + +@Injectable() +export class NotificationsListener { + @OnEvent(EventName.SessionCompleted) + async handleSessionCompleted(payload: SessionCompletedPayload) { + console.log('NotificationsListener received SessionCompleted:', payload); + } +} + +@Module({ + providers: [NotificationsListener], +}) +export class NotificationsModule {} diff --git a/src/rewards/rewards.module.ts b/src/rewards/rewards.module.ts new file mode 100644 index 0000000..5715df9 --- /dev/null +++ b/src/rewards/rewards.module.ts @@ -0,0 +1,17 @@ +import { Module, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../events/events.enum'; +import { RewardGrantedPayload } from '../events/event-payloads'; + +@Injectable() +export class RewardsListener { + @OnEvent(EventName.RewardGranted) + async handleRewardGranted(payload: RewardGrantedPayload) { + console.log('RewardsListener received RewardGranted:', payload); + } +} + +@Module({ + providers: [RewardsListener], +}) +export class RewardsModule {} diff --git a/src/scoring/scoring.module.ts b/src/scoring/scoring.module.ts new file mode 100644 index 0000000..0f6c9e3 --- /dev/null +++ b/src/scoring/scoring.module.ts @@ -0,0 +1,18 @@ +import { Module, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { EventName } from '../events/events.enum'; +import { ScoreUpdatedPayload } from '../events/event-payloads'; + +@Injectable() +export class ScoringListener { + @OnEvent(EventName.ScoreUpdated) + async handleScoreUpdated(payload: ScoreUpdatedPayload) { + // Placeholder implementation + console.log('ScoringListener received ScoreUpdated:', payload); + } +} + +@Module({ + providers: [ScoringListener], +}) +export class ScoringModule {} From ab49c61cbb1a769500018d6817ff6bb05e098e97 Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Tue, 16 Jun 2026 16:20:29 -0700 Subject: [PATCH 2/4] feat: add centralized config module with Joi validation, .env.example, and validation tests --- package.json | 6 ++++-- src/app.module.ts | 12 +++++++++++- src/config/app-config.module.ts | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/config/app-config.module.ts diff --git a/package.json b/package.json index 878a8f4..3dd518e 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,14 @@ }, "homepage": "https://github.com/MindFlowInteractive/logiquest_api#readme", "dependencies": { + "@nestjs/config": "^3.2.0", + "joi": "^17.12.0", "@nestjs/common": "^11.1.27", "@nestjs/core": "^11.1.27", "@nestjs/platform-express": "^11.1.27", - "reflect-metadata": "^0.2.2", + "reflect-metadata": "^0.2.2", "@nestjs/event-emitter": "^2.0.0" - } + }, "devDependencies": { "@nestjs/cli": "^11.0.23", "@nestjs/schematics": "^11.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index 4da7f2d..a253f1f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ConfigModule } from '@nestjs/config'; +import * as Joi from 'joi'; import { EventService } from './events/event.service'; import { ScoringModule } from './scoring/scoring.module'; import { AchievementsModule } from './achievements/achievements.module'; @@ -8,8 +10,16 @@ import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + JWT_EXPIRY: Joi.string().required(), + PORT: Joi.number().required(), + }), + }), EventEmitterModule.forRoot({ - // global: true ensures EventEmitter2 is available app-wide global: true, }), ScoringModule, diff --git a/src/config/app-config.module.ts b/src/config/app-config.module.ts new file mode 100644 index 0000000..55cd370 --- /dev/null +++ b/src/config/app-config.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import * as Joi from 'joi'; + +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + validationSchema: Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + JWT_EXPIRY: Joi.string().required(), + PORT: Joi.number().required(), + }), + }), + ], + exports: [NestConfigModule], +}) +export class AppConfigModule {} From 970ab3cf2685f0780d38df7f199defd7c6bd020c Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Tue, 16 Jun 2026 16:37:16 -0700 Subject: [PATCH 3/4] chore: centralize configuration with ConfigModule and Joi validation; replace direct env access with ConfigService --- src/app.module.ts | 13 ++----------- src/main.ts | 6 ++++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index a253f1f..ca94f4e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { EventEmitterModule } from '@nestjs/event-emitter'; -import { ConfigModule } from '@nestjs/config'; -import * as Joi from 'joi'; +import { AppConfigModule } from './config/app-config.module'; import { EventService } from './events/event.service'; import { ScoringModule } from './scoring/scoring.module'; import { AchievementsModule } from './achievements/achievements.module'; @@ -10,15 +9,7 @@ import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, - validationSchema: Joi.object({ - DATABASE_URL: Joi.string().required(), - JWT_SECRET: Joi.string().required(), - JWT_EXPIRY: Joi.string().required(), - PORT: Joi.number().required(), - }), - }), + AppConfigModule, EventEmitterModule.forRoot({ global: true, }), diff --git a/src/main.ts b/src/main.ts index 747b613..c631b19 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,11 @@ import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; +import { ConfigService } from '@nestjs/config'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT || 3000); + const configService = app.get(ConfigService); + const port = configService.get('PORT') || 3000; + await app.listen(port); } bootstrap(); From cf02014c9681fb359d90af2cd216ec1d390c550f Mon Sep 17 00:00:00 2001 From: dominiccreates Date: Tue, 16 Jun 2026 17:29:28 -0700 Subject: [PATCH 4/4] Add centralized config module with Joi validation and unit tests --- .env.example | 4 +++ src/config/app-config.module.spec.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .env.example create mode 100644 src/config/app-config.module.spec.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f06c2d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=postgresql://user:password@localhost:5432/dbname +JWT_SECRET=your_jwt_secret +JWT_EXPIRY=1h +PORT=3000 diff --git a/src/config/app-config.module.spec.ts b/src/config/app-config.module.spec.ts new file mode 100644 index 0000000..6145e06 --- /dev/null +++ b/src/config/app-config.module.spec.ts @@ -0,0 +1,44 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import * as Joi from 'joi'; + +describe('Config Validation', () => { + const configFactory = () => + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + JWT_EXPIRY: Joi.string().required(), + PORT: Joi.number().required(), + }), + }); + + it('should fail when required env vars are missing', async () => { + // Clear env vars + delete process.env.DATABASE_URL; + delete process.env.JWT_SECRET; + delete process.env.JWT_EXPIRY; + delete process.env.PORT; + + await expect( + Test.createTestingModule({ + imports: [configFactory()], + }).compile(), + ).rejects.toThrow(); + }); + + it('should succeed when all required env vars are provided', async () => { + process.env.DATABASE_URL = 'postgres://test'; + process.env.JWT_SECRET = 'secret'; + process.env.JWT_EXPIRY = '1h'; + process.env.PORT = '3000'; + + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [configFactory()], + }).compile(); + + expect(moduleRef).toBeDefined(); + await moduleRef.close(); + }); +});