Files
pn-new-crm/src/lib/services/reports.service.tsx

349 lines
11 KiB
TypeScript
Raw Normal View History

import { and, desc, eq } from 'drizzle-orm';
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>
2026-05-12 20:55:07 +02:00
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';
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>
2026-05-12 20:55:07 +02:00
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,
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>
2026-05-12 20:55:07 +02:00
type ActivityData,
type OccupancyData,
type PipelineData,
type RevenueData,
} from '@/lib/services/report-generators';
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>
2026-05-12 20:55:07 +02:00
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 ──────────────────────────────────────────────────────────
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>
2026-05-12 20:55:07 +02:00
interface ReportContext {
portName: string;
logoBuffer: Buffer | null;
dateFrom?: string;
dateTo?: string;
currency?: string;
}
const REPORT_TYPE_MAP = {
pipeline: {
fetchData: fetchPipelineData,
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>
2026-05-12 20:55:07 +02:00
render: (data: PipelineData, ctx: ReportContext) => (
<PipelineReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
),
},
revenue: {
fetchData: fetchRevenueData,
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>
2026-05-12 20:55:07 +02:00
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,
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>
2026-05-12 20:55:07 +02:00
render: (data: ActivityData, ctx: ReportContext) => (
<ActivityReportPdf
portName={ctx.portName}
logoBuffer={ctx.logoBuffer}
data={data}
dateFrom={ctx.dateFrom}
dateTo={ctx.dateTo}
/>
),
},
occupancy: {
fetchData: fetchOccupancyData,
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>
2026-05-12 20:55:07 +02:00
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);
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>
2026-05-12 20:55:07 +02:00
// 5. Get port info + brand assets for the PDF header
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
});
fix(audit-wave-9): PDF correctness + brand asset hardening (pdf-auditor) Address the pdf-auditor findings that survived the 2026-05-12 PDF stack overhaul (pdfme → react-pdf). Items C-2/C-3 (tiptap-to-pdfme bugs) were resolved when that 571-LOC bridge was deleted; remaining items: - **M-7 wrong-port brand fallback** — replace `'Port Nimara'` defaults in PDF-rendering services. `reports.service` and `expense-export` throw when the port row is missing (the job is FK-keyed on a real port, so absence = broken state, must not stamp a competitor brand). `record-export` uses `'(port)'` as the visible placeholder. - **M-2 silent field drift in fill-eoi-form** — promote the always-silent catch in `setText` / `setCheckbox` to log a structured warning per missing field (mirroring the existing `setBerthRange` pattern). A re-cut template with drifted AcroForm field names now surfaces in ops logs instead of shipping with empty values. - **M-3 form not flattened** — `fillEoiFormFields` now flattens the AcroForm before save. Documenso pathway flattens server-side; this brings the in-app pathway to parity, so the signer can't edit pre-filled yacht dimensions / address / berth number after the fact. - **M-1 PDF metadata** — set Title / Author / Subject / Lang / Producer / Creator on the generated EOI PDF for downstream readers and a11y tooling. - **M-4 noisy berth-range warnings** — downgrade per-mooring warn to debug; emit a single summary warn per call when any passthrough occurred. Multi-berth EOIs with archived/legacy moorings no longer spam the log on every render. - **M-6 source PDF sha pinning** — pin `assets/eoi-template.pdf` sha256 via `EXPECTED_EOI_SHA256` (exported for tests); `loadEoiTemplatePdf` warns once per process when the bytes drift without an explicit hash bump. Documented the intentional-update workflow in `assets/README.md`. Tests updated in `tests/unit/pdf/fill-eoi-form.test.ts` to reflect flatten + metadata (form fields are gone after flatten; pdf-lib has no getLanguage so we assert the other setters round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:07:57 +02:00
// 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;
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>
2026-05-12 20:55:07 +02:00
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;
}
}