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:
2026-05-11 11:48:44 +02:00
parent ee6e3f3f3f
commit 8e2e2ea113
2 changed files with 103 additions and 20 deletions

View File

@@ -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.