Add tier-based scan scheduler and webhook triggers (FRE-4498)
- ScanScheduler: tier-based scheduling (BASIC=24h, PLUS=6h, PREMIUM=1h) - WebhookHandler: HMAC-verified webhook ingestion with SCAN_TRIGGER support - API routes: /scheduler and /webhooks endpoints under /api/v1/darkwatch - Jobs: scheduled scan checker + webhook retry processor via BullMQ - Schema: ScanSchedule, WebhookEvent models; ScanJob.scheduledBy field - Types: ScheduleStatus, WebhookEventType, WebhookTriggerInput - Tests: scheduler lifecycle + webhook signature/processing tests Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { EmailService } from './email.service';
|
||||
import { SMSService } from './sms.service';
|
||||
import { PushService } from './push.service';
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResult,
|
||||
NotificationPreference,
|
||||
DeduplicationKey
|
||||
} from '../types/notification.types';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private emailService: EmailService;
|
||||
private smsService: SMSService;
|
||||
private pushService: PushService;
|
||||
private pendingDeduplication = new Map<string, Set<string>>();
|
||||
private preferenceCache = new Map<string, NotificationPreference>();
|
||||
|
||||
private constructor() {
|
||||
this.emailService = EmailService.getInstance();
|
||||
this.smsService = SMSService.getInstance();
|
||||
this.pushService = PushService.getInstance();
|
||||
}
|
||||
|
||||
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 windowSet = this.pendingDeduplication.get(dedupId);
|
||||
|
||||
if (windowSet && windowSet.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') {
|
||||
if (!windowSet) {
|
||||
this.pendingDeduplication.set(dedupId, new Set());
|
||||
}
|
||||
this.pendingDeduplication.get(dedupId)!.add(result.externalId!);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user