feat(reports): migrate 4 reports from pdfme to react-pdf
Phase 1 / commits 3-6 of 14 — bundled because every report follows the
same conversion pattern (coordinate-stuffed pdfme template -> JSX brand
kit). Each report now has a real header (logo + port name), structured
KeyValueGrid for summary stats, a chart (BarChart / FunnelChart / PieChart
/ LineChart-ready), and a DataTable for detail rows.
Templates:
activity-report.tsx bar chart of events-per-day, summary KPIs, top
actions table, recent-events table (50 rows)
revenue-report.tsx bar chart of revenue per stage, breakdown table
with totals row, currency-aware formatting
pipeline-report.tsx funnel chart of interests per stage, top interests
table, win rate / cycle KPIs
occupancy-report.tsx donut pie of berth status mix, status breakdown
table with percentages, occupancy rate KPI
reports.service.tsx (renamed .ts -> .tsx for JSX):
- swap REPORT_TYPE_MAP `template`/`buildInputs` for a single `render`
function returning a typed react-pdf element
- inject port logo via resolvePortLogo() and pass through to every
template through a ReportContext object
- keep the existing job queue / storage / file-row / socket-emit
flow intact — only the inner PDF-bytes generation changed
Old pdfme files deleted (4 templates). buildStoragePath / files-table
insert / notifications / status updates all unchanged.
Tests:
tests/unit/report-templates.test.tsx (5 tests): each report renders
to valid PDF bytes given a representative seed-style fixture; empty
data path doesn't throw.
1313/1313 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
340
src/lib/services/reports.service.tsx
Normal file
340
src/lib/services/reports.service.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
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) => (
|
||||
<PipelineReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
|
||||
),
|
||||
},
|
||||
revenue: {
|
||||
fetchData: fetchRevenueData,
|
||||
render: (data: RevenueData, ctx: ReportContext) => (
|
||||
<RevenueReportPdf
|
||||
portName={ctx.portName}
|
||||
logoBuffer={ctx.logoBuffer}
|
||||
data={data}
|
||||
dateFrom={ctx.dateFrom}
|
||||
dateTo={ctx.dateTo}
|
||||
currency={ctx.currency}
|
||||
/>
|
||||
),
|
||||
},
|
||||
activity: {
|
||||
fetchData: fetchActivityData,
|
||||
render: (data: ActivityData, ctx: ReportContext) => (
|
||||
<ActivityReportPdf
|
||||
portName={ctx.portName}
|
||||
logoBuffer={ctx.logoBuffer}
|
||||
data={data}
|
||||
dateFrom={ctx.dateFrom}
|
||||
dateTo={ctx.dateTo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
occupancy: {
|
||||
fetchData: fetchOccupancyData,
|
||||
render: (data: OccupancyData, ctx: ReportContext) => (
|
||||
<OccupancyReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
|
||||
),
|
||||
},
|
||||
} 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<void> {
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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),
|
||||
});
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
const portSlug = port?.slug ?? 'port';
|
||||
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<DocumentProps>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user