FRE-4521 Implement Redis integration for rate limiting and deduplication

- Add ioredis dependency for Redis connection pooling
- Create RedisService singleton with connection management
- Add Redis config (url, dedupWindowSeconds) to notification.config.ts
- Implement NotificationService.checkRateLimit using Redis INCR+EXPIRE
- Implement NotificationService.deduplicateNotification using Redis SET/NX
- Add configurable rate limit windows and thresholds via env vars
- Add 29 unit tests covering Redis operations, rate limiting, and dedup
- All tests pass, TypeScript compiles cleanly for new files
This commit is contained in:
Senior Engineer
2026-05-01 16:13:17 -04:00
committed by Michael Freno
parent 7aed2d8b2b
commit 574bcf2264
9 changed files with 598 additions and 3 deletions

View File

@@ -2,6 +2,8 @@ import { EmailService } from './email.service';
import { SMSService } from './sms.service';
import { PushService } from './push.service';
import { TemplateService } from './template.service';
import { RedisService } from './redis.service';
import { loadNotificationConfig } from '../config/notification.config';
import type {
Notification,
NotificationChannel,
@@ -11,11 +13,21 @@ import type {
} from '../types/notification.types';
import type { TemplateResolutionOptions } from '../types/template.types';
export interface RateLimitResult {
allowed: boolean;
currentCount: number;
limit: number;
remaining: number;
resetInSeconds: number;
}
export class NotificationService {
private static instance: NotificationService;
private emailService: EmailService;
private smsService: SMSService;
private pushService: PushService;
private redisService: RedisService;
private config: ReturnType<typeof loadNotificationConfig>;
private pendingDeduplication = new Map<string, Set<string>>();
private preferenceCache = new Map<string, NotificationPreference>();
@@ -23,6 +35,8 @@ export class NotificationService {
this.emailService = EmailService.getInstance();
this.smsService = SMSService.getInstance();
this.pushService = PushService.getInstance();
this.config = loadNotificationConfig();
this.redisService = RedisService.getInstance({ url: this.config.redis.url });
}
static getInstance(): NotificationService {
@@ -204,7 +218,65 @@ export class NotificationService {
}
}
async checkRateLimit(
identifier: string,
channel: NotificationChannel,
customLimit?: number,
customWindowSeconds?: number
): Promise<RateLimitResult> {
const limit = customLimit || this.getLimitForChannel(channel);
const windowSeconds = customWindowSeconds || this.config.rateLimits.windowSeconds;
const key = `rl:${channel}:${identifier}`;
const currentCount = await this.redisService.increment(key, windowSeconds);
const ttl = await this.redisService.getTTL(key);
return {
allowed: currentCount <= limit,
currentCount,
limit,
remaining: Math.max(0, limit - currentCount),
resetInSeconds: Math.max(1, ttl),
};
}
async deduplicateNotification(
dedupKey: DeduplicationKey,
customWindowSeconds?: number
): Promise<boolean> {
const dedupId = `dedup:${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
const windowSeconds = customWindowSeconds || dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
const wasSet = await this.redisService.setIfNotExists(dedupId, '1', windowSeconds);
return wasSet;
}
getRateLimitConfig(): {
emailPerMinute: number;
smsPerMinute: number;
pushPerMinute: number;
windowSeconds: number;
} {
return {
emailPerMinute: this.config.rateLimits.emailPerMinute,
smsPerMinute: this.config.rateLimits.smsPerMinute,
pushPerMinute: this.config.rateLimits.pushPerMinute,
windowSeconds: this.config.rateLimits.windowSeconds,
};
}
getTemplateService(): TemplateService {
return TemplateService.getInstance();
}
private getLimitForChannel(channel: NotificationChannel): number {
switch (channel) {
case 'email':
return this.config.rateLimits.emailPerMinute;
case 'sms':
return this.config.rateLimits.smsPerMinute;
case 'push':
return this.config.rateLimits.pushPerMinute;
}
}
}