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:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user