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,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { getReportRun } from '@/lib/services/report-runs.service';
/**
* GET /api/v1/reports/runs/[id]
* Single-run detail. Status polling target for clients waiting on the
* render worker; download URL comes from a sibling endpoint
* (`/api/v1/reports/runs/[id]/download` — P3) once status is `complete`.
*/
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new NotFoundError('Report run');
const row = await getReportRun(id, ctx.portId);
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createReportRun, listReportRuns } from '@/lib/services/report-runs.service';
import { createReportRunSchema, listReportRunsSchema } from '@/lib/validators/reports';
/**
* GET /api/v1/reports/runs
* List the port's report-run history. Filterable by kind / status /
* templateId. Used by the /reports/runs surface for "re-run" and
* "re-email" affordances.
*
* POST /api/v1/reports/runs
* Queue a new render. Inserts a `pending` row; the BullMQ render worker
* (P3) picks it up. `templateId` is optional — anonymous one-off runs
* (e.g. the dashboard's "Export as PDF" button after migration) skip the
* template anchor and just pass a config directly.
*/
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req, ctx) => {
try {
const query = parseQuery(req, listReportRunsSchema);
const result = await listReportRuns(ctx.portId, query);
return NextResponse.json({
data: result.data,
total: result.total,
hasMore: result.hasMore,
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, createReportRunSchema);
const row = await createReportRun(body, {
portId: ctx.portId,
triggeredBy: 'user',
triggeredByUserId: ctx.userId,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: row }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteReportSchedule,
getReportSchedule,
updateReportSchedule,
} from '@/lib/services/report-schedules.service';
import { updateReportScheduleSchema } from '@/lib/validators/reports';
/**
* GET /api/v1/reports/schedules/[id]
* PATCH /api/v1/reports/schedules/[id] — update cadence, recipients,
* output, or enabled state.
* Re-enable triggers a fresh
* nextRunAt computation.
* DELETE /api/v1/reports/schedules/[id] — remove the schedule. Past
* report_runs keep their
* schedule_id via ON DELETE
* SET NULL so history stays
* traceable.
*/
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new NotFoundError('Report schedule');
const row = await getReportSchedule(id, ctx.portId);
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('reports', 'export', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new NotFoundError('Report schedule');
const body = await parseBody(req, updateReportScheduleSchema);
const row = await updateReportSchedule(id, body, {
portId: ctx.portId,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('reports', 'export', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new NotFoundError('Report schedule');
await deleteReportSchedule(id, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createReportSchedule, listReportSchedules } from '@/lib/services/report-schedules.service';
import { createReportScheduleSchema, listReportSchedulesSchema } from '@/lib/validators/reports';
/**
* GET /api/v1/reports/schedules
* List the port's recurring schedules. Filterable by enabled flag +
* templateId. Powers the /reports/schedules management surface.
*
* POST /api/v1/reports/schedules
* Create a recurring schedule. `nextRunAt` is computed server-side from
* the cadence; clients don't pass it. Recipients travel as a small JSON
* list — no relational expansion needed at v1 cadences/list sizes.
*/
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req, ctx) => {
try {
const query = parseQuery(req, listReportSchedulesSchema);
const result = await listReportSchedules(ctx.portId, query);
return NextResponse.json({
data: result.data,
total: result.total,
hasMore: result.hasMore,
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, createReportScheduleSchema);
const row = await createReportSchedule(body, {
portId: ctx.portId,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: row }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

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

View File

@@ -0,0 +1,258 @@
/**
* Reports P2 — integration tests for report_runs + report_schedules CRUD.
*
* Covers:
* - createReportRun rejects template id from another port
* - createReportRun rejects when config.kind ≠ outer kind
* - listReportRuns filters by kind / status / templateId
* - createReportSchedule computes nextRunAt deterministically per cadence
* - updateReportSchedule recomputes nextRunAt on cadence change but NOT
* on a no-op edit
* - deleteReportSchedule leaves linked report_runs with a NULL schedule_id
* (ON DELETE SET NULL contract)
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { reportRuns, reportSchedules, reportTemplates } from '@/lib/db/schema/reports';
import { user } from '@/lib/db/schema/users';
let makePort: typeof import('../helpers/factories').makePort;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
let runsSvc: typeof import('@/lib/services/report-runs.service');
let schedulesSvc: typeof import('@/lib/services/report-schedules.service');
let TEST_USER_ID = '';
beforeAll(async () => {
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeAuditMeta = factories.makeAuditMeta;
runsSvc = await import('@/lib/services/report-runs.service');
schedulesSvc = await import('@/lib/services/report-schedules.service');
// Schedules + run-triggered-by-user FK against the real user table; pull
// the first seeded row so test inserts don't trip 23503.
const [u] = await db.select({ id: user.id }).from(user).limit(1);
if (!u) throw new Error('No user available; run pnpm db:seed first');
TEST_USER_ID = u.id;
});
function testMeta(portId: string) {
return makeAuditMeta({ portId, userId: TEST_USER_ID });
}
async function makeTemplate(portId: string, kind = 'dashboard') {
const [row] = await db
.insert(reportTemplates)
.values({
portId,
kind,
name: `T-${crypto.randomUUID().slice(0, 8)}`,
config: { kind },
createdBy: TEST_USER_ID,
})
.returning();
return row!;
}
describe('report-runs.service', () => {
it('rejects a templateId from a different port', async () => {
const portA = await makePort();
const portB = await makePort();
const tmplB = await makeTemplate(portB.id);
await expect(
runsSvc.createReportRun(
{
kind: 'dashboard',
templateId: tmplB.id,
config: { kind: 'dashboard' },
outputFormat: 'pdf',
},
{
portId: portA.id,
triggeredBy: 'user',
triggeredByUserId: TEST_USER_ID,
meta: testMeta(portA.id),
},
),
).rejects.toThrow(/report template/i);
});
it('rejects when config.kind does not match outer kind', async () => {
const port = await makePort();
await expect(
runsSvc.createReportRun(
{ kind: 'clients', config: { kind: 'dashboard' }, outputFormat: 'pdf' },
{
portId: port.id,
triggeredBy: 'user',
triggeredByUserId: TEST_USER_ID,
meta: testMeta(port.id),
},
),
).rejects.toThrow(/config\.kind must equal/);
});
it('filters listReportRuns by kind + status', async () => {
const port = await makePort();
await runsSvc.createReportRun(
{ kind: 'dashboard', config: { kind: 'dashboard' }, outputFormat: 'pdf' },
{
portId: port.id,
triggeredBy: 'user',
triggeredByUserId: TEST_USER_ID,
meta: testMeta(port.id),
},
);
await runsSvc.createReportRun(
{ kind: 'clients', config: { kind: 'clients' }, outputFormat: 'csv' },
{
portId: port.id,
triggeredBy: 'user',
triggeredByUserId: TEST_USER_ID,
meta: testMeta(port.id),
},
);
const dashboardOnly = await runsSvc.listReportRuns(port.id, {
kind: 'dashboard',
page: 1,
pageSize: 20,
});
expect(dashboardOnly.data.every((r) => r.kind === 'dashboard')).toBe(true);
expect(dashboardOnly.total).toBe(1);
const allPending = await runsSvc.listReportRuns(port.id, {
status: 'pending',
page: 1,
pageSize: 20,
});
expect(allPending.total).toBe(2);
});
});
describe('report-schedules.service', () => {
it('nextRunFor weekly_monday_9 always lands on a Monday strictly after now', () => {
const cases = [
new Date('2026-05-25T10:00:00Z'), // Monday after target hour
new Date('2026-05-27T08:00:00Z'), // Wednesday before
new Date('2026-05-31T23:59:00Z'), // Sunday late
];
for (const now of cases) {
const out = schedulesSvc.nextRunFor('weekly_monday_9', now);
expect(out.getUTCDay()).toBe(1);
expect(out.getUTCHours()).toBe(9);
expect(out.getTime()).toBeGreaterThan(now.getTime());
}
});
it('nextRunFor monthly_first_9 lands on the 1st of the next month after target hour', () => {
const now = new Date('2026-05-25T10:00:00Z');
const out = schedulesSvc.nextRunFor('monthly_first_9', now);
expect(out.getUTCDate()).toBe(1);
expect(out.getUTCMonth()).toBe(5); // June
expect(out.getUTCHours()).toBe(9);
});
it('nextRunFor quarterly_first_9 picks the next quarter start', () => {
const now = new Date('2026-05-25T10:00:00Z');
const out = schedulesSvc.nextRunFor('quarterly_first_9', now);
expect(out.getUTCDate()).toBe(1);
expect(out.getUTCMonth()).toBe(6); // July
expect(out.getUTCHours()).toBe(9);
});
it('createReportSchedule computes nextRunAt + persists', async () => {
const port = await makePort();
const tmpl = await makeTemplate(port.id);
const row = await schedulesSvc.createReportSchedule(
{
templateId: tmpl.id,
cadence: 'weekly_monday_9',
recipients: [{ email: 'ops@example.com' }],
outputFormat: 'pdf',
enabled: true,
},
{ portId: port.id, meta: testMeta(port.id) },
);
expect(row.nextRunAt.getUTCDay()).toBe(1);
expect(row.recipients).toHaveLength(1);
expect(row.enabled).toBe(true);
});
it('updateReportSchedule recomputes nextRunAt only on cadence change OR re-enable', async () => {
const port = await makePort();
const tmpl = await makeTemplate(port.id);
const row = await schedulesSvc.createReportSchedule(
{
templateId: tmpl.id,
cadence: 'weekly_monday_9',
recipients: [{ email: 'ops@example.com' }],
outputFormat: 'pdf',
enabled: true,
},
{ portId: port.id, meta: testMeta(port.id) },
);
const originalNext = row.nextRunAt.getTime();
// No-op edit (recipient change) — nextRunAt stays.
const after = await schedulesSvc.updateReportSchedule(
row.id,
{ recipients: [{ email: 'sales@example.com' }] },
{ portId: port.id, meta: testMeta(port.id) },
);
expect(after.nextRunAt.getTime()).toBe(originalNext);
// Cadence change — nextRunAt advances to next monthly first.
const afterCadence = await schedulesSvc.updateReportSchedule(
row.id,
{ cadence: 'monthly_first_9' },
{ portId: port.id, meta: testMeta(port.id) },
);
expect(afterCadence.nextRunAt.getUTCDate()).toBe(1);
});
it('deleteReportSchedule sets schedule_id NULL on linked runs (no orphans)', async () => {
const port = await makePort();
const tmpl = await makeTemplate(port.id);
const sched = await schedulesSvc.createReportSchedule(
{
templateId: tmpl.id,
cadence: 'weekly_monday_9',
recipients: [{ email: 'ops@example.com' }],
outputFormat: 'pdf',
enabled: true,
},
{ portId: port.id, meta: testMeta(port.id) },
);
const run = await runsSvc.createReportRun(
{
kind: 'dashboard',
templateId: tmpl.id,
config: { kind: 'dashboard' },
outputFormat: 'pdf',
},
{
portId: port.id,
triggeredBy: 'schedule',
scheduleId: sched.id,
meta: testMeta(port.id),
},
);
await schedulesSvc.deleteReportSchedule(sched.id, port.id, testMeta(port.id));
const after = await db.query.reportRuns.findFirst({
where: and(eq(reportRuns.id, run.id), eq(reportRuns.portId, port.id)),
});
expect(after?.scheduleId).toBeNull();
});
});
afterAll(async () => {
// No global cleanup; per-test ports keep rows isolated.
void reportRuns;
void reportSchedules;
});