FRE-4533: Merge apps/{api,web,mobile} and shared-db into ShieldAI repo
Merge FrenoCorp apps into ShieldAI packages/: - packages/api: merged routes (notifications), middleware (auth, rate-limit, error, logging), config, services (darkwatch, spamshield, voiceprint), tests - packages/web: new SolidJS web app stub - packages/mobile: new SolidJS mobile app stub - packages/shared-db: new Prisma DB package (separate from existing packages/db) - pnpm-workspace.yaml: restored (apps/* removed, already covered by packages/*) Next: reconcile packages/shared-db with packages/db, and fix server.ts correlationRoutes import
This commit is contained in:
174
packages/api/src/services/darkwatch/alert.pipeline.ts
Normal file
174
packages/api/src/services/darkwatch/alert.pipeline.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
|
||||
import {
|
||||
NotificationService,
|
||||
NotificationPriority,
|
||||
loadNotificationConfig,
|
||||
} from '@shieldsai/shared-notifications';
|
||||
|
||||
const ALERT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class AlertPipeline {
|
||||
private notificationService: NotificationService;
|
||||
|
||||
constructor() {
|
||||
this.notificationService = new NotificationService(loadNotificationConfig());
|
||||
}
|
||||
|
||||
async processNewExposures(exposureIds: string[]) {
|
||||
const exposures = await prisma.exposure.findMany({
|
||||
where: { id: { in: exposureIds }, isFirstTime: true },
|
||||
include: {
|
||||
subscription: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
tier: true,
|
||||
},
|
||||
},
|
||||
watchlistItem: true,
|
||||
},
|
||||
});
|
||||
|
||||
const alertsCreated: Awaited<ReturnType<typeof prisma.alert.create>>[] = [];
|
||||
|
||||
for (const exposure of exposures) {
|
||||
const dedupKey = `exposure:${exposure.subscriptionId}:${exposure.source}:${exposure.identifierHash}`;
|
||||
|
||||
const recentAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
subscriptionId: exposure.subscriptionId,
|
||||
type: AlertType.exposure_detected,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - ALERT_DEDUP_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (recentAlert) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alert = await prisma.alert.create({
|
||||
data: {
|
||||
subscriptionId: exposure.subscriptionId,
|
||||
userId: exposure.subscription.userId,
|
||||
exposureId: exposure.id,
|
||||
type: AlertType.exposure_detected,
|
||||
title: this.buildTitle(exposure),
|
||||
message: this.buildMessage(exposure),
|
||||
severity: this.mapSeverity(exposure.severity),
|
||||
channel: this.getChannelsForTier(exposure.subscription.tier),
|
||||
},
|
||||
});
|
||||
|
||||
alertsCreated.push(alert);
|
||||
|
||||
await this.dispatchNotification(alert, exposure);
|
||||
}
|
||||
|
||||
return alertsCreated;
|
||||
}
|
||||
|
||||
async dispatchScanCompleteAlert(
|
||||
subscriptionId: string,
|
||||
userId: string,
|
||||
exposuresFound: number
|
||||
) {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) return;
|
||||
|
||||
const alert = await prisma.alert.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
userId,
|
||||
type: AlertType.scan_complete,
|
||||
title: 'DarkWatch Scan Complete',
|
||||
message: `Scan found ${exposuresFound} new exposure${exposuresFound === 1 ? '' : 's'}.`,
|
||||
severity: exposuresFound > 0 ? 'warning' : 'info',
|
||||
channel: this.getChannelsForTier(subscription.tier),
|
||||
},
|
||||
});
|
||||
|
||||
await this.dispatchNotification(alert, {
|
||||
source: 'hibp',
|
||||
severity: 'info',
|
||||
identifier: '',
|
||||
dataType: 'email',
|
||||
} as any);
|
||||
|
||||
return alert;
|
||||
}
|
||||
|
||||
private async dispatchNotification(
|
||||
alert: {
|
||||
userId: string;
|
||||
channel: string[];
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AlertSeverity;
|
||||
},
|
||||
exposure: { source: string; severity: string; identifier: string; dataType: string }
|
||||
) {
|
||||
try {
|
||||
if (!this.notificationService.isFullyConfigured()) return;
|
||||
|
||||
await this.notificationService.sendMultiChannelNotification(
|
||||
{
|
||||
userId: alert.userId,
|
||||
},
|
||||
alert.channel as any,
|
||||
alert.title,
|
||||
`<p>${alert.message}</p>
|
||||
<p><strong>Source:</strong> ${exposure.source}</p>
|
||||
<p><strong>Severity:</strong> ${exposure.severity}</p>
|
||||
<p><strong>Type:</strong> ${exposure.dataType}</p>`,
|
||||
alert.severity === 'critical'
|
||||
? NotificationPriority.HIGH
|
||||
: NotificationPriority.NORMAL
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[AlertPipeline] Notification dispatch error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildTitle(exposure: {
|
||||
source: string;
|
||||
dataType: string;
|
||||
severity: string;
|
||||
}): string {
|
||||
return `${exposure.severity.toUpperCase()}: ${exposure.dataType} exposure on ${exposure.source}`;
|
||||
}
|
||||
|
||||
private buildMessage(exposure: {
|
||||
identifier: string;
|
||||
source: string;
|
||||
severity: string;
|
||||
dataType: string;
|
||||
}): string {
|
||||
const masked = exposure.identifier.includes('@')
|
||||
? exposure.identifier.replace(/(?<=.{2}).*(?=@)/, '***')
|
||||
: exposure.identifier.slice(0, 3) + '***';
|
||||
|
||||
return `Your ${exposure.dataType} (${masked}) was found in a ${exposure.source} breach with ${exposure.severity} severity.`;
|
||||
}
|
||||
|
||||
private mapSeverity(severity: string): AlertSeverity {
|
||||
return severity as AlertSeverity;
|
||||
}
|
||||
|
||||
private getChannelsForTier(tier: string): string[] {
|
||||
const channelMap: Record<string, string[]> = {
|
||||
basic: ['email'],
|
||||
plus: ['email', 'push'],
|
||||
premium: ['email', 'push', 'sms'],
|
||||
};
|
||||
return channelMap[tier] || ['email'];
|
||||
}
|
||||
}
|
||||
|
||||
export const alertPipeline = new AlertPipeline();
|
||||
Reference in New Issue
Block a user