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/docs/MFA_ROADMAP.md b/docs/MFA_ROADMAP.md index c4b326ae..6d47ee52 100644 --- a/docs/MFA_ROADMAP.md +++ b/docs/MFA_ROADMAP.md @@ -1,47 +1,3 @@ -<<<<<<< ours -# Multi-Factor Authentication (MFA) Roadmap - -## Purpose -This document outlines the planned MFA implementation for PropChain. MFA is opt-in and will be introduced without breaking current authentication flows. - -## Goals -- Add second-factor verification for sensitive actions and login. -- Support TOTP-based authenticators and backup codes. -- Keep MFA optional for users. -- Provide a developer-friendly path for future enrollment, verification, and recovery. - -## Current placeholder endpoints -The backend exposes the following auth-related endpoints today: - -- `POST /auth/2fa/setup` — initialize enrollment, generate a TOTP secret, QR code URL, and backup codes. -- `POST /auth/2fa/verify` — verify the TOTP code and enable two-factor authentication for the user. -- `POST /auth/2fa/disable` — disable two-factor authentication after password confirmation. - -These endpoints are currently implemented in the `AuthService` as enrollment and verification flow placeholders. - -## Implementation plan - -### Phase 1: Secure enrollment and verification -1. Generate a TOTP secret and hashed backup codes. -2. Store the secret and backup codes in the user record. -3. Present the user with a QR code URL and a code list. -4. Verify the first TOTP code before enabling MFA. - -### Phase 2: Login flow support -1. Require a second factor during login only for users with MFA enabled. -2. Accept either a valid TOTP code or an unused backup code. -3. Consume backup codes on use and maintain a fresh in-memory/hard storage list. - -### Phase 3: Recovery and revocation -1. Add endpoints to regenerate backup codes. -2. Add endpoint to disable MFA after re-authentication. -3. Audit MFA changes in user activity logs. - -## Notes -- The API contract is simple and opt-in. -- Existing auth workflows continue to work for users who do not enable MFA. -- Future expansion can include push notifications, SMS OTP, or hardware keys. -======= # MFA / 2FA Roadmap ## Current State @@ -89,4 +45,3 @@ Two-factor authentication is fully implemented using TOTP (Time-based One-Time P - [ ] Hardware key (WebAuthn/FIDO2) support - [ ] Admin-enforced 2FA for agent/admin roles - [ ] Trusted device management ->>>>>>> theirs diff --git a/docs/architecture.md b/docs/architecture.md index 3b6ee59a..40ac5042 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,102 +1,232 @@ -# Architecture Diagram - -```mermaid -graph TB - Client[Client App] -->|HTTP/WS| Gateway[NestJS Gateway] - - Gateway --> Auth[Auth Module] - Gateway --> Users[Users Module] - Gateway --> Properties[Properties Module] - Gateway --> Transactions[Transactions Module] - Gateway --> Documents[Documents Module] - Gateway --> Search[Search Module] - Gateway --> Admin[Admin Module] - Gateway --> Fraud[Fraud Module] - Gateway --> Notifications[Notifications Module] - - Auth --> JWT[JWT Strategy] - Auth --> Google[Google OAuth] - Auth --> MFA[2FA / TOTP] - Auth --> RateLimit[Rate Limit Guard] - Auth --> APIKeys[API Key Management] - - Users --> Prisma[(Prisma Client)] - Properties --> Prisma - Transactions --> Prisma - Documents --> Prisma - Search --> Prisma - Admin --> Prisma - Fraud --> Prisma - - Prisma --> PostgreSQL[(PostgreSQL)] - - Properties --> Blockchain[Blockchain Service] - Blockchain --> Ethereum[Ethereum / Sepolia] - - Documents --> Uploads[File Uploads] - Uploads --> LocalFS[Local FS / S3] - - Notifications --> WebSocket[WebSocket Gateway] - Notifications --> Email[Email Service] - Notifications --> SMS[SMS Service] - - Fraud --> Email - Admin --> Backup[Backup Service] - Backup --> PgDump[pg_dump] - - Search --> Cache[(Redis Cache)] - RateLimit --> Cache - Cache --> CacheWarming[Cache Warming] - - Admin --> BullMQ[BullMQ Queues] - Transactions --> BullMQ - - Gateway --> GraphQL[GraphQL / Apollo] +# PropChain Backend Architecture + +## Tech Stack + +| Layer | Technology | +| ---------------- | ---------------------------------------- | +| Runtime | Node.js >= 18 | +| Framework | NestJS 10 | +| Language | TypeScript 5.3 | +| Database | PostgreSQL (via Prisma ORM 6.x) | +| Cache | Redis (via ioredis + cache-manager) | +| API | REST (Express) + GraphQL (Apollo Server) | +| Authentication | Passport.js (JWT + Google OAuth2) | +| Blockchain | Web3.js / Ethers.js 6 | +| Job Queue | BullMQ (Redis-backed) | +| Email | Nodemailer via @nestjs-modules/mailer | +| Image Processing | Sharp | +| PDF Generation | PDFKit | +| Documentation | Swagger / OpenAPI | +| Testing | Jest + Supertest | +| Monitoring | Prometheus (prom-client) | +| Realtime | Socket.IO (WebSockets) | + +## Directory Structure + +``` +propchain-backend/ +├── prisma/ +│ ├── schema.prisma # Database schema +│ ├── seed.ts # Seed script +│ └── migrations/ # Migration history +├── src/ +│ ├── main.ts # Application bootstrap +│ ├── app.module.ts # Root module +│ ├── app.controller.ts # Health check / root routes +│ ├── admin/ # Admin panel & management +│ ├── analytics/ # Search & property analytics +│ ├── audit/ # Audit logging +│ ├── auth/ # Auth (JWT, OAuth, guards, RBAC) +│ ├── backup/ # Database backup/restore +│ ├── blockchain/ # On-chain recording & verification +│ ├── cache/ # Redis caching layer +│ ├── commissions/ # Agent commission tracking +│ ├── common/ # Shared types, middleware, decorators +│ ├── config/ # Swagger, env validation +│ ├── content/ # Content management +│ ├── dashboard/ # Dashboard aggregations +│ ├── database/ # Prisma service & module +│ ├── documents/ # Document upload, versioning, signing +│ ├── duplicate-detection/ # Property duplicate detection +│ ├── email/ # Email sending +│ ├── email-digest/ # Digest emails +│ ├── favorites/ # User favorites/bookmarks +│ ├── fraud/ # Fraud detection & investigation +│ ├── integrations/ # Third-party adapters +│ ├── metrics/ # Prometheus metrics +│ ├── mortgage-calculator/ # Mortgage estimation +│ ├── neighborhoods/ # Neighborhoods, schools, amenities +│ ├── notifications/ # Push, in-app, SMS notifications +│ ├── open-house/ # Open house scheduling & RSVP +│ ├── properties/ # Core property CRUD & images +│ ├── property-comparison/ # Side-by-side comparison +│ ├── property-views/ # View tracking & analytics +│ ├── search/ # Full-text search & suggestions +│ ├── sessions/ # User session management +│ ├── support-tickets/ # Support ticket system +│ ├── tracking/ # Link click tracking +│ ├── transactions/ # Transaction lifecycle +│ ├── trust-score/ # User trust scoring +│ ├── types/ # Shared TypeScript types +│ ├── users/ # User profiles & preferences +│ ├── utils/ # Utility functions +│ ├── versioning/ # API versioning +│ └── webhooks/ # Webhook management +├── test/ # E2E tests +├── docs/ # Developer documentation +└── package.json +``` + +## Module Dependency Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ AppModule │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Config │ │ Prisma │ │ GraphQL │ │ ScheduleModule │ │ +│ │ Module │ │ Module │ │ Module │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └──────────┘ └──────────────────┘ │ +│ │ │ │ +│ ┌────▼──────────────▼────────────────────────────────────────────┐ │ +│ │ Core Infrastructure │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ +│ │ │ Cache │ │ Auth │ │Database │ │ Rate Limit │ │ │ +│ │ │ Module │ │ Module │ │ Module │ │ Service │ │ │ +│ │ └──────────┘ └────┬─────┘ └──────────┘ └──────────────┘ │ │ +│ └─────────────────────┼─────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────▼─────────────────────────────────────────┐ │ +│ │ Feature Modules │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ +│ │ │Properties │ │Transactions│ │ Documents │ │ │ +│ │ │ Module │──│ Module │──│ Module │ │ │ +│ │ └─────┬─────┘ └─────┬──────┘ └──────────────────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌─────▼─────┐ ┌─────▼──────┐ ┌──────────────────────┐ │ │ +│ │ │Property │ │Commissions │ │ Neighborhoods │ │ │ +│ │ │ Images │ │ Module │ │ Module │ │ │ +│ │ │ Module │ └────────────┘ └──────────────────────┘ │ │ +│ │ └───────────┘ │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ +│ │ │Favorites │ │ Search │ │ Notifications │ │ │ +│ │ │ Module │ │ Module │ │ Module │ │ │ +│ │ └───────────┘ └────────────┘ └──────────────────────┘ │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ +│ │ │ Fraud │ │ Blockchain│ │ Analytics │ │ │ +│ │ │ Module │ │ Module │ │ Module │ │ │ +│ │ └───────────┘ └────────────┘ └──────────────────────┘ │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────────┐ │ │ +│ │ │ Admin │ │ Backup │ │ Metrics │ │ │ +│ │ │ Module │ │ Module │ │ Module │ │ │ +│ │ └───────────┘ └────────────┘ └──────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Data Flow Overview + +### Request Lifecycle + +``` +Client Request + │ + ▼ +┌──────────────────┐ +│ Rate Limiting │ IP-based + user-based throttling +│ (Guard) │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ JWT Auth │ Token validation, session check +│ (Guard) │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ RBAC Guard │ Role + permission checking +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ ValidationPipe │ DTO validation, whitelisting +│ (NestJS) │ +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ Controller │ Route handling +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ +│ Service │ Business logic, authorization +└────────┬─────────┘ + │ + ┌────┴────┐ + ▼ ▼ +┌────────┐ ┌────────┐ +│ Prisma │ │ Redis │ +│ (PostgreSQL) │ Cache │ +└────────┘ └────────┘ ``` -## Request Flow - -```mermaid -sequenceDiagram - participant C as Client - participant G as NestJS Gateway - participant RL as Rate Limit Guard - participant AU as Auth Service - participant S as Feature Service - participant DB as PostgreSQL - participant BC as Blockchain - - C->>G: HTTP Request - G->>RL: canActivate() - RL->>RL: Check Redis counters - alt Rate limit exceeded - RL-->>C: 429 Too Many Requests - else Within limits - RL->>G: Allow - G->>AU: Validate JWT - AU->>AU: Verify token + roles - AU->>G: Authenticated context - G->>S: Handle request - S->>DB: Query/Mutate - DB-->>S: Result - alt Blockchain required - S->>BC: Record transaction - BC-->>S: Tx hash - end - S-->>C: Response - end +### Property Image Pipeline + ``` +Upload (multer/memory buffer) + │ + ▼ +┌──────────────────────┐ +│ Validate │ Size, mime type, per-property cap +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Sharp Pipeline │ Auto-rotate, resize, convert +│ ├── Full (1920px) │ WebP quality 85 +│ ├── Medium (800px) │ WebP quality 80 +│ └── Thumbnail (300px)│ WebP quality 75 +│ Strip EXIF metadata │ Privacy protection +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Duplicate Detection │ Perceptual hash (SHA-256 prefix) +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Persist to DB + Disk │ PropertyImage record + files on disk +└──────────────────────┘ +``` + +### Transaction Lifecycle + +``` +PENDING → UNDER_CONTRACT → COMPLETED + │ │ + └── CANCELLED ─┘ + +Each state change: + 1. Validate transition (status machine) + 2. Update property status + 3. Record transaction history + 4. Emit notifications + 5. Optional: record on blockchain +``` + +## Key Design Decisions -## Module Relationships - -| Module | Depends On | Provides | -|--------|-----------|----------| -| Auth | Users, JWT, Redis | Authentication, 2FA, API keys | -| Users | Prisma | User CRUD, profiles | -| Properties | Prisma, Blockchain, Geocoding | Property listings, images | -| Transactions | Prisma, Blockchain | Transaction lifecycle | -| Documents | Prisma, Uploads | Document management, signing | -| Search | Prisma, Redis | Full-text search, facets, autocomplete | -| Fraud | Prisma, Email | Fraud detection, alerts, auto-block | -| Admin | Prisma, Backup, BullMQ | Admin ops, backups, reports | -| Notifications | WebSocket, Email, SMS | Real-time + async notifications | +1. **Dual API Surface**: REST (primary) + GraphQL (queries/subscriptions) +2. **Prisma as Single Source of Truth**: All DB access through PrismaService +3. **Decorator-based RBAC**: `@RequirePermissions()` for endpoint authorization +4. **Event-Driven Notifications**: WebSocket gateway for real-time delivery +5. **Blockchain as Audit Trail**: Optional on-chain recording for transactions +6. **Image Variants via Sharp**: Three sizes generated at upload time, stored on disk +7. **Fraud Detection Pipeline**: Pattern matching with configurable severity levels diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcaa7562..ff16de9b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -300,20 +300,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]) @@ -1149,6 +1151,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) @@ -1156,6 +1159,7 @@ model EmailBounce { @@index([userId]) @@index([email]) @@index([bounceType]) + @@index([spamAction]) @@map("email_bounces") } @@ -1357,6 +1361,29 @@ enum JobStatus { 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") +} + // ─── Webhook Event System (#958) ────────────────────────────────────────────── enum WebhookStatus { @@ -1550,4 +1577,4 @@ model SupportTicketNote { // Note: Webhook, TourRequest, AgentAvailability, SupportTicket relations // are defined on the User model through @relation directives above. -// Additional User relations needed: \ No newline at end of file +// Additional User relations needed: 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.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 83708809..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'; @@ -48,6 +49,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 +90,7 @@ export class AuthService { private readonly emailService: EmailService, private readonly rateLimitService: LoginRateLimitService, private readonly fraudService: FraudService, + @Optional() private readonly apiKeyAnalyticsService?: ApiKeyAnalyticsService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; this.jwtRefreshSecret = @@ -965,6 +968,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 +1196,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 +1208,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 +1357,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.spec.ts b/src/email/email.service.spec.ts index 4e525c8c..142b1959 100644 --- a/src/email/email.service.spec.ts +++ b/src/email/email.service.spec.ts @@ -39,7 +39,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' }, diff --git a/src/email/email.service.ts b/src/email/email.service.ts index e3fe4c50..0d30688c 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -9,6 +9,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; @@ -145,7 +147,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({ @@ -156,6 +158,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 }, @@ -164,6 +168,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; @@ -209,6 +302,8 @@ export class EmailService { // 3. Add to Queue try { + const listUnsubscribe = this.buildListUnsubscribeHeader(options.userId, options.to); + await this.mailQueue.add( 'sendEmail', { @@ -218,6 +313,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 97e23962..2cf9a0dc 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; @@ -132,6 +133,12 @@ export enum MilestoneStatus { DELAYED = 'DELAYED', } +export enum SpamAction { + NONE = 'NONE', + COMPLAINED = 'COMPLAINED', + UNSUBSCRIBED = 'UNSUBSCRIBED', +} + export interface PropertyAgent { id: string; propertyId: string; 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,