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:
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