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);
}
}),
);