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

@@ -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<ListReportRunsResult> {
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<ReportRun> {
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<ReportRun> {
// 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<ReportRun> {
const existing = await getReportRun(id, portId);
const updates: Partial<NewReportRun> = { 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;
}

View File

@@ -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<ListReportSchedulesResult> {
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<ReportSchedule> {
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<ReportSchedule> {
// 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<ReportSchedule> {
const existing = await getReportSchedule(id, options.portId);
const updates: Partial<NewReportSchedule> = { 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<void> {
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,
});
}

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>;