422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 } 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 ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export interface AuditMeta {
|
||
|
|
userId: string;
|
||
|
|
portId: string;
|
||
|
|
ipAddress: string;
|
||
|
|
userAgent: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A version entry reconstructed from audit_log records.
|
||
|
|
*/
|
||
|
|
export interface TemplateVersion {
|
||
|
|
version: number;
|
||
|
|
content: Record<string, unknown>;
|
||
|
|
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<string, unknown> | null {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(bodyHtml) as unknown;
|
||
|
|
if (
|
||
|
|
parsed !== null &&
|
||
|
|
typeof parsed === 'object' &&
|
||
|
|
'type' in (parsed as Record<string, unknown>)
|
||
|
|
) {
|
||
|
|
return parsed as Record<string, unknown>;
|
||
|
|
}
|
||
|
|
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<string, unknown>,
|
||
|
|
): void {
|
||
|
|
const unsupported = validateTipTapDocument(
|
||
|
|
content as unknown as Parameters<typeof validateTipTapDocument>[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<typeof documentTemplates.$inferInsert> = {
|
||
|
|
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<TemplateVersion[]> {
|
||
|
|
// 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<string, unknown> | null;
|
||
|
|
return (
|
||
|
|
meta !== null &&
|
||
|
|
typeof meta === 'object' &&
|
||
|
|
'versionSnapshot' in meta &&
|
||
|
|
'content' in meta
|
||
|
|
);
|
||
|
|
})
|
||
|
|
.map((log) => {
|
||
|
|
const meta = log.metadata as Record<string, unknown>;
|
||
|
|
return {
|
||
|
|
version: meta.versionSnapshot as number,
|
||
|
|
content: meta.content as Record<string, unknown>,
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|