From ce3b2a36718cba0cbe5f732e21e4138da3e83559 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 26 Jul 2026 18:07:10 +0100 Subject: [PATCH 1/3] feat: email bounce handling, queue monitoring, API key analytics, benchmark suite Closes #943, #944, #945, #946 - Email bounce/complaint webhook handling with spam complaint support and sender reputation dashboard (GET /email/reputation) - List-Unsubscribe header added to marketing emails - Hard bounce auto-suppress sets user emailStatus to BOUNCED - Background job monitoring dashboard for BullMQ queues under admin/queues with retry/cancel endpoints (admin-only) - API key usage analytics with daily aggregation, configurable monthly quota, 429 on exceeded, usage alerts at 75/90/100% - GET /auth/api-keys/:id/usage returns detailed usage breakdown - Performance benchmark suite (scripts/benchmark.ts) for 10 critical endpoints with p50/p95/p99 latencies and performance budgets - GitHub Actions workflow for weekly and PR-triggered benchmarks - Prisma schema: ApiKeyUsageDaily model, monthlyQuota on ApiKey, SpamAction enum and spamAction field on EmailBounce --- .github/workflows/benchmark.yml | 118 +++++++++++++++++++ prisma/schema.prisma | 55 ++++++--- scripts/benchmark.ts | 162 ++++++++++++++++++++++++++ src/admin/admin.module.ts | 3 +- src/admin/queue/queue.controller.ts | 68 +++++++++++ src/admin/queue/queue.module.ts | 16 +++ src/admin/queue/queue.service.ts | 159 +++++++++++++++++++++++++ src/auth/api-key-analytics.service.ts | 154 ++++++++++++++++++++++++ src/auth/auth.controller.ts | 8 +- src/auth/auth.module.ts | 3 + src/auth/auth.service.ts | 10 ++ src/auth/dto/auth.dto.ts | 3 + src/email/email-webhook.controller.ts | 38 +++++- src/email/email.service.ts | 100 +++++++++++++++- src/types/prisma.types.ts | 7 ++ 15 files changed, 880 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/benchmark.yml create mode 100644 scripts/benchmark.ts create mode 100644 src/admin/queue/queue.controller.ts create mode 100644 src/admin/queue/queue.module.ts create mode 100644 src/admin/queue/queue.service.ts create mode 100644 src/auth/api-key-analytics.service.ts diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..bbe5cce5 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,118 @@ +name: API Performance Benchmark + +on: + pull_request: + branches: [main, develop] + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 6am UTC + workflow_dispatch: + inputs: + iterations: + description: 'Number of iterations per endpoint' + required: false + default: '50' + +jobs: + benchmark: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma Client + run: npx prisma generate + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test + + - name: Sync database schema + run: npx prisma db push --skip-generate --accept-data-loss + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test + + - name: Build application + run: npm run build + + - name: Start application + run: | + npm run start:dist & + for i in $(seq 1 30); do + if curl -s http://localhost:3000/api > /dev/null 2>&1; then + echo "Server is up" + break + fi + echo "Waiting for server... ($i)" + sleep 2 + done + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test + REDIS_URL: redis://localhost:6379 + JWT_SECRET: bench-secret + JWT_REFRESH_SECRET: bench-refresh-secret + NODE_ENV: production + + - name: Run benchmarks + run: npx ts-node scripts/benchmark.ts + env: + BENCHMARK_BASE_URL: http://localhost:3000/api + BENCHMARK_ITERATIONS: ${{ github.event.inputs.iterations || '100' }} + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + if: always() + with: + name: benchmark-results + path: benchmark-results.json + + - name: Comment on PR with results + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + try { + const report = JSON.parse(fs.readFileSync('benchmark-results.json', 'utf8')); + const lines = ['## API Benchmark Results\n']; + lines.push(`| Endpoint | p50 (ms) | p95 (ms) | p99 (ms) | Budget (ms) | Status |`); + lines.push(`|----------|----------|----------|----------|-------------|--------|`); + for (const r of report.results) { + const status = r.passed ? 'PASS' : 'FAIL'; + lines.push(`| ${r.endpoint} | ${r.latencyMs.p50} | ${r.latencyMs.p95} | ${r.latencyMs.p99} | ${r.budgetMs} | ${status} |`); + } + lines.push(`\n**Summary:** ${report.summary.passed}/${report.summary.total} passed`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: lines.join('\n'), + }); + } catch (e) { + console.log('Could not post benchmark results:', e.message); + } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88bfd379..fd5ddc10 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -292,20 +292,22 @@ model BackupScheduleConfig { } model ApiKey { - id String @id @default(uuid()) - userId String @map("user_id") - name String - keyPrefix String @map("key_prefix") - keyHash String @unique @map("key_hash") - permissions String[] @default([]) - usageCount Int @default(0) @map("usage_count") - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - revokedAt DateTime? @map("revoked_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + userId String @map("user_id") + name String + keyPrefix String @map("key_prefix") + keyHash String @unique @map("key_hash") + permissions String[] @default([]) + usageCount Int @default(0) @map("usage_count") + monthlyQuota Int? @map("monthly_quota") + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + usageDaily ApiKeyUsageDaily[] @@index([userId]) @@index([keyPrefix]) @@ -1137,6 +1139,7 @@ model EmailBounce { bounceType BounceType @map("bounce_type") reason String? rawEvent Json? @map("raw_event") + spamAction SpamAction @default(NONE) @map("spam_action") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -1144,6 +1147,7 @@ model EmailBounce { @@index([userId]) @@index([email]) @@index([bounceType]) + @@index([spamAction]) @@map("email_bounces") } @@ -1328,4 +1332,27 @@ enum JobStatus { PROCESSING COMPLETED FAILED +} + +enum SpamAction { + NONE + COMPLAINED + UNSUBSCRIBED +} + +// Daily API key usage aggregation for analytics (#945) +model ApiKeyUsageDaily { + id String @id @default(uuid()) + apiKeyId String @map("api_key_id") + date DateTime @db.Date + requestCount Int @default(0) @map("request_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + @@unique([apiKeyId, date]) + @@index([apiKeyId, date]) + @@index([date]) + @@map("api_key_usage_daily") } \ No newline at end of file diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts new file mode 100644 index 00000000..3c5ba531 --- /dev/null +++ b/scripts/benchmark.ts @@ -0,0 +1,162 @@ +// @ts-nocheck + +/** + * Performance Benchmark Suite for PropChain API Endpoints (#946) + * + * Benchmarks the 10 most-used endpoints and records p50/p95/p99 latencies. + * Performance budget: p95 < 500ms for list endpoints, p95 < 200ms for detail endpoints. + * + * Usage: npx ts-node scripts/benchmark.ts [--base-url=http://localhost:3000] [--iterations=100] + */ + +const BASE_URL = process.env.BENCHMARK_BASE_URL || 'http://localhost:3000/api'; +const ITERATIONS = parseInt(process.env.BENCHMARK_ITERATIONS || '100', 10); +const WARMUP_ROUNDS = 5; + +interface BenchmarkResult { + endpoint: string; + method: string; + category: 'list' | 'detail' | 'search'; + latencyMs: { p50: number; p95: number; p99: number; max: number; avg: number }; + statusCodes: Record; + passed: boolean; + budgetMs: number; +} + +interface BenchmarkReport { + timestamp: string; + baseUrl: string; + iterations: number; + results: BenchmarkResult[]; + summary: { total: number; passed: number; failed: number }; +} + +function percentile(sorted: number[], p: number): number { + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function benchmarkEndpoint( + name: string, + method: string, + path: string, + category: 'list' | 'detail' | 'search', + budgetMs: number, +): Promise { + const latencies: number[] = []; + const statusCodes: Record = {}; + + // Warmup + for (let i = 0; i < WARMUP_ROUNDS; i++) { + try { + await fetch(`${BASE_URL}${path}`, { method }); + } catch { + // ignore warmup errors + } + } + + // Benchmark + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + try { + const res = await fetch(`${BASE_URL}${path}`, { method }); + const elapsed = performance.now() - start; + latencies.push(elapsed); + const status = res.status; + statusCodes[status] = (statusCodes[status] || 0) + 1; + } catch { + const elapsed = performance.now() - start; + latencies.push(elapsed); + statusCodes[0] = (statusCodes[0] || 0) + 1; + } + } + + latencies.sort((a, b) => a - b); + + const p50 = Math.round(percentile(latencies, 50) * 100) / 100; + const p95 = Math.round(percentile(latencies, 95) * 100) / 100; + const p99 = Math.round(percentile(latencies, 99) * 100) / 100; + const max = Math.round(latencies[latencies.length - 1] * 100) / 100; + const avg = Math.round((latencies.reduce((a, b) => a + b, 0) / latencies.length) * 100) / 100; + + const passed = p95 < budgetMs; + + return { + endpoint: `${name} (${method} ${path})`, + method, + category, + latencyMs: { p50, p95, p99, max, avg }, + statusCodes, + passed, + budgetMs, + }; +} + +async function runBenchmark(): Promise { + console.log(`\n PropChain API Benchmark Suite`); + console.log(` Base URL: ${BASE_URL}`); + console.log(` Iterations: ${ITERATIONS} (warmup: ${WARMUP_ROUNDS})\n`); + + const endpoints = [ + { name: 'Properties List', method: 'GET', path: '/properties?limit=20', category: 'list' as const, budget: 500 }, + { name: 'Property Detail', method: 'GET', path: '/properties/test-id', category: 'detail' as const, budget: 200 }, + { name: 'Transactions List', method: 'GET', path: '/transactions?limit=20', category: 'list' as const, budget: 500 }, + { name: 'Transaction Detail', method: 'GET', path: '/transactions/test-id', category: 'detail' as const, budget: 200 }, + { name: 'Search Properties', method: 'GET', path: '/search?q=apartment', category: 'search' as const, budget: 500 }, + { name: 'User Profile (me)', method: 'GET', path: '/auth/me', category: 'detail' as const, budget: 200 }, + { name: 'List API Keys', method: 'GET', path: '/auth/api-keys', category: 'list' as const, budget: 500 }, + { name: 'Dashboard Stats', method: 'GET', path: '/admin/dashboard', category: 'detail' as const, budget: 200 }, + { name: 'Email Reputation', method: 'GET', path: '/email/reputation', category: 'detail' as const, budget: 200 }, + { name: 'Queue Metrics', method: 'GET', path: '/admin/queues/metrics', category: 'detail' as const, budget: 200 }, + ]; + + const results: BenchmarkResult[] = []; + + for (const ep of endpoints) { + process.stdout.write(` Benchmarking: ${ep.name} ... `); + const result = await benchmarkEndpoint(ep.name, ep.method, ep.path, ep.category, ep.budget); + results.push(result); + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(`${status} (p95=${result.latencyMs.p95}ms, budget=${result.budgetMs}ms)`); + } + + const passed = results.filter((r) => r.passed).length; + + const report: BenchmarkReport = { + timestamp: new Date().toISOString(), + baseUrl: BASE_URL, + iterations: ITERATIONS, + results, + summary: { total: results.length, passed, failed: results.length - passed }, + }; + + console.log(`\n Summary: ${passed}/${results.length} passed\n`); + + if (results.some((r) => !r.passed)) { + console.log(' Failed benchmarks:'); + for (const r of results.filter((r) => !r.passed)) { + console.log(` - ${r.endpoint}: p95=${r.latencyMs.p95}ms > budget=${r.budgetMs}ms`); + } + console.log(''); + } + + return report; +} + +async function main() { + const report = await runBenchmark(); + const outputPath = 'benchmark-results.json'; + const fs = require('fs'); + fs.writeFileSync(outputPath, JSON.stringify(report, null, 2)); + console.log(` Results written to ${outputPath}\n`); + process.exit(report.summary.failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('Benchmark failed:', err); + process.exit(1); +}); diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 5cd94fc6..5f13a416 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -9,9 +9,10 @@ import { FraudModule } from '../fraud/fraud.module'; import { BackupModule } from '../backup/backup.module'; import { TransactionsModule } from '../transactions/transactions.module'; import { SessionsModule } from '../sessions/sessions.module'; +import { QueueModule } from './queue/queue.module'; @Module({ - imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule, SessionsModule], + imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule, SessionsModule, QueueModule], controllers: [AdminController], providers: [AdminService, AdminAuditInterceptor], exports: [AdminService], diff --git a/src/admin/queue/queue.controller.ts b/src/admin/queue/queue.controller.ts new file mode 100644 index 00000000..ea78a8f4 --- /dev/null +++ b/src/admin/queue/queue.controller.ts @@ -0,0 +1,68 @@ +// @ts-nocheck + +import { + Controller, + Get, + Post, + Delete, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { AuthUserPayload } from '../../auth/types/auth-user.type'; +import { UserRole } from '../../types/prisma.types'; +import { QueueMonitoringService } from './queue.service'; + +@ApiTags('Admin - Queue Monitoring') +@Controller('admin/queues') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class QueueController { + constructor(private readonly queueService: QueueMonitoringService) {} + + @Get() + @ApiOperation({ summary: 'List all queues with job counts' }) + listQueues() { + return this.queueService.getAllQueues(); + } + + @Get('metrics') + @ApiOperation({ summary: 'Get queue depth metrics for monitoring' }) + getMetrics() { + return this.queueService.getQueueMetrics(); + } + + @Get(':name/failed') + @ApiOperation({ summary: 'List failed jobs in a queue' }) + getFailedJobs(@Param('name') name: string) { + return this.queueService.getFailedJobs(name); + } + + @Post(':name/jobs/:jobId/retry') + @ApiOperation({ summary: 'Retry a failed job' }) + retryJob( + @Param('name') name: string, + @Param('jobId') jobId: string, + ) { + return this.queueService.retryJob(name, jobId); + } + + @Post(':name/failed/retry-all') + @ApiOperation({ summary: 'Retry all failed jobs in a queue' }) + retryAllFailed(@Param('name') name: string) { + return this.queueService.retryAllFailedJobs(name); + } + + @Delete(':name/jobs/:jobId') + @ApiOperation({ summary: 'Remove a job from a queue' }) + removeJob( + @Param('name') name: string, + @Param('jobId') jobId: string, + ) { + return this.queueService.removeJob(name, jobId); + } +} diff --git a/src/admin/queue/queue.module.ts b/src/admin/queue/queue.module.ts new file mode 100644 index 00000000..131b8aa2 --- /dev/null +++ b/src/admin/queue/queue.module.ts @@ -0,0 +1,16 @@ +// @ts-nocheck + +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { QueueController } from './queue.controller'; +import { QueueMonitoringService } from './queue.service'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: 'mail' }), + ], + controllers: [QueueController], + providers: [QueueMonitoringService], + exports: [QueueMonitoringService], +}) +export class QueueModule {} diff --git a/src/admin/queue/queue.service.ts b/src/admin/queue/queue.service.ts new file mode 100644 index 00000000..6cec156e --- /dev/null +++ b/src/admin/queue/queue.service.ts @@ -0,0 +1,159 @@ +// @ts-nocheck + +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +const KNOWN_QUEUES = ['mail', 'export', 'email-digest'] as const; + +@Injectable() +export class QueueMonitoringService { + private readonly logger = new Logger(QueueMonitoringService.name); + + constructor( + @InjectQueue('mail') private readonly mailQueue: Queue, + ) {} + + async getAllQueues() { + const queues: any[] = []; + + for (const queueName of KNOWN_QUEUES) { + try { + const queue = this.getQueueByName(queueName); + if (!queue) continue; + + const [waiting, active, completed, failed, delayed, paused] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + queue.getPausedCount(), + ]); + + queues.push({ + name: queueName, + counts: { waiting, active, completed, failed, delayed, paused }, + }); + } catch (error) { + this.logger.error(`Failed to get stats for queue ${queueName}: ${error.message}`); + queues.push({ + name: queueName, + counts: { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0, paused: 0 }, + error: error.message, + }); + } + } + + return { queues }; + } + + async getQueueByName(name: string): Promise { + const queues: Record = { + mail: this.mailQueue, + }; + return queues[name] ?? null; + } + + async getFailedJobs(queueName: string) { + const queue = await this.getQueueByName(queueName); + if (!queue) { + throw new NotFoundException(`Queue '${queueName}' not found`); + } + + const jobs = await queue.getFailed(0, 50); + return { + queue: queueName, + total: await queue.getFailedCount(), + jobs: jobs.map((job: any) => ({ + id: job.id, + name: job.name, + data: job.data, + failedReason: job.failedReason, + stacktrace: job.stacktrace?.slice(-5), + attemptsMade: job.attemptsMade, + timestamp: job.timestamp, + finishedOn: job.finishedOn, + })), + }; + } + + async retryJob(queueName: string, jobId: string) { + const queue = await this.getQueueByName(queueName); + if (!queue) { + throw new NotFoundException(`Queue '${queueName}' not found`); + } + + const job = await queue.getJob(jobId); + if (!job) { + throw new NotFoundException(`Job '${jobId}' not found in queue '${queueName}'`); + } + + await job.retry(); + return { message: `Job '${jobId}' retried successfully`, queue: queueName }; + } + + async retryAllFailedJobs(queueName: string) { + const queue = await this.getQueueByName(queueName); + if (!queue) { + throw new NotFoundException(`Queue '${queueName}' not found`); + } + + const jobs = await queue.getFailed(); + let retried = 0; + for (const job of jobs) { + await job.retry(); + retried++; + } + + return { message: `Retried ${retried} failed jobs`, queue: queueName, retriedCount: retried }; + } + + async removeJob(queueName: string, jobId: string) { + const queue = await this.getQueueByName(queueName); + if (!queue) { + throw new NotFoundException(`Queue '${queueName}' not found`); + } + + const job = await queue.getJob(jobId); + if (!job) { + throw new NotFoundException(`Job '${jobId}' not found in queue '${queueName}'`); + } + + await job.remove(); + return { message: `Job '${jobId}' removed successfully`, queue: queueName }; + } + + async getQueueMetrics() { + const metrics: any[] = []; + + for (const queueName of KNOWN_QUEUES) { + const queue = await this.getQueueByName(queueName); + if (!queue) continue; + + try { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + ]); + + metrics.push({ + queue: queueName, + depth: waiting + active + delayed, + waiting, + active, + completed, + failed, + delayed, + }); + } catch (error) { + this.logger.error(`Failed to get metrics for queue ${queueName}: ${error.message}`); + } + } + + return { metrics }; + } +} diff --git a/src/auth/api-key-analytics.service.ts b/src/auth/api-key-analytics.service.ts new file mode 100644 index 00000000..5af43a19 --- /dev/null +++ b/src/auth/api-key-analytics.service.ts @@ -0,0 +1,154 @@ +// @ts-nocheck + +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +const USAGE_ALERT_THRESHOLDS = [0.75, 0.9, 1.0] as const; + +@Injectable() +export class ApiKeyAnalyticsService { + private readonly logger = new Logger(ApiKeyAnalyticsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async recordUsage(apiKeyId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + await this.prisma.apiKeyUsageDaily.upsert({ + where: { + apiKeyId_date: { apiKeyId, date: today }, + }, + update: { requestCount: { increment: 1 } }, + create: { apiKeyId, date: today, requestCount: 1 }, + }); + + const apiKey = await this.prisma.apiKey.findUnique({ where: { id: apiKeyId } }); + if (!apiKey) return; + + if (apiKey.monthlyQuota) { + const usage = await this.getMonthlyUsage(apiKeyId); + if (usage >= apiKey.monthlyQuota) { + this.logger.warn( + `API key ${apiKeyId} has exceeded monthly quota: ${usage}/${apiKey.monthlyQuota}`, + ); + } else { + for (const threshold of USAGE_ALERT_THRESHOLDS) { + const ratio = usage / apiKey.monthlyQuota; + if (ratio >= threshold) { + this.logger.warn( + `API key ${apiKeyId} usage alert: ${Math.round(ratio * 100)}% of monthly quota (${usage}/${apiKey.monthlyQuota})`, + ); + break; + } + } + } + } + } + + async checkQuota(apiKeyId: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ where: { id: apiKeyId } }); + if (!apiKey || !apiKey.monthlyQuota) return; + + const usage = await this.getMonthlyUsage(apiKeyId); + if (usage >= apiKey.monthlyQuota) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Monthly API key quota exceeded', + quota: apiKey.monthlyQuota, + usage, + retryAfter: this.getSecondsUntilMonthReset(), + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + async getMonthlyUsage(apiKeyId: string): Promise { + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const result = await this.prisma.apiKeyUsageDaily.aggregate({ + where: { + apiKeyId, + date: { gte: startOfMonth }, + }, + _sum: { requestCount: true }, + }); + + return result._sum.requestCount ?? 0; + } + + async getUsageAnalytics(apiKeyId: string, userId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { id: apiKeyId, userId }, + }); + + if (!apiKey) { + throw new HttpException('API key not found', HttpStatus.NOT_FOUND); + } + + const now = new Date(); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const startOfWeek = new Date(now); + startOfWeek.setDate(now.getDate() - now.getDay()); + startOfWeek.setHours(0, 0, 0, 0); + + const [monthlyUsage, dailyUsage, totalUsage] = await Promise.all([ + this.prisma.apiKeyUsageDaily.aggregate({ + where: { apiKeyId, date: { gte: startOfMonth } }, + _sum: { requestCount: true }, + }), + this.prisma.apiKeyUsageDaily.findMany({ + where: { apiKeyId, date: { gte: startOfWeek } }, + orderBy: { date: 'asc' }, + select: { date: true, requestCount: true }, + }), + this.prisma.apiKeyUsageDaily.aggregate({ + where: { apiKeyId }, + _sum: { requestCount: true }, + }), + ]); + + const monthlyTotal = monthlyUsage._sum.requestCount ?? 0; + const lifetimeTotal = totalUsage._sum.requestCount ?? 0; + + let quotaUsage: any = null; + if (apiKey.monthlyQuota) { + quotaUsage = { + limit: apiKey.monthlyQuota, + used: monthlyTotal, + remaining: Math.max(0, apiKey.monthlyQuota - monthlyTotal), + percentage: Math.round((monthlyTotal / apiKey.monthlyQuota) * 100), + resetsAt: new Date(now.getFullYear(), now.getMonth() + 1, 1), + }; + } + + return { + apiKeyId: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + monthly: { + used: monthlyTotal, + resetsAt: new Date(now.getFullYear(), now.getMonth() + 1, 1), + }, + weekly: dailyUsage.map((d: any) => ({ + date: d.date, + requests: d.requestCount, + })), + lifetime: { + totalRequests: lifetimeTotal, + createdAt: apiKey.createdAt, + }, + quota: quotaUsage, + lastUsedAt: apiKey.lastUsedAt, + }; + } + + private getSecondsUntilMonthReset(): number { + const now = new Date(); + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return Math.ceil((nextMonth.getTime() - now.getTime()) / 1000); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index b8e7c3ba..5018e115 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; import { ChangePasswordDto, CreateApiKeyDto, @@ -29,7 +30,10 @@ import { VerifyEmailDto } from '../users/dto/email-change.dto'; @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly apiKeyAnalyticsService: ApiKeyAnalyticsService, + ) {} @Post('register') register(@Body() registerDto: RegisterDto, @Req() request: Request) { @@ -166,7 +170,7 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Get('api-keys/:id/usage') getApiKeyUsage(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { - return this.authService.getApiKeyUsage(user, id); + return this.apiKeyAnalyticsService.getUsageAnalytics(id, user.sub); } @Post('password-reset/request') diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 2399ce9f..9b725634 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -8,6 +8,7 @@ import { SessionsModule } from '../sessions/sessions.module'; import { EmailModule } from '../email/email.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; import { LoginRateLimitService } from './login-rate-limit.service'; import { RateLimitService } from './rate-limit.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; @@ -24,6 +25,7 @@ import { FraudModule } from '../fraud/fraud.module'; controllers: [AuthController, RateLimitAdminController], providers: [ AuthService, + ApiKeyAnalyticsService, LoginRateLimitService, RateLimitService, JwtAuthGuard, @@ -35,6 +37,7 @@ import { FraudModule } from '../fraud/fraud.module'; ], exports: [ AuthService, + ApiKeyAnalyticsService, RolesGuard, LoginRateLimitService, RateLimitService, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 83708809..d34acc2b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -48,6 +48,7 @@ import { GoogleProfile } from './strategies/google.strategy'; import { LoginRateLimitService } from './login-rate-limit.service'; import { UserRole } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; type JwtPayload = { sub: string; @@ -88,6 +89,7 @@ export class AuthService { private readonly emailService: EmailService, private readonly rateLimitService: LoginRateLimitService, private readonly fraudService: FraudService, + private readonly apiKeyAnalyticsService: ApiKeyAnalyticsService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; this.jwtRefreshSecret = @@ -965,6 +967,7 @@ export class AuthService { keyPrefix: apiKeyValue.slice(0, 12), keyHash: createSha256(apiKeyValue), permissions, + monthlyQuota: data.monthlyQuota ?? null, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, }, }); @@ -1192,6 +1195,8 @@ export class AuthService { throw new UnauthorizedException('User account is blocked'); } + await this.apiKeyAnalyticsService.checkQuota(apiKey.id); + await this.prisma.apiKey.update({ where: { id: apiKey.id }, data: { @@ -1202,6 +1207,10 @@ export class AuthService { }, }); + await this.apiKeyAnalyticsService.recordUsage(apiKey.id).catch((err) => { + this.logger.error(`Failed to record API key usage: ${err.message}`); + }); + return { sub: apiKey.userId, email: apiKey.user.email, @@ -1347,6 +1356,7 @@ export class AuthService { keyPrefix: apiKey.keyPrefix, permissions: apiKey.permissions, usageCount: apiKey.usageCount, + monthlyQuota: apiKey.monthlyQuota, lastUsedAt: apiKey.lastUsedAt, expiresAt: apiKey.expiresAt, revokedAt: apiKey.revokedAt, diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts index 18aad9a4..5c8c25be 100644 --- a/src/auth/dto/auth.dto.ts +++ b/src/auth/dto/auth.dto.ts @@ -100,6 +100,9 @@ export class CreateApiKeyDto { @IsOptional() @IsDateString() expiresAt?: string; + + @IsOptional() + monthlyQuota?: number; } export class UpdateApiKeyPermissionsDto { diff --git a/src/email/email-webhook.controller.ts b/src/email/email-webhook.controller.ts index c0164ab4..d639294a 100644 --- a/src/email/email-webhook.controller.ts +++ b/src/email/email-webhook.controller.ts @@ -1,19 +1,24 @@ // @ts-nocheck -import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { Controller, Post, Body, Get, HttpCode, UseGuards } from '@nestjs/common'; import { EmailService } from './email.service'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUserPayload } from '../auth/types/auth-user.type'; +import { UserRole } from '../types/prisma.types'; -@ApiTags('webhooks') -@Controller('webhooks/email') +@ApiTags('Email') +@Controller('email') export class EmailWebhookController { constructor(private emailService: EmailService) {} - @Post('bounce') + @Post('webhook/bounce') @HttpCode(200) - @ApiOperation({ summary: 'Handle email bounce webhooks' }) + @ApiOperation({ summary: 'Handle email bounce/complaint webhooks' }) async handleBounce(@Body() payload: any) { - // Basic extraction logic - in a real app, this would be provider-specific const email = payload.email || payload.recipient; const type = payload.type || (payload.bounceType === 'Hard' ? 'HARD' : 'SOFT'); const reason = payload.reason || payload.diagnosticCode; @@ -24,4 +29,25 @@ export class EmailWebhookController { return { received: true }; } + + @Post('webhook/complaint') + @HttpCode(200) + @ApiOperation({ summary: 'Handle spam complaint webhooks' }) + async handleComplaint(@Body() payload: any) { + const email = payload.email || payload.recipient; + + if (email) { + await this.emailService.handleComplaint(email, payload); + } + + return { received: true }; + } + + @Get('reputation') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get sender reputation metrics' }) + async getReputation() { + return this.emailService.getSenderReputation(); + } } diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 74db60fb..2530c9de 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -8,6 +8,8 @@ import { v4 as uuidv4 } from 'uuid'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; +const UNSUBSCRIBE_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; + export interface EmailOptions { to: string; subject: string; @@ -142,7 +144,7 @@ export class EmailService { if (type === 'HARD') { await this.prisma.user.update({ where: { id: user.id }, - data: { emailStatus: 'INVALID' }, + data: { emailStatus: 'BOUNCED' }, }); await this.prisma.userPreferences.upsert({ @@ -153,6 +155,8 @@ export class EmailService { emailNotifications: false, }, }); + + this.logger.warn(`Hard bounce processed for ${email}: user marked as BOUNCED, email notifications disabled`); } else { await this.prisma.user.update({ where: { id: user.id }, @@ -161,6 +165,95 @@ export class EmailService { } } + async handleComplaint(email: string, rawEvent?: any): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return; + + await this.prisma.emailBounce.create({ + data: { + userId: user.id, + email, + bounceType: 'HARD', + reason: 'Spam complaint', + rawEvent, + spamAction: 'COMPLAINED', + }, + }); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { emailStatus: 'BOUNCED' }, + }); + + await this.prisma.userPreferences.upsert({ + where: { userId: user.id }, + update: { emailNotifications: false }, + create: { + userId: user.id, + emailNotifications: false, + }, + }); + + this.logger.warn(`Spam complaint processed for ${email}: user marked as BOUNCED`); + } + + async handleUnsubscribe(email: string): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (!user) return; + + await this.prisma.userPreferences.upsert({ + where: { userId: user.id }, + update: { emailNotifications: false }, + create: { + userId: user.id, + emailNotifications: false, + }, + }); + + this.logger.log(`Unsubscribe processed for ${email}`); + } + + async getSenderReputation() { + const [ + totalBounced, + totalComplaints, + totalUsers, + bouncedUsers, + complainedUsers, + ] = await Promise.all([ + this.prisma.emailBounce.count({ where: { bounceType: 'HARD' } }), + this.prisma.emailBounce.count({ where: { spamAction: 'COMPLAINED' } }), + this.prisma.user.count(), + this.prisma.user.count({ where: { emailStatus: 'BOUNCED' } }), + this.prisma.user.count({ where: { isBlocked: false } }), + ]); + + const bounceRate = totalUsers > 0 ? (bouncedUsers / totalUsers) * 100 : 0; + const complaintRate = totalUsers > 0 ? (complainedUsers > 0 ? (complainedUsers / totalUsers) * 100 : 0) : 0; + const reputationScore = Math.max(0, 100 - bounceRate * 10 - complaintRate * 20); + + return { + totals: { + totalUsers, + bouncedUsers, + totalBouncedEvents: totalBounced, + totalComplaints, + }, + rates: { + bounceRate: Math.round(bounceRate * 100) / 100, + complaintRate: Math.round(complaintRate * 100) / 100, + }, + reputationScore: Math.round(reputationScore * 100) / 100, + health: reputationScore >= 90 ? 'GOOD' : reputationScore >= 70 ? 'FAIR' : 'POOR', + }; + } + + buildListUnsubscribeHeader(userId?: string, email?: string): string | null { + if (!userId || !email) return null; + const token = Buffer.from(`${userId}:${email}`).toString('base64'); + return `<${UNSUBSCRIBE_URL}/unsubscribe?token=${token}>`; + } + async sendEmail(options: EmailOptions): Promise { const baseUrl = this.configService.get('API_URL', 'http://localhost:3000/api'); const html = options.html; @@ -197,6 +290,8 @@ export class EmailService { // 3. Add to Queue try { + const listUnsubscribe = this.buildListUnsubscribeHeader(options.userId, options.to); + await this.mailQueue.add( 'sendEmail', { @@ -206,6 +301,9 @@ export class EmailService { context: options.context, html: options.html, text: options.text, + headers: { + ...(listUnsubscribe ? { 'List-Unsubscribe': listUnsubscribe } : {}), + }, }, { attempts: 3, diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 55065167..0cca823e 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -42,6 +42,7 @@ export interface ApiKey { keyHash: string; permissions: string[]; usageCount: number; + monthlyQuota: number | null; lastUsedAt: Date | null; expiresAt: Date | null; revokedAt: Date | null; @@ -131,6 +132,12 @@ export enum MilestoneStatus { DELAYED = 'DELAYED', } +export enum SpamAction { + NONE = 'NONE', + COMPLAINED = 'COMPLAINED', + UNSUBSCRIBED = 'UNSUBSCRIBED', +} + export interface PropertyAgent { id: string; propertyId: string; From 1ad663a42af129227c0853894fde235a85e95068 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 26 Jul 2026 18:22:20 +0100 Subject: [PATCH 2/3] fix: add Optional to ApiKeyAnalyticsService dependency, fix test mocks --- src/auth/auth.service.captcha.spec.ts | 2 ++ src/auth/auth.service.ts | 3 ++- test/e2e/fraud-auto-block.e2e.spec.ts | 8 ++++++++ test/e2e/rate-limit-burst.e2e.spec.ts | 8 ++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/auth/auth.service.captcha.spec.ts b/src/auth/auth.service.captcha.spec.ts index 5833e3e8..b6bafe67 100644 --- a/src/auth/auth.service.captcha.spec.ts +++ b/src/auth/auth.service.captcha.spec.ts @@ -8,6 +8,7 @@ import { SessionsService } from '../sessions/sessions.service'; import { EmailService } from '../email/email.service'; import { LoginRateLimitService } from './login-rate-limit.service'; import { FraudService } from '../fraud/fraud.service'; +import { ApiKeyAnalyticsService } from './api-key-analytics.service'; describe('AuthService – CAPTCHA failure lockout', () => { let service: AuthService; @@ -73,6 +74,7 @@ describe('AuthService – CAPTCHA failure lockout', () => { { provide: EmailService, useValue: emailService }, { provide: LoginRateLimitService, useValue: rateLimitService }, { provide: FraudService, useValue: fraudService }, + { provide: ApiKeyAnalyticsService, useValue: { trackUsage: jest.fn(), getUsageStats: jest.fn() } }, { provide: ConfigService, useValue: configService }, ], }).compile(); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d34acc2b..0e45ffe2 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,6 +5,7 @@ import { Injectable, Logger, NotFoundException, + Optional, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -89,7 +90,7 @@ export class AuthService { private readonly emailService: EmailService, private readonly rateLimitService: LoginRateLimitService, private readonly fraudService: FraudService, - private readonly apiKeyAnalyticsService: ApiKeyAnalyticsService, + @Optional() private readonly apiKeyAnalyticsService?: ApiKeyAnalyticsService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; this.jwtRefreshSecret = diff --git a/test/e2e/fraud-auto-block.e2e.spec.ts b/test/e2e/fraud-auto-block.e2e.spec.ts index 3e1a28a5..f6847c74 100644 --- a/test/e2e/fraud-auto-block.e2e.spec.ts +++ b/test/e2e/fraud-auto-block.e2e.spec.ts @@ -9,6 +9,7 @@ import { SessionsService } from '../../src/sessions/sessions.service'; import { EmailService } from '../../src/email/email.service'; import { LoginRateLimitService } from '../../src/auth/login-rate-limit.service'; import { FraudService } from '../../src/fraud/fraud.service'; +import { ApiKeyAnalyticsService } from '../../src/auth/api-key-analytics.service'; import { ConfigService } from '@nestjs/config'; import { createSha256, hashPassword } from '../../src/auth/security.utils'; import * as jwt from 'jsonwebtoken'; @@ -257,6 +258,13 @@ describe('Fraud alert auto-block e2e', () => { const moduleRef = await Test.createTestingModule({ controllers: [AuthController], providers: [ + { + provide: ApiKeyAnalyticsService, + useValue: { + trackUsage: jest.fn().mockResolvedValue(undefined), + getUsageStats: jest.fn().mockResolvedValue({}), + }, + }, AuthService, LoginRateLimitService, { provide: PrismaService, useValue: prisma }, diff --git a/test/e2e/rate-limit-burst.e2e.spec.ts b/test/e2e/rate-limit-burst.e2e.spec.ts index 2e978911..7e146ab5 100644 --- a/test/e2e/rate-limit-burst.e2e.spec.ts +++ b/test/e2e/rate-limit-burst.e2e.spec.ts @@ -13,6 +13,7 @@ import { RateLimitService } from '../../src/auth/rate-limit.service'; import { RateLimitGuard } from '../../src/auth/guards/rate-limit.guard'; import { RateLimitHeadersInterceptor } from '../../src/auth/interceptors/rate-limit-headers.interceptor'; import { FraudService } from '../../src/fraud/fraud.service'; +import { ApiKeyAnalyticsService } from '../../src/auth/api-key-analytics.service'; import { ConfigService } from '@nestjs/config'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -187,6 +188,13 @@ describe('Rate-limit guard e2e – burst traffic', () => { RateLimitGuard, RateLimitHeadersInterceptor, Reflector, + { + provide: ApiKeyAnalyticsService, + useValue: { + trackUsage: jest.fn().mockResolvedValue(undefined), + getUsageStats: jest.fn().mockResolvedValue({}), + }, + }, { provide: PrismaService, useValue: prisma }, { provide: UsersService, From d865b9b9ae261b1866b6c19b7a1334db61461939 Mon Sep 17 00:00:00 2001 From: bigben-7 Date: Sun, 26 Jul 2026 18:27:15 +0100 Subject: [PATCH 3/3] fix: update email bounce test expectation BOUNCED not INVALID --- src/email/email.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/email/email.service.spec.ts b/src/email/email.service.spec.ts index 4ae9caae..7ed02bb2 100644 --- a/src/email/email.service.spec.ts +++ b/src/email/email.service.spec.ts @@ -38,7 +38,7 @@ describe('EmailService.handleBounce', () => { }); expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, - data: { emailStatus: 'INVALID' }, + data: { emailStatus: 'BOUNCED' }, }); expect(prisma.userPreferences.upsert).toHaveBeenCalledWith({ where: { userId: 'user-1' },