feat(reports): saved-template store + CRUD + dialog integration (phase C)
Saves rep-configured export setups so a "Monthly board report" or
"Weekly pipeline review" template only has to be assembled once.
Schema (migration 0079_report_templates.sql + drizzle entry):
- report_templates: id, port_id, kind, name, description, config
(jsonb), created_by, created_at, updated_at.
- Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so
Port A and Port B can both have "Quarterly review" without
colliding, and two different KINDS in the same port can share a
name (a clients "Quarterly review" + an interests "Quarterly
review" coexist).
- port_id FK cascades on delete; templates evaporate with the
parent port. No cross-port enumeration risk since every query
filters by port_id.
Service (src/lib/services/report-templates.service.ts):
- createReportTemplate / listReportTemplates / getReportTemplate /
updateReportTemplate / deleteReportTemplate.
- Audit-logs every write with old/new values for the rename case.
- Surfaces sibling-name collisions as ConflictError with a
rep-readable message ('A "Monthly board report" template
already exists for the dashboard kind').
Routes:
- GET /api/v1/reports/templates?kind=clients
- POST /api/v1/reports/templates
- GET /api/v1/reports/templates/[id]
- PATCH /api/v1/reports/templates/[id]
- DELETE /api/v1/reports/templates/[id]
All gated on `reports.export` — same permission as generating
reports lets the rep manage the templates that drive them.
POST cross-validates that `body.kind === body.config.kind` so a
rep can't sneak a dashboard config into a clients template and
confuse the rendering path at use time.
UI:
- SavedTemplatesPicker reusable component — dropdown of templates
for this port + kind, inline "Save as template" toggle that
expands to a name input + Save button, delete button next to
the picker once a template is selected.
- Wired into both ExportDashboardPdfButton + ExportListPdfButton.
Applying a saved template hydrates the dialog's form (selected
widgets / filters / title) from the saved config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
src/app/api/v1/reports/templates/[id]/route.ts
Normal file
78
src/app/api/v1/reports/templates/[id]/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
deleteReportTemplate,
|
||||
getReportTemplate,
|
||||
updateReportTemplate,
|
||||
} from '@/lib/services/report-templates.service';
|
||||
|
||||
const patchBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(400).nullable().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.refine((v) => v.name !== undefined || v.description !== undefined || v.config !== undefined, {
|
||||
message: 'At least one field must be provided',
|
||||
});
|
||||
|
||||
/**
|
||||
* GET — single template (used by the dialog to hydrate a saved
|
||||
* template into the form when the rep picks it).
|
||||
* PATCH — rename, retitle, or rewrite the config.
|
||||
* DELETE — remove. Cascade-safe: no FKs reference a saved template.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'export', async (_req, ctx, params) => {
|
||||
try {
|
||||
const row = await getReportTemplate(params.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 body = await parseBody(req, patchBodySchema);
|
||||
const row = await updateReportTemplate({
|
||||
id: params.id!,
|
||||
portId: ctx.portId,
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
config: body.config,
|
||||
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 {
|
||||
await deleteReportTemplate(params.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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
77
src/app/api/v1/reports/templates/route.ts
Normal file
77
src/app/api/v1/reports/templates/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
|
||||
|
||||
const createBodySchema = z.object({
|
||||
kind: z.enum(['dashboard', 'clients', 'berths', 'interests']),
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(400).nullable().optional(),
|
||||
// Config is the raw discriminated-union payload; the
|
||||
// /api/v1/reports/generate route re-validates at use time, so we
|
||||
// accept it as `record` here without imposing the full union shape.
|
||||
config: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/reports/templates?kind=clients
|
||||
* List saved templates for the active port, optionally filtered to
|
||||
* one kind. Used by the Export dialog's saved-templates dropdown.
|
||||
*
|
||||
* POST /api/v1/reports/templates
|
||||
* Persist a template. The dialog calls this when the rep ticks
|
||||
* "Save as template" while configuring an export.
|
||||
*
|
||||
* Both gated on `reports.export` — the same permission that lets
|
||||
* the rep generate reports also lets them save templates.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'export', async (req, ctx) => {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const kind = url.searchParams.get('kind') ?? undefined;
|
||||
const rows = await listReportTemplates(ctx.portId, kind ?? undefined);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('reports', 'export', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createBodySchema);
|
||||
// Cross-validate that the config's discriminator matches the
|
||||
// outer `kind`. Without this, a rep could save a clients-kind
|
||||
// template with a dashboard config and confuse the rendering
|
||||
// path at use time.
|
||||
const configKind = (body.config as { kind?: unknown }).kind;
|
||||
if (configKind !== body.kind) {
|
||||
return NextResponse.json(
|
||||
{ error: `config.kind must equal "${body.kind}"` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const row = await createReportTemplate({
|
||||
portId: ctx.portId,
|
||||
kind: body.kind,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
config: body.config,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ data: row });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user