import { and, desc, eq } from 'drizzle-orm'; import type { ReactElement } from 'react'; import type { DocumentProps } from '@react-pdf/renderer'; import { db } from '@/lib/db'; import { generatedReports } from '@/lib/db/schema/operations'; import { notifications } from '@/lib/db/schema/operations'; import { files } from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { renderPdf } from '@/lib/pdf/render'; import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo'; import { buildStoragePath } from '@/lib/minio/index'; import { getStorageBackend, presignDownloadUrl } from '@/lib/storage'; import { emitToRoom } from '@/lib/socket/server'; import { getQueue } from '@/lib/queue'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { CodedError, ConflictError, NotFoundError } from '@/lib/errors'; import { fetchPipelineData, fetchRevenueData, fetchActivityData, fetchOccupancyData, type ActivityData, type OccupancyData, type PipelineData, type RevenueData, } from '@/lib/services/report-generators'; import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report'; import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report'; import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report'; import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report'; import type { RequestReportInput, ListReportsInput } from '@/lib/validators/reports'; // ─── Report Type Map ────────────────────────────────────────────────────────── interface ReportContext { portName: string; logoBuffer: Buffer | null; dateFrom?: string; dateTo?: string; currency?: string; } const REPORT_TYPE_MAP = { pipeline: { fetchData: fetchPipelineData, render: (data: PipelineData, ctx: ReportContext) => ( ), }, revenue: { fetchData: fetchRevenueData, render: (data: RevenueData, ctx: ReportContext) => ( ), }, activity: { fetchData: fetchActivityData, render: (data: ActivityData, ctx: ReportContext) => ( ), }, occupancy: { fetchData: fetchOccupancyData, render: (data: OccupancyData, ctx: ReportContext) => ( ), }, } as const; type ReportType = keyof typeof REPORT_TYPE_MAP; // ─── requestReport ──────────────────────────────────────────────────────────── export async function requestReport(portId: string, userId: string, data: RequestReportInput) { const [report] = await db .insert(generatedReports) .values({ portId, reportType: data.reportType, name: data.name, status: 'queued', parameters: data.parameters ?? {}, requestedBy: userId, }) .returning(); if (!report) { throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to create report record', }); } await getQueue('reports').add('generate-report', { reportJobId: report.id }); emitToRoom(`user:${userId}`, 'report:queued', { reportId: report.id, reportType: report.reportType, name: report.name, }); return report; } // ─── listReports ────────────────────────────────────────────────────────────── export async function listReports(portId: string, query: ListReportsInput) { const conditions = [eq(generatedReports.portId, portId)]; if (query.status) { conditions.push(eq(generatedReports.status, query.status)); } const offset = (query.page - 1) * query.limit; const [rows, countResult] = await Promise.all([ db .select() .from(generatedReports) .where(and(...conditions)) .orderBy(desc(generatedReports.createdAt)) .limit(query.limit) .offset(offset), db.$count(generatedReports, and(...conditions)), ]); return { data: rows, total: Number(countResult), }; } // ─── getReport ──────────────────────────────────────────────────────────────── export async function getReport(reportId: string, portId: string) { const report = await db.query.generatedReports.findFirst({ where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)), }); if (!report) { throw new NotFoundError('Report'); } return report; } // ─── getDownloadUrl ─────────────────────────────────────────────────────────── export async function getDownloadUrl(reportId: string, portId: string) { const report = await db.query.generatedReports.findFirst({ where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)), }); if (!report) { throw new NotFoundError('Report'); } if (report.status !== 'ready' || !report.fileId) { throw new ConflictError('Report is not ready for download'); } // Defense-in-depth: scope the file lookup to the same port. report.fileId // is currently assigned at generation time in the same-port flow, but // pinning portId in the WHERE means a future bulk-import or admin-bypass // path that ever crossed port boundaries cannot leak a foreign file via // a presigned URL. const file = await db.query.files.findFirst({ where: and(eq(files.id, report.fileId), eq(files.portId, portId)), }); if (!file) { throw new NotFoundError('File'); } const url = await presignDownloadUrl(file.storagePath); return { url }; } // ─── generateReport ─────────────────────────────────────────────────────────── export async function generateReport(reportJobId: string): Promise { // 1. Fetch the generatedReports record const report = await db.query.generatedReports.findFirst({ where: eq(generatedReports.id, reportJobId), }); if (!report) { throw new NotFoundError('report job'); } const { portId, reportType, name, parameters, requestedBy } = report; try { // 2. Update status = 'processing', startedAt = now await db .update(generatedReports) .set({ status: 'processing', startedAt: new Date() }) .where(eq(generatedReports.id, reportJobId)); // 3. Look up REPORT_TYPE_MAP[reportType] const typeKey = reportType as ReportType; const config = REPORT_TYPE_MAP[typeKey]; if (!config) { throw new CodedError('VALIDATION_ERROR', { internalMessage: `Unknown report type: ${reportType}`, }); } const params = (parameters ?? {}) as Record; // 4. Fetch data const data = await config.fetchData(portId, params); // 5. Get port info + brand assets for the PDF header const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); // The job is keyed on a real port id - port should always resolve here. // If the row is missing, the deployment is in a broken state and we // must not stamp a competitor port's brand on the artifact. Fail loudly. if (!port) { throw new Error( `Cannot render report PDF: port ${portId} not found. Check report job FK integrity.`, ); } const portName = port.name; const portSlug = port.slug; const logo = await resolvePortLogo(portId); // 6. Render PDF via react-pdf brand kit const ctx: ReportContext = { portName, logoBuffer: logo.buffer, dateFrom: params.dateFrom as string | undefined, dateTo: params.dateTo as string | undefined, currency: params.currency as string | undefined, }; // The render fn is typed per report; we widen here because typeKey // narrowing across the union loses the link between data shape and // render signature. The fetchData / render pair is guaranteed in lock- // step by the map definition above. const renderFn = config.render as ( data: unknown, ctx: ReportContext, ) => ReactElement; const pdfBytes = await renderPdf(renderFn(data, ctx)); // 8. Build storage path const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf'); // 9. Upload PDF via the active storage backend (filesystem or s3) const buffer = Buffer.from(pdfBytes); const backend = await getStorageBackend(); await backend.put(storagePath, buffer, { contentType: 'application/pdf', sizeBytes: buffer.length, }); // 10. Insert into files table const [fileRecord] = await db .insert(files) .values({ id: fileId, portId, filename: `${name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.pdf`, originalName: `${name}.pdf`, mimeType: 'application/pdf', sizeBytes: String(buffer.length), storagePath, storageBucket: env.MINIO_BUCKET, category: 'misc', uploadedBy: requestedBy, }) .returning(); if (!fileRecord) { throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to insert file record for generated report', }); } // 11. Update generatedReports: status='ready', fileId, completedAt await db .update(generatedReports) .set({ status: 'ready', fileId: fileRecord.id, completedAt: new Date(), updatedAt: new Date(), }) .where(eq(generatedReports.id, reportJobId)); // 12. Emit report:ready socket event emitToRoom(`user:${requestedBy}`, 'report:ready', { reportId: reportJobId, name, }); // 13. Create notification for requestedBy user await db.insert(notifications).values({ portId, userId: requestedBy, type: 'system_alert', title: 'Report Ready', description: `Your report "${name}" is ready to download.`, entityType: 'report', entityId: reportJobId, }); logger.info({ reportJobId, reportType }, 'Report generated successfully'); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; logger.error({ reportJobId, err }, 'Report generation failed'); await db .update(generatedReports) .set({ status: 'failed', errorMessage, updatedAt: new Date(), }) .where(eq(generatedReports.id, reportJobId)); emitToRoom(`user:${requestedBy}`, 'report:failed', { reportId: reportJobId, name, error: errorMessage, }); throw err; } }