feat(documents): Phase A schema + service skeletons

Adds Phase A data model deltas to documents/templates and the new
document_watchers table. Introduces createFromWizard/createFromUpload
stubs, getDocumentDetail aggregator, cancelDocument flow, signed-doc
email composer, reservation agreement context, and notifyDocumentEvent
fan-out. Validator update accepts new template formats with html-only
bodyHtml requirement. EOI cadence backfilled to 1 day to preserve
current effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:12:05 +02:00
parent d8ac62f6f4
commit 0eff6050ae
11 changed files with 9961 additions and 72 deletions

View File

@@ -49,22 +49,16 @@ export interface TemplateVersion {
* 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 {
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__:'),
);
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[] {
function buildMergeFieldsWithVersion(version: number): string[] {
return [`__version__:${version}`];
}
@@ -72,9 +66,7 @@ function buildMergeFieldsWithVersion(
* 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 {
function parseTipTapContent(bodyHtml: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(bodyHtml) as unknown;
if (
@@ -92,10 +84,7 @@ function parseTipTapContent(
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listAdminTemplates(
portId: string,
query: ListAdminTemplatesInput,
) {
export async function listAdminTemplates(portId: string, query: ListAdminTemplatesInput) {
const { type, isActive } = query;
const conditions = [eq(documentTemplates.portId, portId)];
@@ -116,21 +105,15 @@ export async function listAdminTemplates(
return rows.map((row) => ({
...row,
version: getVersionFromRecord(row),
content: parseTipTapContent(row.bodyHtml),
content: parseTipTapContent(row.bodyHtml ?? ''),
}));
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getAdminTemplate(
portId: string,
templateId: string,
) {
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),
),
where: and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)),
});
if (!row) {
@@ -140,15 +123,13 @@ export async function getAdminTemplate(
return {
...row,
version: getVersionFromRecord(row),
content: parseTipTapContent(row.bodyHtml),
content: parseTipTapContent(row.bodyHtml ?? ''),
};
}
// ─── Validate TipTap Content ─────────────────────────────────────────────────
function assertValidContent(
content: Record<string, unknown>,
): void {
function assertValidContent(content: Record<string, unknown>): void {
const unsupported = validateTipTapDocument(
content as unknown as Parameters<typeof validateTipTapDocument>[0],
);
@@ -257,21 +238,13 @@ export async function updateAdminTemplate(
const [updated] = await db
.update(documentTemplates)
.set(updateValues)
.where(
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
)
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
.returning();
return {
...updated!,
version: newVersion,
content:
data.content !== undefined
? data.content
: existing.content,
content: data.content !== undefined ? data.content : existing.content,
};
}
@@ -287,12 +260,7 @@ export async function deleteAdminTemplate(
await db
.delete(documentTemplates)
.where(
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
);
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)));
void createAuditLog({
userId: meta.userId,
@@ -337,10 +305,7 @@ export async function getAdminTemplateVersions(
.filter((log) => {
const meta = log.metadata as Record<string, unknown> | null;
return (
meta !== null &&
typeof meta === 'object' &&
'versionSnapshot' in meta &&
'content' in meta
meta !== null && typeof meta === 'object' && 'versionSnapshot' in meta && 'content' in meta
);
})
.map((log) => {
@@ -403,12 +368,7 @@ export async function rollbackAdminTemplate(
mergeFields: buildMergeFieldsWithVersion(newVersion),
updatedAt: new Date(),
})
.where(
and(
eq(documentTemplates.id, templateId),
eq(documentTemplates.portId, portId),
),
)
.where(and(eq(documentTemplates.id, templateId), eq(documentTemplates.portId, portId)))
.returning();
return {