Add Protection Report Generator with HTML/PDF output and scheduled delivery (FRE-4575)
- Report service: data collection from all three engines, HTML rendering (Handlebars), PDF generation (pdfkit) - REST API: /reports endpoints for generate, history, view, PDF download, scheduling - BullMQ workers: queued report generation with retry, monthly/annual scheduler triggers - DB: SecurityReport model with Prisma schema and type exports - Email: report_ready template in shared-notifications - All dependencies wired through existing packages Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -17,9 +17,11 @@
|
|||||||
"@shieldai/db": "workspace:*",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "workspace:*",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
|
"@shieldai/report": "workspace:*",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"@shieldai/voiceprint": "workspace:*"
|
"@shieldai/voiceprint": "workspace:*",
|
||||||
|
"@shieldai/monitoring": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from './auth.middleware';
|
|||||||
import { voiceprintRoutes } from './voiceprint.routes';
|
import { voiceprintRoutes } from './voiceprint.routes';
|
||||||
import { spamshieldRoutes } from './spamshield.routes';
|
import { spamshieldRoutes } from './spamshield.routes';
|
||||||
import { darkwatchRoutes } from './darkwatch.routes';
|
import { darkwatchRoutes } from './darkwatch.routes';
|
||||||
|
import { reportRoutes } from './report.routes';
|
||||||
|
|
||||||
export async function routes(fastify: FastifyInstance) {
|
export async function routes(fastify: FastifyInstance) {
|
||||||
// Authenticated routes group
|
// Authenticated routes group
|
||||||
@@ -139,4 +140,12 @@ export async function routes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
{ prefix: '/darkwatch' }
|
{ prefix: '/darkwatch' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Report routes
|
||||||
|
fastify.register(
|
||||||
|
async (reportRouter) => {
|
||||||
|
await reportRoutes(reportRouter);
|
||||||
|
},
|
||||||
|
{ prefix: '/reports' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
169
packages/api/src/routes/report.routes.ts
Normal file
169
packages/api/src/routes/report.routes.ts
Normal file
@@ -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<string, string>;
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ model User {
|
|||||||
spamRules SpamRule[]
|
spamRules SpamRule[]
|
||||||
normalizedAlerts NormalizedAlert[]
|
normalizedAlerts NormalizedAlert[]
|
||||||
correlationGroups CorrelationGroup[]
|
correlationGroups CorrelationGroup[]
|
||||||
|
securityReports SecurityReport[]
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -521,3 +522,50 @@ model CorrelationGroup {
|
|||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type {
|
|||||||
SpamRule,
|
SpamRule,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
KPISnapshot,
|
KPISnapshot,
|
||||||
|
SecurityReport,
|
||||||
UserRole,
|
UserRole,
|
||||||
FamilyMemberRole,
|
FamilyMemberRole,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
@@ -65,6 +66,8 @@ export type {
|
|||||||
FeedbackType,
|
FeedbackType,
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleAction,
|
RuleAction,
|
||||||
|
ReportType,
|
||||||
|
ReportStatus,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
export * as PrismaModels from '@prisma/client';
|
export * as PrismaModels from '@prisma/client';
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@shieldai/db": "workspace:*",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "workspace:*",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
|
"@shieldai/report": "workspace:*",
|
||||||
|
"@shieldai/shared-notifications": "workspace:*",
|
||||||
"ioredis": "^5.4.0"
|
"ioredis": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -135,3 +135,16 @@ export async function scheduleWebhookProcessor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Job workers started");
|
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);
|
||||||
|
|||||||
254
packages/jobs/src/report.jobs.ts
Normal file
254
packages/jobs/src/report.jobs.ts
Normal file
@@ -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: `
|
||||||
|
<h2>Your ShieldAI Protection Report is Ready</h2>
|
||||||
|
<p><strong>${report.title}</strong></p>
|
||||||
|
<p>${report.summary || 'View your report to see detailed protection statistics.'}</p>
|
||||||
|
<p><a href="${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/reports/${report.id}">View Report</a></p>
|
||||||
|
<p><a href="${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/api/v1/reports/${report.id}/pdf">Download PDF</a></p>
|
||||||
|
`,
|
||||||
|
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,
|
||||||
|
};
|
||||||
27
packages/report/package.json
Normal file
27
packages/report/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
306
packages/report/src/data-collector.ts
Normal file
306
packages/report/src/data-collector.ts
Normal file
@@ -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<ReportExposureSummary> {
|
||||||
|
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<string, number> = {};
|
||||||
|
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<ReportSpamStats> {
|
||||||
|
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<string, unknown> | null;
|
||||||
|
return meta?.channel === 'call';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const textsBlocked = spamEvents.filter((f) => {
|
||||||
|
const meta = f.metadata as Record<string, unknown> | 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<ReportVoiceStats> {
|
||||||
|
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<ReportHomeTitleStats> {
|
||||||
|
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<ReportDataPayload> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
245
packages/report/src/html-renderer.ts
Normal file
245
packages/report/src/html-renderer.ts
Normal file
@@ -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 = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{reportTitle}}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #1a202c; line-height: 1.6; }
|
||||||
|
.container { max-width: 640px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #2563eb, #1d4ed8); color: white; padding: 32px 24px; border-radius: 12px 12px 0 0; text-align: center; }
|
||||||
|
.header h1 { font-size: 24px; margin-bottom: 8px; }
|
||||||
|
.header p { opacity: 0.9; font-size: 14px; }
|
||||||
|
.section { background: white; margin: 16px 0; padding: 24px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.section h2 { font-size: 18px; color: #1e40af; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid #e2e8f0; }
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||||
|
.stat-card { background: #f8fafc; padding: 16px; border-radius: 8px; text-align: center; }
|
||||||
|
.stat-value { font-size: 28px; font-weight: 700; color: #2563eb; }
|
||||||
|
.stat-label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||||
|
.score-ring { width: 120px; height: 120px; border-radius: 50%; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; flex-direction: column; }
|
||||||
|
.score-ring.high { background: linear-gradient(135deg, #22c55e, #16a34a); color: white; }
|
||||||
|
.score-ring.medium { background: linear-gradient(135deg, #eab308, #ca8a04); color: white; }
|
||||||
|
.score-ring.low { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; }
|
||||||
|
.score-number { font-size: 36px; font-weight: 700; }
|
||||||
|
.score-label { font-size: 12px; opacity: 0.9; }
|
||||||
|
.recommendation { padding: 12px 16px; margin: 8px 0; border-radius: 6px; border-left: 4px solid; }
|
||||||
|
.recommendation.high { background: #fef2f2; border-color: #ef4444; }
|
||||||
|
.recommendation.medium { background: #fffbeb; border-color: #eab308; }
|
||||||
|
.recommendation.low { background: #f0fdf4; border-color: #22c55e; }
|
||||||
|
.recommendation h3 { font-size: 14px; margin-bottom: 4px; }
|
||||||
|
.recommendation p { font-size: 13px; color: #475569; }
|
||||||
|
.footer { text-align: center; padding: 24px; color: #94a3b8; font-size: 12px; }
|
||||||
|
.footer a { color: #2563eb; text-decoration: none; }
|
||||||
|
.severity-critical { color: #ef4444; font-weight: 600; }
|
||||||
|
.severity-warning { color: #eab308; font-weight: 600; }
|
||||||
|
.severity-info { color: #2563eb; font-weight: 600; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
|
||||||
|
th { background: #f8fafc; font-weight: 600; color: #475569; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{reportTitle}}</h1>
|
||||||
|
<p>{{periodStart}} — {{periodEnd}}</p>
|
||||||
|
<p>Generated {{generatedAt}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Protection Score</h2>
|
||||||
|
<div class="score-ring {{scoreClass}}">
|
||||||
|
<span class="score-number">{{data.protectionScore}}</span>
|
||||||
|
<span class="score-label">/ 100</span>
|
||||||
|
</div>
|
||||||
|
{{#if data.previousProtectionScore}}
|
||||||
|
<p style="text-align:center; font-size:14px; color:#64748b;">
|
||||||
|
Previous period: {{data.previousProtectionScore}}/100
|
||||||
|
{{#if scoreImproved}} 📈 Improved{{else}} 📉 Declined{{/if}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Dark Web Exposure Summary</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value severity-critical">{{data.exposureSummary.criticalExposures}}</div>
|
||||||
|
<div class="stat-label">Critical</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value severity-warning">{{data.exposureSummary.warningExposures}}</div>
|
||||||
|
<div class="stat-label">Warnings</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.exposureSummary.newExposures}}</div>
|
||||||
|
<div class="stat-label">New Findings</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" style="color:#22c55e">{{data.exposureSummary.resolvedExposures}}</div>
|
||||||
|
<div class="stat-label">Resolved</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{#if exposureSources}}
|
||||||
|
<table>
|
||||||
|
<tr><th>Source</th><th>Exposures</th></tr>
|
||||||
|
{{#exposureSources}}
|
||||||
|
<tr><td>{{key}}</td><td>{{value}}</td></tr>
|
||||||
|
{{/exposureSources}}
|
||||||
|
</table>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Spam Protection</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.spamStats.callsBlocked}}</div>
|
||||||
|
<div class="stat-label">Calls Blocked</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.spamStats.textsBlocked}}</div>
|
||||||
|
<div class="stat-label">Texts Blocked</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.spamStats.totalSpamEvents}}</div>
|
||||||
|
<div class="stat-label">Total Events</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.spamStats.falsePositives}}</div>
|
||||||
|
<div class="stat-label">False Positives</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Voice Protection</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.voiceStats.analysesRun}}</div>
|
||||||
|
<div class="stat-label">Analyses Run</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.voiceStats.threatsDetected}}</div>
|
||||||
|
<div class="stat-label">Threats Detected</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.voiceStats.enrollmentsActive}}</div>
|
||||||
|
<div class="stat-label">Active Enrollments</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.voiceStats.syntheticDetections}}</div>
|
||||||
|
<div class="stat-label">Synthetic Voices</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if data.homeTitleStats}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Home Title Monitoring</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.homeTitleStats.propertiesMonitored}}</div>
|
||||||
|
<div class="stat-label">Properties Monitored</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.homeTitleStats.changesDetected}}</div>
|
||||||
|
<div class="stat-label">Changes Detected</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{data.homeTitleStats.alertsTriggered}}</div>
|
||||||
|
<div class="stat-label">Alerts Triggered</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if data.recommendations}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Recommendations</h2>
|
||||||
|
{{#data.recommendations}}
|
||||||
|
<div class="recommendation {{priority}}">
|
||||||
|
<h3>{{title}}</h3>
|
||||||
|
<p>{{description}}</p>
|
||||||
|
</div>
|
||||||
|
{{/data.recommendations}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>ShieldAI — Your Digital Identity Protection</p>
|
||||||
|
<p><a href="{{dashboardUrl}}">View Full Report Dashboard</a></p>
|
||||||
|
<p>Report ID: {{reportId}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class HtmlRenderer {
|
||||||
|
private template: ReturnType<typeof compile>;
|
||||||
|
|
||||||
|
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();
|
||||||
4
packages/report/src/index.ts
Normal file
4
packages/report/src/index.ts
Normal file
@@ -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';
|
||||||
202
packages/report/src/pdf-generator.ts
Normal file
202
packages/report/src/pdf-generator.ts
Normal file
@@ -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<Buffer> {
|
||||||
|
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();
|
||||||
292
packages/report/src/report.service.ts
Normal file
292
packages/report/src/report.service.ts
Normal file
@@ -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<SecurityReportOutput> {
|
||||||
|
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<SecurityReportOutput[]> {
|
||||||
|
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<SecurityReportOutput> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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();
|
||||||
17
packages/report/tsconfig.json
Normal file
17
packages/report/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
@@ -94,6 +94,31 @@ export const DefaultEmailTemplates: TemplateDefinition[] = [
|
|||||||
{ name: 'report_url', type: 'string', required: true },
|
{ 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: `
|
||||||
|
<h2>Your ShieldAI Report is Ready</h2>
|
||||||
|
<p>Hi {{name}},</p>
|
||||||
|
<p><strong>{{report_title}}</strong></p>
|
||||||
|
<p>{{report_summary}}</p>
|
||||||
|
<p><a href="{{report_url}}">View Report</a></p>
|
||||||
|
<p><a href="{{pdf_url}}">Download PDF</a></p>
|
||||||
|
<p>Best regards,<br>The ShieldAI Team</p>
|
||||||
|
`,
|
||||||
|
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[] = [
|
export const DefaultSMSTemplates: TemplateDefinition[] = [
|
||||||
|
|||||||
@@ -272,3 +272,96 @@ export interface SchedulerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { generateRequestId, extractOrGenerateRequestId } from "./requestId";
|
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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user