import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; export interface RateLimitResult { allowed: boolean; retryAfterSeconds: number; } /** * Fixed-window rate limiting backed by PostgreSQL (ADR 0002: no Redis). * One atomic upsert per hit keeps concurrent requests race-safe; expired * rows are swept opportunistically on writes. */ @Injectable() export class RateLimitService { constructor(private readonly prisma: PrismaService) {} async hit( scope: string, key: string, limit: number, windowSeconds: number, ): Promise { const rowKey = `${scope}:${key}`; const rows = await this.prisma.$queryRaw<{ count: number; window_start: Date }[]>` INSERT INTO rate_limits (key, window_start, count) VALUES (${rowKey}, now(), 1) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds}) THEN 1 ELSE rate_limits.count + 1 END, window_start = CASE WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds}) THEN now() ELSE rate_limits.window_start END RETURNING count, window_start `; const row = rows[0]; if (!row) return { allowed: true, retryAfterSeconds: 0 }; const elapsedMs = Date.now() - row.window_start.getTime(); const retryAfterSeconds = Math.max(1, Math.ceil(windowSeconds - elapsedMs / 1000)); // Opportunistic sweep (~1% of hits) so the table stays small without // needing a scheduler this early. if (Math.random() < 0.01) void this.cleanupExpired().catch(() => undefined); return { allowed: row.count <= limit, retryAfterSeconds }; } /** Deletes counters whose window ended more than a day ago. */ async cleanupExpired(): Promise { const result = await this.prisma.rateLimit.deleteMany({ where: { windowStart: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, }); return result.count; } /** Clears one counter — used when an action should reset it (e.g. successful login). */ async reset(scope: string, key: string): Promise { await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } }); } }