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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||||||
@@ -113,6 +114,21 @@ export function ExportDashboardPdfButton() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<SavedTemplatesPicker
|
||||||
|
kind="dashboard"
|
||||||
|
currentConfig={{ widgetIds: selected }}
|
||||||
|
onApply={(t: SavedTemplate) => {
|
||||||
|
const cfg = t.config as { widgetIds?: string[] };
|
||||||
|
if (Array.isArray(cfg.widgetIds)) {
|
||||||
|
setSelected(
|
||||||
|
cfg.widgetIds.filter((id): id is PdfDashboardWidgetId =>
|
||||||
|
PDF_DASHBOARD_WIDGETS.some((w) => w.id === id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (t.name) setTitle(t.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="export-title">Title</Label>
|
<Label htmlFor="export-title">Title</Label>
|
||||||
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||||
|
|
||||||
type ListKind = 'clients' | 'berths' | 'interests';
|
type ListKind = 'clients' | 'berths' | 'interests';
|
||||||
|
|
||||||
@@ -110,6 +111,17 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<SavedTemplatesPicker
|
||||||
|
kind={kind}
|
||||||
|
currentConfig={{ filters: { includeArchived } }}
|
||||||
|
onApply={(t: SavedTemplate) => {
|
||||||
|
const cfg = t.config as { filters?: { includeArchived?: boolean } };
|
||||||
|
if (cfg.filters?.includeArchived !== undefined) {
|
||||||
|
setIncludeArchived(Boolean(cfg.filters.includeArchived));
|
||||||
|
}
|
||||||
|
if (t.name) setTitle(t.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={`export-title-${kind}`}>Title</Label>
|
<Label htmlFor={`export-title-${kind}`}>Title</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
205
src/components/reports/saved-templates-picker.tsx
Normal file
205
src/components/reports/saved-templates-picker.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Bookmark, Loader2, Save, Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
export interface SavedTemplate {
|
||||||
|
id: string;
|
||||||
|
portId: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
config: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
|
||||||
|
/** Called when the rep picks a template from the dropdown — the
|
||||||
|
* parent hydrates its form from the returned config. */
|
||||||
|
onApply: (template: SavedTemplate) => void;
|
||||||
|
/** Used by the "Save as template" toggle to capture the current
|
||||||
|
* dialog state when the rep checks the box. */
|
||||||
|
currentConfig: Record<string, unknown>;
|
||||||
|
/** When set, allow saving the current dialog state as a new
|
||||||
|
* template. The dialog manages the toggle + name input
|
||||||
|
* inline so reps don't need to leave the export flow. */
|
||||||
|
showSave?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable Saved-templates picker. Mounted at the top of both
|
||||||
|
* dashboard / list export dialogs.
|
||||||
|
*
|
||||||
|
* - Dropdown lists existing templates for this port + kind.
|
||||||
|
* - "Save as template" expands to a name field + Save button when
|
||||||
|
* showSave is true.
|
||||||
|
* - Delete action sits next to the dropdown when a template is
|
||||||
|
* selected, so the rep can prune the list without leaving the
|
||||||
|
* dialog.
|
||||||
|
*/
|
||||||
|
export function SavedTemplatesPicker({ kind, onApply, currentConfig, showSave = true }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selectedId, setSelectedId] = useState<string>('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveName, setSaveName] = useState('');
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: SavedTemplate[] }>({
|
||||||
|
queryKey: ['report-templates', kind],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: SavedTemplate[] }>(
|
||||||
|
`/api/v1/reports/templates?kind=${encodeURIComponent(kind)}`,
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await apiFetch(`/api/v1/reports/templates/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Template deleted');
|
||||||
|
setSelectedId('');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!saveName.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/v1/reports/templates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
kind,
|
||||||
|
name: saveName.trim(),
|
||||||
|
config: { ...currentConfig, kind },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success('Template saved');
|
||||||
|
setSaveName('');
|
||||||
|
setSaveOpen(false);
|
||||||
|
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApply(id: string) {
|
||||||
|
setSelectedId(id);
|
||||||
|
const template = data?.data.find((t) => t.id === id);
|
||||||
|
if (template) onApply(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<Bookmark className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
Saved templates
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={selectedId} onValueChange={handleApply} disabled={isLoading}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoading
|
||||||
|
? 'Loading…'
|
||||||
|
: templates.length === 0
|
||||||
|
? 'No saved templates yet'
|
||||||
|
: 'Choose a saved template'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteMutation.mutate(selectedId)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
aria-label="Delete template"
|
||||||
|
title="Delete this saved template"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showSave ? (
|
||||||
|
saveOpen ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => setSaveName(e.target.value)}
|
||||||
|
placeholder="Template name"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!saveName.trim() || saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSaveOpen(false);
|
||||||
|
setSaveName('');
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={false}
|
||||||
|
onCheckedChange={(c) => setSaveOpen(Boolean(c))}
|
||||||
|
aria-label="Save current config as a template"
|
||||||
|
/>
|
||||||
|
Save the current configuration as a template
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/lib/db/migrations/0079_report_templates.sql
Normal file
35
src/lib/db/migrations/0079_report_templates.sql
Normal 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));
|
||||||
@@ -74,6 +74,9 @@ export * from './supplemental-forms';
|
|||||||
// Pipeline refactor — qualification criteria, payment records
|
// Pipeline refactor — qualification criteria, payment records
|
||||||
export * from './pipeline';
|
export * from './pipeline';
|
||||||
|
|
||||||
|
// Saved PDF-report templates (`/api/v1/reports/templates`).
|
||||||
|
export * from './reports';
|
||||||
|
|
||||||
// Relations (must come last - references all tables)
|
// Relations (must come last - references all tables)
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
export * from './tracked-links';
|
export * from './tracked-links';
|
||||||
|
|||||||
53
src/lib/db/schema/reports.ts
Normal file
53
src/lib/db/schema/reports.ts
Normal 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;
|
||||||
176
src/lib/services/report-templates.service.ts
Normal file
176
src/lib/services/report-templates.service.ts
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user