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:
25
packages/shared-notifications/package.json
Normal file
25
packages/shared-notifications/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@shieldai/shared-notifications",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^3.0.0",
|
||||
"firebase-admin": "^12.0.0",
|
||||
"twilio": "^4.0.0",
|
||||
"zod": "^3.22.0",
|
||||
"express": "^4.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NotificationConfigSchema = z.object({
|
||||
resend: z.object({
|
||||
apiKey: z.string().min(1, 'RESEND_API_KEY required'),
|
||||
baseUrl: z.string().default('https://api.resend.com'),
|
||||
}),
|
||||
fcm: z.object({
|
||||
privateKey: z.string().min(1, 'FCM_PRIVATE_KEY required'),
|
||||
projectId: z.string().min(1, 'FCM_PROJECT_ID required'),
|
||||
clientEmail: z.string().email(),
|
||||
}),
|
||||
apns: z.object({
|
||||
key: z.string().min(1, 'APNS_KEY required'),
|
||||
keyId: z.string().min(1, 'APNS_KEY_ID required'),
|
||||
teamId: z.string().min(1, 'APNS_TEAM_ID required'),
|
||||
bundleId: z.string().min(1, 'APNS_BUNDLE_ID required'),
|
||||
}),
|
||||
twilio: z.object({
|
||||
accountSid: z.string().min(1, 'TWILIO_ACCOUNT_SID required'),
|
||||
authToken: z.string().min(1, 'TWILIO_AUTH_TOKEN required'),
|
||||
messagingServiceSid: z.string().min(1, 'TWILIO_MESSAGING_SERVICE_SID required'),
|
||||
}),
|
||||
rateLimits: z.object({
|
||||
emailPerMinute: z.number().default(60),
|
||||
smsPerMinute: z.number().default(30),
|
||||
pushPerMinute: z.number().default(100),
|
||||
}),
|
||||
});
|
||||
|
||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>;
|
||||
|
||||
export const loadNotificationConfig = (): NotificationConfig => ({
|
||||
resend: {
|
||||
apiKey: process.env.RESEND_API_KEY!,
|
||||
baseUrl: process.env.RESEND_BASE_URL || 'https://api.resend.com',
|
||||
},
|
||||
fcm: {
|
||||
privateKey: process.env.FCM_PRIVATE_KEY!,
|
||||
projectId: process.env.FCM_PROJECT_ID!,
|
||||
clientEmail: process.env.FCM_CLIENT_EMAIL!,
|
||||
},
|
||||
apns: {
|
||||
key: process.env.APNS_KEY!,
|
||||
keyId: process.env.APNS_KEY_ID!,
|
||||
teamId: process.env.APNS_TEAM_ID!,
|
||||
bundleId: process.env.APNS_BUNDLE_ID!,
|
||||
},
|
||||
twilio: {
|
||||
accountSid: process.env.TWILIO_ACCOUNT_SID!,
|
||||
authToken: process.env.TWILIO_AUTH_TOKEN!,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID!,
|
||||
},
|
||||
rateLimits: {
|
||||
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
||||
smsPerMinute: parseInt(process.env.SMS_RATE_LIMIT || '30', 10),
|
||||
pushPerMinute: parseInt(process.env.PUSH_RATE_LIMIT || '100', 10),
|
||||
},
|
||||
});
|
||||
8
packages/shared-notifications/src/index.ts
Normal file
8
packages/shared-notifications/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { EmailService } from './services/email.service';
|
||||
export { SMSService } from './services/sms.service';
|
||||
export { PushService } from './services/push.service';
|
||||
export { NotificationService } from './services/notification.service';
|
||||
export { loadNotificationConfig, NotificationConfigSchema } from './config/notification.config';
|
||||
export { notificationRoutes } from './routes/notification.routes';
|
||||
|
||||
export * from './types/notification.types';
|
||||
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 };
|
||||
96
packages/shared-notifications/src/services/email.service.ts
Normal file
96
packages/shared-notifications/src/services/email.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Resend } from 'resend';
|
||||
import { loadNotificationConfig } from '../config/notification.config';
|
||||
import type { EmailNotification, NotificationResult } from '../types/notification.types';
|
||||
|
||||
const config = loadNotificationConfig();
|
||||
const resend = new Resend(config.resend.apiKey);
|
||||
|
||||
export class EmailService {
|
||||
private static instance: EmailService;
|
||||
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(): EmailService {
|
||||
if (!EmailService.instance) {
|
||||
EmailService.instance = new EmailService();
|
||||
}
|
||||
return EmailService.instance;
|
||||
}
|
||||
|
||||
async send(notification: EmailNotification): Promise<NotificationResult> {
|
||||
const rateLimitKey = `email:${notification.to}`;
|
||||
const currentCount = this.sentCount.get(rateLimitKey) || 0;
|
||||
|
||||
if (currentCount >= config.rateLimits.emailPerMinute) {
|
||||
throw new Error(`Email rate limit exceeded for ${notification.to}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: notification.from || 'ShieldAI <noreply@shieldai.com>',
|
||||
to: [notification.to],
|
||||
subject: notification.subject,
|
||||
html: notification.htmlBody,
|
||||
text: notification.textBody,
|
||||
metadata: notification.metadata,
|
||||
attachments: notification.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
data: att.content,
|
||||
contentType: att.mimeType,
|
||||
})),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
notificationId: `email-${Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'failed',
|
||||
externalId: data?.id,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
||||
|
||||
return {
|
||||
notificationId: `email-${data?.id || Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'sent',
|
||||
externalId: data?.id,
|
||||
deliveredAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
notificationId: `email-${Date.now()}`,
|
||||
channel: 'email',
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(notifications: EmailNotification[]): Promise<NotificationResult[]> {
|
||||
const results = await Promise.all(
|
||||
notifications.map(n => this.send(n))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
getRateLimitStatus(): { remaining: number; limit: number } {
|
||||
return {
|
||||
remaining: config.rateLimits.emailPerMinute - this.sentCount.size,
|
||||
limit: config.rateLimits.emailPerMinute,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
112
packages/shared-notifications/src/services/push.service.ts
Normal file
112
packages/shared-notifications/src/services/push.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
82
packages/shared-notifications/src/services/sms.service.ts
Normal file
82
packages/shared-notifications/src/services/sms.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export type NotificationChannel = 'email' | 'sms' | 'push';
|
||||
|
||||
export type NotificationStatus =
|
||||
| 'pending'
|
||||
| 'sent'
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'bounced'
|
||||
| 'read';
|
||||
|
||||
export interface NotificationRecipient {
|
||||
userId: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
}
|
||||
|
||||
export interface EmailNotification {
|
||||
channel: 'email';
|
||||
to: string;
|
||||
from?: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
textBody?: string;
|
||||
metadata?: Record<string, string>;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
mimeType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SMSNotification {
|
||||
channel: 'sms';
|
||||
to: string;
|
||||
body: string;
|
||||
from?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PushNotification {
|
||||
channel: 'push';
|
||||
userId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
badge?: number;
|
||||
sound?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export type Notification = EmailNotification | SMSNotification | PushNotification;
|
||||
|
||||
export interface NotificationResult {
|
||||
notificationId: string;
|
||||
channel: NotificationChannel;
|
||||
status: NotificationStatus;
|
||||
externalId?: string;
|
||||
error?: string;
|
||||
deliveredAt?: Date;
|
||||
readAt?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
channel: NotificationChannel;
|
||||
subject?: string;
|
||||
body: string;
|
||||
locale: string;
|
||||
variables: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
userId: string;
|
||||
channel: NotificationChannel;
|
||||
enabled: boolean;
|
||||
categories: string[];
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DeduplicationKey {
|
||||
userId: string;
|
||||
templateId: string;
|
||||
key: string;
|
||||
windowMinutes: number;
|
||||
}
|
||||
12
packages/shared-notifications/tsconfig.json
Normal file
12
packages/shared-notifications/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user