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:
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from './auth.middleware';
|
||||
import { voiceprintRoutes } from './voiceprint.routes';
|
||||
import { spamshieldRoutes } from './spamshield.routes';
|
||||
import { darkwatchRoutes } from './darkwatch.routes';
|
||||
import { reportRoutes } from './report.routes';
|
||||
|
||||
export async function routes(fastify: FastifyInstance) {
|
||||
// Authenticated routes group
|
||||
@@ -139,4 +140,12 @@ export async function routes(fastify: FastifyInstance) {
|
||||
},
|
||||
{ prefix: '/darkwatch' }
|
||||
);
|
||||
|
||||
// Report routes
|
||||
fastify.register(
|
||||
async (reportRouter) => {
|
||||
await reportRoutes(reportRouter);
|
||||
},
|
||||
{ prefix: '/reports' }
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user