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:
2026-05-21 20:46:52 +02:00
parent 47c2ba9a99
commit 1cdc2fdc6d
9 changed files with 655 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
-- Saved-template store for the PDF report exporter.
--
-- Each row holds a named, port-scoped report configuration that
-- admins / sales-managers can save once and reps re-run with a
-- single click ("Monthly board report", "Weekly pipeline review",
-- etc.). The `config` JSONB matches the discriminated-union shape
-- the route schema enforces, so the same `kind` + `widgetIds` /
-- `columns` / `filters` keys round-trip cleanly.
--
-- Apply in dev:
-- PGPASSWORD=changeme psql -h localhost -p 5434 -U crm \
-- -d port_nimara_crm -f src/lib/db/migrations/0079_report_templates.sql
CREATE TABLE IF NOT EXISTS report_templates (
id text PRIMARY KEY,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
kind text NOT NULL,
name text NOT NULL,
description text,
config jsonb NOT NULL,
created_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_report_templates_port
ON report_templates (port_id);
CREATE INDEX IF NOT EXISTS idx_report_templates_port_kind
ON report_templates (port_id, kind);
-- Sibling-name uniqueness within a port + kind so reps don't end up
-- with two "Monthly board report" templates of the same kind.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_report_templates_port_kind_name
ON report_templates (port_id, kind, LOWER(name));

View File

@@ -74,6 +74,9 @@ export * from './supplemental-forms';
// Pipeline refactor — qualification criteria, payment records
export * from './pipeline';
// Saved PDF-report templates (`/api/v1/reports/templates`).
export * from './reports';
// Relations (must come last - references all tables)
export * from './relations';
export * from './tracked-links';

View File

@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { ports } from './ports';
/**
* Saved report templates. Each row captures a named, port-scoped
* configuration that backs the "Saved templates" dropdown in the
* Export-as-PDF dialog. The `config` JSONB matches the discriminated-
* union shape the route schema enforces, so reusing a template is a
* straight pass-through to /api/v1/reports/generate.
*
* Migration: 0079_report_templates.sql.
*/
export const reportTemplates = pgTable(
'report_templates',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
/** Mirrors the discriminator on ReportConfig — 'dashboard' |
* 'clients' | 'berths' | 'interests'. Validated at the route
* layer. */
kind: text('kind').notNull(),
name: text('name').notNull(),
description: text('description'),
/** Untyped at the Drizzle layer; route + service layers validate
* via the same zod schemas they use on `/api/v1/reports/generate`. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config: jsonb('config').$type<Record<string, any>>().notNull(),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_report_templates_port').on(table.portId),
index('idx_report_templates_port_kind').on(table.portId, table.kind),
// Sibling-name uniqueness per port+kind. The lower() expression in
// the SQL migration mirrors how case-insensitive collisions are
// handled elsewhere in the schema.
uniqueIndex('uniq_report_templates_port_kind_name').on(
table.portId,
table.kind,
sql`LOWER(${table.name})`,
),
],
);
export type ReportTemplate = typeof reportTemplates.$inferSelect;
export type NewReportTemplate = typeof reportTemplates.$inferInsert;

View File

@@ -0,0 +1,176 @@
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { reportTemplates, type ReportTemplate } from '@/lib/db/schema/reports';
import { ConflictError, NotFoundError } from '@/lib/errors';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
/**
* CRUD for saved PDF-report templates. Multi-tenant safe — every
* query carries `port_id = ctx.portId` and the unique sibling-name
* index is `(port_id, kind, LOWER(name))`, so two different ports
* can both have a "Monthly board report" template without colliding.
*
* The `config` field is opaque to this service; the route layer
* validates it via the same zod discriminated-union schema it uses
* on `/api/v1/reports/generate`, then passes the parsed value
* through.
*/
export interface CreateReportTemplateInput {
portId: string;
kind: string;
name: string;
description?: string | null;
config: Record<string, unknown>;
meta: AuditMeta;
}
export async function createReportTemplate(
input: CreateReportTemplateInput,
): Promise<ReportTemplate> {
try {
const [row] = await db
.insert(reportTemplates)
.values({
portId: input.portId,
kind: input.kind,
name: input.name,
description: input.description ?? null,
config: input.config,
createdBy: input.meta.userId,
})
.returning();
if (!row) throw new Error('createReportTemplate: insert returned no row');
void createAuditLog({
portId: input.portId,
userId: input.meta.userId,
action: 'create',
entityType: 'report_template',
entityId: row.id,
newValue: { kind: row.kind, name: row.name },
ipAddress: input.meta.ipAddress,
userAgent: input.meta.userAgent,
});
return row;
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError(
`A "${input.name}" template already exists for the ${input.kind} kind. Pick a different name.`,
);
}
throw err;
}
}
export async function listReportTemplates(
portId: string,
kind?: string,
): Promise<ReportTemplate[]> {
const whereClause = kind
? and(eq(reportTemplates.portId, portId), eq(reportTemplates.kind, kind))
: eq(reportTemplates.portId, portId);
return db
.select()
.from(reportTemplates)
.where(whereClause)
.orderBy(asc(reportTemplates.kind), asc(reportTemplates.name));
}
export async function getReportTemplate(id: string, portId: string): Promise<ReportTemplate> {
const row = await db.query.reportTemplates.findFirst({
where: and(eq(reportTemplates.id, id), eq(reportTemplates.portId, portId)),
});
if (!row) throw new NotFoundError('Report template');
return row;
}
export interface UpdateReportTemplateInput {
id: string;
portId: string;
name?: string;
description?: string | null;
config?: Record<string, unknown>;
meta: AuditMeta;
}
export async function updateReportTemplate(
input: UpdateReportTemplateInput,
): Promise<ReportTemplate> {
const existing = await getReportTemplate(input.id, input.portId);
const patch: Partial<typeof reportTemplates.$inferInsert> = {
updatedAt: new Date(),
};
if (input.name !== undefined) patch.name = input.name;
if (input.description !== undefined) patch.description = input.description;
if (input.config !== undefined) patch.config = input.config;
try {
const [row] = await db
.update(reportTemplates)
.set(patch)
.where(and(eq(reportTemplates.id, input.id), eq(reportTemplates.portId, input.portId)))
.returning();
if (!row) throw new NotFoundError('Report template');
void createAuditLog({
portId: input.portId,
userId: input.meta.userId,
action: 'update',
entityType: 'report_template',
entityId: row.id,
oldValue: { name: existing.name, description: existing.description },
newValue: { name: row.name, description: row.description },
ipAddress: input.meta.ipAddress,
userAgent: input.meta.userAgent,
});
return row;
} catch (err) {
if (isSiblingNameConflict(err)) {
throw new ConflictError(
`A template with that name already exists for the ${existing.kind} kind.`,
);
}
throw err;
}
}
export async function deleteReportTemplate(
id: string,
portId: string,
meta: AuditMeta,
): Promise<void> {
const existing = await getReportTemplate(id, portId);
await db
.delete(reportTemplates)
.where(and(eq(reportTemplates.id, id), eq(reportTemplates.portId, portId)));
void createAuditLog({
portId,
userId: meta.userId,
action: 'delete',
entityType: 'report_template',
entityId: id,
oldValue: { kind: existing.kind, name: existing.name },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
function isSiblingNameConflict(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
};
const code = e.code ?? e.cause?.code;
if (code !== '23505') return false;
const constraint =
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
return constraint === 'uniq_report_templates_port_kind_name';
}