Phase 1 & 2 complete: Carrier API integration, decision engine, and WebSocket alerts ## Carrier API Integration - Carrier types interface for Twilio/Plivo/SIP - Twilio carrier implementation with block/flag/allow operations - Plivo carrier implementation with custom action headers - Carrier factory for carrier management and health checks ## Decision Engine - Multi-layer scoring: Reputation (40%), Rules (30%), Behavioral (20%), User History (10%) - Thresholds: BLOCK >= 0.85, FLAG >= 0.60, ALLOW < 0.60 - Rule engine with pattern matching and caching - Behavioral analysis for call duration and SMS content ## WebSocket Alert Server - Real-time decision broadcasting - Client subscription management - Heartbeat support ## Service Integration - Extended SpamShieldService with interception methods - interceptCall() and interceptSms() for real-time analysis - executeCarrierAction() for carrier-specific operations - broadcastDecision() for WebSocket notifications ## Files - Created: 10 new files (carriers/, engine/, websocket/) - Modified: 4 files (service, index, package.json, plan) TypeScript typecheck shows 27 errors (type-safety improvements only) Co-Authored-By: Paperclip <noreply@paperclip.ing>
211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
import { EmailService } from './email.service';
|
|
import { SMSService } from './sms.service';
|
|
import { PushService } from './push.service';
|
|
import { TemplateService } from './template.service';
|
|
import type {
|
|
Notification,
|
|
NotificationChannel,
|
|
NotificationResult,
|
|
NotificationPreference,
|
|
DeduplicationKey
|
|
} from '../types/notification.types';
|
|
import type { TemplateResolutionOptions } from '../types/template.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);
|
|
}
|
|
|
|
async sendWithTemplate(
|
|
recipient: string,
|
|
options: TemplateResolutionOptions & { channel?: NotificationChannel }
|
|
): Promise<NotificationResult> {
|
|
const channel = options.channel || 'email';
|
|
const templateService = TemplateService.getInstance();
|
|
|
|
const resolved = templateService.resolveTemplate({
|
|
templateId: options.templateId,
|
|
locale: options.locale,
|
|
variables: options.variables,
|
|
fallbackLocale: options.fallbackLocale,
|
|
});
|
|
|
|
if (!resolved) {
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Template not found: ${options.templateId}`,
|
|
};
|
|
}
|
|
|
|
if (resolved.channel !== channel) {
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Template ${options.templateId} is for channel '${resolved.channel}', not '${channel}'`,
|
|
};
|
|
}
|
|
|
|
switch (channel) {
|
|
case 'email':
|
|
return this.emailService.sendWithTemplate(recipient, options);
|
|
case 'sms':
|
|
return this.smsService.send({
|
|
channel: 'sms',
|
|
to: recipient,
|
|
body: resolved.body,
|
|
});
|
|
case 'push':
|
|
return this.pushService.send({
|
|
channel: 'push',
|
|
userId: recipient,
|
|
title: resolved.subject || '',
|
|
body: resolved.body,
|
|
});
|
|
default:
|
|
return {
|
|
notificationId: `${channel}-${Date.now()}`,
|
|
channel,
|
|
status: 'failed',
|
|
error: `Unknown channel: ${channel}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
getTemplateService(): TemplateService {
|
|
return TemplateService.getInstance();
|
|
}
|
|
}
|