Layer 1: createReportRun rejects a user-triggered run whose coverBrandPortId is a port the triggering user can't access (userCanAccessPort: super-admin or userPortRoles membership). Layer 2: renderReportRun only honors the override when it equals run.portId or the run's user is a member, else falls back to the source port's branding — so a forged/scheduled config can't leak another tenant's logo/name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
4.9 KiB
TypeScript
120 lines
4.9 KiB
TypeScript
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>;
|
|
|
|
// ─── 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',
|
|
'sales',
|
|
'operational',
|
|
'custom',
|
|
] 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. NOTE: the optional
|
|
// `config.coverBrandPortId` (cover-page brand swap) is intentionally NOT
|
|
// shape-validated here — it requires a runtime per-user membership check
|
|
// that Zod can't express. `createReportRun` rejects an override the
|
|
// triggering user can't access (H11), and the renderer strips a
|
|
// stale/forged one as defense-in-depth.
|
|
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),
|
|
// Empty recipients list = "run + archive, don't email". Per locked
|
|
// decision (2026-05-27): auto-email is OPTIONAL — an admin can
|
|
// schedule a run that just appears in /reports/runs without
|
|
// forcing an email blast.
|
|
recipients: z.array(recipientSchema).max(50).default([]),
|
|
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).max(50).optional(),
|
|
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).optional(),
|
|
enabled: z.boolean().optional(),
|
|
});
|
|
export type UpdateReportScheduleInput = z.infer<typeof updateReportScheduleSchema>;
|