diff --git a/apps/api/package.json b/apps/api/package.json index d1a6881d5..a6726aa9b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "@fastify/helmet": "^13.0.2", "@shieldsai/shared-auth": "*", "@shieldsai/shared-db": "*", + "@shieldsai/shared-notifications": "*", "@shieldsai/shared-utils": "*", "fastify": "^4.25.0", "fastify-plugin": "^4.5.0" diff --git a/apps/api/src/routes/notifications.routes.ts b/apps/api/src/routes/notifications.routes.ts new file mode 100644 index 000000000..b99a9919b --- /dev/null +++ b/apps/api/src/routes/notifications.routes.ts @@ -0,0 +1,213 @@ +import { FastifyInstance } from 'fastify'; +import { NotificationService } from '@shieldsai/shared-notifications'; + +export async function notificationRoutes(fastify: FastifyInstance): Promise { + let notificationService: NotificationService | undefined; + + // Initialize notification service (will be injected via config) + fastify.addHook('onReady', async () => { + // Notification service will be initialized from config + notificationService = fastify.notificationService; + }); + + /** + * POST /api/v1/notifications/send + * Send a notification to a user + */ + fastify.post( + '/notifications/send', + { + schema: { + body: { + type: 'object', + required: ['userId', 'channel', 'subject', 'body'], + properties: { + userId: { type: 'string' }, + channel: { type: 'string', enum: ['email', 'push', 'sms'] }, + subject: { type: 'string' }, + body: { type: 'string' }, + email: { type: 'string' }, + phone: { type: 'string' }, + fcmToken: { type: 'string' }, + apnsToken: { type: 'string' }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'] }, + metadata: { type: 'object' }, + }, + }, + }, + }, + async (request, reply) => { + const { userId, channel, subject, body, priority, metadata } = request.body; + + const recipient = { + userId, + email: request.body.email, + phone: request.body.phone, + fcmToken: request.body.fcmToken, + apnsToken: request.body.apnsToken, + }; + + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const notifications = await notificationService.sendMultiChannelNotification( + recipient, + channel, + subject, + body, + priority, + metadata + ); + + return reply.send({ + success: true, + notifications, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * GET /api/v1/notifications/:userId/preferences + * Get notification preferences for a user + */ + fastify.get( + '/notifications/:userId/preferences', + { + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + }, + }, + }, + }, + async (request, reply) => { + const { userId } = request.params; + + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const preferences = await notificationService.getNotificationPreferences(userId); + + return reply.send({ + success: true, + preferences, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * PUT /api/v1/notifications/:userId/preferences + * Update notification preferences for a user + */ + fastify.put( + '/notifications/:userId/preferences', + { + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + }, + }, + body: { + type: 'object', + properties: { + email: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + push: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + sms: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + categories: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { userId } = request.params; + const updates = request.body; + + try { + // TODO: Update preferences in database + return reply.send({ + success: true, + message: 'Preferences updated', + userId, + updates, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + ); + + /** + * GET /api/v1/notifications/config + * Get notification configuration status + */ + fastify.get('/notifications/config', async (request, reply) => { + try { + if (!notificationService) { + return reply.status(503).send({ + success: false, + error: 'Notification service not initialized', + }); + } + + const config = notificationService.getConfigSummary(); + + return reply.send({ + success: true, + config, + }); + } catch (error) { + return reply.status(500).send({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); +} diff --git a/packages/shared-notifications/package.json b/packages/shared-notifications/package.json new file mode 100644 index 000000000..cdb60704d --- /dev/null +++ b/packages/shared-notifications/package.json @@ -0,0 +1,28 @@ +{ + "name": "@shieldsai/shared-notifications", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src/" + }, + "dependencies": { + "resend": "^6.12.2", + "firebase-admin": "^13.2.0", + "twilio": "^5.4.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/shared-notifications/src/config/notification.config.ts b/packages/shared-notifications/src/config/notification.config.ts new file mode 100644 index 000000000..bb289d875 --- /dev/null +++ b/packages/shared-notifications/src/config/notification.config.ts @@ -0,0 +1,92 @@ +import { RateLimitConfig, NotificationChannel } from '../types/notification.types'; + +// Resend configuration +export interface ResendConfig { + apiKey: string; + fromEmail: string; + fromName: string; +} + +// Firebase Cloud Messaging configuration +export interface FCMConfig { + projectId: string; + privateKey: string; + clientEmail: string; + keyPath?: string; // Path to service account key file +} + +// APNs configuration +export interface APNsConfig { + keyPath: string; // Path to .p8 key file + keyId: string; + teamId: string; + bundleId: string; +} + +// Twilio configuration +export interface TwilioConfig { + accountSid: string; + authToken: string; + fromNumber?: string; // Optional default sender number +} + +// Combined notification config +export interface NotificationConfig { + resend: ResendConfig; + fcm?: FCMConfig; + apns?: APNsConfig; + twilio?: TwilioConfig; + rateLimits: { + email: RateLimitConfig; + push: RateLimitConfig; + sms: RateLimitConfig; + }; +} + +// Default rate limits +export const defaultRateLimits: Record = { + [NotificationChannel.EMAIL]: { + maxPerWindow: 100, + windowMs: 60 * 60 * 1000, // 1 hour + key: 'user', + }, + [NotificationChannel.PUSH]: { + maxPerWindow: 50, + windowMs: 60 * 60 * 1000, // 1 hour + key: 'user', + }, + [NotificationChannel.SMS]: { + maxPerWindow: 20, + windowMs: 60 * 60 * 1000, // 1 hour + key: 'user', + }, +}; + +// Load config from environment variables +export function loadNotificationConfig(): NotificationConfig { + return { + resend: { + apiKey: process.env.RESEND_API_KEY!, + fromEmail: process.env.RESEND_FROM_EMAIL || 'noreply@shieldsai.com', + fromName: process.env.RESEND_FROM_NAME || 'ShieldAI', + }, + fcm: process.env.FCM_PROJECT_ID ? { + projectId: process.env.FCM_PROJECT_ID, + privateKey: process.env.FCM_PRIVATE_KEY!.replace(/\\n/g, '\n'), + clientEmail: process.env.FCM_CLIENT_EMAIL!, + keyPath: process.env.FCM_KEY_PATH, + } : undefined, + apns: process.env.APNS_KEY_PATH ? { + keyPath: process.env.APNS_KEY_PATH, + keyId: process.env.APNS_KEY_ID!, + teamId: process.env.APNS_TEAM_ID!, + bundleId: process.env.APNS_BUNDLE_ID!, + } : undefined, + twilio: process.env.TWILIO_ACCOUNT_SID ? { + accountSid: process.env.TWILIO_ACCOUNT_SID!, + authToken: process.env.TWILIO_AUTH_TOKEN!, + fromNumber: process.env.TWILIO_FROM_NUMBER, + } : undefined, + rateLimits: defaultRateLimits, + }; +} diff --git a/packages/shared-notifications/src/index.ts b/packages/shared-notifications/src/index.ts new file mode 100644 index 000000000..9c2b03253 --- /dev/null +++ b/packages/shared-notifications/src/index.ts @@ -0,0 +1,11 @@ +// Types +export * from './types/notification.types'; + +// Config +export * from './config/notification.config'; + +// Services +export { EmailService } from './services/email.service'; +export { PushService } from './services/push.service'; +export { SMSService } from './services/sms.service'; +export { NotificationService, createNotificationService } from './services/notification.service'; diff --git a/packages/shared-notifications/src/services/email.service.ts b/packages/shared-notifications/src/services/email.service.ts new file mode 100644 index 000000000..5f7111bc8 --- /dev/null +++ b/packages/shared-notifications/src/services/email.service.ts @@ -0,0 +1,170 @@ +import { Resend } from 'resend'; +import { ResendConfig } from '../config/notification.config'; +import { + EmailNotification, + NotificationStatus, + NotificationPriority, + NotificationRecipient, + NotificationChannel, +} from '../types/notification.types'; + +export class EmailService { + private resend: Resend; + private config: ResendConfig; + + constructor(config: ResendConfig) { + this.config = config; + this.resend = new Resend(config.apiKey); + } + + /** + * Send a transactional email + */ + async sendEmail( + recipient: NotificationRecipient, + subject: string, + htmlBody: string, + textBody?: string, + attachments?: Array<{ + filename: string; + content: Buffer | string; + mimeType?: string; + }> + ): Promise { + const notification: EmailNotification = { + id: `email_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.EMAIL, + templateId: 'custom', // Can be updated to use actual template + priority: NotificationPriority.NORMAL, + status: NotificationStatus.PENDING, + to: recipient.email!, + subject, + htmlBody, + textBody, + attachments, + createdAt: new Date(), + }; + + try { + const { data, error } = await this.resend.emails.send({ + from: `${this.config.fromName} <${this.config.fromEmail}>`, + to: [recipient.email!], + subject, + html: htmlBody, + text: textBody, + attachments: attachments?.map((att) => ({ + filename: att.filename, + content: typeof att.content === 'string' ? Buffer.from(att.content) : att.content, + mimeType: att.mimeType, + })), + metadata: { + userId: recipient.userId, + notificationId: notification.id, + }, + }); + + if (error) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = error.message; + } else { + notification.status = NotificationStatus.SENT; + notification.sentAt = new Date(); + } + + return notification; + } catch (error) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return notification; + } + } + + /** + * Send email with retry logic + */ + async sendEmailWithRetry( + recipient: NotificationRecipient, + subject: string, + htmlBody: string, + textBody?: string, + maxRetries: number = 3 + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const result = await this.sendEmail(recipient, subject, htmlBody, textBody); + + if (result.status === NotificationStatus.SENT) { + return result; + } + + lastError = new Error(result.errorMessage); + } catch (error) { + lastError = error as Error; + } + + // Wait before retry (exponential backoff) + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + + return { + id: `email_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.EMAIL, + templateId: 'custom', + priority: NotificationPriority.NORMAL, + status: NotificationStatus.FAILED, + to: recipient.email!, + subject, + htmlBody, + textBody, + createdAt: new Date(), + failedAt: new Date(), + errorMessage: lastError?.message, + }; + } + + /** + * Check email delivery status + */ + async getDeliveryStatus(notificationId: string): Promise { + try { + const emailId = notificationId.replace('email_', ''); + const { data } = await this.resend.emails.get(emailId); + + if (data.status === 'sent') { + return NotificationStatus.SENT; + } else if (data.status === 'delivered') { + return NotificationStatus.DELIVERED; + } else if (data.status === 'failed') { + return NotificationStatus.FAILED; + } + + return NotificationStatus.PENDING; + } catch { + return NotificationStatus.PENDING; + } + } + + /** + * Batch send emails + */ + async batchSendEmails( + recipients: Array<{ + recipient: NotificationRecipient; + subject: string; + htmlBody: string; + textBody?: string; + }> + ): Promise { + const promises = recipients.map(async ({ recipient, subject, htmlBody, textBody }) => { + return this.sendEmail(recipient, subject, htmlBody, textBody); + }); + + return Promise.all(promises); + } +} diff --git a/packages/shared-notifications/src/services/notification.service.ts b/packages/shared-notifications/src/services/notification.service.ts new file mode 100644 index 000000000..47c3edbe1 --- /dev/null +++ b/packages/shared-notifications/src/services/notification.service.ts @@ -0,0 +1,271 @@ +import { EmailService } from './email.service'; +import { PushService } from './push.service'; +import { SMSService } from './sms.service'; +import { + Notification, + NotificationChannel, + NotificationPreferences, + NotificationRecipient, + NotificationStatus, + NotificationPriority, +} from '../types/notification.types'; +import { NotificationConfig } from '../config/notification.config'; + +/** + * Main notification service that orchestrates all notification channels + */ +export class NotificationService { + private emailService?: EmailService; + private pushService?: PushService; + private smsService?: SMSService; + private config: NotificationConfig; + + constructor(config: NotificationConfig) { + this.config = config; + + // Initialize services based on configuration + if (config.resend) { + this.emailService = new EmailService(config.resend); + } + + if (config.fcm || config.apns) { + this.pushService = new PushService(config.fcm, config.apns); + } + + if (config.twilio) { + this.smsService = new SMSService(config.twilio); + } + } + + /** + * Send notification to all enabled channels for a user + */ + async sendMultiChannelNotification( + recipient: NotificationRecipient, + channel: NotificationChannel | NotificationChannel[], + subject: string, + body: string, + priority: NotificationPriority = NotificationPriority.NORMAL, + metadata?: Record + ): Promise { + const channels = Array.isArray(channel) ? channel : [channel]; + const notifications: Notification[] = []; + + for (const ch of channels) { + const prefs = await this.getNotificationPreferences(recipient.userId); + + if (!this.isChannelEnabled(prefs, ch)) { + continue; + } + + let notification: Notification; + + switch (ch) { + case NotificationChannel.EMAIL: + if (this.emailService && recipient.email) { + notification = await this.emailService.sendEmail( + recipient, + subject, + body, + body // Plain text fallback + ); + notifications.push(notification); + } + break; + + case NotificationChannel.PUSH: + if (this.pushService) { + notification = await this.pushService.sendPush( + recipient, + subject, + body, + metadata as Record, + undefined, + 'default' + ); + notifications.push(notification); + } + break; + + case NotificationChannel.SMS: + if (this.smsService && recipient.phone) { + notification = await this.smsService.sendSMS(recipient, body); + notifications.push(notification); + } + break; + } + } + + return notifications; + } + + /** + * Send email notification + */ + async sendEmail( + recipient: NotificationRecipient, + subject: string, + htmlBody: string, + textBody?: string + ): Promise { + if (!this.emailService) { + return null; + } + + return this.emailService.sendEmail(recipient, subject, htmlBody, textBody); + } + + /** + * Send push notification + */ + async sendPush( + recipient: NotificationRecipient, + title: string, + body: string, + data?: Record + ): Promise { + if (!this.pushService) { + return null; + } + + return this.pushService.sendPush(recipient, title, body, data); + } + + /** + * Send SMS notification + */ + async sendSMS( + recipient: NotificationRecipient, + body: string, + fromNumber?: string + ): Promise { + if (!this.smsService) { + return null; + } + + return this.smsService.sendSMS(recipient, body, fromNumber); + } + + /** + * Get notification preferences for a user + */ + async getNotificationPreferences( + userId: string + ): Promise { + // TODO: Fetch from database + // For now, return default preferences + return { + userId, + email: { + enabled: true, + categories: ['marketing', 'transactional', 'alerts'], + }, + push: { + enabled: true, + categories: ['marketing', 'transactional', 'alerts'], + }, + sms: { + enabled: true, + categories: ['alerts'], + }, + }; + } + + /** + * Check if a channel is enabled for a user + */ + private isChannelEnabled( + prefs: NotificationPreferences, + channel: NotificationChannel + ): boolean { + switch (channel) { + case NotificationChannel.EMAIL: + return prefs.email.enabled; + case NotificationChannel.PUSH: + return prefs.push.enabled; + case NotificationChannel.SMS: + return prefs.sms.enabled; + } + } + + /** + * Deduplicate notifications (prevent duplicate sends) + */ + async deduplicateNotification( + userId: string, + templateId: string, + windowMs: number = 5 * 60 * 1000 // 5 minutes default + ): Promise { + // TODO: Check recent notifications in database + // For now, return true (not a duplicate) + return true; + } + + /** + * Check rate limit for a channel + */ + async checkRateLimit( + userId: string, + channel: NotificationChannel + ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> { + const rateLimit = this.config.rateLimits[channel]; + + // TODO: Implement actual rate limiting with Redis or database + // For now, return default values + return { + allowed: true, + remaining: rateLimit.maxPerWindow, + resetAt: new Date(Date.now() + rateLimit.windowMs), + }; + } + + /** + * Get email service instance + */ + getEmailService(): EmailService | undefined { + return this.emailService; + } + + /** + * Get push service instance + */ + getPushService(): PushService | undefined { + return this.pushService; + } + + /** + * Get SMS service instance + */ + getSMSService(): SMSService | undefined { + return this.smsService; + } + + /** + * Check if all services are initialized + */ + isFullyConfigured(): boolean { + return !!(this.emailService && this.pushService && this.smsService); + } + + /** + * Get configuration summary + */ + getConfigSummary(): { + email: boolean; + push: boolean; + sms: boolean; + } { + return { + email: !!this.emailService, + push: !!this.pushService, + sms: !!this.smsService, + }; + } +} + +// Export singleton instance creator +export function createNotificationService( + config: NotificationConfig +): NotificationService { + return new NotificationService(config); +} diff --git a/packages/shared-notifications/src/services/push.service.ts b/packages/shared-notifications/src/services/push.service.ts new file mode 100644 index 000000000..6142ff8ab --- /dev/null +++ b/packages/shared-notifications/src/services/push.service.ts @@ -0,0 +1,257 @@ +import admin from 'firebase-admin'; +import * as path from 'path'; +import { + PushNotification, + NotificationStatus, + NotificationPriority, + NotificationRecipient, + NotificationChannel, +} from '../types/notification.types'; +import { FCMConfig, APNsConfig } from '../config/notification.config'; + +export class PushService { + private fcm?: admin.app.App; + private apnsConfig?: APNsConfig; + + constructor(fcmConfig?: FCMConfig, apnsConfig?: APNsConfig) { + if (fcmConfig) { + if (!admin.apps.length && fcmConfig.keyPath) { + this.fcm = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: fcmConfig.projectId, + privateKey: fcmConfig.privateKey.replace(/\\n/g, '\n'), + clientEmail: fcmConfig.clientEmail, + }), + storageBucket: `${fcmConfig.projectId}.appspot.com`, + }); + } else if (!admin.apps.length) { + this.fcm = admin.initializeApp({ + credential: admin.credential.cert({ + projectId: fcmConfig.projectId, + privateKey: fcmConfig.privateKey.replace(/\\n/g, '\n'), + clientEmail: fcmConfig.clientEmail, + }), + }); + } + } + + this.apnsConfig = apnsConfig; + } + + /** + * Send push notification to FCM device + */ + async sendFCMPush( + recipient: NotificationRecipient, + title: string, + body: string, + data?: Record, + badge?: number, + sound?: string + ): Promise { + const notification: PushNotification = { + id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.PUSH, + templateId: 'custom', + priority: NotificationPriority.NORMAL, + status: NotificationStatus.PENDING, + title, + body, + data, + badge, + sound, + fcmToken: recipient.fcmToken, + createdAt: new Date(), + }; + + if (!this.fcm || !recipient.fcmToken) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = !this.fcm ? 'FCM not configured' : 'Missing FCM token'; + return notification; + } + + try { + const message: admin.messaging.Message = { + token: recipient.fcmToken, + notification: { + title, + body, + }, + data: data + ? Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, String(value)]) + ) + : undefined, + apns: { + payload: { + aps: { + badge, + sound: sound || 'default', + }, + }, + }, + android: { + priority: 'high', + notification: { + title, + body, + clickAction: 'FLUTTER_NOTIFICATION_CLICK', + }, + }, + }; + + const response = await this.fcm.messaging().send(message); + + notification.status = NotificationStatus.SENT; + notification.sentAt = new Date(); + + return notification; + } catch (error) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return notification; + } + } + + /** + * Send push notification using APNs + */ + async sendAPNSPush( + recipient: NotificationRecipient, + title: string, + body: string, + data?: Record, + badge?: number + ): Promise { + const notification: PushNotification = { + id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.PUSH, + templateId: 'custom', + priority: NotificationPriority.NORMAL, + status: NotificationStatus.PENDING, + title, + body, + data, + badge, + apnsToken: recipient.apnsToken, + createdAt: new Date(), + }; + + if (!this.apnsConfig || !recipient.apnsToken) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = !this.apnsConfig ? 'APNs not configured' : 'Missing APNs token'; + return notification; + } + + // APNs implementation would go here + // For now, we'll use FCM for iOS as well (FCM supports APNs) + if (this.fcm && recipient.apnsToken) { + const message: admin.messaging.Message = { + token: recipient.apnsToken, + notification: { + title, + body, + }, + data: data + ? Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, String(value)]) + ) + : undefined, + apns: { + payload: { + aps: { + badge, + sound: 'default', + contentAvailable: true, + }, + }, + }, + }; + + try { + await this.fcm.messaging().send(message); + notification.status = NotificationStatus.SENT; + notification.sentAt = new Date(); + } catch (error) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + } + } + + return notification; + } + + /** + * Send push notification (auto-detect platform) + */ + async sendPush( + recipient: NotificationRecipient, + title: string, + body: string, + data?: Record, + badge?: number, + sound?: string + ): Promise { + // Prefer APNs for iOS tokens, FCM for Android + if (recipient.apnsToken) { + return this.sendAPNSPush(recipient, title, body, data, badge); + } else if (recipient.fcmToken) { + return this.sendFCMPush(recipient, title, body, data, badge, sound); + } else { + return { + id: `push_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.PUSH, + templateId: 'custom', + priority: NotificationPriority.NORMAL, + status: NotificationStatus.FAILED, + title, + body, + data, + badge, + sound, + createdAt: new Date(), + failedAt: new Date(), + errorMessage: 'No push token available', + }; + } + } + + /** + * Send broadcast push to multiple devices + */ + async sendBroadcastPush( + recipients: Array, + title: string, + body: string, + data?: Record + ): Promise { + const promises = recipients.map((recipient) => + this.sendPush(recipient, title, body, data) + ); + + return Promise.all(promises); + } + + /** + * Check if FCM is properly configured + */ + isFCMConfigured(): boolean { + return !!this.fcm; + } + + /** + * Shutdown FCM app + */ + async shutdown(): Promise { + if (this.fcm) { + await this.fcm.terminate(); + } + } +} diff --git a/packages/shared-notifications/src/services/sms.service.ts b/packages/shared-notifications/src/services/sms.service.ts new file mode 100644 index 000000000..a8b6be07d --- /dev/null +++ b/packages/shared-notifications/src/services/sms.service.ts @@ -0,0 +1,175 @@ +import { Twilio } from 'twilio'; +import { + SMSNotification, + NotificationStatus, + NotificationPriority, + NotificationRecipient, + NotificationChannel, +} from '../types/notification.types'; +import { TwilioConfig } from '../config/notification.config'; + +export class SMSService { + private twilio: Twilio; + private config: TwilioConfig; + private defaultFromNumber?: string; + + constructor(config: TwilioConfig) { + this.config = config; + this.twilio = new Twilio(config.accountSid, config.authToken); + this.defaultFromNumber = config.fromNumber; + } + + /** + * Send SMS message + */ + async sendSMS( + recipient: NotificationRecipient, + body: string, + fromNumber?: string + ): Promise { + const notification: SMSNotification = { + id: `sms_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + userId: recipient.userId, + channel: NotificationChannel.SMS, + templateId: 'custom', + priority: NotificationPriority.NORMAL, + status: NotificationStatus.PENDING, + to: recipient.phone!, + body, + from: fromNumber || this.defaultFromNumber, + createdAt: new Date(), + }; + + if (!recipient.phone) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = 'Missing phone number'; + return notification; + } + + try { + const message = await this.twilio.messages.create({ + body, + from: fromNumber || this.defaultFromNumber, + to: recipient.phone, + }); + + notification.status = NotificationStatus.SENT; + notification.sentAt = new Date(); + notification.id = message.sid; + + return notification; + } catch (error) { + notification.status = NotificationStatus.FAILED; + notification.failedAt = new Date(); + notification.errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return notification; + } + } + + /** + * Send SMS with delivery status tracking + */ + async sendSMSWithTracking( + recipient: NotificationRecipient, + body: string, + fromNumber?: string + ): Promise { + const notification = await this.sendSMS(recipient, body, fromNumber); + + if (notification.status === NotificationStatus.SENT && notification.id) { + try { + const message = await this.twilio.messages(notification.id).fetch(); + + if (message.status === 'delivered') { + notification.status = NotificationStatus.DELIVERED; + notification.deliveredAt = new Date(message.dateUpdated || message.dateSent); + } + } catch (error) { + console.warn(`Failed to fetch delivery status for SMS ${notification.id}:`, error); + } + } + + return notification; + } + + /** + * Check SMS delivery status + */ + async getDeliveryStatus(smsId: string): Promise { + try { + const message = await this.twilio.messages(smsId).fetch(); + + switch (message.status) { + case 'sent': + case 'delivered': + return NotificationStatus.DELIVERED; + case 'failed': + case 'undelivered': + return NotificationStatus.FAILED; + case 'queued': + case 'sending': + return NotificationStatus.PENDING; + default: + return NotificationStatus.PENDING; + } + } catch { + return NotificationStatus.PENDING; + } + } + + /** + * Send bulk SMS messages + */ + async bulkSendSMS( + recipients: Array<{ + recipient: NotificationRecipient; + body: string; + fromNumber?: string; + }> + ): Promise { + const promises = recipients.map(async ({ recipient, body, fromNumber }) => { + return this.sendSMS(recipient, body, fromNumber); + }); + + return Promise.all(promises); + } + + /** + * Send transactional SMS (e.g., verification codes) + */ + async sendTransactionSMS( + recipient: NotificationRecipient, + template: 'verification' | 'password_reset' | 'welcome', + variables?: Record + ): Promise { + const templates: Record) => string> = { + verification: (vars) => + `Your verification code is: ${vars?.code || '123456'}. Valid for 10 minutes.`, + password_reset: (vars) => + `Password reset requested for ${vars?.email || 'your account'}. Click the link to reset.`, + welcome: (vars) => + `Welcome ${vars?.name || 'there'}! Your account has been created successfully.`, + }; + + const body = templates[template](variables); + + return this.sendSMS(recipient, body); + } + + /** + * Validate phone number format + */ + isValidPhoneNumber(phone: string): boolean { + // Basic E.164 format validation + const e164Regex = /^\+[1-9]\d{1,14}$/; + return e164Regex.test(phone); + } + + /** + * Get Twilio client for advanced operations + */ + getClient(): Twilio { + return this.twilio; + } +} diff --git a/packages/shared-notifications/src/types/notification.types.ts b/packages/shared-notifications/src/types/notification.types.ts new file mode 100644 index 000000000..f2356f4e8 --- /dev/null +++ b/packages/shared-notifications/src/types/notification.types.ts @@ -0,0 +1,133 @@ +// Notification channels +export enum NotificationChannel { + EMAIL = 'email', + PUSH = 'push', + SMS = 'sms', +} + +// Notification priorities +export enum NotificationPriority { + LOW = 'low', + NORMAL = 'normal', + HIGH = 'high', + URGENT = 'urgent', +} + +// Notification status +export enum NotificationStatus { + PENDING = 'pending', + SENT = 'sent', + DELIVERED = 'delivered', + READ = 'read', + FAILED = 'failed', +} + +// Template types +export enum TemplateType { + WELCOME = 'welcome', + PASSWORD_RESET = 'password_reset', + EMAIL_VERIFICATION = 'email_verification', + SMS_VERIFICATION = 'sms_verification', + PUSH_WELCOME = 'push_welcome', + CUSTOM = 'custom', +} + +// Notification recipient +export interface NotificationRecipient { + userId: string; + email?: string; + phone?: string; + fcmToken?: string; + apnsToken?: string; +} + +// Notification template +export interface NotificationTemplate { + id: string; + type: TemplateType; + channel: NotificationChannel; + subject?: string; // For email + title?: string; // For push + body: string; + locale: string; + variables: Record; + isActive: boolean; +} + +// Notification preferences +export interface NotificationPreferences { + userId: string; + email: { + enabled: boolean; + categories: string[]; // e.g., ['marketing', 'transactional', 'alerts'] + }; + push: { + enabled: boolean; + categories: string[]; + }; + sms: { + enabled: boolean; + categories: string[]; + }; +} + +// Base notification interface +export interface BaseNotification { + id: string; + userId: string; + channel: NotificationChannel; + templateId: string; + priority: NotificationPriority; + status: NotificationStatus; + metadata?: Record; + createdAt: Date; + sentAt?: Date; + deliveredAt?: Date; + readAt?: Date; + failedAt?: Date; + errorMessage?: string; +} + +// Email-specific notification +export interface EmailNotification extends BaseNotification { + channel: NotificationChannel.EMAIL; + to: string; + subject: string; + htmlBody?: string; + textBody?: string; + attachments?: Array<{ + filename: string; + content: Buffer | string; + mimeType?: string; + }>; +} + +// Push notification +export interface PushNotification extends BaseNotification { + channel: NotificationChannel.PUSH; + title: string; + body: string; + data?: Record; + badge?: number; + sound?: string; + fcmToken?: string; + apnsToken?: string; +} + +// SMS notification +export interface SMSNotification extends BaseNotification { + channel: NotificationChannel.SMS; + to: string; + body: string; + from?: string; +} + +// Union type for all notification types +export type Notification = EmailNotification | PushNotification | SMSNotification; + +// Rate limit configuration +export interface RateLimitConfig { + maxPerWindow: number; + windowMs: number; + key: string; // User ID or template ID +} diff --git a/packages/shared-notifications/tsconfig.json b/packages/shared-notifications/tsconfig.json new file mode 100644 index 000000000..e229935f5 --- /dev/null +++ b/packages/shared-notifications/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}