- 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>
203 lines
7.0 KiB
TypeScript
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();
|