/** * Admin Document Template Service — TipTap JSON-based templates * * This service manages templates whose content is stored as TipTap JSON * (serialised to the `bodyHtml` text column). Version history is maintained * via audit_log entries (action='update', entityType='document_template', * metadata.version + metadata.content). * * Template type values: eoi | contract | nda | reservation_agreement | letter | other * These are stored in the `templateType` column. */ import { and, eq, desc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentTemplates } from '@/lib/db/schema/documents'; import { auditLogs } from '@/lib/db/schema/system'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { validateTipTapDocument } from '@/lib/pdf/tiptap-to-pdfme'; import type { CreateAdminTemplateInput, UpdateAdminTemplateInput, ListAdminTemplatesInput, } from '@/lib/validators/document-templates'; // ─── Types ──────────────────────────────────────────────────────────────────── /** * A version entry reconstructed from audit_log records. */ export interface TemplateVersion { version: number; content: Record; changedBy: string | null; changedAt: Date; auditLogId: string; } /** * Helper: extract the numeric version stored in a templateType-encoded field. * We use a convention: version is stored in the `mergeFields` jsonb array * as `["__version__:N"]` to avoid adding a new column. */ function getVersionFromRecord(record: typeof documentTemplates.$inferSelect): number { const mf = record.mergeFields as unknown; if (!Array.isArray(mf)) return 1; const versionEntry = (mf as string[]).find((e) => e.startsWith('__version__:')); if (!versionEntry) return 1; const n = parseInt(versionEntry.split(':')[1] ?? '1', 10); return isNaN(n) ? 1 : n; } function buildMergeFieldsWithVersion(version: number): string[] { return [`__version__:${version}`]; } /** * Parse TipTap JSON from bodyHtml field. Returns the parsed object, or null * if bodyHtml is plain HTML (legacy records). */ function parseTipTapContent(bodyHtml: string): Record | null { try { const parsed = JSON.parse(bodyHtml) as unknown; if ( parsed !== null && typeof parsed === 'object' && 'type' in (parsed as Record) ) { return parsed as Record; } return null; } catch { return null; } } // ─── List ───────────────────────────────────────────────────────────────────── export async function listAdminTemplates(portId: string, query: ListAdminTemplatesInput) { const { type, isActive } = query; const conditions = [eq(documentTemplates.portId, portId)]; if (type) { conditions.push(eq(documentTemplates.templateType, type)); } if (isActive !== undefined) { conditions.push(eq(documentTemplates.isActive, isActive)); } const rows = await db .select() .from(documentTemplates) .where(and(...conditions)) .orderBy(documentTemplates.name); return rows.map((row) => ({ ...row, version: getVersionFromRecord(row), content: parseTipTapContent(row.bodyHtml ?? ''), })); } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getAdminTemplate(portId: string, templateId: string) { const row = await db.query.documentTemplates.findFirst({ where: and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)), }); if (!row) { throw new NotFoundError('Document template'); } return { ...row, version: getVersionFromRecord(row), content: parseTipTapContent(row.bodyHtml ?? ''), }; } // ─── Validate TipTap Content ───────────────────────────────────────────────── function assertValidContent(content: Record): void { const unsupported = validateTipTapDocument( content as unknown as Parameters[0], ); if (unsupported.length > 0) { throw new ValidationError( `Template content contains unsupported node types: ${unsupported.join(', ')}. ` + 'Supported: paragraph, heading (h1-h3), bulletList, orderedList, listItem, ' + 'table, tableRow, tableCell, tableHeader, image, hardBreak, text.', ); } } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createAdminTemplate( portId: string, userId: string, data: CreateAdminTemplateInput, meta: AuditMeta, ) { assertValidContent(data.content); const [template] = await db .insert(documentTemplates) .values({ portId, name: data.name, templateType: data.type, bodyHtml: JSON.stringify(data.content), mergeFields: buildMergeFieldsWithVersion(1), isActive: true, createdBy: userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document_template', entityId: template!.id, newValue: { name: template!.name, type: data.type, version: 1 }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { ...template!, version: 1, content: data.content, }; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateAdminTemplate( portId: string, templateId: string, userId: string, data: UpdateAdminTemplateInput, meta: AuditMeta, ) { const existing = await getAdminTemplate(portId, templateId); if (data.content !== undefined) { assertValidContent(data.content); } const currentVersion = existing.version; const newVersion = data.content !== undefined ? currentVersion + 1 : currentVersion; // Before updating content, save old content to audit log for versioning if (data.content !== undefined) { void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document_template', entityId: templateId, oldValue: { version: currentVersion, name: existing.name }, newValue: { version: newVersion, name: data.name ?? existing.name }, metadata: { versionSnapshot: currentVersion, content: existing.content ?? {}, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } const updateValues: Partial = { updatedAt: new Date(), }; if (data.name !== undefined) { updateValues.name = data.name; } if (data.content !== undefined) { updateValues.bodyHtml = JSON.stringify(data.content); updateValues.mergeFields = buildMergeFieldsWithVersion(newVersion); } if (data.isActive !== undefined) { updateValues.isActive = data.isActive; } const [updated] = await db .update(documentTemplates) .set(updateValues) .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId))) .returning(); return { ...updated!, version: newVersion, content: data.content !== undefined ? data.content : existing.content, }; } // ─── Delete ─────────────────────────────────────────────────────────────────── export async function deleteAdminTemplate( portId: string, templateId: string, userId: string, meta: AuditMeta, ) { const existing = await getAdminTemplate(portId, templateId); await db .delete(documentTemplates) .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'document_template', entityId: templateId, oldValue: { name: existing.name, type: existing.templateType, version: existing.version }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } // ─── Version History ────────────────────────────────────────────────────────── /** * Retrieves version history for a template by querying audit_logs. * Each 'update' audit log entry with entityType='document_template' and * metadata.versionSnapshot contains a saved version. */ export async function getAdminTemplateVersions( portId: string, templateId: string, ): Promise { // Verify template exists and belongs to port await getAdminTemplate(portId, templateId); const logs = await db .select() .from(auditLogs) .where( and( eq(auditLogs.entityType, 'document_template'), eq(auditLogs.entityId, templateId), eq(auditLogs.action, 'update'), eq(auditLogs.portId, portId), ), ) .orderBy(desc(auditLogs.createdAt)); return logs .filter((log) => { const meta = log.metadata as Record | null; return ( meta !== null && typeof meta === 'object' && 'versionSnapshot' in meta && 'content' in meta ); }) .map((log) => { const meta = log.metadata as Record; return { version: meta.versionSnapshot as number, content: meta.content as Record, changedBy: log.userId, changedAt: log.createdAt, auditLogId: log.id, }; }); } // ─── Rollback ───────────────────────────────────────────────────────────────── /** * Restores a template to a previous version found in audit_logs. * Creates a new version number (current + 1) with the restored content. */ export async function rollbackAdminTemplate( portId: string, templateId: string, version: number, userId: string, meta: AuditMeta, ) { const existing = await getAdminTemplate(portId, templateId); const versions = await getAdminTemplateVersions(portId, templateId); const targetVersion = versions.find((v) => v.version === version); if (!targetVersion) { throw new NotFoundError(`Template version ${version}`); } const newVersion = existing.version + 1; // Save current state to audit log before rollback void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document_template', entityId: templateId, oldValue: { version: existing.version, name: existing.name }, newValue: { version: newVersion, name: existing.name, rolledBackTo: version }, metadata: { versionSnapshot: existing.version, content: existing.content ?? {}, rolledBackTo: version, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); const [updated] = await db .update(documentTemplates) .set({ bodyHtml: JSON.stringify(targetVersion.content), mergeFields: buildMergeFieldsWithVersion(newVersion), updatedAt: new Date(), }) .where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId))) .returning(); return { ...updated!, version: newVersion, content: targetVersion.content, rolledBackFrom: existing.version, rolledBackTo: version, }; }