fix(documents): tighten owner resolution + cover company/yacht paths
Three follow-ups from Task 7 code review: 1. Drop the dead interest.yachtId fallback branch. interests.clientId is NOT NULL so the yacht branch was unreachable. Comment explains the schema constraint so the branch can be re-added if that constraint is ever relaxed. 2. Add defense-in-depth port_id filter to the interests lookup inside resolveDocumentOwner (matches CLAUDE.md convention and every other interests query in this file). 3. Add two integration test cases for direct-company and direct-yacht owner resolution — closes the coverage gap where the signed-file row's companyId/yachtId columns are populated for the first time in this commit chain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1079,27 +1079,30 @@ interface ResolvedOwner {
|
||||
* 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> {
|
||||
async function resolveDocumentOwner(
|
||||
portId: string,
|
||||
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) {
|
||||
// interests.clientId is NOT NULL — if the interest row exists, the
|
||||
// client owner is always resolvable through it. The yacht-only path
|
||||
// would require relaxing the schema constraint first.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, doc.interestId),
|
||||
columns: { clientId: true, yachtId: true },
|
||||
where: and(eq(interests.id, doc.interestId), eq(interests.portId, portId)),
|
||||
columns: { clientId: true },
|
||||
});
|
||||
if (interest?.clientId) {
|
||||
return { entityType: 'client', entityId: interest.clientId };
|
||||
}
|
||||
if (interest?.yachtId) {
|
||||
return { entityType: 'yacht', entityId: interest.yachtId };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1129,12 +1132,17 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
|
||||
// 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);
|
||||
const owner = await resolveDocumentOwner(doc.portId, doc);
|
||||
|
||||
let entityFolderId: string | null = null;
|
||||
if (owner) {
|
||||
try {
|
||||
const folder = await ensureEntityFolder(doc.portId, owner.entityType, owner.entityId, 'system');
|
||||
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.
|
||||
|
||||
@@ -23,7 +23,7 @@ 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';
|
||||
import { makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories';
|
||||
|
||||
// Stub Documenso download — do NOT hit the network.
|
||||
vi.mock('@/lib/services/documenso-client', async (importOriginal) => {
|
||||
@@ -116,10 +116,7 @@ describe('handleDocumentCompleted · auto-deposit', () => {
|
||||
|
||||
// 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),
|
||||
),
|
||||
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||||
});
|
||||
expect(folder).toBeDefined();
|
||||
expect(fileRow?.folderId).toBe(folder!.id);
|
||||
@@ -199,13 +196,91 @@ describe('handleDocumentCompleted · auto-deposit', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('company-direct: signed PDF lands in the company subfolder', async () => {
|
||||
const company = await makeCompany({ portId });
|
||||
|
||||
const documensoId = `docu-auto-deposit-company-${Date.now()}`;
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId,
|
||||
companyId: company.id,
|
||||
documentType: 'other',
|
||||
title: 'Auto-deposit test (company direct)',
|
||||
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?.companyId).toBe(company.id);
|
||||
expect(fileRow?.folderId).not.toBeNull();
|
||||
|
||||
const folder = await db.query.documentFolders.findFirst({
|
||||
where: and(
|
||||
eq(documentFolders.entityType, 'client'),
|
||||
eq(documentFolders.entityId, clientId),
|
||||
eq(documentFolders.entityType, 'company'),
|
||||
eq(documentFolders.entityId, company.id),
|
||||
),
|
||||
});
|
||||
expect(folder).toBeDefined();
|
||||
expect(fileRow?.folderId).toBe(folder!.id);
|
||||
});
|
||||
|
||||
it('yacht-direct: signed PDF lands in the yacht subfolder', async () => {
|
||||
// Yachts require a real owner client per schema constraint.
|
||||
const ownerClient = await makeClient({ portId });
|
||||
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: ownerClient.id });
|
||||
|
||||
const documensoId = `docu-auto-deposit-yacht-${Date.now()}`;
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId,
|
||||
yachtId: yacht.id,
|
||||
documentType: 'other',
|
||||
title: 'Auto-deposit test (yacht direct)',
|
||||
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?.yachtId).toBe(yacht.id);
|
||||
expect(fileRow?.folderId).not.toBeNull();
|
||||
|
||||
const folder = await db.query.documentFolders.findFirst({
|
||||
where: and(eq(documentFolders.entityType, 'yacht'), eq(documentFolders.entityId, yacht.id)),
|
||||
});
|
||||
expect(folder).toBeDefined();
|
||||
expect(fileRow?.folderId).toBe(folder!.id);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user