diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..aa05227 --- /dev/null +++ b/TODO.md @@ -0,0 +1,12 @@ +# Queue Dashboard - Revised (Custom REST API Dashboard, no new deps needed) + +## Plan Steps: + +1. [x] Add Redis config ✅ +2. [x] Create src/queue/queue.module.ts & queue.controller.ts ✅ +3. [x] Update src/events/events.module.ts & events.service.ts with processor ✅ +4. [x] Update src/app.module.ts: import QueueModule ✅ +5. [x] Add example test-job endpoint to health.controller.ts ✅ +6. [ ] Test: docker-compose up redis db backend, POST /health/test-job, GET /queue/dashboard + +Next: Test & complete. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..db10ef4 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,28 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': ['ts-jest', { + tsconfig: { + module: 'commonjs', + moduleResolution: 'node', + esModuleInterop: true, + allowSyntheticDefaultImports: true, + resolvePackageJsonExports: false, + emitDecoratorMetadata: true, + experimentalDecorators: true, + } + }] + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^src/(.*)$': '/$1', + }, + testTimeout: 30000, + maxWorkers: 1, +}; + + diff --git a/package.json b/package.json index 236eaf6..0c3ec10 100644 --- a/package.json +++ b/package.json @@ -84,22 +84,5 @@ }, "prisma": { "seed": "npx ts-node --esm prisma/seed.ts" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/src/app.module.ts b/src/app.module.ts index beeda03..3dd82f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { AdoptionModule } from './adoption/adoption.module'; import { CustodyModule } from './custody/custody.module'; import { EscrowModule } from './escrow/escrow.module'; import { EventsModule } from './events/events.module'; +import { QueueModule } from './queue/queue.module'; import { StellarModule } from './stellar/stellar.module'; import { AuthModule } from './auth/auth.module'; import { HealthModule } from './health/health.module'; @@ -22,6 +23,7 @@ import { CloudinaryModule } from './cloudinary/cloudinary.module'; CustodyModule, EscrowModule, EventsModule, + QueueModule, StellarModule, AuthModule, HealthModule, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index e69de29..ed97231 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -0,0 +1,8 @@ +import { config } from 'dotenv'; + +config(); + +export default () => ({ + port: parseInt(process.env.PORT || '3000', 10), + redis: process.env.REDIS_URL || 'redis://localhost:6379', +}); diff --git a/src/events/events.module.ts b/src/events/events.module.ts index 6936e26..afbb087 100644 --- a/src/events/events.module.ts +++ b/src/events/events.module.ts @@ -2,10 +2,11 @@ import { Global, Module } from '@nestjs/common'; import { EventsService } from './events.service'; import { PrismaModule } from '../prisma/prisma.module'; -@Global() // Makes EventsService available application-wide without needing to import EventsModule everywhere +@Global() @Module({ imports: [PrismaModule], providers: [EventsService], - exports: [EventsService], // Export it so other modules can use it + exports: [EventsService], }) export class EventsModule {} + diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 70289a3..df125bd 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Post, Body } from '@nestjs/common'; import { HealthCheckService, HealthCheck, @@ -6,6 +6,10 @@ import { } from '@nestjs/terminus'; import { PrismaService } from '../prisma/prisma.service'; import { PrismaClient } from '@prisma/client'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import { EventEntityType, EventType } from '@prisma/client'; +import { EventsService } from '../events/events.service'; @Controller('health') export class HealthController { @@ -13,6 +17,8 @@ export class HealthController { private health: HealthCheckService, private db: PrismaHealthIndicator, private prisma: PrismaService, + private eventsService: EventsService, + @InjectQueue('events') private eventsQueue: Queue, ) {} @Get() @@ -30,12 +36,24 @@ export class HealthController { () => this.db.pingCheck('database', this.prisma), ]); } - // @Get() - // @HealthCheck() - // check() { - // return this.health.check([ - // // This checks if the database is reachable via Prisma - // () => this.db.pingCheck('database', this.prisma as PrismaClient), - // ]); - // } + + @Post('test-job') + async addTestJob( + @Body() + body: { + entityType: EventEntityType; + entityId: string; + eventType: EventType; + payload?: any; + }, + ) { + const jobData = { + entityType: body.entityType || 'PET', + entityId: body.entityId || 'test-pet-1', + eventType: body.eventType || 'PET_LISTED', + payload: body.payload || { test: true }, + }; + const job = await this.eventsQueue.add('log-event', jobData); + return { jobId: job.id, message: 'Test job added to events queue' }; + } } diff --git a/src/queue/queue.controller.ts b/src/queue/queue.controller.ts new file mode 100644 index 0000000..317be36 --- /dev/null +++ b/src/queue/queue.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Post, + Param, + ParseUUIDPipe, + Body, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue, Job } from 'bullmq'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('Queue Dashboard') +@Controller('queue') +export class QueueController { + constructor( + @InjectQueue('events') private readonly eventsQueue: Queue, + @InjectQueue('notifications') private readonly notificationsQueue: Queue, + ) {} + + @Get('dashboard') + @ApiOperation({ + summary: 'Get queue dashboard overview (counts and recent jobs)', + }) + @ApiResponse({ status: 200, description: 'Dashboard data' }) + async getDashboard() { + const states = ['active', 'waiting', 'completed', 'failed'] as const; + const queues = ['events', 'notifications'] as const; + const data: Record = {}; + + for (const queueName of queues) { + const queue = + queueName === 'events' ? this.eventsQueue : this.notificationsQueue; + data[queueName] = {}; + for (const state of states) { + const jobs = await queue.getJobs([state]); + data[queueName][state] = { + count: jobs.length, + jobs: jobs.slice(0, 5), // recent 5 + }; + } + } + + return { + timestamp: new Date().toISOString(), + queues: data, + }; + } + + @Get(':name/:state') + @ApiOperation({ summary: 'List jobs in specific state' }) + async getJobs(@Param('name') name: string, @Param('state') state: string) { + const queue = + name === 'events' ? this.eventsQueue : this.notificationsQueue; + const jobs = await queue.getJobs([state as any]); + return jobs.map((job: Job) => job.toJSON()); + } + + @Get(':name/job/:id') + @ApiOperation({ summary: 'Inspect single job' }) + async getJob(@Param('name') name: string, @Param('id') id: string) { + const queue = + name === 'events' ? this.eventsQueue : this.notificationsQueue; + const job = await queue.getJob(id); + return job ? job.toJSON() : null; + } + + @Post(':name/job/:id/retry') + @ApiOperation({ summary: 'Retry a failed job' }) + async retryJob(@Param('name') name: string, @Param('id') id: string) { + const queue = + name === 'events' ? this.eventsQueue : this.notificationsQueue; + const job = await queue.getJob(id); + if (!job) return { error: 'Job not found' }; + await job.retry(); + return { success: true, message: 'Job retried' }; + } + + @Post('add/:name') + @ApiOperation({ summary: 'Add test job to queue' }) + async addJob(@Param('name') name: string, @Body() data: any) { + const queue = + name === 'events' ? this.eventsQueue : this.notificationsQueue; + const job = await queue.add('test', data); + return { jobId: job.id }; + } +} diff --git a/src/queue/queue.module.ts b/src/queue/queue.module.ts new file mode 100644 index 0000000..ec54e13 --- /dev/null +++ b/src/queue/queue.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { QueueController } from './queue.controller'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const redisUrl = configService.get('redis') || 'redis://localhost:6379'; + return { + connection: { url: redisUrl }, + }; + }, + inject: [ConfigService], + }), + BullModule.registerQueue({ + name: 'events', + }), + BullModule.registerQueue({ + name: 'notifications', + }), + ], + controllers: [QueueController], +}) +export class QueueModule {} + + diff --git a/src/auth/auth.controller.spec.ts b/test/e2e/auth.controller.e2e-spec.ts similarity index 100% rename from src/auth/auth.controller.spec.ts rename to test/e2e/auth.controller.e2e-spec.ts diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..e61bf26 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -4,6 +4,17 @@ "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } + "^.+\\.(t|j)s$": ["ts-jest", { + "tsconfig": { + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolvePackageJsonExports": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true + } + }] + }, + "testTimeout": 30000 }