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:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user