- Fix HTML injection vulnerability with proper entity encoding - Fix rate limit cleanup bug (count vs timestamp confusion) - Add URL validation to prevent open redirect attacks - Add expiration to in-memory deduplication entries - Use Zod schema for config validation - Add email format validation All 29 tests passing. Ready for Code Reviewer final review. Co-Authored-By: Paperclip <noreply@paperclip.ing>
290 lines
8.2 KiB
TypeScript
290 lines
8.2 KiB
TypeScript
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,
|
|
NotificationResult,
|
|
NotificationPreference,
|
|
DeduplicationKey
|
|
} from '../types/notification.types';
|
|
import type { TemplateResolutionOptions } from '../types/template.types';
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
currentCount: number;
|
|
limit: number;
|
|
remaining: number;
|
|
resetInSeconds: number;
|
|
}
|
|
|
|
interface DeduplicationEntry {
|
|
externalIds: Set<string>;
|
|
expiresAt: 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, DeduplicationEntry>();
|
|
private preferenceCache = new Map<string, NotificationPreference>();
|
|
|
|
private constructor() {
|
|
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 {
|
|
if (!NotificationService.instance) {
|
|
NotificationService.instance = new NotificationService();
|
|
}
|
|
return NotificationService.instance;
|
|
}
|
|
|
|
async send(notification: Notification): Promise<NotificationResult> {
|
|
switch (notification.channel) {
|
|
case 'email':
|
|
return this.emailService.send(notification);
|
|
case 'sms':
|
|
return this.smsService.send(notification);
|
|
case 'push':
|
|
return this.pushService.send(notification);
|
|
default:
|
|
throw new Error(`Unknown notification channel: ${(notification as any).channel}`);
|
|
}
|
|
}
|
|
|
|
async sendWithDeduplication(
|
|
notification: Notification,
|
|
dedupKey: DeduplicationKey
|
|
): Promise<NotificationResult> {
|
|
const dedupId = `${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
|
|
const windowSeconds = dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
|
|
const entry = this.pendingDeduplication.get(dedupId);
|
|
|
|
if (entry && Date.now() < entry.expiresAt && entry.externalIds.size > 0) {
|
|
return {
|
|
notificationId: `dedup-${Date.now()}`,
|
|
channel: notification.channel,
|
|
status: 'pending',
|
|
error: 'Duplicate notification within deduplication window',
|
|
};
|
|
}
|
|
|
|
const result = await this.send(notification);
|
|
|
|
if (result.status === 'sent') {
|
|
const now = Date.now();
|
|
this.pendingDeduplication.set(dedupId, {
|
|
externalIds: new Set([result.externalId!]),
|
|
expiresAt: now + windowSeconds * 1000,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async setPreference(
|
|
userId: string,
|
|
channel: NotificationPreference['channel'],
|
|
enabled: boolean,
|
|
categories?: string[]
|
|
): Promise<NotificationPreference> {
|
|
const preference: NotificationPreference = {
|
|
userId,
|
|
channel,
|
|
enabled,
|
|
categories: categories || [],
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
this.preferenceCache.set(`${userId}:${channel}`, preference);
|
|
return preference;
|
|
}
|
|
|
|
async getPreference(
|
|
userId: string,
|
|
channel: NotificationPreference['channel']
|
|
): Promise<NotificationPreference | null> {
|
|
return this.preferenceCache.get(`${userId}:${channel}`) || null;
|
|
}
|
|
|
|
async shouldSend(
|
|
userId: string,
|
|
channel: NotificationPreference['channel'],
|
|
category: string
|
|
): Promise<boolean> {
|
|
const preference = await this.getPreference(userId, channel);
|
|
|
|
if (!preference) {
|
|
return true;
|
|
}
|
|
|
|
if (!preference.enabled) {
|
|
return false;
|
|
}
|
|
|
|
if (preference.categories.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
return preference.categories.includes(category);
|
|
}
|
|
|
|
async sendWithPreferences(
|
|
notification: Notification,
|
|
category: string
|
|
): Promise<NotificationResult | null> {
|
|
const userId = notification.channel === 'push'
|
|
? notification.userId
|
|
: `user-${Date.now()}`;
|
|
|
|
const shouldSend = await this.shouldSend(
|
|
userId,
|
|
notification.channel,
|
|
category
|
|
);
|
|
|
|
if (!shouldSend) {
|
|
return {
|
|
notificationId: `pref-${Date.now()}`,
|
|
channel: notification.channel,
|
|
status: 'pending',
|
|
error: 'Notification disabled for user preference',
|
|
};
|
|
}
|
|
|
|
return this.send(notification);
|
|
}
|
|
|
|
async sendWithTemplate(
|
|
recipient: string,
|
|
options: TemplateResolutionOptions & { channel?: NotificationChannel }
|
|
): Promise<NotificationResult> {
|
|
const channel = options.channel || 'email';
|
|
const templateService = TemplateService.getInstance();
|
|
|
|
const resolved = templateService.resolveTemplate({
|
|
templateId: options.templateId,
|
|
locale: options.locale,
|
|
variables: options.variables,
|
|
fallbackLocale: options.fallbackLocale,
|
|
});
|
|
|
|
if (!resolved) {
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Template not found: ${options.templateId}`,
|
|
};
|
|
}
|
|
|
|
if (resolved.channel !== channel) {
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Template ${options.templateId} is for channel '${resolved.channel}', not '${channel}'`,
|
|
};
|
|
}
|
|
|
|
switch (channel) {
|
|
case 'email':
|
|
return this.emailService.sendWithTemplate(recipient, options);
|
|
case 'sms':
|
|
return this.smsService.send({
|
|
channel: 'sms',
|
|
to: recipient,
|
|
body: resolved.body,
|
|
});
|
|
case 'push':
|
|
return this.pushService.send({
|
|
channel: 'push',
|
|
userId: recipient,
|
|
title: resolved.subject || '',
|
|
body: resolved.body,
|
|
});
|
|
default:
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Unknown channel: ${channel}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|