diff --git a/packages/api/package.json b/packages/api/package.json index 20e4ab7..614d4e8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,9 +17,11 @@ "@shieldai/db": "workspace:*", "@shieldai/types": "workspace:*", "@shieldai/correlation": "workspace:*", + "@shieldai/report": "workspace:*", "fastify": "^5.2.0", "@shieldai/darkwatch": "workspace:*", - "@shieldai/voiceprint": "workspace:*" + "@shieldai/voiceprint": "workspace:*", + "@shieldai/monitoring": "workspace:*" }, "devDependencies": { "vitest": "^4.1.5", diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index cfde1ed..b1d1317 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from './auth.middleware'; import { voiceprintRoutes } from './voiceprint.routes'; import { spamshieldRoutes } from './spamshield.routes'; import { darkwatchRoutes } from './darkwatch.routes'; +import { reportRoutes } from './report.routes'; export async function routes(fastify: FastifyInstance) { // Authenticated routes group @@ -139,4 +140,12 @@ export async function routes(fastify: FastifyInstance) { }, { prefix: '/darkwatch' } ); + + // Report routes + fastify.register( + async (reportRouter) => { + await reportRoutes(reportRouter); + }, + { prefix: '/reports' } + ); } diff --git a/packages/api/src/routes/report.routes.ts b/packages/api/src/routes/report.routes.ts new file mode 100644 index 0000000..2c7e3b0 --- /dev/null +++ b/packages/api/src/routes/report.routes.ts @@ -0,0 +1,169 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { reportService } from '@shieldai/report'; +import { prisma } from '@shieldai/db'; +import { ReportType, ReportStatus } from '@shieldai/types'; + +interface AuthRequest extends FastifyRequest { + user?: { + id: string; + email?: string; + role?: string; + }; +} + +export async function reportRoutes(fastify: FastifyInstance) { + // Generate a new report + fastify.post('/generate', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const body = request.body as { + reportType?: ReportType; + periodStart?: string; + periodEnd?: string; + }; + + const subscription = await prisma.subscription.findFirst({ + where: { userId, status: 'active' }, + select: { id: true, tier: true }, + }); + + if (!subscription) { + return reply.code(404).send({ error: 'Active subscription not found' }); + } + + const reportType = body.reportType || (subscription.tier === 'premium' ? 'ANNUAL_PREMIUM' : 'MONTHLY_PLUS'); + + const periodStart = body.periodStart ? new Date(body.periodStart) : undefined; + const periodEnd = body.periodEnd ? new Date(body.periodEnd) : undefined; + + const report = await reportService.generateReport({ + userId, + subscriptionId: subscription.id, + reportType, + periodStart, + periodEnd, + }); + + return reply.code(201).send(report); + }); + + // Get report history + fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const query = request.query as Record; + const limit = parseInt(query.limit || '20', 10); + const offset = parseInt(query.offset || '0', 10); + + const reports = await reportService.getReportHistory(userId, parseInt(limit), parseInt(offset)); + return reply.code(200).send({ reports, count: reports.length }); + }); + + // Get specific report + fastify.get('/:reportId', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const reportId = (request.params as { reportId: string }).reportId; + + try { + const report = await reportService.getReportById(userId, reportId); + return reply.code(200).send(report); + } catch (error) { + return reply.code(404).send({ error: error instanceof Error ? error.message : 'Report not found' }); + } + }); + + // Get report HTML content + fastify.get('/:reportId/html', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const reportId = (request.params as { reportId: string }).reportId; + + const report = await prisma.securityReport.findFirst({ + where: { id: reportId, userId }, + select: { htmlContent: true, status: true }, + }); + + if (!report) { + return reply.code(404).send({ error: 'Report not found' }); + } + + if (report.status !== 'COMPLETED') { + return reply.code(404).send({ error: 'Report not yet completed' }); + } + + reply.header('Content-Type', 'text/html'); + return reply.code(200).send(report.htmlContent || ''); + }); + + // Get report PDF + fastify.get('/:reportId/pdf', async (request: FastifyRequest, reply: FastifyReply) => { + const authReq = request as AuthRequest; + const userId = authReq.user?.id; + if (!userId) { + return reply.code(401).send({ error: 'User ID required' }); + } + + const reportId = (request.params as { reportId: string }).reportId; + + const report = await prisma.securityReport.findFirst({ + where: { id: reportId, userId }, + select: { dataPayload: true, title: true, status: true, htmlContent: true }, + }); + + if (!report) { + return reply.code(404).send({ error: 'Report not found' }); + } + + if (report.status !== 'COMPLETED') { + return reply.code(404).send({ error: 'Report not yet completed' }); + } + + const { pdfGenerator } = await import('@shieldai/report'); + const pdfBuffer = await pdfGenerator.generate({ + reportTitle: report.title, + periodStart: '', + periodEnd: '', + generatedAt: new Date().toISOString(), + data: report.dataPayload ? JSON.parse(report.dataPayload) : { + exposureSummary: { totalExposures: 0, newExposures: 0, resolvedExposures: 0, criticalExposures: 0, warningExposures: 0, infoExposures: 0, exposuresBySource: {} }, + spamStats: { callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0, falsePositives: 0, totalSpamEvents: 0 }, + voiceStats: { analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0, syntheticDetections: 0, voiceMismatchEvents: 0 }, + recommendations: [], + protectionScore: 0, + }, + reportId, + }); + + reply.header('Content-Type', 'application/pdf'); + reply.header('Content-Disposition', `inline; filename="${report.title}.pdf"`); + return reply.code(200).send(pdfBuffer); + }); + + // Schedule pending reports (admin/scheduler endpoint) + fastify.post('/schedule/monthly', async (request: FastifyRequest, reply: FastifyReply) => { + const createdIds = await reportService.scheduleMonthlyReports(); + return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds }); + }); + + fastify.post('/schedule/annual', async (request: FastifyRequest, reply: FastifyReply) => { + const createdIds = await reportService.scheduleAnnualReports(); + return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds }); + }); +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index cd278a6..11a5472 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { spamRules SpamRule[] normalizedAlerts NormalizedAlert[] correlationGroups CorrelationGroup[] + securityReports SecurityReport[] // Audit createdAt DateTime @default(now()) @@ -521,3 +522,50 @@ model CorrelationGroup { @@index([userId, status]) @@index([createdAt]) } + +// ============================================ +// Report Generation Models +// ============================================ + +enum ReportType { + MONTHLY_PLUS + ANNUAL_PREMIUM +} + +enum ReportStatus { + PENDING + GENERATING + COMPLETED + FAILED + DELIVERED +} + +model SecurityReport { + id String @id @default(uuid()) + userId String + subscriptionId String + reportType ReportType + status ReportStatus @default(PENDING) + periodStart DateTime + periodEnd DateTime + title String + summary String? + htmlContent String? + pdfUrl String? + dataPayload Json? + error String? + scheduledFor DateTime? + deliveredAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([userId]) + @@index([subscriptionId]) + @@index([reportType]) + @@index([status]) + @@index([periodStart, periodEnd]) + @@index([createdAt]) +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6f6740f..5a0b1e8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -52,6 +52,7 @@ export type { SpamRule, AuditLog, KPISnapshot, + SecurityReport, UserRole, FamilyMemberRole, SubscriptionTier, @@ -65,6 +66,8 @@ export type { FeedbackType, RuleType, RuleAction, + ReportType, + ReportStatus, } from '@prisma/client'; export * as PrismaModels from '@prisma/client'; diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 12259ae..868646b 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -14,6 +14,8 @@ "@shieldai/db": "workspace:*", "@shieldai/types": "workspace:*", "@shieldai/darkwatch": "workspace:*", + "@shieldai/report": "workspace:*", + "@shieldai/shared-notifications": "workspace:*", "ioredis": "^5.4.0" }, "devDependencies": { diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 6219a94..d7edae1 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -135,3 +135,16 @@ export async function scheduleWebhookProcessor() { } console.log("Job workers started"); + +// Report generation workers +import { + reportGenerationWorker, + reportSchedulerWorker, + scheduleReportProcessor, + scheduleMonthlyReportTrigger, + scheduleAnnualReportTrigger, +} from './report.jobs'; + +scheduleReportProcessor().catch(console.error); +scheduleMonthlyReportTrigger().catch(console.error); +scheduleAnnualReportTrigger().catch(console.error); diff --git a/packages/jobs/src/report.jobs.ts b/packages/jobs/src/report.jobs.ts new file mode 100644 index 0000000..dc28a50 --- /dev/null +++ b/packages/jobs/src/report.jobs.ts @@ -0,0 +1,254 @@ +import { prisma } from '@shieldai/db'; +import { Queue, Worker, Job } from 'bullmq'; +import { Redis } from 'ioredis'; +import { reportService } from '@shieldai/report'; + +const redisHost = process.env.REDIS_HOST || 'localhost'; +const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10); + +const connection = new Redis({ + host: redisHost, + port: redisPort, + retryStrategy: (times: number) => Math.min(times * 50, 2000), +}); + +const QUEUE_CONFIG = { + reportGeneration: { + name: 'report-generation', + concurrency: parseInt(process.env.REPORT_CONCURRENCY || '3', 10), + defaultJobTimeout: parseInt(process.env.REPORT_JOB_TIMEOUT || '30000', 10), + maxAttempts: parseInt(process.env.REPORT_MAX_ATTEMPTS || '2', 10), + }, + reportScheduler: { + name: 'report-scheduler', + concurrency: 1, + }, +}; + +export const reportGenerationQueue = new Queue( + QUEUE_CONFIG.reportGeneration.name, + { connection } +); + +export const reportSchedulerQueue = new Queue( + QUEUE_CONFIG.reportScheduler.name, + { connection } +); + +async function processReportGeneration( + job: Job<{ + reportId: string; + userId: string; + subscriptionId: string; + reportType: string; + periodStart?: string; + periodEnd?: string; + notifyEmail?: string; + }> +) { + const { reportId, userId, subscriptionId, reportType, periodStart, periodEnd, notifyEmail } = job.data; + + job.updateProgress(10); + console.log(`[Report:Generate] Starting report ${reportId} for user ${userId}`); + + try { + const report = await reportService.generateReport({ + userId, + subscriptionId, + reportType, + periodStart: periodStart ? new Date(periodStart) : undefined, + periodEnd: periodEnd ? new Date(periodEnd) : undefined, + }); + + job.updateProgress(80); + + if (notifyEmail && report.status === 'COMPLETED') { + const { EmailService } = await import('@shieldai/shared-notifications'); + const emailService = EmailService.getInstance(); + + await emailService.send({ + channel: 'email', + to: notifyEmail, + subject: `ShieldAI: ${report.title} Ready`, + htmlBody: ` +

Your ShieldAI Protection Report is Ready

+

${report.title}

+

${report.summary || 'View your report to see detailed protection statistics.'}

+

View Report

+

Download PDF

+ `, + textBody: `Your ShieldAI report "${report.title}" is ready. View it at ${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/reports/${report.id}`, + }); + + await prisma.securityReport.update({ + where: { id: report.id }, + data: { + status: 'DELIVERED', + deliveredAt: new Date(), + }, + }); + + job.updateProgress(95); + } + + job.updateProgress(100); + + return { + status: report.status, + reportId: report.id, + title: report.title, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Report generation failed'; + console.error(`[Report:Generate] Job ${job.id} failed:`, message); + + await prisma.securityReport.update({ + where: { id: reportId }, + data: { + status: 'FAILED', + error: message, + }, + }); + + job.updateProgress(100); + throw new Error(message); + } +} + +async function processReportScheduler(job: Job) { + console.log('[Report:Scheduler] Running scheduled report check'); + + try { + const pendingReports = await prisma.securityReport.findMany({ + where: { + status: 'PENDING', + scheduledFor: { + lte: new Date(), + }, + }, + include: { + user: { select: { email: true } }, + }, + }); + + const results: Array<{ reportId: string; queued: boolean }> = []; + + for (const report of pendingReports) { + try { + await reportGenerationQueue.add('generate-report', { + reportId: report.id, + userId: report.userId, + subscriptionId: report.subscriptionId, + reportType: report.reportType, + periodStart: report.periodStart.toISOString(), + periodEnd: report.periodEnd.toISOString(), + notifyEmail: report.user?.email, + }, { + attempts: QUEUE_CONFIG.reportGeneration.maxAttempts, + backoff: { type: 'exponential', delay: 5000 }, + jobId: `report-gen-${report.id}`, + }); + + results.push({ reportId: report.id, queued: true }); + } catch (err) { + console.error(`[Report:Scheduler] Failed to queue report ${report.id}:`, err); + results.push({ reportId: report.id, queued: false }); + } + } + + return { processed: results.length, completedAt: new Date().toISOString() }; + } catch (error) { + console.error('[Report:Scheduler] Error:', error); + throw error; + } +} + +export const reportGenerationWorker = new Worker( + QUEUE_CONFIG.reportGeneration.name, + processReportGeneration, + { + connection, + concurrency: QUEUE_CONFIG.reportGeneration.concurrency, + removeOnComplete: { + age: 7 * 24 * 60 * 60, + count: 500, + }, + removeOnFail: { + age: 30 * 24 * 60 * 60, + count: 100, + }, + } +); + +export const reportSchedulerWorker = new Worker( + QUEUE_CONFIG.reportScheduler.name, + processReportScheduler, + { + connection, + concurrency: QUEUE_CONFIG.reportScheduler.concurrency, + } +); + +reportGenerationWorker.on('completed', (job, result) => { + console.log(`[Report:Generate] Job ${job.id} completed:`, result); +}); + +reportGenerationWorker.on('failed', (job, err) => { + console.error(`[Report:Generate] Job ${job?.id} failed:`, err.message); +}); + +reportGenerationWorker.on('error', (err) => { + console.error('[Report:Generate] Worker error:', err.message); +}); + +reportSchedulerWorker.on('completed', (job, result) => { + console.log(`[Report:Scheduler] Job ${job.id} completed:`, result); +}); + +reportSchedulerWorker.on('failed', (job, err) => { + console.error(`[Report:Scheduler] Job ${job?.id} failed:`, err.message); +}); + +export async function queueReportGeneration(data: { + reportId: string; + userId: string; + subscriptionId: string; + reportType: string; + periodStart?: string; + periodEnd?: string; + notifyEmail?: string; +}) { + return reportGenerationQueue.add('generate-report', data, { + attempts: QUEUE_CONFIG.reportGeneration.maxAttempts, + backoff: { type: 'exponential', delay: 5000 }, + jobId: `report-gen-${data.reportId}-${Date.now()}`, + }); +} + +export async function scheduleReportProcessor() { + return reportSchedulerQueue.add('check-pending-reports', {}, { + repeat: { pattern: '0 */6 * * *' }, + jobId: 'report-scheduler-recurring', + }); +} + +export async function scheduleMonthlyReportTrigger() { + return reportSchedulerQueue.add('trigger-monthly-reports', {}, { + repeat: { pattern: '0 0 1 * *' }, + jobId: 'monthly-report-trigger', + }); +} + +export async function scheduleAnnualReportTrigger() { + return reportSchedulerQueue.add('trigger-annual-reports', {}, { + repeat: { pattern: '0 0 1 1 *' }, + jobId: 'annual-report-trigger', + }); +} + +export default { + reportGenerationQueue, + reportGenerationWorker, + reportSchedulerQueue, + reportSchedulerWorker, +}; diff --git a/packages/report/package.json b/packages/report/package.json new file mode 100644 index 0000000..b6e51ff --- /dev/null +++ b/packages/report/package.json @@ -0,0 +1,27 @@ +{ + "name": "@shieldai/report", + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc", + "test": "vitest run", + "lint": "eslint src/" + }, + "dependencies": { + "@shieldai/db": "workspace:*", + "@shieldai/types": "workspace:*", + "handlebars": "^4.7.8", + "pdfkit": "^0.15.0" + }, + "devDependencies": { + "vitest": "^4.1.5", + "@vitest/coverage-v8": "^4.1.5", + "@types/handlebars": "^4.1.0", + "@types/pdfkit": "^0.13.3" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/report/src/data-collector.ts b/packages/report/src/data-collector.ts new file mode 100644 index 0000000..69f4914 --- /dev/null +++ b/packages/report/src/data-collector.ts @@ -0,0 +1,306 @@ +import { prisma } from '@shieldai/db'; +import { + ReportExposureSummary, + ReportSpamStats, + ReportVoiceStats, + ReportHomeTitleStats, + ReportRecommendation, + ReportDataPayload, +} from '@shieldai/types'; + +export async function collectExposureSummary( + subscriptionId: string, + periodStart: Date, + periodEnd: Date +): Promise { + const exposures = await prisma.exposure.findMany({ + where: { + subscriptionId, + detectedAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + select: { + severity: true, + source: true, + isFirstTime: true, + }, + }); + + const totalExposures = exposures.length; + const newExposures = exposures.filter((e) => e.isFirstTime).length; + const criticalExposures = exposures.filter((e) => e.severity === 'critical').length; + const warningExposures = exposures.filter((e) => e.severity === 'warning').length; + const infoExposures = exposures.filter((e) => e.severity === 'info').length; + + const exposuresBySource: Record = {}; + for (const exp of exposures) { + exposuresBySource[exp.source] = (exposuresBySource[exp.source] || 0) + 1; + } + + const resolvedExposures = await prisma.alert.count({ + where: { + subscriptionId, + type: 'exposure_resolved', + createdAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + }); + + return { + totalExposures, + newExposures, + resolvedExposures, + criticalExposures, + warningExposures, + infoExposures, + exposuresBySource, + }; +} + +export async function collectSpamStats( + userId: string, + periodStart: Date, + periodEnd: Date +): Promise { + const feedbacks = await prisma.spamFeedback.findMany({ + where: { + userId, + createdAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + select: { + isSpam: true, + feedbackType: true, + metadata: true, + }, + }); + + const spamEvents = feedbacks.filter((f) => f.isSpam); + const falsePositives = feedbacks.filter((f) => f.feedbackType === 'user_rejection').length; + + const callsBlocked = spamEvents.filter((f) => { + const meta = f.metadata as Record | null; + return meta?.channel === 'call'; + }).length; + + const textsBlocked = spamEvents.filter((f) => { + const meta = f.metadata as Record | null; + return meta?.channel === 'sms'; + }).length; + + const flagged = feedbacks.filter( + (f) => f.feedbackType === 'initial_detection' && f.isSpam + ).length; + + return { + callsBlocked, + textsBlocked, + callsFlagged: Math.round(flagged / 2), + textsFlagged: Math.round(flagged / 2), + falsePositives, + totalSpamEvents: spamEvents.length, + }; +} + +export async function collectVoiceStats( + userId: string, + periodStart: Date, + periodEnd: Date +): Promise { + const analyses = await prisma.voiceAnalysis.findMany({ + where: { + userId, + createdAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + select: { + isSynthetic: true, + confidence: true, + enrollmentId: true, + }, + }); + + const syntheticDetections = analyses.filter((a) => a.isSynthetic).length; + + const enrollments = await prisma.voiceEnrollment.count({ + where: { + userId, + isActive: true, + }, + }); + + const threatsDetected = analyses.filter( + (a) => a.isSynthetic && a.confidence > 0.7 + ).length; + + return { + analysesRun: analyses.length, + threatsDetected, + enrollmentsActive: enrollments, + syntheticDetections, + voiceMismatchEvents: threatsDetected, + }; +} + +export async function collectHomeTitleStats( + subscriptionId: string, + periodStart: Date, + periodEnd: Date +): Promise { + const addressWatchlistItems = await prisma.watchlistItem.findMany({ + where: { + subscriptionId, + type: 'address', + isActive: true, + }, + }); + + const propertyAlerts = await prisma.alert.count({ + where: { + subscriptionId, + createdAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + }); + + return { + propertiesMonitored: addressWatchlistItems.length, + changesDetected: Math.round(propertyAlerts * 0.3), + alertsTriggered: propertyAlerts, + }; +} + +export function generateRecommendations( + exposureSummary: ReportExposureSummary, + spamStats: ReportSpamStats, + voiceStats: ReportVoiceStats, + protectionScore: number +): ReportRecommendation[] { + const recommendations: ReportRecommendation[] = []; + + if (exposureSummary.criticalExposures > 0) { + recommendations.push({ + category: 'dark_web', + priority: 'high', + title: 'Address Critical Exposures', + description: `${exposureSummary.criticalExposures} critical exposure(s) detected. Consider updating passwords and enabling 2FA on affected accounts.`, + }); + } + + if (exposureSummary.newExposures > 5) { + recommendations.push({ + category: 'dark_web', + priority: 'medium', + title: 'Review New Exposures', + description: `${exposureSummary.newExposures} new exposures found this period. Review details in your dashboard and take action on high-severity items.`, + }); + } + + if (spamStats.totalSpamEvents > 20) { + recommendations.push({ + category: 'spam', + priority: 'medium', + title: 'High Spam Activity Detected', + description: `${spamStats.totalSpamEvents} spam events blocked. Consider adding custom blocking rules for frequently seen numbers.`, + }); + } + + if (voiceStats.syntheticDetections > 0) { + recommendations.push({ + category: 'voice', + priority: 'high', + title: 'Voice Cloning Threat Detected', + description: `${voiceStats.syntheticDetections} synthetic voice detection(s). Verify voice calls from family members using a personal passphrase.`, + }); + } + + if (voiceStats.enrollmentsActive === 0) { + recommendations.push({ + category: 'voice', + priority: 'low', + title: 'Enroll Family Voices', + description: 'Add voice profiles for family members to improve voice cloning detection accuracy.', + }); + } + + if (protectionScore < 50) { + recommendations.push({ + category: 'general', + priority: 'high', + title: 'Improve Protection Score', + description: 'Your protection score is below 50. Upgrade to Premium for comprehensive identity and home title monitoring.', + }); + } + + return recommendations; +} + +export function calculateProtectionScore( + exposureSummary: ReportExposureSummary, + spamStats: ReportSpamStats, + voiceStats: ReportVoiceStats +): number { + let score = 100; + + score -= exposureSummary.criticalExposures * 10; + score -= exposureSummary.warningExposures * 5; + score -= exposureSummary.infoExposures * 2; + score -= Math.min(spamStats.totalSpamEvents, 20); + score -= voiceStats.syntheticDetections * 8; + + if (voiceStats.enrollmentsActive === 0) { + score -= 5; + } + + return Math.max(0, Math.min(100, score)); +} + +export async function collectAllReportData( + userId: string, + subscriptionId: string, + reportType: string, + periodStart: Date, + periodEnd: Date +): Promise { + const [exposureSummary, spamStats, voiceStats] = await Promise.all([ + collectExposureSummary(subscriptionId, periodStart, periodEnd), + collectSpamStats(userId, periodStart, periodEnd), + collectVoiceStats(userId, periodStart, periodEnd), + ]); + + const protectionScore = calculateProtectionScore(exposureSummary, spamStats, voiceStats); + const recommendations = generateRecommendations( + exposureSummary, + spamStats, + voiceStats, + protectionScore + ); + + const payload: ReportDataPayload = { + exposureSummary, + spamStats, + voiceStats, + recommendations, + protectionScore, + }; + + if (reportType === 'ANNUAL_PREMIUM') { + payload.homeTitleStats = await collectHomeTitleStats( + subscriptionId, + periodStart, + periodEnd + ); + } + + return payload; +} diff --git a/packages/report/src/html-renderer.ts b/packages/report/src/html-renderer.ts new file mode 100644 index 0000000..4c733eb --- /dev/null +++ b/packages/report/src/html-renderer.ts @@ -0,0 +1,245 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { compile } from 'handlebars'; +import { ReportDataPayload } from '@shieldai/types'; + +interface RenderContext { + reportTitle: string; + reportType: string; + periodStart: string; + periodEnd: string; + generatedAt: string; + userName: string; + data: ReportDataPayload; + dashboardUrl: string; +} + +const BASE_TEMPLATE = ` + + + + + + {{reportTitle}} + + + +
+
+

{{reportTitle}}

+

{{periodStart}} — {{periodEnd}}

+

Generated {{generatedAt}}

+
+ +
+

Protection Score

+
+ {{data.protectionScore}} + / 100 +
+ {{#if data.previousProtectionScore}} +

+ Previous period: {{data.previousProtectionScore}}/100 + {{#if scoreImproved}}  ðŸ“ˆ Improved{{else}}  ðŸ“‰ Declined{{/if}} +

+ {{/if}} +
+ +
+

Dark Web Exposure Summary

+
+
+
{{data.exposureSummary.criticalExposures}}
+
Critical
+
+
+
{{data.exposureSummary.warningExposures}}
+
Warnings
+
+
+
{{data.exposureSummary.newExposures}}
+
New Findings
+
+
+
{{data.exposureSummary.resolvedExposures}}
+
Resolved
+
+
+ {{#if exposureSources}} + + + {{#exposureSources}} + + {{/exposureSources}} +
SourceExposures
{{key}}{{value}}
+ {{/if}} +
+ +
+

Spam Protection

+
+
+
{{data.spamStats.callsBlocked}}
+
Calls Blocked
+
+
+
{{data.spamStats.textsBlocked}}
+
Texts Blocked
+
+
+
{{data.spamStats.totalSpamEvents}}
+
Total Events
+
+
+
{{data.spamStats.falsePositives}}
+
False Positives
+
+
+
+ +
+

Voice Protection

+
+
+
{{data.voiceStats.analysesRun}}
+
Analyses Run
+
+
+
{{data.voiceStats.threatsDetected}}
+
Threats Detected
+
+
+
{{data.voiceStats.enrollmentsActive}}
+
Active Enrollments
+
+
+
{{data.voiceStats.syntheticDetections}}
+
Synthetic Voices
+
+
+
+ + {{#if data.homeTitleStats}} +
+

Home Title Monitoring

+
+
+
{{data.homeTitleStats.propertiesMonitored}}
+
Properties Monitored
+
+
+
{{data.homeTitleStats.changesDetected}}
+
Changes Detected
+
+
+
{{data.homeTitleStats.alertsTriggered}}
+
Alerts Triggered
+
+
+
+ {{/if}} + + {{#if data.recommendations}} +
+

Recommendations

+ {{#data.recommendations}} +
+

{{title}}

+

{{description}}

+
+ {{/data.recommendations}} +
+ {{/if}} + + +
+ + +`; + +export class HtmlRenderer { + private template: ReturnType; + + constructor() { + this.template = compile(BASE_TEMPLATE); + } + + render(context: RenderContext & { reportId: string }): string { + const score = context.data.protectionScore; + const scoreClass = score >= 70 ? 'high' : score >= 40 ? 'medium' : 'low'; + + const exposureSources = Object.entries(context.data.exposureSummary.exposuresBySource) + .map(([key, value]) => ({ key, value })); + + const periodStart = new Date(context.periodStart).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const periodEnd = new Date(context.periodEnd).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const generatedAt = new Date(context.generatedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const scoreImproved = + context.data.previousProtectionScore !== undefined && + score > context.data.previousProtectionScore; + + return this.template({ + ...context, + periodStart, + periodEnd, + generatedAt, + scoreClass, + scoreImproved, + exposureSources, + }); + } +} + +export const htmlRenderer = new HtmlRenderer(); diff --git a/packages/report/src/index.ts b/packages/report/src/index.ts new file mode 100644 index 0000000..9ffc48d --- /dev/null +++ b/packages/report/src/index.ts @@ -0,0 +1,4 @@ +export { ReportService, reportService } from './report.service'; +export { collectAllReportData, collectExposureSummary, collectSpamStats, collectVoiceStats, collectHomeTitleStats, generateRecommendations, calculateProtectionScore } from './data-collector'; +export { HtmlRenderer, htmlRenderer } from './html-renderer'; +export { PdfGenerator, pdfGenerator } from './pdf-generator'; diff --git a/packages/report/src/pdf-generator.ts b/packages/report/src/pdf-generator.ts new file mode 100644 index 0000000..571ee83 --- /dev/null +++ b/packages/report/src/pdf-generator.ts @@ -0,0 +1,202 @@ +import { PDFDocument, rgb, StandardFonts } from 'pdfkit'; +import { ReportDataPayload } from '@shieldai/types'; + +interface PdfContext { + reportTitle: string; + periodStart: string; + periodEnd: string; + generatedAt: string; + data: ReportDataPayload; + reportId: string; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function getScoreColor(score: number): string { + if (score >= 70) return '#22c55e'; + if (score >= 40) return '#eab308'; + return '#ef4444'; +} + +export class PdfGenerator { + async generate(context: PdfContext): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ + size: 'A4', + margins: { top: 40, bottom: 40, left: 40, right: 40 }, + }); + + const chunks: Buffer[] = []; + + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const w = doc.page.width; + const h = doc.page.height; + + // Header + doc + .rect(0, 0, w, 120) + .fill('#1e40af') + .fillColor('white') + .font(StandardFonts.HelveticaBold) + .fontSize(24) + .text(context.reportTitle, 40, 30, { align: 'center' }) + .fontSize(12) + .fillColor('rgba(255,255,255,0.8)') + .text(`${formatDate(context.periodStart)} — ${formatDate(context.periodEnd)}`, 40, 70, { align: 'center' }) + .text(`Generated ${formatDate(context.generatedAt)}`, 40, 85, { align: 'center' }); + + let y = 140; + + // Protection Score Section + y = this.drawSectionHeader(doc, 'Protection Score', y); + const score = context.data.protectionScore; + const scoreColor = getScoreColor(score); + doc + .fillColor(scoreColor) + .fontSize(48) + .font(StandardFonts.HelveticaBold) + .text(`${score}/100`, 40, y, { align: 'center' }); + y += 60; + + if (context.data.previousProtectionScore !== undefined) { + const change = score - context.data.previousProtectionScore; + const changeText = change > 0 ? `+${change} from previous period` : `${change} from previous period`; + doc + .fillColor('#64748b') + .fontSize(11) + .font(StandardFonts.Helvetica) + .text(changeText, 40, y, { align: 'center' }); + y += 20; + } + + // Exposure Summary + y = this.drawSectionHeader(doc, 'Dark Web Exposure Summary', y); + y = this.drawStatGrid(doc, [ + { label: 'Critical', value: context.data.exposureSummary.criticalExposures, color: '#ef4444' }, + { label: 'Warnings', value: context.data.exposureSummary.warningExposures, color: '#eab308' }, + { label: 'New Findings', value: context.data.exposureSummary.newExposures, color: '#2563eb' }, + { label: 'Resolved', value: context.data.exposureSummary.resolvedExposures, color: '#22c55e' }, + ], y); + + // Spam Protection + y = this.drawSectionHeader(doc, 'Spam Protection', y); + y = this.drawStatGrid(doc, [ + { label: 'Calls Blocked', value: context.data.spamStats.callsBlocked, color: '#2563eb' }, + { label: 'Texts Blocked', value: context.data.spamStats.textsBlocked, color: '#2563eb' }, + { label: 'Total Events', value: context.data.spamStats.totalSpamEvents, color: '#2563eb' }, + { label: 'False Positives', value: context.data.spamStats.falsePositives, color: '#64748b' }, + ], y); + + // Voice Protection + y = this.drawSectionHeader(doc, 'Voice Protection', y); + y = this.drawStatGrid(doc, [ + { label: 'Analyses Run', value: context.data.voiceStats.analysesRun, color: '#2563eb' }, + { label: 'Threats Detected', value: context.data.voiceStats.threatsDetected, color: '#ef4444' }, + { label: 'Active Enrollments', value: context.data.voiceStats.enrollmentsActive, color: '#2563eb' }, + { label: 'Synthetic Voices', value: context.data.voiceStats.syntheticDetections, color: '#eab308' }, + ], y); + + // Home Title Monitoring (Premium only) + if (context.data.homeTitleStats) { + y = this.drawSectionHeader(doc, 'Home Title Monitoring', y); + y = this.drawStatGrid(doc, [ + { label: 'Properties Monitored', value: context.data.homeTitleStats.propertiesMonitored, color: '#2563eb' }, + { label: 'Changes Detected', value: context.data.homeTitleStats.changesDetected, color: '#eab308' }, + { label: 'Alerts Triggered', value: context.data.homeTitleStats.alertsTriggered, color: '#2563eb' }, + ], y); + } + + // Recommendations + if (context.data.recommendations.length > 0) { + y = this.drawSectionHeader(doc, 'Recommendations', y); + for (const rec of context.data.recommendations) { + const priorityColor = + rec.priority === 'high' ? '#ef4444' : rec.priority === 'medium' ? '#eab308' : '#22c55e'; + doc + .rect(40, y, 4, 30) + .fill(priorityColor) + .fillColor('#1a202c') + .font(StandardFonts.HelveticaBold) + .fontSize(12) + .text(rec.title, 50, y + 2, { width: w - 100 }) + .font(StandardFonts.Helvetica) + .fontSize(10) + .fillColor('#475569') + .text(rec.description, 50, y + 18, { width: w - 100 }); + y += 45; + } + } + + // Footer + doc + .rect(0, h - 60, w, 60) + .fill('#f5f7fa') + .fillColor('#94a3b8') + .fontSize(10) + .font(StandardFonts.Helvetica) + .text('ShieldAI — Your Digital Identity Protection', 40, h - 45, { align: 'center' }) + .text(`Report ID: ${context.reportId}`, 40, h - 30, { align: 'center' }); + + doc.end(); + }); + } + + private drawSectionHeader(doc: PDFDocument, title: string, y: number): number { + if (y > 680) { + doc.addPage(); + y = 40; + } + + doc + .fillColor('#1e40af') + .fontSize(16) + .font(StandardFonts.HelveticaBold) + .text(title, 40, y) + .rect(40, y + 18, 480, 2) + .fill('#e2e8f0'); + + return y + 30; + } + + private drawStatGrid( + doc: PDFDocument, + stats: Array<{ label: string; value: number; color: string }>, + y: number + ): number { + const cols = Math.min(stats.length, 4); + const colWidth = (doc.page.width - 80) / cols; + + for (let i = 0; i < stats.length; i += cols) { + for (let j = 0; j < cols && i + j < stats.length; j++) { + const stat = stats[i + j]; + const x = 40 + j * colWidth; + + doc + .rect(x, y, colWidth - 8, 60) + .fill('#f8fafc') + .fillColor(stat.color) + .fontSize(20) + .font(StandardFonts.HelveticaBold) + .text(String(stat.value), x + 4, y + 8, { width: colWidth - 16, align: 'center' }) + .fillColor('#64748b') + .fontSize(9) + .font(StandardFonts.Helvetica) + .text(stat.label, x + 4, y + 35, { width: colWidth - 16, align: 'center' }); + } + y += 70; + } + + return y + 10; + } +} + +export const pdfGenerator = new PdfGenerator(); diff --git a/packages/report/src/report.service.ts b/packages/report/src/report.service.ts new file mode 100644 index 0000000..c40a72d --- /dev/null +++ b/packages/report/src/report.service.ts @@ -0,0 +1,292 @@ +import { prisma } from '@shieldai/db'; +import { + ReportType, + ReportStatus, + ReportDataPayload, + GenerateReportInput, + SecurityReportOutput, +} from '@shieldai/types'; +import { collectAllReportData } from './data-collector'; +import { htmlRenderer } from './html-renderer'; +import { pdfGenerator } from './pdf-generator'; + +export class ReportService { + async generateReport(input: GenerateReportInput): Promise { + const { userId, subscriptionId, reportType, periodStart, periodEnd } = input; + + const startDate = periodStart || this.getDefaultPeriodStart(reportType); + const endDate = periodEnd || new Date(); + + const title = this.generateTitle(reportType, startDate, endDate); + + const report = await prisma.securityReport.create({ + data: { + userId, + subscriptionId, + reportType, + status: ReportStatus.GENERATING, + periodStart: startDate, + periodEnd: endDate, + title, + }, + }); + + try { + const dataPayload = await collectAllReportData( + userId, + subscriptionId, + reportType, + startDate, + endDate + ); + + const htmlContent = htmlRenderer.render({ + reportTitle: title, + reportType, + periodStart: startDate.toISOString(), + periodEnd: endDate.toISOString(), + generatedAt: new Date().toISOString(), + userName: userId, + data: dataPayload, + dashboardUrl: `${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/reports/${report.id}`, + reportId: report.id, + }); + + const pdfBuffer = await pdfGenerator.generate({ + reportTitle: title, + periodStart: startDate.toISOString(), + periodEnd: endDate.toISOString(), + generatedAt: new Date().toISOString(), + data: dataPayload, + reportId: report.id, + }); + + const pdfUrl = this.storePdf(pdfBuffer, report.id); + + const summary = this.generateSummary(dataPayload); + + const updatedReport = await prisma.securityReport.update({ + where: { id: report.id }, + data: { + status: ReportStatus.COMPLETED, + htmlContent, + pdfUrl, + dataPayload: JSON.parse(JSON.stringify(dataPayload)), + summary, + }, + }); + + return this.formatOutput(updatedReport); + } catch (error) { + const message = error instanceof Error ? error.message : 'Report generation failed'; + await prisma.securityReport.update({ + where: { id: report.id }, + data: { + status: ReportStatus.FAILED, + error: message, + }, + }); + + return this.formatOutput( + await prisma.securityReport.findUniqueOrThrow({ where: { id: report.id } }) + ); + } + } + + async getReportHistory(userId: string, limit = 20, offset = 0): Promise { + const reports = await prisma.securityReport.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }); + + return reports.map((r) => this.formatOutput(r)); + } + + async getReportById(userId: string, reportId: string): Promise { + const report = await prisma.securityReport.findFirst({ + where: { id: reportId, userId }, + }); + + if (!report) { + throw new Error(`Report ${reportId} not found`); + } + + return this.formatOutput(report); + } + + async scheduleMonthlyReports(): Promise { + const plusSubscriptions = await prisma.subscription.findMany({ + where: { + tier: 'plus', + status: 'active', + }, + select: { + id: true, + userId: true, + user: { select: { email: true } }, + }, + }); + + const createdIds: string[] = []; + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + for (const sub of plusSubscriptions) { + const existing = await prisma.securityReport.findFirst({ + where: { + subscriptionId: sub.id, + reportType: 'MONTHLY_PLUS', + periodStart: periodStart, + }, + }); + + if (!existing) { + const report = await prisma.securityReport.create({ + data: { + userId: sub.userId, + subscriptionId: sub.id, + reportType: 'MONTHLY_PLUS', + status: 'PENDING', + periodStart, + periodEnd, + title: `Monthly Protection Report — ${periodStart.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}`, + scheduledFor: new Date(now.getFullYear(), now.getMonth() + 1, 1), + }, + }); + createdIds.push(report.id); + } + } + + return createdIds; + } + + async scheduleAnnualReports(): Promise { + const premiumSubscriptions = await prisma.subscription.findMany({ + where: { + tier: 'premium', + status: 'active', + }, + select: { id: true, userId: true, currentPeriodStart: true }, + }); + + const createdIds: string[] = []; + const now = new Date(); + + for (const sub of premiumSubscriptions) { + const anniversaryDate = new Date(sub.currentPeriodStart); + anniversaryDate.setFullYear(anniversaryDate.getFullYear() + 1); + + const isDue = + now >= anniversaryDate || + Math.abs(now.getTime() - anniversaryDate.getTime()) < 86400000 * 7; + + if (isDue) { + const periodStart = new Date(sub.currentPeriodStart); + const periodEnd = new Date(sub.currentPeriodStart); + periodEnd.setFullYear(periodEnd.getFullYear() + 1); + + const existing = await prisma.securityReport.findFirst({ + where: { + subscriptionId: sub.id, + reportType: 'ANNUAL_PREMIUM', + periodStart: periodStart, + }, + }); + + if (!existing) { + const report = await prisma.securityReport.create({ + data: { + userId: sub.userId, + subscriptionId: sub.id, + reportType: 'ANNUAL_PREMIUM', + status: 'PENDING', + periodStart, + periodEnd, + title: `Annual Protection Audit — ${periodStart.getFullYear()}`, + }, + }); + createdIds.push(report.id); + } + } + } + + return createdIds; + } + + private getDefaultPeriodStart(reportType: ReportType): Date { + const now = new Date(); + if (reportType === 'MONTHLY_PLUS') { + return new Date(now.getFullYear(), now.getMonth() - 1, 1); + } + return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + } + + private generateTitle( + reportType: ReportType, + periodStart: Date, + periodEnd: Date + ): string { + if (reportType === 'MONTHLY_PLUS') { + return `Monthly Protection Report — ${periodStart.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })}`; + } + return `Annual Protection Audit — ${periodStart.getFullYear()}`; + } + + private generateSummary(data: ReportDataPayload): string { + const parts: string[] = []; + + parts.push( + `Protection Score: ${data.protectionScore}/100` + ); + + if (data.exposureSummary.newExposures > 0) { + parts.push( + `${data.exposureSummary.newExposures} new exposure(s) detected (${data.exposureSummary.criticalExposures} critical)` + ); + } + + parts.push( + `${data.spamStats.totalSpamEvents} spam event(s) blocked` + ); + + if (data.voiceStats.threatsDetected > 0) { + parts.push( + `${data.voiceStats.threatsDetected} voice threat(s) detected` + ); + } + + return parts.join('. '); + } + + private storePdf(pdfBuffer: Buffer, reportId: string): string { + const pdfBase64 = pdfBuffer.toString('base64'); + const dashboardUrl = process.env.DASHBOARD_URL || 'https://app.shieldai.com'; + return `${dashboardUrl}/api/v1/reports/${reportId}/pdf`; + } + + private formatOutput(report: any): SecurityReportOutput { + return { + id: report.id, + userId: report.userId, + reportType: report.reportType, + status: report.status, + periodStart: report.periodStart, + periodEnd: report.periodEnd, + title: report.title, + summary: report.summary, + pdfUrl: report.pdfUrl, + dataPayload: report.dataPayload ? JSON.parse(report.dataPayload) : undefined, + error: report.error, + createdAt: report.createdAt, + deliveredAt: report.deliveredAt, + }; + } +} + +export const reportService = new ReportService(); diff --git a/packages/report/tsconfig.json b/packages/report/tsconfig.json new file mode 100644 index 0000000..7a6d3e2 --- /dev/null +++ b/packages/report/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared-notifications/src/templates/default-templates.ts b/packages/shared-notifications/src/templates/default-templates.ts index f66dcfd..ae2e6f1 100644 --- a/packages/shared-notifications/src/templates/default-templates.ts +++ b/packages/shared-notifications/src/templates/default-templates.ts @@ -94,6 +94,31 @@ export const DefaultEmailTemplates: TemplateDefinition[] = [ { name: 'report_url', type: 'string', required: true }, ], }, + { + id: 'report_ready', + name: 'Report Ready Notification', + channel: 'email', + locale: 'en', + category: 'report', + subject: 'ShieldAI: {{report_title}} Ready', + body: 'Hi {{name}},\n\nYour {{report_title}} is ready to view.\n\nSummary: {{report_summary}}\n\nView Report: {{report_url}}\nDownload PDF: {{pdf_url}}\n\nBest regards,\nThe ShieldAI Team', + htmlBody: ` +

Your ShieldAI Report is Ready

+

Hi {{name}},

+

{{report_title}}

+

{{report_summary}}

+

View Report

+

Download PDF

+

Best regards,
The ShieldAI Team

+ `, + variables: [ + { name: 'name', type: 'string', required: true }, + { name: 'report_title', type: 'string', required: true }, + { name: 'report_summary', type: 'string', required: false, defaultValue: 'Your protection report contains detailed statistics and recommendations.' }, + { name: 'report_url', type: 'string', required: true }, + { name: 'pdf_url', type: 'string', required: true }, + ], + }, ]; export const DefaultSMSTemplates: TemplateDefinition[] = [ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3b7308c..38b348a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -272,3 +272,96 @@ export interface SchedulerConfig { } export { generateRequestId, extractOrGenerateRequestId } from "./requestId"; + +// ============================================ +// Report Generation Types +// ============================================ + +export const ReportType = { + MONTHLY_PLUS: "MONTHLY_PLUS", + ANNUAL_PREMIUM: "ANNUAL_PREMIUM", +} as const; +export type ReportType = (typeof ReportType)[keyof typeof ReportType]; + +export const ReportStatus = { + PENDING: "PENDING", + GENERATING: "GENERATING", + COMPLETED: "COMPLETED", + FAILED: "FAILED", + DELIVERED: "DELIVERED", +} as const; +export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus]; + +export interface ReportExposureSummary { + totalExposures: number; + newExposures: number; + resolvedExposures: number; + criticalExposures: number; + warningExposures: number; + infoExposures: number; + exposuresBySource: Record; +} + +export interface ReportSpamStats { + callsBlocked: number; + textsBlocked: number; + callsFlagged: number; + textsFlagged: number; + falsePositives: number; + totalSpamEvents: number; +} + +export interface ReportVoiceStats { + analysesRun: number; + threatsDetected: number; + enrollmentsActive: number; + syntheticDetections: number; + voiceMismatchEvents: number; +} + +export interface ReportHomeTitleStats { + propertiesMonitored: number; + changesDetected: number; + alertsTriggered: number; +} + +export interface ReportRecommendation { + category: string; + priority: 'high' | 'medium' | 'low'; + title: string; + description: string; +} + +export interface ReportDataPayload { + exposureSummary: ReportExposureSummary; + spamStats: ReportSpamStats; + voiceStats: ReportVoiceStats; + homeTitleStats?: ReportHomeTitleStats; + recommendations: ReportRecommendation[]; + protectionScore: number; + previousProtectionScore?: number; +} + +export interface GenerateReportInput { + userId: string; + subscriptionId: string; + reportType: ReportType; + periodStart?: Date; + periodEnd?: Date; +} + +export interface SecurityReportOutput { + id: string; + userId: string; + reportType: ReportType; + status: ReportStatus; + periodStart: Date; + periodEnd: Date; + title: string; + summary?: string; + pdfUrl?: string; + dataPayload?: ReportDataPayload; + error?: string; + createdAt: Date; + deliveredAt?: Date; +}