feat(reports-p3): BullMQ render + email + schedule poll for report_runs

- new report-render.service.ts: renderReportRun(reportRunId) +
  emailReportRun(reportRunId). Render path fetches the run row,
  advances status to 'rendering', resolves the kind→fetcher+template
  pair from REPORT_RENDER_MAP (dashboard→pipeline, clients→activity,
  berths→occupancy, interests→revenue), generates the PDF, uploads to
  storage, mirrors onto `files` so the standard download/attachment
  surfaces serve it, and stamps storageKey + sizeBytes + status='complete'.
  Failure path stamps 'failed' + errorMessage + compensating
  storage.delete to keep blobs from orphaning. Email path resolves the
  schedule's recipients + the rendered file via the standard
  resolveAttachments port-isolation check, sends one message per
  recipient via the existing sendEmail helper, and stamps emailedAt.
- reports worker (src/lib/queue/workers/reports.ts) gains 3 jobs:
  - 'report-schedules-poll': scans report_schedules where enabled=true
    AND nextRunAt <= now, mints a report_runs row per due schedule via
    createReportRun (triggeredBy='schedule'), advances next_run_at via
    nextRunFor() BEFORE enqueue so a downstream failure doesn't pin the
    schedule on the same tick, then enqueues report-run-render.
  - 'report-run-render': calls renderReportRun + auto-cascades into
    report-run-email when the run was schedule-triggered.
  - 'report-run-email': calls emailReportRun.
  These coexist with the legacy 'report-scheduler' + 'generate-report'
  jobs operating on scheduled_reports/generated_reports.
- scheduler.ts registers 'report-schedules-poll' on a 1-minute cron so
  the system catches due schedules even when no API event nudges them.
- POST /api/v1/reports/runs now enqueues 'report-run-render' after
  createReportRun. Enqueue failures are logged + swallowed so the API
  still returns 201; the schedule poll picks pending rows up as a
  safety net.

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:42:53 +02:00
parent db14056018
commit e9ef5831aa
4 changed files with 384 additions and 0 deletions

View File

