Files
Kordant/packages/report/src/pdf-generator.ts
Michael Freno 2521c4e998 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>
2026-05-09 22:54:46 -04:00

203 lines
7.0 KiB
TypeScript

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