import { and, desc, eq } from 'drizzle-orm'; 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 { generatePdf } from '@/lib/pdf/generate'; import { minioClient, getPresignedUrl, buildStoragePath } from '@/lib/minio/index'; import { emitToRoom } from '@/lib/socket/server'; import { getQueue } from '@/lib/queue'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { NotFoundError } from '@/lib/errors'; import { fetchPipelineData, fetchRevenueData, fetchActivityData, fetchOccupancyData, } from '@/lib/services/report-generators'; import { pipelineReportTemplate, buildPipelineInputs, } from '@/lib/pdf/templates/reports/pipeline-report'; import { revenueReportTemplate, buildRevenueInputs, } from '@/lib/pdf/templates/reports/revenue-report'; import { activityReportTemplate, buildActivityInputs, } from '@/lib/pdf/templates/reports/activity-report'; import { occupancyReportTemplate, buildOccupancyInputs, } from '@/lib/pdf/templates/reports/occupancy-report'; import type { RequestReportInput, ListReportsInput } from '@/lib/validators/reports'; // ─── Report Type Map ────────────────────────────────────────────────────────── const REPORT_TYPE_MAP = { pipeline: { fetchData: fetchPipelineData, template: pipelineReportTemplate, buildInputs: buildPipelineInputs, }, revenue: { fetchData: fetchRevenueData, template: revenueReportTemplate, buildInputs: buildRevenueInputs, }, activity: { fetchData: fetchActivityData, template: activityReportTemplate, buildInputs: buildActivityInputs, }, occupancy: { fetchData: fetchOccupancyData, template: occupancyReportTemplate, buildInputs: buildOccupancyInputs, }, } 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 Error('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 Error('Report is not ready for download'); } const file = await db.query.files.findFirst({ where: eq(files.id, report.fileId), }); if (!file) { throw new NotFoundError('File'); } const url = await getPresignedUrl(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 Error(`Report job not found: ${reportJobId}`); } 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 Error(`Unknown report type: ${reportType}`); } const params = (parameters ?? {}) as Record; // 4. Fetch data const data = await config.fetchData(portId, params); // 5. Get port info for name in PDF const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); const portName = port?.name ?? 'Port Nimara'; const portSlug = port?.slug ?? 'port'; // 6. Build inputs (pass portName) const inputs = (config.buildInputs as (data: unknown, portName: string) => Record[])(data, portName); // 7. Generate PDF const pdfBytes = await generatePdf(config.template, inputs); // 8. Build storage path const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf'); // 9. Upload PDF to MinIO const buffer = Buffer.from(pdfBytes); await minioClient.putObject( env.MINIO_BUCKET, storagePath, buffer, buffer.length, { 'Content-Type': 'application/pdf', 'report-type': reportType }, ); // 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 Error('Failed to insert file record'); } // 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; } }