@@ -0,0 +1,255 @@
/**
* Reports P3 — render + email orchestration for `report_runs` rows.
*
* Two entry points are called from the BullMQ reports worker:
*
* - `renderReportRun(reportRunId)` — fetches the run, advances to
* `rendering`, generates the artefact via the registered renderer
* for that `kind`, uploads to storage, and stamps the run row with
* storage key + size + status='complete' (or 'failed' + errorMessage).
* - `emailReportRun(reportRunId)` — for runs born from a schedule:
* reads the schedule's recipients + the stored artefact and ships
* an email with the file attached via the existing sendEmail helper.
*
* Kind-specific renderers live in REPORT_RENDER_MAP. v1 wires the four
* new-system kinds (dashboard/clients/berths/interests) onto the
* existing legacy fetcher+template pairs; dedicated per-kind templates
* land alongside the builder UI (P4+).
*/
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { reportRuns, reportSchedules, type ReportRun } from '@/lib/db/schema/reports';
import { ports } from '@/lib/db/schema/ports';
import { files } from '@/lib/db/schema/documents';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { renderPdf } from '@/lib/pdf/render';
import { sendEmail } from '@/lib/email';
import { updateReportRunStatus } from '@/lib/services/report-runs.service';
import { CodedError, NotFoundError } from '@/lib/errors';
import {
fetchActivityData,
fetchOccupancyData,
fetchPipelineData,
fetchRevenueData,
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';
interface RenderCtx {
portName: string;
logoBuffer: Buffer | null;
}
interface KindRenderer {
fetchData: (portId: string, params: Record<string, unknown>) => Promise<unknown>;
render: (data: unknown, ctx: RenderCtx) => Promise<Buffer>;
}
const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
dashboard: {
fetchData: fetchPipelineData as KindRenderer['fetchData'],
render: async (data, ctx) =>
renderPdf(
PipelineReportPdf({
portName: ctx.portName,
logoBuffer: ctx.logoBuffer ?? null,
data: data as PipelineData,
}),
),
},
clients: {
fetchData: fetchActivityData as KindRenderer['fetchData'],
render: async (data, ctx) =>
renderPdf(
ActivityReportPdf({
portName: ctx.portName,
logoBuffer: ctx.logoBuffer ?? null,
data: data as ActivityData,
}),
),
},
berths: {
fetchData: fetchOccupancyData as KindRenderer['fetchData'],
render: async (data, ctx) =>
renderPdf(
OccupancyReportPdf({
portName: ctx.portName,
logoBuffer: ctx.logoBuffer ?? null,
data: data as OccupancyData,
}),
),
},
interests: {
fetchData: fetchRevenueData as KindRenderer['fetchData'],
render: async (data, ctx) =>
renderPdf(
RevenueReportPdf({
portName: ctx.portName,
logoBuffer: ctx.logoBuffer ?? null,
data: data as RevenueData,
}),
),
},
};
export async function renderReportRun(reportRunId: string): Promise<ReportRun> {
const run = await db.query.reportRuns.findFirst({
where: eq(reportRuns.id, reportRunId),
});
if (!run) throw new NotFoundError('Report run');
if (run.status === 'complete' && run.storageKey) {
return run;
}
await updateReportRunStatus(run.id, run.portId, { status: 'rendering' });
let putStoragePath: string | null = null;
try {
const renderer = REPORT_RENDER_MAP[run.kind];
if (!renderer) {
throw new CodedError('VALIDATION_ERROR', {
internalMessage: `No renderer registered for report kind "${run.kind}"`,
});
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
if (!port) {
throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`);
}
const logo = await resolvePortLogo(run.portId).catch(() => ({
buffer: null as Buffer | null,
}));
const ctx: RenderCtx = { portName: port.name, logoBuffer: logo.buffer ?? null };
const params = (run.config as Record<string, unknown>) ?? {};
const data = await renderer.fetchData(run.portId, params);
const pdfBytes = await renderer.render(data, ctx);
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, 'pdf');
const backend = await getStorageBackend();
await backend.put(storagePath, pdfBytes, {
contentType: 'application/pdf',
sizeBytes: pdfBytes.length,
});
putStoragePath = storagePath;
await db.insert(files).values({
id: fileId,
portId: run.portId,
filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
originalName: `${run.kind}-report.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(pdfBytes.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'misc',
uploadedBy: run.triggeredByUserId ?? 'system',
});
const updated = await updateReportRunStatus(run.id, run.portId, {
status: 'complete',
storageKey: fileId,
sizeBytes: pdfBytes.length,
});
putStoragePath = null;
return updated;
} catch (err) {
logger.error({ err, reportRunId: run.id }, 'renderReportRun failed');
await updateReportRunStatus(run.id, run.portId, {
status: 'failed',
errorMessage: err instanceof Error ? err.message : String(err),
}).catch(() => undefined);
if (putStoragePath) {
try {
await (await getStorageBackend()).delete(putStoragePath);
} catch (compErr) {
logger.error(
{ compErr, putStoragePath },
'Compensating storage.delete failed after render error',
);
}
}
throw err;
}
}
/**
* Schedule-driven email side effect. Looks up the schedule's recipients
* and ships an email with the rendered PDF attached. Stamps `emailedAt`
* on success; logs + rethrows on failure so BullMQ retries.
*/
export async function emailReportRun(reportRunId: string): Promise<void> {
const run = await db.query.reportRuns.findFirst({
where: eq(reportRuns.id, reportRunId),
});
if (!run) throw new NotFoundError('Report run');
if (run.status !== 'complete' || !run.storageKey) {
throw new CodedError('VALIDATION_ERROR', {
internalMessage: `Cannot email report ${run.id} — status=${run.status}, storageKey=${run.storageKey}`,
});
}
if (!run.scheduleId) {
logger.info({ reportRunId: run.id }, 'Skipping email for user-triggered report (no schedule)');
return;
}
const schedule = await db.query.reportSchedules.findFirst({
where: eq(reportSchedules.id, run.scheduleId),
});
if (!schedule) {
logger.warn(
{ reportRunId: run.id, scheduleId: run.scheduleId },
'Schedule deleted before email could fire; skipping',
);
return;
}
const recipients = (schedule.recipients ?? []).filter(
(r): r is { name?: string; email: string } => Boolean(r?.email),
);
if (recipients.length === 0) {
logger.info({ reportRunId: run.id }, 'No recipients on schedule; nothing to email');
return;
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
if (!port) throw new Error(`Port ${run.portId} missing during emailReportRun`);
const fileRow = await db.query.files.findFirst({
where: and(eq(files.id, run.storageKey), eq(files.portId, run.portId)),
});
if (!fileRow) {
throw new Error(`Report artefact file ${run.storageKey} missing during emailReportRun`);
}
const subject = `${port.name} · ${run.kind} report`;
const html = `<p>Your scheduled ${run.kind} report is attached.</p>`;
for (const recipient of recipients) {
await sendEmail(recipient.email, subject, html, undefined, undefined, run.portId, [
{ fileId: fileRow.id, filename: fileRow.originalName ?? `${run.kind}-report.pdf` },
]);
}
await updateReportRunStatus(run.id, run.portId, {
status: 'complete',
emailedAt: new Date(),
});
}