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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user