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:
@@ -44,6 +44,10 @@ export async function registerRecurringJobs(): Promise<void> {
|
||||
// Report scheduler - checks every minute for reports due to run
|
||||
{ queue: 'reports', name: 'report-scheduler', pattern: '* * * * *' },
|
||||
|
||||
// Reports P3 — new-system poll (report_schedules + report_runs).
|
||||
// Runs alongside the legacy poll above; the two queues coexist.
|
||||
{ queue: 'reports', name: 'report-schedules-poll', pattern: '* * * * *' },
|
||||
|
||||
// Notification digest - fires hourly globally; the worker checks each
|
||||
// user's `notification_digest_paused_until` and unread-count threshold
|
||||
// before composing a digest, so most ticks are no-ops. Per-user time-
|
||||
|
||||
@@ -98,6 +98,111 @@ export const reportsWorker = new Worker(
|
||||
break;
|
||||
}
|
||||
|
||||
// Reports P3 — new system: report_schedules + report_runs.
|
||||
case 'report-schedules-poll': {
|
||||
// Scan report_schedules due to fire, mint a report_runs row per
|
||||
// schedule, advance next_run_at by cadence math, enqueue render.
|
||||
const { db } = await import('@/lib/db');
|
||||
const { reportSchedules, reportTemplates } = await import('@/lib/db/schema/reports');
|
||||
const { createReportRun } = await import('@/lib/services/report-runs.service');
|
||||
const { nextRunFor } = await import('@/lib/services/report-schedules.service');
|
||||
const { and, eq, lte } = await import('drizzle-orm');
|
||||
|
||||
const now = new Date();
|
||||
const due = await db
|
||||
.select()
|
||||
.from(reportSchedules)
|
||||
.where(and(eq(reportSchedules.enabled, true), lte(reportSchedules.nextRunAt, now)));
|
||||
|
||||
for (const schedule of due) {
|
||||
const template = await db.query.reportTemplates.findFirst({
|
||||
where: eq(reportTemplates.id, schedule.templateId),
|
||||
});
|
||||
if (!template) {
|
||||
logger.warn(
|
||||
{ scheduleId: schedule.id, templateId: schedule.templateId },
|
||||
'Skipping schedule: template missing (likely archived); pausing',
|
||||
);
|
||||
await db
|
||||
.update(reportSchedules)
|
||||
.set({ enabled: false, updatedAt: new Date() })
|
||||
.where(eq(reportSchedules.id, schedule.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute the next fire BEFORE the enqueue so a downstream
|
||||
// failure (storage outage, etc.) doesn't pin the schedule on
|
||||
// the same tick — preserves the "no-op doesn't slip" rule.
|
||||
await db
|
||||
.update(reportSchedules)
|
||||
.set({
|
||||
lastRunAt: now,
|
||||
nextRunAt: nextRunFor(schedule.cadence as Parameters<typeof nextRunFor>[0], now),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(reportSchedules.id, schedule.id));
|
||||
|
||||
try {
|
||||
const run = await createReportRun(
|
||||
{
|
||||
kind: template.kind as 'dashboard' | 'clients' | 'berths' | 'interests',
|
||||
config: template.config,
|
||||
outputFormat: schedule.outputFormat as 'pdf' | 'csv' | 'png',
|
||||
templateId: template.id,
|
||||
},
|
||||
{
|
||||
portId: schedule.portId,
|
||||
triggeredBy: 'schedule',
|
||||
scheduleId: schedule.id,
|
||||
meta: {
|
||||
userId: 'system',
|
||||
portId: schedule.portId,
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'scheduler',
|
||||
},
|
||||
},
|
||||
);
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-render',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-render:${run.id}` },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, scheduleId: schedule.id },
|
||||
'Failed to mint report_run for due schedule; will retry on next poll',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'report-run-render': {
|
||||
const { reportRunId } = job.data as { reportRunId: string };
|
||||
const { renderReportRun } = await import('@/lib/services/report-render.service');
|
||||
const run = await renderReportRun(reportRunId);
|
||||
|
||||
// Schedule-driven runs auto-cascade into the email job. User-
|
||||
// triggered runs are inert — the rep downloads via the UI.
|
||||
if (run.triggeredBy === 'schedule' && run.status === 'complete') {
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-email',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-email:${run.id}` },
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'report-run-email': {
|
||||
const { reportRunId } = job.data as { reportRunId: string };
|
||||
const { emailReportRun } = await import('@/lib/services/report-render.service');
|
||||
await emailReportRun(reportRunId);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown reports job');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user