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:
159
packages/shared-notifications/src/routes/notification.routes.ts
Normal file
159
packages/shared-notifications/src/routes/notification.routes.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user