From d258b66f7f74001c763b2f65fe8a9c7befb07ffe Mon Sep 17 00:00:00 2001 From: AbdulSnk Date: Thu, 27 Aug 2026 14:16:48 +0100 Subject: [PATCH] feat: add admin controls and CDN delivery management --- .env.example | 9 ++ src/admin/admin.module.ts | 10 ++ .../__tests__/admin-audit.controller.spec.ts | 41 ++++++ .../controllers/admin-audit.controller.ts | 38 ++++++ src/admin/controllers/admin-cdn.controller.ts | 43 +++++++ .../admin-notifications.controller.ts | 52 ++++++++ .../controllers/admin-puzzles.controller.ts | 3 +- src/app.module.ts | 5 + src/cdn/cdn.controller.ts | 30 +++++ src/cdn/cdn.module.ts | 14 ++ src/cdn/cdn.service.spec.ts | 63 +++++++++ src/cdn/cdn.service.ts | 120 ++++++++++++++++++ src/cdn/config/cdn.config.ts | 17 +++ 13 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 src/admin/controllers/__tests__/admin-audit.controller.spec.ts create mode 100644 src/admin/controllers/admin-audit.controller.ts create mode 100644 src/admin/controllers/admin-cdn.controller.ts create mode 100644 src/admin/controllers/admin-notifications.controller.ts create mode 100644 src/cdn/cdn.controller.ts create mode 100644 src/cdn/cdn.module.ts create mode 100644 src/cdn/cdn.service.spec.ts create mode 100644 src/cdn/cdn.service.ts create mode 100644 src/cdn/config/cdn.config.ts diff --git a/.env.example b/.env.example index 21e2cc16..640d693b 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,15 @@ CACHE_ADAPTIVE_WARMING_LIMIT=30 CACHE_HIT_RATE_TARGET=0.85 REDIS_PASSWORD=redis123 +#CDN Delivery +CDN_BASE_URL=https://cdn.example.com +CDN_FALLBACK_URL=https://storage.example.com +CDN_PROVIDER=generic +CDN_PURGE_URL= +CDN_PURGE_TOKEN= +CDN_DEFAULT_TTL_SECONDS=86400 +CDN_STALE_WHILE_REVALIDATE_SECONDS=3600 + #Backup Configuration BACKUP_PATH=./backups BACKUP_RETENTION_DAYS=30 diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 2404909d..e42e9883 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -8,12 +8,17 @@ import { AdminUsersController } from './controllers/admin-users.controller'; import { AdminAnalyticsController } from './controllers/admin-analytics.controller'; import { AdminModerationController } from './controllers/admin-moderation.controller'; import { AdminMonitoringController } from './controllers/admin-monitoring.controller'; +import { AdminAuditController } from './controllers/admin-audit.controller'; +import { AdminNotificationsController } from './controllers/admin-notifications.controller'; +import { AdminCdnController } from './controllers/admin-cdn.controller'; import { PuzzlesModule } from '../puzzles/puzzles.module'; import { AuthModule } from '../auth/auth.module'; import { AnalyticsModule } from '../analytics/analytics.module'; import { User } from '../auth/entities/user.entity'; import { Role } from '../auth/entities/role.entity'; import { PrivacyModule } from '../privacy/privacy.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { CdnModule } from '../cdn/cdn.module'; @Module({ imports: [ @@ -22,6 +27,8 @@ import { PrivacyModule } from '../privacy/privacy.module'; AuthModule, AnalyticsModule, PrivacyModule, + NotificationsModule, + CdnModule, ], controllers: [ AdminPuzzlesController, @@ -29,6 +36,9 @@ import { PrivacyModule } from '../privacy/privacy.module'; AdminAnalyticsController, AdminModerationController, AdminMonitoringController, + AdminAuditController, + AdminNotificationsController, + AdminCdnController, ], providers: [AdminAuditLogService, AdminUsersService], exports: [AdminAuditLogService, AdminUsersService], diff --git a/src/admin/controllers/__tests__/admin-audit.controller.spec.ts b/src/admin/controllers/__tests__/admin-audit.controller.spec.ts new file mode 100644 index 00000000..81e7a91c --- /dev/null +++ b/src/admin/controllers/__tests__/admin-audit.controller.spec.ts @@ -0,0 +1,41 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminAuditController } from '../admin-audit.controller'; +import { AdminAuditLogService } from '../../services/admin-audit-log.service'; + +describe('AdminAuditController', () => { + let controller: AdminAuditController; + const auditLogService = { getLogs: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminAuditController], + providers: [{ provide: AdminAuditLogService, useValue: auditLogService }], + }).compile(); + + controller = module.get(AdminAuditController); + auditLogService.getLogs.mockResolvedValue([[], 0]); + }); + + it('returns filtered and bounded audit logs', async () => { + await controller.getLogs( + 'admin-1', + 'UPDATE_USER_ROLE', + 'USER', + '2026-01-01T00:00:00.000Z', + undefined, + '500', + '-4', + ); + + expect(auditLogService.getLogs).toHaveBeenCalledWith( + expect.objectContaining({ + adminId: 'admin-1', + action: 'UPDATE_USER_ROLE', + targetType: 'USER', + startDate: new Date('2026-01-01T00:00:00.000Z'), + }), + 100, + 0, + ); + }); +}); diff --git a/src/admin/controllers/admin-audit.controller.ts b/src/admin/controllers/admin-audit.controller.ts new file mode 100644 index 00000000..d9694f06 --- /dev/null +++ b/src/admin/controllers/admin-audit.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; + +@Controller('admin/audit-logs') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminAuditController { + constructor(private readonly auditLogService: AdminAuditLogService) {} + + @Get() + async getLogs( + @Query('adminId') adminId?: string, + @Query('action') action?: string, + @Query('targetType') targetType?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('limit') limit = '50', + @Query('offset') offset = '0', + ) { + const [data, total] = await this.auditLogService.getLogs( + { + adminId, + action, + targetType, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + }, + Math.min(Math.max(Number(limit) || 50, 1), 100), + Math.max(Number(offset) || 0, 0), + ); + + return { data, total }; + } +} diff --git a/src/admin/controllers/admin-cdn.controller.ts b/src/admin/controllers/admin-cdn.controller.ts new file mode 100644 index 00000000..b9125819 --- /dev/null +++ b/src/admin/controllers/admin-cdn.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { CdnService } from '../../cdn/cdn.service'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; +import { ActiveUser } from '../../auth/decorators/active-user.decorator'; + +@Controller('admin/cdn') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminCdnController { + constructor( + private readonly cdnService: CdnService, + private readonly auditLogService: AdminAuditLogService, + ) {} + + @Get('metrics') + getMetrics() { + return this.cdnService.getMetrics(); + } + + @Get('health') + health() { + return this.cdnService.healthCheck(); + } + + @Post('purge') + async purge( + @Body('keys') keys: string[], + @ActiveUser() admin: { id: string }, + ) { + const result = await this.cdnService.purge(keys || []); + await this.auditLogService.log({ + adminId: admin.id, + action: 'PURGE_CDN', + targetType: 'CDN', + details: { keys: keys || [], ...result }, + }); + return result; + } +} diff --git a/src/admin/controllers/admin-notifications.controller.ts b/src/admin/controllers/admin-notifications.controller.ts new file mode 100644 index 00000000..078b2ff7 --- /dev/null +++ b/src/admin/controllers/admin-notifications.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../auth/constants'; +import { NotificationsService } from '../../notifications/services/notifications.service'; +import { AdminAuditLogService } from '../services/admin-audit-log.service'; +import { ActiveUser } from '../../auth/decorators/active-user.decorator'; + +@Controller('admin/notifications') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class AdminNotificationsController { + constructor( + private readonly notificationsService: NotificationsService, + private readonly auditLogService: AdminAuditLogService, + ) {} + + @Post('broadcast') + async broadcast( + @Body() + body: { + userIds: string[]; + type: any; + title: string; + message: string; + data?: Record; + }, + @ActiveUser() admin: { id: string }, + ) { + const notifications = []; + for (const userId of [...new Set(body.userIds || [])]) { + const notification = await this.notificationsService.create({ + userId, + type: body.type, + title: body.title, + message: body.message, + data: body.data, + }); + if (notification) notifications.push(notification); + } + + await this.auditLogService.log({ + adminId: admin.id, + action: 'BROADCAST_NOTIFICATION', + targetType: 'USER_BATCH', + details: { requested: body.userIds?.length || 0, delivered: notifications.length }, + }); + + return { requested: body.userIds?.length || 0, created: notifications.length }; + } +} diff --git a/src/admin/controllers/admin-puzzles.controller.ts b/src/admin/controllers/admin-puzzles.controller.ts index f46caaad..c69acac3 100644 --- a/src/admin/controllers/admin-puzzles.controller.ts +++ b/src/admin/controllers/admin-puzzles.controller.ts @@ -8,6 +8,7 @@ import { Delete, UseGuards, ParseUUIDPipe, + Query, HttpStatus, HttpCode, Req, @@ -35,7 +36,7 @@ export class AdminPuzzlesController { ) {} @Get() - async findAll(@Body() searchDto: SearchPuzzleDto) { + async findAll(@Query() searchDto: SearchPuzzleDto) { return await this.puzzlesService.findAll(searchDto); } diff --git a/src/app.module.ts b/src/app.module.ts index 26d29b3d..65de8f42 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,8 @@ import { Event } from './events/event.entity'; import { EventsModule } from './events/events.module'; import { JobsModule } from './jobs/jobs.module'; import { Job } from './jobs/job.entity'; +import { CdnModule } from './cdn/cdn.module'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -39,6 +41,7 @@ import { Job } from './jobs/job.entity'; password: configService.get('DB_PASSWORD', 'password'), database: configService.get('DB_NAME', 'cache_warming_db'), entities: [CacheJob, PreloadData, Metric, Event, DeadLetterEvent, Job], + autoLoadEntities: true, synchronize: configService.get('NODE_ENV') !== 'production', logging: configService.get('NODE_ENV') === 'development', }), @@ -48,6 +51,8 @@ import { Job } from './jobs/job.entity'; CacheWarmingModule, EventsModule, JobsModule, + CdnModule, + AdminModule, ], }) export class AppModule {} diff --git a/src/cdn/cdn.controller.ts b/src/cdn/cdn.controller.ts new file mode 100644 index 00000000..78d0df85 --- /dev/null +++ b/src/cdn/cdn.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Headers, Param, Query, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { CdnService } from './cdn.service'; + +@Controller('assets') +export class CdnController { + constructor(private readonly cdnService: CdnService) {} + + @Get('*') + redirectToCdn( + @Param('0') key: string, + @Query('v') version = 'latest', + @Headers('if-none-match') ifNoneMatch: string | undefined, + @Res() response: Response, + ) { + const asset = this.cdnService.resolveAsset(key, version); + this.cdnService.recordRequest(ifNoneMatch === asset.etag); + + if (ifNoneMatch === asset.etag) { + response.status(304).setHeader('ETag', asset.etag).send(); + return; + } + + response + .status(302) + .setHeader('Cache-Control', asset.cacheControl) + .setHeader('ETag', asset.etag) + .redirect(asset.url); + } +} diff --git a/src/cdn/cdn.module.ts b/src/cdn/cdn.module.ts new file mode 100644 index 00000000..60768da4 --- /dev/null +++ b/src/cdn/cdn.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import cdnConfig from './config/cdn.config'; +import { CdnController } from './cdn.controller'; +import { CdnService } from './cdn.service'; + +@Global() +@Module({ + imports: [ConfigModule.forFeature(cdnConfig)], + controllers: [CdnController], + providers: [CdnService], + exports: [CdnService], +}) +export class CdnModule {} diff --git a/src/cdn/cdn.service.spec.ts b/src/cdn/cdn.service.spec.ts new file mode 100644 index 00000000..6ff11135 --- /dev/null +++ b/src/cdn/cdn.service.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CdnService } from './cdn.service'; + +const config = { + primaryUrl: 'https://cdn.example.com', + fallbackUrl: 'https://fallback.example.com', + provider: 'generic', + purgeUrl: '', + purgeToken: '', + defaultTtlSeconds: 3600, + staleWhileRevalidateSeconds: 300, +}; + +describe('CdnService', () => { + let service: CdnService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CdnService, + { provide: 'CONFIGURATION(cdn)', useValue: config }, + ], + }).compile(); + + service = module.get(CdnService); + }); + + it('creates versioned CDN URLs with immutable caching', () => { + const asset = service.resolveAsset('/puzzles/one.png', 'abc123'); + + expect(asset.url).toBe('https://cdn.example.com/puzzles/one.png?v=abc123'); + expect(asset.cacheControl).toContain('immutable'); + expect(asset.etag).toBe('"puzzles/one.png:abc123"'); + }); + + it('records cache metrics', () => { + service.recordRequest(true); + service.recordRequest(false); + + expect(service.getMetrics()).toMatchObject({ + requests: 2, + cacheHits: 1, + cacheMisses: 1, + }); + }); + + it('deduplicates purge keys when no provider endpoint is configured', async () => { + await expect(service.purge(['a.png', 'a.png', ' b.png '])).resolves.toEqual({ + purged: 2, + failed: false, + }); + }); + + it('uses the fallback CDN when the primary is unavailable', async () => { + jest.spyOn((service as any).httpClient, 'head').mockRejectedValue(new Error('offline')); + + await expect(service.healthCheck()).resolves.toEqual({ + available: true, + url: config.fallbackUrl, + }); + expect(service.getMetrics().failoverRequests).toBe(1); + }); +}); diff --git a/src/cdn/cdn.service.ts b/src/cdn/cdn.service.ts new file mode 100644 index 00000000..086fa14a --- /dev/null +++ b/src/cdn/cdn.service.ts @@ -0,0 +1,120 @@ +import { + Injectable, + Inject, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import cdnConfig from './config/cdn.config'; + +export interface CdnAsset { + url: string; + cacheControl: string; + etag: string; + version: string; +} + +export interface CdnMetrics { + requests: number; + cacheHits: number; + cacheMisses: number; + purgeRequests: number; + purgeFailures: number; + failoverRequests: number; +} + +@Injectable() +export class CdnService { + private readonly logger = new Logger(CdnService.name); + private readonly httpClient: AxiosInstance; + private readonly metrics: CdnMetrics = { + requests: 0, + cacheHits: 0, + cacheMisses: 0, + purgeRequests: 0, + purgeFailures: 0, + failoverRequests: 0, + }; + + constructor( + @Inject(cdnConfig.KEY) + private readonly config: ConfigType, + ) { + this.httpClient = axios.create({ timeout: 5000 }); + } + + resolveAsset(key: string, version: string | number = 1): CdnAsset { + const normalizedKey = key.replace(/^\/+/, '').replace(/\s+/g, '-'); + const normalizedVersion = String(version).replace(/[^a-zA-Z0-9._-]/g, ''); + const baseUrl = this.config.primaryUrl || this.config.fallbackUrl; + + if (!baseUrl) { + throw new ServiceUnavailableException('CDN is not configured'); + } + + return { + url: `${baseUrl.replace(/\/$/, '')}/${normalizedKey}?v=${normalizedVersion}`, + cacheControl: this.getCacheControl(normalizedVersion), + etag: `"${normalizedKey}:${normalizedVersion}"`, + version: normalizedVersion, + }; + } + + recordRequest(cacheHit: boolean): void { + this.metrics.requests += 1; + if (cacheHit) this.metrics.cacheHits += 1; + else this.metrics.cacheMisses += 1; + } + + async purge(keys: string[]): Promise<{ purged: number; failed: boolean }> { + const uniqueKeys = [...new Set(keys.map((key) => key.trim()).filter(Boolean))]; + if (uniqueKeys.length === 0) return { purged: 0, failed: false }; + + this.metrics.purgeRequests += 1; + if (!this.config.purgeUrl) { + return { purged: uniqueKeys.length, failed: false }; + } + + try { + await this.httpClient.post( + this.config.purgeUrl, + { keys: uniqueKeys }, + this.config.purgeToken + ? { headers: { Authorization: `Bearer ${this.config.purgeToken}` } } + : undefined, + ); + return { purged: uniqueKeys.length, failed: false }; + } catch (error) { + this.metrics.purgeFailures += 1; + this.logger.error('CDN purge failed', error); + return { purged: 0, failed: true }; + } + } + + getMetrics(): CdnMetrics { + return { ...this.metrics }; + } + + getCacheControl(version: string): string { + const immutable = Boolean(version) && version !== 'latest'; + const ttl = immutable ? 31536000 : this.config.defaultTtlSeconds; + return `public, max-age=${ttl}, s-maxage=${ttl}, stale-while-revalidate=${this.config.staleWhileRevalidateSeconds}${immutable ? ', immutable' : ''}`; + } + + async healthCheck(): Promise<{ available: boolean; url: string }> { + const primary = this.config.primaryUrl; + if (!primary) return { available: Boolean(this.config.fallbackUrl), url: this.config.fallbackUrl }; + + try { + await this.httpClient.head(primary, { timeout: 2000 }); + return { available: true, url: primary }; + } catch { + if (this.config.fallbackUrl) { + this.metrics.failoverRequests += 1; + return { available: true, url: this.config.fallbackUrl }; + } + return { available: false, url: primary }; + } + } +} diff --git a/src/cdn/config/cdn.config.ts b/src/cdn/config/cdn.config.ts new file mode 100644 index 00000000..c710886a --- /dev/null +++ b/src/cdn/config/cdn.config.ts @@ -0,0 +1,17 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('cdn', () => ({ + primaryUrl: process.env.CDN_BASE_URL || '', + fallbackUrl: process.env.CDN_FALLBACK_URL || '', + provider: process.env.CDN_PROVIDER || 'generic', + purgeUrl: process.env.CDN_PURGE_URL || '', + purgeToken: process.env.CDN_PURGE_TOKEN || '', + defaultTtlSeconds: Number.parseInt( + process.env.CDN_DEFAULT_TTL_SECONDS || '86400', + 10, + ), + staleWhileRevalidateSeconds: Number.parseInt( + process.env.CDN_STALE_WHILE_REVALIDATE_SECONDS || '3600', + 10, + ), +}));