Files
pn-new-crm/src/lib/validators/reports.ts

103 lines
4.2 KiB
TypeScript
Raw Normal View History

import { z } from 'zod';
export const requestReportSchema = z.object({
reportType: z.enum(['pipeline', 'revenue', 'activity', 'occupancy']),
name: z.string().min(1).max(200),
parameters: z
.object({
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
})
.optional()
.default({}),
});
export const listReportsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
status: z.enum(['queued', 'processing', 'ready', 'failed']).optional(),
});
export type RequestReportInput = z.infer<typeof requestReportSchema>;
export type ListReportsInput = z.infer<typeof listReportsSchema>;
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>
2026-05-25 14:26:18 +02:00
// ─── 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>;