Files
Kordant/packages/shared-notifications/src/services/notification.service.ts
Michael Freno 8b30cad462 FRE-4499: Implement real-time SpamShield interception engine
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>
2026-05-01 10:04:25 -04:00

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();
}
}