Files
Kordant/packages/shared-notifications/src/services/sms.service.ts
Michael Freno 9fb5379b7a 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>
2026-04-30 10:57:56 -04:00

83 lines
2.3 KiB
TypeScript

import twilio from 'twilio';
import { loadNotificationConfig } from '../config/notification.config';
import type { SMSNotification, NotificationResult } from '../types/notification.types';
const config = loadNotificationConfig();
const twilioClient = twilio(
config.twilio.accountSid,
config.twilio.authToken
);
export class SMSService {
private static instance: SMSService;
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(): SMSService {
if (!SMSService.instance) {
SMSService.instance = new SMSService();
}
return SMSService.instance;
}
async send(notification: SMSNotification): Promise<NotificationResult> {
const rateLimitKey = `sms:${notification.to}`;
const currentCount = this.sentCount.get(rateLimitKey) || 0;
if (currentCount >= config.rateLimits.smsPerMinute) {
throw new Error(`SMS rate limit exceeded for ${notification.to}`);
}
try {
const message = await twilioClient.messages.create({
body: notification.body,
from: notification.from || config.twilio.messagingServiceSid,
to: notification.to,
metadata: notification.metadata,
});
this.sentCount.set(rateLimitKey, currentCount + 1);
return {
notificationId: `sms-${message.sid}`,
channel: 'sms',
status: 'sent',
externalId: message.sid,
deliveredAt: new Date(),
};
} catch (error) {
return {
notificationId: `sms-${Date.now()}`,
channel: 'sms',
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async sendBatch(notifications: SMSNotification[]): Promise<NotificationResult[]> {
const results = await Promise.all(
notifications.map(n => this.send(n))
);
return results;
}
getRateLimitStatus(): { remaining: number; limit: number } {
return {
remaining: config.rateLimits.smsPerMinute - this.sentCount.size,
limit: config.rateLimits.smsPerMinute,
};
}
}