feat(documents): auto-deposit signed PDFs into entity folders
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) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,9 @@ import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
|||||||
import {
|
import {
|
||||||
listTree,
|
listTree,
|
||||||
collectDescendantIds,
|
collectDescendantIds,
|
||||||
|
ensureEntityFolder,
|
||||||
type FolderNode,
|
type FolderNode,
|
||||||
|
type EntityType,
|
||||||
} from '@/lib/services/document-folders.service';
|
} from '@/lib/services/document-folders.service';
|
||||||
import type {
|
import type {
|
||||||
CreateDocumentInput,
|
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<ResolvedOwner | null> {
|
||||||
|
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 }) {
|
export async function handleDocumentCompleted(eventData: { documentId: string; portId?: string }) {
|
||||||
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
const doc = await resolveWebhookDocument(eventData.documentId, eventData.portId);
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
@@ -1085,11 +1127,34 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
|||||||
sizeBytes: signedPdfBuffer.length,
|
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
|
const [fileRecord] = await db
|
||||||
.insert(files)
|
.insert(files)
|
||||||
.values({
|
.values({
|
||||||
portId: doc.portId,
|
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`,
|
filename: `signed-${doc.id}.pdf`,
|
||||||
originalName: `signed-${doc.id}.pdf`,
|
originalName: `signed-${doc.id}.pdf`,
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
|||||||
211
tests/integration/documents-completion-auto-deposit.test.ts
Normal file
211
tests/integration/documents-completion-auto-deposit.test.ts
Normal file
@@ -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<typeof import('@/lib/services/documenso-client')>();
|
||||||
|
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<string, Buffer>();
|
||||||
|
vi.mock('@/lib/storage', async (importOriginal) => {
|
||||||
|
const real = await importOriginal<typeof import('@/lib/storage')>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user