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:
2026-04-30 10:57:56 -04:00
parent 76d431e1ec
commit 9fb5379b7a
43 changed files with 7819 additions and 93 deletions

View File

@@ -0,0 +1,159 @@
import { Router, Request, Response } from 'express';
import { NotificationService } from '../services/notification.service';
import type { EmailNotification, SMSNotification, PushNotification } from '../types/notification.types';
const router = Router();
const notificationService = NotificationService.getInstance();
export interface SendNotificationRequest {
channel: 'email' | 'sms' | 'push';
to?: string;
userId?: string;
subject?: string;
body: string;
htmlBody?: string;
title?: string;
data?: Record<string, unknown>;
metadata?: Record<string, string>;
category?: string;
}
router.post('/send', async (req: Request, res: Response) => {
try {
const { channel, ...payload } = req.body as SendNotificationRequest;
let notification: EmailNotification | SMSNotification | PushNotification;
switch (channel) {
case 'email':
if (!payload.to || !payload.subject || !payload.htmlBody) {
res.status(400).json({ error: 'Email requires to, subject, and htmlBody' });
return;
}
notification = {
channel: 'email',
to: payload.to,
subject: payload.subject,
htmlBody: payload.htmlBody,
textBody: payload.body,
metadata: payload.metadata,
};
break;
case 'sms':
if (!payload.to) {
res.status(400).json({ error: 'SMS requires to field' });
return;
}
notification = {
channel: 'sms',
to: payload.to,
body: payload.body,
metadata: payload.metadata,
};
break;
case 'push':
if (!payload.userId || !payload.title) {
res.status(400).json({ error: 'Push requires userId and title' });
return;
}
notification = {
channel: 'push',
userId: payload.userId,
title: payload.title,
body: payload.body,
data: payload.data,
};
break;
default:
res.status(400).json({ error: `Unknown channel: ${channel}` });
return;
}
const result = await notificationService.sendWithPreferences(
notification,
payload.category || 'default'
);
res.json(result);
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
router.post('/send/batch', async (req: Request, res: Response) => {
try {
const notifications = req.body.notifications as SendNotificationRequest[];
const results = await Promise.all(
notifications.map(n => {
const notif = {
channel: n.channel,
to: n.to,
userId: n.userId,
subject: n.subject,
body: n.body,
htmlBody: n.htmlBody,
title: n.title,
data: n.data,
metadata: n.metadata,
};
return notificationService.sendWithPreferences(
notif as EmailNotification | SMSNotification | PushNotification,
n.category || 'default'
);
})
);
res.json({ results });
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
router.post('/preferences/:userId', async (req: Request, res: Response) => {
try {
const { userId } = req.params;
const { channel, enabled, categories } = req.body;
const preference = await notificationService.setPreference(
userId,
channel,
enabled,
categories
);
res.json(preference);
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
router.get('/preferences/:userId', async (req: Request, res: Response) => {
try {
const { userId } = req.params;
const { channel } = req.query;
if (channel) {
const preference = await notificationService.getPreference(
userId,
channel as 'email' | 'sms' | 'push'
);
res.json(preference);
} else {
res.json({ userId });
}
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});
export { router as notificationRoutes };