- 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>
113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
import admin from 'firebase-admin';
|
|
import { loadNotificationConfig } from '../config/notification.config';
|
|
import type { PushNotification, NotificationResult } from '../types/notification.types';
|
|
|
|
const config = loadNotificationConfig();
|
|
|
|
let fcmApp: admin.app.App | null = null;
|
|
|
|
function getFCMApp(): admin.app.App {
|
|
if (!fcmApp) {
|
|
fcmApp = admin.initializeApp({
|
|
credential: admin.credential.cert({
|
|
projectId: config.fcm.projectId,
|
|
clientEmail: config.fcm.clientEmail,
|
|
privateKey: config.fcm.privateKey.replace(/\\n/g, '\n'),
|
|
}),
|
|
});
|
|
}
|
|
return fcmApp;
|
|
}
|
|
|
|
export class PushService {
|
|
private static instance: PushService;
|
|
private sentCount = new Map<string, number>();
|
|
private cleanupInterval: NodeJS.Timeout;
|
|
|
|
private constructor() {
|
|
this.cleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, timestamp] of this.sentCount.entries()) {
|
|
if (now - timestamp > 60000) {
|
|
this.sentCount.delete(key);
|
|
}
|
|
}
|
|
}, 60000);
|
|
}
|
|
|
|
static getInstance(): PushService {
|
|
if (!PushService.instance) {
|
|
PushService.instance = new PushService();
|
|
}
|
|
return PushService.instance;
|
|
}
|
|
|
|
async send(notification: PushNotification): Promise<NotificationResult> {
|
|
const rateLimitKey = `push:${notification.userId}`;
|
|
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
|
|
|
if (currentCount >= config.rateLimits.pushPerMinute) {
|
|
throw new Error(`Push rate limit exceeded for user ${notification.userId}`);
|
|
}
|
|
|
|
try {
|
|
const fcmApp = getFCMApp();
|
|
const messaging = admin.messaging(fcmApp);
|
|
|
|
const message: admin.messaging.Message = {
|
|
notification: {
|
|
title: notification.title,
|
|
body: notification.body,
|
|
},
|
|
data: notification.data ?
|
|
Object.fromEntries(
|
|
Object.entries(notification.data).map(([k, v]) => [k, String(v)])
|
|
) : undefined,
|
|
token: notification.userId,
|
|
apns: {
|
|
payload: {
|
|
aps: {
|
|
badge: notification.badge,
|
|
sound: notification.sound || 'default',
|
|
category: notification.category,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const response = await messaging.send(message);
|
|
|
|
this.sentCount.set(rateLimitKey, currentCount + 1);
|
|
|
|
return {
|
|
notificationId: `push-${response}`,
|
|
channel: 'push',
|
|
status: 'sent',
|
|
externalId: response,
|
|
deliveredAt: new Date(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
notificationId: `push-${Date.now()}`,
|
|
channel: 'push',
|
|
status: 'failed',
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
};
|
|
}
|
|
}
|
|
|
|
async sendBatch(notifications: PushNotification[]): Promise<NotificationResult[]> {
|
|
const results = await Promise.all(
|
|
notifications.map(n => this.send(n))
|
|
);
|
|
return results;
|
|
}
|
|
|
|
getRateLimitStatus(): { remaining: number; limit: number } {
|
|
return {
|
|
remaining: config.rateLimits.pushPerMinute - this.sentCount.size,
|
|
limit: config.rateLimits.pushPerMinute,
|
|
};
|
|
}
|
|
}
|