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:
2026-04-30 10:57:56 -04:00
parent 76d431e1ec
commit 9fb5379b7a
43 changed files with 7819 additions and 93 deletions

View File

@@ -77,49 +77,55 @@ enum DetectionVerdict {
}
model User {
id String @id @default(uuid())
email String @unique
name String?
subscriptionTier SubscriptionTier @default(BASIC)
familyGroupId String?
watchListItems WatchListItem[]
alerts Alert[]
scanJobs ScanJob[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
name String?
subscriptionTier SubscriptionTier @default(BASIC)
familyGroupId String?
watchListItems WatchListItem[]
alerts Alert[]
scanJobs ScanJob[]
scanSchedules ScanSchedule[]
voiceEnrollments VoiceEnrollment[]
analysisJobs AnalysisJob[]
spamFeedback SpamFeedback[]
spamCallAnalyses SpamCallAnalysis[]
spamAuditLogs SpamAuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model WatchListItem {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
identifierType IdentifierType
identifierValue String
identifierHash String @unique
status WatchListStatus @default(ACTIVE)
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
identifierType IdentifierType
identifierValue String
identifierHash String @unique
status WatchListStatus @default(ACTIVE)
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([identifierHash])
}
model Exposure {
id String @id @default(uuid())
watchListItemId String
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
dataSource DataSource
breachName String
exposedAt DateTime
dataType String[]
severity Severity
details String?
contentHash String @unique
alert Alert?
createdAt DateTime @default(now())
id String @id @default(uuid())
watchListItemId String
watchListItem WatchListItem @relation(fields: [watchListItemId], references: [id], onDelete: Cascade)
dataSource DataSource
breachName String
exposedAt DateTime
dataType String[]
severity Severity
details String?
contentHash String @unique
alert Alert?
createdAt DateTime @default(now())
@@index([watchListItemId])
@@index([contentHash])
@@ -127,84 +133,202 @@ model Exposure {
}
model Alert {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposureId String @unique
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
severity Severity
channel AlertChannel
status AlertStatus @default(PENDING)
dedupKey String
sentAt DateTime?
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposureId String @unique
exposure Exposure @relation(fields: [exposureId], references: [id], onDelete: Cascade)
severity Severity
channel AlertChannel
status AlertStatus @default(PENDING)
dedupKey String
sentAt DateTime?
createdAt DateTime @default(now())
@@index([userId, status])
@@index([dedupKey])
}
model ScanJob {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status ScanJobStatus @default(PENDING)
source DataSource?
resultCount Int @default(0)
errorMessage String?
completedAt DateTime?
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status ScanJobStatus @default(PENDING)
source DataSource?
resultCount Int @default(0)
errorMessage String?
scheduledBy String?
webhookEvents WebhookEvent[]
completedAt DateTime?
createdAt DateTime @default(now())
@@index([userId, status])
@@index([createdAt])
}
model VoiceEnrollment {
id String @id @default(uuid())
enum ScheduleStatus {
ACTIVE
PAUSED
}
model ScanSchedule {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
intervalMinutes Int // minutes between scans
cronExpression String // cron expression for scheduling
status ScheduleStatus @default(ACTIVE)
lastScanAt DateTime?
nextScanAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId])
@@index([status])
}
enum WebhookEventType {
SCAN_TRIGGER
BREACH_DETECTED
SUBSCRIPTION_CHANGE
}
model WebhookEvent {
id String @id @default(uuid())
eventType WebhookEventType
payload String
source String?
signature String?
processed Boolean @default(false)
processedAt DateTime?
scanJobId String?
scanJob ScanJob? @relation(fields: [scanJobId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([eventType, processed])
@@index([createdAt])
}
model VoiceEnrollment {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
label String
embeddingVector Float[]
embeddingDim Int @default(192)
embeddingDim Int @default(192)
audioFilePath String?
sampleRate Int @default(16000)
sampleRate Int @default(16000)
durationSec Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([embeddingDim])
}
model AnalysisJob {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analysisType AnalysisType
audioFilePath String
status AnalysisJobStatus @default(PENDING)
result AnalysisResult?
errorMessage String?
completedAt DateTime?
createdAt DateTime @default(now())
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analysisType AnalysisType
audioFilePath String
status AnalysisJobStatus @default(PENDING)
result AnalysisResult?
errorMessage String?
completedAt DateTime?
createdAt DateTime @default(now())
@@index([userId, status])
@@index([createdAt])
}
model AnalysisResult {
id String @id @default(uuid())
analysisJobId String @unique
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
syntheticScore Float
verdict DetectionVerdict
matchedEnrollmentId String?
matchedSimilarity Float?
confidence Float
processingTimeMs Int
modelVersion String?
metadata String?
createdAt DateTime @default(now())
id String @id @default(uuid())
analysisJobId String @unique
analysisJob AnalysisJob @relation(fields: [analysisJobId], references: [id], onDelete: Cascade)
syntheticScore Float
verdict DetectionVerdict
matchedEnrollmentId String?
matchedSimilarity Float?
confidence Float
processingTimeMs Int
modelVersion String?
metadata String?
createdAt DateTime @default(now())
@@index([analysisJobId])
@@index([verdict])
}
enum SpamDecision {
BLOCK
FLAG
ALLOW
}
model SpamFeedback {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String // AES-256 encrypted PII
phoneNumberHash String // SHA-256 hash for anonymized lookup
isSpam Boolean
label String?
metadata String? // Unbounded JSON
createdAt DateTime @default(now())
@@index([userId])
@@index([phoneNumberHash])
@@index([createdAt])
}
model SpamCallAnalysis {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
callTimestamp DateTime
hiyaReputationScore Float?
truecallerSpamScore Float?
decision SpamDecision
confidence Float
ruleMatches String[] // IDs of matched rules
auditLogs SpamAuditLog[]
createdAt DateTime @default(now())
@@index([userId])
@@index([phoneNumber])
@@index([callTimestamp])
}
model SpamRule {
id String @id @default(uuid())
name String @unique
pattern String // Regex pattern - needs ReDoS validation
decision SpamDecision
description String?
isActive Boolean @default(true)
priority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([priority])
}
model SpamAuditLog {
id String @id @default(uuid())
analysisId String?
analysis SpamCallAnalysis? @relation(fields: [analysisId], references: [id], onDelete: SetNull)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
phoneNumber String
decision SpamDecision
reason String
ruleId String?
createdAt DateTime @default(now())
@@index([userId])
@@index([createdAt])
@@index([decision])
}

View File

@@ -1,4 +1,7 @@
import { PrismaClient } from "@prisma/client";
import { PrismaClient } from '@prisma/client';
import { FieldEncryptionService } from './services/field-encryption.service';
export const prisma = new PrismaClient();
export default prisma;
export { FieldEncryptionService };
export type { PrismaClient };

View File

@@ -0,0 +1,33 @@
import crypto from 'crypto';
const ENCRYPTION_KEY = process.env.PII_ENCRYPTION_KEY || 'default-32-byte-key-for-aes-256';
const IV_LENGTH = 16;
export class FieldEncryptionService {
static encrypt(text: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
return `${iv.toString('base64')}:${encrypted}`;
}
static decrypt(encryptedText: string): string {
const [ivBase64, ciphertext] = encryptedText.split(':');
const iv = Buffer.from(ivBase64, 'base64');
const key = crypto.createHash('sha256').update(ENCRYPTION_KEY).digest();
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
static hashPhoneNumber(phoneNumber: string): string {
return crypto.createHash('sha256').update(phoneNumber).digest('hex');
}
}