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:
24
src/app/api/v1/reports/runs/[id]/route.ts
Normal file
24
src/app/api/v1/reports/runs/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
57
src/app/api/v1/reports/runs/route.ts
Normal file
57
src/app/api/v1/reports/runs/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
76
src/app/api/v1/reports/schedules/[id]/route.ts
Normal file
76
src/app/api/v1/reports/schedules/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
53
src/app/api/v1/reports/schedules/route.ts
Normal file
53
src/app/api/v1/reports/schedules/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
192
src/lib/services/report-runs.service.ts
Normal file
192
src/lib/services/report-runs.service.ts
Normal 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;
|
||||
}
|
||||
250
src/lib/services/report-schedules.service.ts
Normal file
250
src/lib/services/report-schedules.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
258
tests/integration/report-runs-schedules.test.ts
Normal file
258
tests/integration/report-runs-schedules.test.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user