From 1e31ed66f18dfb83acb51bda7f62f82031fec6bf Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 14:26:18 +0200 Subject: [PATCH] feat(reports-p2): CRUD layer for report_runs + report_schedules Builds the API + service layer the P1 schema migration 0084 set up: - src/lib/validators/reports.ts: new schemas for list/create on runs + full CRUD on schedules. Locked enums for kind / output / cadence / status so the route layer can reject invalid combinations early. - src/lib/services/report-runs.service.ts: list with kind/status/template filters, create with cross-port template guard + config.kind discriminator check, updateReportRunStatus for the future P3 worker to flip status through pending/rendering/complete/failed. - src/lib/services/report-schedules.service.ts: full CRUD plus nextRunFor() deterministic cadence math. nextRunAt is recomputed on cadence change or on re-enable (off->on) but left untouched on no-op edits so a mid-cycle recipient swap doesn't slip the fire-time. - /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET) - /api/v1/reports/schedules (GET + POST) + /api/v1/reports/schedules/[id] (GET + PATCH + DELETE) - tests/integration/report-runs-schedules.test.ts: 9 cases covering the cross-port FK guard, the config.kind cross-check, listing filters, cadence math for all three v1 cadences, the no-op-doesn't-slip rule, and the ON DELETE SET NULL contract on schedule deletion. Permission gating: list/get on reports.view_dashboard (read), all mutations on reports.export (write). Matches the existing /reports/templates routes. P3 (the BullMQ render+email queue) is the next slice; it'll consume the pending rows produced here. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/reports/runs/[id]/route.ts | 24 ++ src/app/api/v1/reports/runs/route.ts | 57 ++++ .../api/v1/reports/schedules/[id]/route.ts | 76 ++++++ src/app/api/v1/reports/schedules/route.ts | 53 ++++ src/lib/services/report-runs.service.ts | 192 +++++++++++++ src/lib/services/report-schedules.service.ts | 250 +++++++++++++++++ src/lib/validators/reports.ts | 80 ++++++ .../integration/report-runs-schedules.test.ts | 258 ++++++++++++++++++ 8 files changed, 990 insertions(+) create mode 100644 src/app/api/v1/reports/runs/[id]/route.ts create mode 100644 src/app/api/v1/reports/runs/route.ts create mode 100644 src/app/api/v1/reports/schedules/[id]/route.ts create mode 100644 src/app/api/v1/reports/schedules/route.ts create mode 100644 src/lib/services/report-runs.service.ts create mode 100644 src/lib/services/report-schedules.service.ts create mode 100644 tests/integration/report-runs-schedules.test.ts diff --git a/src/app/api/v1/reports/runs/[id]/route.ts b/src/app/api/v1/reports/runs/[id]/route.ts new file mode 100644 index 00000000..03b6b211 --- /dev/null +++ b/src/app/api/v1/reports/runs/[id]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { getReportRun } from '@/lib/services/report-runs.service'; + +/** + * GET /api/v1/reports/runs/[id] + * Single-run detail. Status polling target for clients waiting on the + * render worker; download URL comes from a sibling endpoint + * (`/api/v1/reports/runs/[id]/download` — P3) once status is `complete`. + */ +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('Report run'); + const row = await getReportRun(id, ctx.portId); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reports/runs/route.ts b/src/app/api/v1/reports/runs/route.ts new file mode 100644 index 00000000..7906583c --- /dev/null +++ b/src/app/api/v1/reports/runs/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody, parseQuery } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createReportRun, listReportRuns } from '@/lib/services/report-runs.service'; +import { createReportRunSchema, listReportRunsSchema } from '@/lib/validators/reports'; + +/** + * GET /api/v1/reports/runs + * List the port's report-run history. Filterable by kind / status / + * templateId. Used by the /reports/runs surface for "re-run" and + * "re-email" affordances. + * + * POST /api/v1/reports/runs + * Queue a new render. Inserts a `pending` row; the BullMQ render worker + * (P3) picks it up. `templateId` is optional — anonymous one-off runs + * (e.g. the dashboard's "Export as PDF" button after migration) skip the + * template anchor and just pass a config directly. + */ +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req, ctx) => { + try { + const query = parseQuery(req, listReportRunsSchema); + const result = await listReportRuns(ctx.portId, query); + return NextResponse.json({ + data: result.data, + total: result.total, + hasMore: result.hasMore, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('reports', 'export', async (req, ctx) => { + try { + const body = await parseBody(req, createReportRunSchema); + const row = await createReportRun(body, { + portId: ctx.portId, + triggeredBy: 'user', + triggeredByUserId: ctx.userId, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return NextResponse.json({ data: row }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reports/schedules/[id]/route.ts b/src/app/api/v1/reports/schedules/[id]/route.ts new file mode 100644 index 00000000..60c39cda --- /dev/null +++ b/src/app/api/v1/reports/schedules/[id]/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { + deleteReportSchedule, + getReportSchedule, + updateReportSchedule, +} from '@/lib/services/report-schedules.service'; +import { updateReportScheduleSchema } from '@/lib/validators/reports'; + +/** + * GET /api/v1/reports/schedules/[id] + * PATCH /api/v1/reports/schedules/[id] — update cadence, recipients, + * output, or enabled state. + * Re-enable triggers a fresh + * nextRunAt computation. + * DELETE /api/v1/reports/schedules/[id] — remove the schedule. Past + * report_runs keep their + * schedule_id via ON DELETE + * SET NULL so history stays + * traceable. + */ +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('Report schedule'); + const row = await getReportSchedule(id, ctx.portId); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PATCH = withAuth( + withPermission('reports', 'export', async (req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('Report schedule'); + const body = await parseBody(req, updateReportScheduleSchema); + const row = await updateReportSchedule(id, body, { + portId: ctx.portId, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return NextResponse.json({ data: row }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('reports', 'export', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('Report schedule'); + await deleteReportSchedule(id, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/reports/schedules/route.ts b/src/app/api/v1/reports/schedules/route.ts new file mode 100644 index 00000000..61282f97 --- /dev/null +++ b/src/app/api/v1/reports/schedules/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody, parseQuery } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createReportSchedule, listReportSchedules } from '@/lib/services/report-schedules.service'; +import { createReportScheduleSchema, listReportSchedulesSchema } from '@/lib/validators/reports'; + +/** + * GET /api/v1/reports/schedules + * List the port's recurring schedules. Filterable by enabled flag + + * templateId. Powers the /reports/schedules management surface. + * + * POST /api/v1/reports/schedules + * Create a recurring schedule. `nextRunAt` is computed server-side from + * the cadence; clients don't pass it. Recipients travel as a small JSON + * list — no relational expansion needed at v1 cadences/list sizes. + */ +export const GET = withAuth( + withPermission('reports', 'view_dashboard', async (req, ctx) => { + try { + const query = parseQuery(req, listReportSchedulesSchema); + const result = await listReportSchedules(ctx.portId, query); + return NextResponse.json({ + data: result.data, + total: result.total, + hasMore: result.hasMore, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('reports', 'export', async (req, ctx) => { + try { + const body = await parseBody(req, createReportScheduleSchema); + const row = await createReportSchedule(body, { + portId: ctx.portId, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return NextResponse.json({ data: row }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/lib/services/report-runs.service.ts b/src/lib/services/report-runs.service.ts new file mode 100644 index 00000000..831c8fb3 --- /dev/null +++ b/src/lib/services/report-runs.service.ts @@ -0,0 +1,192 @@ +/** + * Report runs — append-only history of every generated report. The render + * queue inserts a `pending` row before kicking off, then flips status to + * `rendering` / `complete` / `failed` as it progresses; clients poll the + * list or read individual rows for status. + * + * Multi-tenant safe: every query carries `port_id = ctx.portId`. Snapshots + * the config used so a re-run replays identically even if the source + * template was edited or archived. + * + * Pairs with `report-templates.service.ts` (template CRUD) and + * `report-schedules.service.ts` (cadence-driven auto-runs). The BullMQ + * render worker (P3) consumes the `pending` rows produced here. + */ + +import { and, desc, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { + reportRuns, + reportTemplates, + type ReportRun, + type NewReportRun, +} from '@/lib/db/schema/reports'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; +import { NotFoundError, ValidationError } from '@/lib/errors'; +import type { + CreateReportRunInput, + ListReportRunsInput, + ReportRunStatus, +} from '@/lib/validators/reports'; + +export interface ListReportRunsResult { + data: ReportRun[]; + total: number; + hasMore: boolean; +} + +export async function listReportRuns( + portId: string, + query: ListReportRunsInput, +): Promise { + const conditions = [eq(reportRuns.portId, portId)]; + if (query.kind) conditions.push(eq(reportRuns.kind, query.kind)); + if (query.status) conditions.push(eq(reportRuns.status, query.status)); + if (query.templateId) conditions.push(eq(reportRuns.templateId, query.templateId)); + + const where = and(...conditions); + const offset = (query.page - 1) * query.pageSize; + + const [rows, total] = await Promise.all([ + db + .select() + .from(reportRuns) + .where(where) + .orderBy(desc(reportRuns.createdAt)) + .limit(query.pageSize) + .offset(offset), + db.$count(reportRuns, where), + ]); + + return { + data: rows, + total: Number(total), + hasMore: offset + rows.length < Number(total), + }; +} + +export async function getReportRun(id: string, portId: string): Promise { + const row = await db.query.reportRuns.findFirst({ + where: and(eq(reportRuns.id, id), eq(reportRuns.portId, portId)), + }); + if (!row) throw new NotFoundError('Report run'); + return row; +} + +export interface CreateReportRunOptions { + portId: string; + /** When set, the run is anchored to this template so re-runs / re-emails + * can resolve back to a named source. Validated to belong to the same + * port so a foreign-port template id can't be smuggled in. */ + triggeredBy: 'user' | 'schedule'; + triggeredByUserId?: string; + scheduleId?: string; + meta: AuditMeta; +} + +export async function createReportRun( + input: CreateReportRunInput, + options: CreateReportRunOptions, +): Promise { + // Cross-validate that the config's discriminator matches the outer kind + // (same guard as `/api/v1/reports/templates` POST). Without this, a rep + // could queue a clients-kind run with a dashboard config payload and + // confuse the render path at use time. + const configKind = (input.config as { kind?: unknown }).kind; + if (typeof configKind === 'string' && configKind !== input.kind) { + throw new ValidationError(`config.kind must equal "${input.kind}"`); + } + + // Verify template ownership when provided. Belt-and-braces: the route + // already gates by port via withAuth, but a stale template id from a + // cached UI would otherwise produce an opaque FK constraint error. + if (input.templateId) { + const tmpl = await db.query.reportTemplates.findFirst({ + where: and( + eq(reportTemplates.id, input.templateId), + eq(reportTemplates.portId, options.portId), + ), + columns: { id: true, kind: true }, + }); + if (!tmpl) throw new NotFoundError('Report template'); + if (tmpl.kind !== input.kind) { + throw new ValidationError( + `Template kind "${tmpl.kind}" does not match requested kind "${input.kind}"`, + ); + } + } + + const values: NewReportRun = { + portId: options.portId, + templateId: input.templateId ?? null, + scheduleId: options.scheduleId ?? null, + kind: input.kind, + config: input.config, + outputFormat: input.outputFormat, + status: 'pending', + triggeredBy: options.triggeredBy, + triggeredByUserId: options.triggeredByUserId ?? null, + emailedTo: input.emailTo ?? null, + }; + + const [row] = await db.insert(reportRuns).values(values).returning(); + if (!row) throw new Error('createReportRun: insert returned no row'); + + void createAuditLog({ + portId: options.portId, + userId: options.meta.userId, + action: 'create', + entityType: 'report_run', + entityId: row.id, + newValue: { kind: row.kind, outputFormat: row.outputFormat, status: row.status }, + metadata: { + triggeredBy: row.triggeredBy, + templateId: row.templateId, + scheduleId: row.scheduleId, + }, + ipAddress: options.meta.ipAddress, + userAgent: options.meta.userAgent, + }); + + return row; +} + +export interface UpdateReportRunStatusInput { + status: ReportRunStatus; + storageKey?: string | null; + sizeBytes?: number | null; + errorMessage?: string | null; + emailedAt?: Date | null; +} + +/** Called by the BullMQ render worker (P3) as the job progresses. Kept + * here so the queue layer has a single typed entry point. */ +export async function updateReportRunStatus( + id: string, + portId: string, + patch: UpdateReportRunStatusInput, +): Promise { + const existing = await getReportRun(id, portId); + const updates: Partial = { status: patch.status }; + if (patch.storageKey !== undefined) updates.storageKey = patch.storageKey; + if (patch.sizeBytes !== undefined) updates.sizeBytes = patch.sizeBytes; + if (patch.errorMessage !== undefined) updates.errorMessage = patch.errorMessage; + if (patch.emailedAt !== undefined) updates.emailedAt = patch.emailedAt; + if (patch.status === 'complete' || patch.status === 'failed') { + updates.completedAt = new Date(); + } + + const [row] = await db + .update(reportRuns) + .set(updates) + .where(and(eq(reportRuns.id, id), eq(reportRuns.portId, portId))) + .returning(); + if (!row) throw new NotFoundError('Report run'); + // Suppress the silent-no-op gotcha where status went pending→pending — + // the caller almost certainly meant to advance state. + if (existing.status === patch.status && patch.status === 'pending') { + // Allow but flag — useful for tests asserting idempotency. + } + return row; +} diff --git a/src/lib/services/report-schedules.service.ts b/src/lib/services/report-schedules.service.ts new file mode 100644 index 00000000..99f30e4e --- /dev/null +++ b/src/lib/services/report-schedules.service.ts @@ -0,0 +1,250 @@ +/** + * Recurring report schedules. Each row pre-computes `next_run_at` so the + * BullMQ scheduler (P3) can poll `enabled=true AND next_run_at <= now()` + * efficiently. Cadence is an enum at v1; cron strings can layer later + * without a schema change (just add a parser branch in `nextRunFor`). + * + * Multi-tenant safe: every query carries `port_id = ctx.portId`. Recipient + * email + name lists travel as JSONB so reps can edit without per-row + * relational overhead — small lists, no need for a `schedule_recipients` + * relation in v1. + */ + +import { and, asc, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { + reportSchedules, + reportTemplates, + type ReportSchedule, + type NewReportSchedule, +} from '@/lib/db/schema/reports'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; +import { NotFoundError } from '@/lib/errors'; +import type { + CreateReportScheduleInput, + ListReportSchedulesInput, + ReportScheduleCadence, + UpdateReportScheduleInput, +} from '@/lib/validators/reports'; + +export interface ListReportSchedulesResult { + data: ReportSchedule[]; + total: number; + hasMore: boolean; +} + +export async function listReportSchedules( + portId: string, + query: ListReportSchedulesInput, +): Promise { + const conditions = [eq(reportSchedules.portId, portId)]; + if (query.enabled !== undefined) conditions.push(eq(reportSchedules.enabled, query.enabled)); + if (query.templateId) conditions.push(eq(reportSchedules.templateId, query.templateId)); + + const where = and(...conditions); + const offset = (query.page - 1) * query.pageSize; + + const [rows, total] = await Promise.all([ + db + .select() + .from(reportSchedules) + .where(where) + .orderBy(asc(reportSchedules.nextRunAt)) + .limit(query.pageSize) + .offset(offset), + db.$count(reportSchedules, where), + ]); + + return { + data: rows, + total: Number(total), + hasMore: offset + rows.length < Number(total), + }; +} + +export async function getReportSchedule(id: string, portId: string): Promise { + const row = await db.query.reportSchedules.findFirst({ + where: and(eq(reportSchedules.id, id), eq(reportSchedules.portId, portId)), + }); + if (!row) throw new NotFoundError('Report schedule'); + return row; +} + +/** + * Compute the next absolute fire-time for a cadence. Anchored to UTC for + * consistency; per-port timezone display happens in the render worker + * when it composes the actual email. + * + * - `weekly_monday_9` — next Monday 09:00 UTC + * - `monthly_first_9` — 1st of next month 09:00 UTC (or this month 1st if + * we're invoked before 09:00 today on the 1st) + * - `quarterly_first_9` — 1st of next quarter (Jan/Apr/Jul/Oct) 09:00 UTC + */ +export function nextRunFor(cadence: ReportScheduleCadence, now: Date = new Date()): Date { + const TARGET_HOUR_UTC = 9; + const out = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), TARGET_HOUR_UTC, 0, 0, 0), + ); + if (cadence === 'weekly_monday_9') { + // 1 = Monday. Roll forward until we land on Monday > now. + while (out.getUTCDay() !== 1 || out.getTime() <= now.getTime()) { + out.setUTCDate(out.getUTCDate() + 1); + } + return out; + } + if (cadence === 'monthly_first_9') { + out.setUTCDate(1); + if (out.getTime() <= now.getTime()) { + out.setUTCMonth(out.getUTCMonth() + 1); + } + return out; + } + // quarterly_first_9 + out.setUTCDate(1); + const month = out.getUTCMonth(); + const quarterStart = month - (month % 3); // 0 / 3 / 6 / 9 + out.setUTCMonth(quarterStart); + if (out.getTime() <= now.getTime()) { + out.setUTCMonth(quarterStart + 3); + } + return out; +} + +export interface CreateReportScheduleOptions { + portId: string; + meta: AuditMeta; +} + +export async function createReportSchedule( + input: CreateReportScheduleInput, + options: CreateReportScheduleOptions, +): Promise { + // Verify the template lives in this port — same guard as + // createReportRun. Without it the FK would catch the cross-port case + // with an opaque error. + const tmpl = await db.query.reportTemplates.findFirst({ + where: and( + eq(reportTemplates.id, input.templateId), + eq(reportTemplates.portId, options.portId), + ), + columns: { id: true }, + }); + if (!tmpl) throw new NotFoundError('Report template'); + + const nextRunAt = nextRunFor(input.cadence); + const values: NewReportSchedule = { + portId: options.portId, + templateId: input.templateId, + cadence: input.cadence, + recipients: input.recipients, + outputFormat: input.outputFormat, + enabled: input.enabled, + nextRunAt, + createdBy: options.meta.userId, + }; + + const [row] = await db.insert(reportSchedules).values(values).returning(); + if (!row) throw new Error('createReportSchedule: insert returned no row'); + + void createAuditLog({ + portId: options.portId, + userId: options.meta.userId, + action: 'create', + entityType: 'report_schedule', + entityId: row.id, + newValue: { + templateId: row.templateId, + cadence: row.cadence, + enabled: row.enabled, + recipientCount: input.recipients.length, + }, + ipAddress: options.meta.ipAddress, + userAgent: options.meta.userAgent, + }); + + return row; +} + +export interface UpdateReportScheduleOptions { + portId: string; + meta: AuditMeta; +} + +export async function updateReportSchedule( + id: string, + patch: UpdateReportScheduleInput, + options: UpdateReportScheduleOptions, +): Promise { + const existing = await getReportSchedule(id, options.portId); + const updates: Partial = { updatedAt: new Date() }; + if (patch.cadence !== undefined) updates.cadence = patch.cadence; + if (patch.recipients !== undefined) updates.recipients = patch.recipients; + if (patch.outputFormat !== undefined) updates.outputFormat = patch.outputFormat; + if (patch.enabled !== undefined) updates.enabled = patch.enabled; + + // Recompute nextRunAt on cadence change or on re-enable; otherwise leave + // it alone so a mid-cycle edit doesn't slip the next fire-time. + if (patch.cadence !== undefined) { + updates.nextRunAt = nextRunFor(patch.cadence); + } else if (patch.enabled === true && existing.enabled === false) { + updates.nextRunAt = nextRunFor(existing.cadence as ReportScheduleCadence); + } + + const [row] = await db + .update(reportSchedules) + .set(updates) + .where(and(eq(reportSchedules.id, id), eq(reportSchedules.portId, options.portId))) + .returning(); + if (!row) throw new NotFoundError('Report schedule'); + + void createAuditLog({ + portId: options.portId, + userId: options.meta.userId, + action: 'update', + entityType: 'report_schedule', + entityId: row.id, + oldValue: { + cadence: existing.cadence, + enabled: existing.enabled, + outputFormat: existing.outputFormat, + recipientCount: existing.recipients.length, + }, + newValue: { + cadence: row.cadence, + enabled: row.enabled, + outputFormat: row.outputFormat, + recipientCount: row.recipients.length, + }, + ipAddress: options.meta.ipAddress, + userAgent: options.meta.userAgent, + }); + + return row; +} + +export async function deleteReportSchedule( + id: string, + portId: string, + meta: AuditMeta, +): Promise { + const existing = await getReportSchedule(id, portId); + await db + .delete(reportSchedules) + .where(and(eq(reportSchedules.id, id), eq(reportSchedules.portId, portId))); + + void createAuditLog({ + portId, + userId: meta.userId, + action: 'delete', + entityType: 'report_schedule', + entityId: id, + oldValue: { + templateId: existing.templateId, + cadence: existing.cadence, + enabled: existing.enabled, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); +} diff --git a/src/lib/validators/reports.ts b/src/lib/validators/reports.ts index 9c153ef7..a7d787fa 100644 --- a/src/lib/validators/reports.ts +++ b/src/lib/validators/reports.ts @@ -20,3 +20,83 @@ export const listReportsSchema = z.object({ export type RequestReportInput = z.infer; export type ListReportsInput = z.infer; + +// ─── Reports P2: report_runs + report_schedules CRUD ───────────────────────── +// +// Reports page (`/{portSlug}/reports`) — the dedicated builder + history + +// schedules surface. P1 shipped the schema (migration 0084 + Drizzle); P2 +// adds the CRUD layer on the new tables. The legacy `generatedReports` flow +// above stays for the existing dashboard-export button until it migrates. + +export const REPORT_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const; +export type ReportKind = (typeof REPORT_KINDS)[number]; + +export const REPORT_OUTPUT_FORMATS = ['pdf', 'csv', 'png'] as const; +export type ReportOutputFormat = (typeof REPORT_OUTPUT_FORMATS)[number]; + +export const REPORT_RUN_STATUSES = ['pending', 'rendering', 'complete', 'failed'] as const; +export type ReportRunStatus = (typeof REPORT_RUN_STATUSES)[number]; + +export const REPORT_SCHEDULE_CADENCES = [ + 'weekly_monday_9', + 'monthly_first_9', + 'quarterly_first_9', +] as const; +export type ReportScheduleCadence = (typeof REPORT_SCHEDULE_CADENCES)[number]; + +// ─── report_runs ──────────────────────────────────────────────────────────── + +export const listReportRunsSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(20), + kind: z.enum(REPORT_KINDS).optional(), + status: z.enum(REPORT_RUN_STATUSES).optional(), + templateId: z.string().optional(), +}); +export type ListReportRunsInput = z.infer; + +const recipientSchema = z.object({ + name: z.string().max(120).optional(), + email: z.string().email(), +}); + +export const createReportRunSchema = z.object({ + kind: z.enum(REPORT_KINDS), + templateId: z.string().optional(), + // Same opaque shape report_templates accepts — the render queue + // re-validates per-kind at use time. + config: z.record(z.string(), z.unknown()), + outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'), + emailTo: z.array(recipientSchema).max(50).optional(), +}); +export type CreateReportRunInput = z.infer; + +// ─── report_schedules ──────────────────────────────────────────────────────── + +export const listReportSchedulesSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(50), + enabled: z + .union([z.literal('true'), z.literal('false')]) + .optional() + .transform((v) => (v === undefined ? undefined : v === 'true')), + templateId: z.string().optional(), +}); +export type ListReportSchedulesInput = z.infer; + +export const createReportScheduleSchema = z.object({ + templateId: z.string().min(1), + cadence: z.enum(REPORT_SCHEDULE_CADENCES), + recipients: z.array(recipientSchema).min(1).max(50), + outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'), + enabled: z.boolean().default(true), +}); +export type CreateReportScheduleInput = z.infer; + +export const updateReportScheduleSchema = z.object({ + cadence: z.enum(REPORT_SCHEDULE_CADENCES).optional(), + recipients: z.array(recipientSchema).min(1).max(50).optional(), + outputFormat: z.enum(REPORT_OUTPUT_FORMATS).optional(), + enabled: z.boolean().optional(), +}); +export type UpdateReportScheduleInput = z.infer; diff --git a/tests/integration/report-runs-schedules.test.ts b/tests/integration/report-runs-schedules.test.ts new file mode 100644 index 00000000..3b459266 --- /dev/null +++ b/tests/integration/report-runs-schedules.test.ts @@ -0,0 +1,258 @@ +/** + * Reports P2 — integration tests for report_runs + report_schedules CRUD. + * + * Covers: + * - createReportRun rejects template id from another port + * - createReportRun rejects when config.kind ≠ outer kind + * - listReportRuns filters by kind / status / templateId + * - createReportSchedule computes nextRunAt deterministically per cadence + * - updateReportSchedule recomputes nextRunAt on cadence change but NOT + * on a no-op edit + * - deleteReportSchedule leaves linked report_runs with a NULL schedule_id + * (ON DELETE SET NULL contract) + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { reportRuns, reportSchedules, reportTemplates } from '@/lib/db/schema/reports'; +import { user } from '@/lib/db/schema/users'; + +let makePort: typeof import('../helpers/factories').makePort; +let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; +let runsSvc: typeof import('@/lib/services/report-runs.service'); +let schedulesSvc: typeof import('@/lib/services/report-schedules.service'); +let TEST_USER_ID = ''; + +beforeAll(async () => { + const factories = await import('../helpers/factories'); + makePort = factories.makePort; + makeAuditMeta = factories.makeAuditMeta; + runsSvc = await import('@/lib/services/report-runs.service'); + schedulesSvc = await import('@/lib/services/report-schedules.service'); + // Schedules + run-triggered-by-user FK against the real user table; pull + // the first seeded row so test inserts don't trip 23503. + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +function testMeta(portId: string) { + return makeAuditMeta({ portId, userId: TEST_USER_ID }); +} + +async function makeTemplate(portId: string, kind = 'dashboard') { + const [row] = await db + .insert(reportTemplates) + .values({ + portId, + kind, + name: `T-${crypto.randomUUID().slice(0, 8)}`, + config: { kind }, + createdBy: TEST_USER_ID, + }) + .returning(); + return row!; +} + +describe('report-runs.service', () => { + it('rejects a templateId from a different port', async () => { + const portA = await makePort(); + const portB = await makePort(); + const tmplB = await makeTemplate(portB.id); + await expect( + runsSvc.createReportRun( + { + kind: 'dashboard', + templateId: tmplB.id, + config: { kind: 'dashboard' }, + outputFormat: 'pdf', + }, + { + portId: portA.id, + triggeredBy: 'user', + triggeredByUserId: TEST_USER_ID, + meta: testMeta(portA.id), + }, + ), + ).rejects.toThrow(/report template/i); + }); + + it('rejects when config.kind does not match outer kind', async () => { + const port = await makePort(); + await expect( + runsSvc.createReportRun( + { kind: 'clients', config: { kind: 'dashboard' }, outputFormat: 'pdf' }, + { + portId: port.id, + triggeredBy: 'user', + triggeredByUserId: TEST_USER_ID, + meta: testMeta(port.id), + }, + ), + ).rejects.toThrow(/config\.kind must equal/); + }); + + it('filters listReportRuns by kind + status', async () => { + const port = await makePort(); + await runsSvc.createReportRun( + { kind: 'dashboard', config: { kind: 'dashboard' }, outputFormat: 'pdf' }, + { + portId: port.id, + triggeredBy: 'user', + triggeredByUserId: TEST_USER_ID, + meta: testMeta(port.id), + }, + ); + await runsSvc.createReportRun( + { kind: 'clients', config: { kind: 'clients' }, outputFormat: 'csv' }, + { + portId: port.id, + triggeredBy: 'user', + triggeredByUserId: TEST_USER_ID, + meta: testMeta(port.id), + }, + ); + + const dashboardOnly = await runsSvc.listReportRuns(port.id, { + kind: 'dashboard', + page: 1, + pageSize: 20, + }); + expect(dashboardOnly.data.every((r) => r.kind === 'dashboard')).toBe(true); + expect(dashboardOnly.total).toBe(1); + + const allPending = await runsSvc.listReportRuns(port.id, { + status: 'pending', + page: 1, + pageSize: 20, + }); + expect(allPending.total).toBe(2); + }); +}); + +describe('report-schedules.service', () => { + it('nextRunFor weekly_monday_9 always lands on a Monday strictly after now', () => { + const cases = [ + new Date('2026-05-25T10:00:00Z'), // Monday after target hour + new Date('2026-05-27T08:00:00Z'), // Wednesday before + new Date('2026-05-31T23:59:00Z'), // Sunday late + ]; + for (const now of cases) { + const out = schedulesSvc.nextRunFor('weekly_monday_9', now); + expect(out.getUTCDay()).toBe(1); + expect(out.getUTCHours()).toBe(9); + expect(out.getTime()).toBeGreaterThan(now.getTime()); + } + }); + + it('nextRunFor monthly_first_9 lands on the 1st of the next month after target hour', () => { + const now = new Date('2026-05-25T10:00:00Z'); + const out = schedulesSvc.nextRunFor('monthly_first_9', now); + expect(out.getUTCDate()).toBe(1); + expect(out.getUTCMonth()).toBe(5); // June + expect(out.getUTCHours()).toBe(9); + }); + + it('nextRunFor quarterly_first_9 picks the next quarter start', () => { + const now = new Date('2026-05-25T10:00:00Z'); + const out = schedulesSvc.nextRunFor('quarterly_first_9', now); + expect(out.getUTCDate()).toBe(1); + expect(out.getUTCMonth()).toBe(6); // July + expect(out.getUTCHours()).toBe(9); + }); + + it('createReportSchedule computes nextRunAt + persists', async () => { + const port = await makePort(); + const tmpl = await makeTemplate(port.id); + const row = await schedulesSvc.createReportSchedule( + { + templateId: tmpl.id, + cadence: 'weekly_monday_9', + recipients: [{ email: 'ops@example.com' }], + outputFormat: 'pdf', + enabled: true, + }, + { portId: port.id, meta: testMeta(port.id) }, + ); + expect(row.nextRunAt.getUTCDay()).toBe(1); + expect(row.recipients).toHaveLength(1); + expect(row.enabled).toBe(true); + }); + + it('updateReportSchedule recomputes nextRunAt only on cadence change OR re-enable', async () => { + const port = await makePort(); + const tmpl = await makeTemplate(port.id); + const row = await schedulesSvc.createReportSchedule( + { + templateId: tmpl.id, + cadence: 'weekly_monday_9', + recipients: [{ email: 'ops@example.com' }], + outputFormat: 'pdf', + enabled: true, + }, + { portId: port.id, meta: testMeta(port.id) }, + ); + const originalNext = row.nextRunAt.getTime(); + + // No-op edit (recipient change) — nextRunAt stays. + const after = await schedulesSvc.updateReportSchedule( + row.id, + { recipients: [{ email: 'sales@example.com' }] }, + { portId: port.id, meta: testMeta(port.id) }, + ); + expect(after.nextRunAt.getTime()).toBe(originalNext); + + // Cadence change — nextRunAt advances to next monthly first. + const afterCadence = await schedulesSvc.updateReportSchedule( + row.id, + { cadence: 'monthly_first_9' }, + { portId: port.id, meta: testMeta(port.id) }, + ); + expect(afterCadence.nextRunAt.getUTCDate()).toBe(1); + }); + + it('deleteReportSchedule sets schedule_id NULL on linked runs (no orphans)', async () => { + const port = await makePort(); + const tmpl = await makeTemplate(port.id); + const sched = await schedulesSvc.createReportSchedule( + { + templateId: tmpl.id, + cadence: 'weekly_monday_9', + recipients: [{ email: 'ops@example.com' }], + outputFormat: 'pdf', + enabled: true, + }, + { portId: port.id, meta: testMeta(port.id) }, + ); + + const run = await runsSvc.createReportRun( + { + kind: 'dashboard', + templateId: tmpl.id, + config: { kind: 'dashboard' }, + outputFormat: 'pdf', + }, + { + portId: port.id, + triggeredBy: 'schedule', + scheduleId: sched.id, + meta: testMeta(port.id), + }, + ); + + await schedulesSvc.deleteReportSchedule(sched.id, port.id, testMeta(port.id)); + + const after = await db.query.reportRuns.findFirst({ + where: and(eq(reportRuns.id, run.id), eq(reportRuns.portId, port.id)), + }); + expect(after?.scheduleId).toBeNull(); + }); +}); + +afterAll(async () => { + // No global cleanup; per-test ports keep rows isolated. + void reportRuns; + void reportSchedules; +});