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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:26:18 +02:00
parent 7476eabec6
commit 1e31ed66f1
8 changed files with 990 additions and 0 deletions

View File

@@ -20,3 +20,83 @@ export const listReportsSchema = z.object({
export type RequestReportInput = z.infer<typeof requestReportSchema>;
export type ListReportsInput = z.infer<typeof listReportsSchema>;
// ─── 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<typeof listReportRunsSchema>;
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<typeof createReportRunSchema>;
// ─── 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<typeof listReportSchedulesSchema>;
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<typeof createReportScheduleSchema>;
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<typeof updateReportScheduleSchema>;