Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View 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,
};
}