From ee6e3f3f3f19a0a43fe414989b43a4b5b6ce9481 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 11:41:47 +0200 Subject: [PATCH] feat(documents): auto-deposit signed PDFs into entity folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleDocumentCompleted resolves the workflow owner via the Owner-wins chain (document.clientId → companyId → yachtId, then interest.clientId → yachtId), ensures the matching entity subfolder, and sets files.folder_id + the matching entity FK on the signed file row. Falls back to root (folder_id=null) when no owner is resolvable. ensureEntityFolder failures are logged at warn level — the signed PDF always lands; the backfill script heals missing folders. The interest fallback omits the company branch because interests table has no companyId column. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/documents.service.ts | 67 +++++- .../documents-completion-auto-deposit.test.ts | 211 ++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 tests/integration/documents-completion-auto-deposit.test.ts diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 35b6ade7..7471035c 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -37,7 +37,9 @@ import { getPortEoiSigners } from '@/lib/services/documenso-payload'; import { listTree, collectDescendantIds, + ensureEntityFolder, type FolderNode, + type EntityType, } from '@/lib/services/document-folders.service'; import type { CreateDocumentInput, @@ -1062,6 +1064,46 @@ export async function handleRecipientSigned(eventData: { }); } +// ─── Owner-wins resolution ──────────────────────────────────────────────────── + +interface ResolvedOwner { + entityType: EntityType; + entityId: string; +} + +/** + * Owner-wins owner resolution chain — see spec §"Routing on workflow + * completion" §3a. Returns the first non-null candidate in priority + * order: direct client/company/yacht FK on the document, then via the + * linked interest's client / yacht FK. The interests table has no + * companyId (per schema), so the company branch is omitted from the + * interest fallback. Returns null when no owner is resolvable. + */ +async function resolveDocumentOwner(doc: { + clientId: string | null; + companyId: string | null; + yachtId: string | null; + interestId: string | null; +}): Promise { + if (doc.clientId) return { entityType: 'client', entityId: doc.clientId }; + if (doc.companyId) return { entityType: 'company', entityId: doc.companyId }; + if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId }; + + if (doc.interestId) { + const interest = await db.query.interests.findFirst({ + where: eq(interests.id, doc.interestId), + columns: { clientId: true, yachtId: true }, + }); + if (interest?.clientId) { + return { entityType: 'client', entityId: interest.clientId }; + } + if (interest?.yachtId) { + return { entityType: 'yacht', entityId: interest.yachtId }; + } + } + return null; +} + export async function handleDocumentCompleted(eventData: { documentId: string; portId?: string }) { const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId); if (!doc) return; @@ -1085,11 +1127,34 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p sizeBytes: signedPdfBuffer.length, }); + // Resolve owner via the Owner-wins chain. The signed PDF lands in + // this owner's auto-created entity subfolder (or at root if no owner). + const owner = await resolveDocumentOwner(doc); + + let entityFolderId: string | null = null; + if (owner) { + try { + const folder = await ensureEntityFolder(doc.portId, owner.entityType, owner.entityId, 'system'); + entityFolderId = folder.id; + } catch (err) { + // Folder creation is best-effort — signed file still lands at root. + // Logged at warn level: missing entity folder is recoverable via + // the backfill script. + logger.warn( + { err, documentId: doc.id, owner }, + 'ensureEntityFolder failed during document completion', + ); + } + } + const [fileRecord] = await db .insert(files) .values({ portId: doc.portId, - clientId: doc.clientId ?? null, + clientId: owner?.entityType === 'client' ? owner.entityId : (doc.clientId ?? null), + companyId: owner?.entityType === 'company' ? owner.entityId : (doc.companyId ?? null), + yachtId: owner?.entityType === 'yacht' ? owner.entityId : (doc.yachtId ?? null), + folderId: entityFolderId, filename: `signed-${doc.id}.pdf`, originalName: `signed-${doc.id}.pdf`, mimeType: 'application/pdf', diff --git a/tests/integration/documents-completion-auto-deposit.test.ts b/tests/integration/documents-completion-auto-deposit.test.ts new file mode 100644 index 00000000..2c1c23c1 --- /dev/null +++ b/tests/integration/documents-completion-auto-deposit.test.ts @@ -0,0 +1,211 @@ +/** + * Task 7 — handleDocumentCompleted auto-deposit. + * + * Verifies that when a document is completed: + * - The signed PDF is deposited into the owner's entity subfolder + * (files.folderId set + the matching entity FK set). + * - Owner is resolved via the Owner-wins chain: + * direct clientId / companyId / yachtId on the document, then + * interest.clientId / interest.yachtId when only interestId is set. + * - No owner → folderId=null, entity FKs null. + * + * Fixture convention: makePort + makeClient from helpers/factories; + * TEST_USER_ID resolved once via beforeAll from a seeded user, matching + * document-folders-crud.test.ts and document-folders-system-folders.test.ts. + */ + +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, files, documentFolders } from '@/lib/db/schema/documents'; +import { interests } from '@/lib/db/schema/interests'; +import { user } from '@/lib/db/schema/users'; +import { handleDocumentCompleted } from '@/lib/services/documents.service'; +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { makeClient, makePort } from '../helpers/factories'; + +// Stub Documenso download — do NOT hit the network. +vi.mock('@/lib/services/documenso-client', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')), + }; +}); + +// Stub storage backend — write to an in-memory map so no MinIO required. +const stubPuts = new Map(); +vi.mock('@/lib/storage', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + getStorageBackend: vi.fn(async () => ({ + put: async (path: string, data: Buffer) => { + stubPuts.set(path, data); + }, + get: async (path: string) => stubPuts.get(path) ?? Buffer.alloc(0), + head: async (path: string) => { + const buf = stubPuts.get(path); + return buf ? { sizeBytes: buf.length, contentType: 'application/pdf' } : null; + }, + delete: async (path: string) => { + stubPuts.delete(path); + }, + presignedGet: async () => 'http://stub-url', + presignedPut: async () => ({ url: 'http://stub-url', fields: {} }), + })), + }; +}); + +let TEST_USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +describe('handleDocumentCompleted · auto-deposit', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + stubPuts.clear(); + const port = await makePort(); + portId = port.id; + + // Ensure system roots exist (required for ensureEntityFolder to work). + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + + const client = await makeClient({ portId }); + clientId = client.id; + }); + + it('client-direct: signed PDF lands in the client subfolder', async () => { + const documensoId = `docu-auto-deposit-client-${Date.now()}`; + const [doc] = await db + .insert(documents) + .values({ + portId, + clientId, + documentType: 'eoi', + title: 'Auto-deposit test EOI (client)', + status: 'partially_signed', + documensoId, + createdBy: 'seed', + }) + .returning(); + + await handleDocumentCompleted({ documentId: documensoId, portId }); + + // Document should be marked completed. + const updatedDoc = await db.query.documents.findFirst({ + where: eq(documents.id, doc!.id), + }); + expect(updatedDoc?.status).toBe('completed'); + expect(updatedDoc?.signedFileId).not.toBeNull(); + + // Signed file should have folderId set (the client entity subfolder). + const fileRow = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + expect(fileRow?.folderId).not.toBeNull(); + expect(fileRow?.clientId).toBe(clientId); + + // Verify the folder is the entity folder for this client. + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.entityType, 'client'), + eq(documentFolders.entityId, clientId), + ), + }); + expect(folder).toBeDefined(); + expect(fileRow?.folderId).toBe(folder!.id); + }); + + it('no owner: signed PDF lands at root with folder_id=null', async () => { + const documensoId = `docu-auto-deposit-noowner-${Date.now()}`; + const [doc] = await db + .insert(documents) + .values({ + portId, + // No clientId, companyId, yachtId, or interestId. + documentType: 'other', + title: 'Auto-deposit test (no owner)', + status: 'partially_signed', + documensoId, + createdBy: 'seed', + }) + .returning(); + + await handleDocumentCompleted({ documentId: documensoId, portId }); + + const updatedDoc = await db.query.documents.findFirst({ + where: eq(documents.id, doc!.id), + }); + expect(updatedDoc?.status).toBe('completed'); + expect(updatedDoc?.signedFileId).not.toBeNull(); + + const fileRow = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + expect(fileRow?.folderId).toBeNull(); + expect(fileRow?.clientId).toBeNull(); + expect(fileRow?.companyId).toBeNull(); + expect(fileRow?.yachtId).toBeNull(); + }); + + it('via interest.clientId: resolves owner through linked interest', async () => { + // Create an interest with clientId pointing to our client. + const [interest] = await db + .insert(interests) + .values({ + portId, + clientId, + pipelineStage: 'eoi_sent', + }) + .returning(); + + const documensoId = `docu-auto-deposit-interest-${Date.now()}`; + const [doc] = await db + .insert(documents) + .values({ + portId, + interestId: interest!.id, + // All direct owner FKs null — owner must be resolved via interest. + documentType: 'eoi', + title: 'Auto-deposit test EOI (via interest)', + status: 'partially_signed', + documensoId, + createdBy: 'seed', + }) + .returning(); + + await handleDocumentCompleted({ documentId: documensoId, portId }); + + const updatedDoc = await db.query.documents.findFirst({ + where: eq(documents.id, doc!.id), + }); + expect(updatedDoc?.status).toBe('completed'); + expect(updatedDoc?.signedFileId).not.toBeNull(); + + const fileRow = await db.query.files.findFirst({ + where: eq(files.id, updatedDoc!.signedFileId!), + }); + // Should have been deposited with the interest's clientId. + expect(fileRow?.clientId).toBe(clientId); + expect(fileRow?.folderId).not.toBeNull(); + + // And the folder should be the client entity folder. + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.entityType, 'client'), + eq(documentFolders.entityId, clientId), + ), + }); + expect(folder).toBeDefined(); + expect(fileRow?.folderId).toBe(folder!.id); + }); +});