Files
pn-new-crm/src/app/api/v1/reports/templates/route.ts

78 lines
2.7 KiB
TypeScript
Raw Normal View History

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>
2026-05-21 20:46:52 +02:00
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
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>
2026-05-21 20:46:52 +02:00
* 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);
}
}),
);