Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
421
src/lib/services/document-templates.service.ts
Normal file
421
src/lib/services/document-templates.service.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user