feat(tenancies-p3): webhook auto-create on signed Reservation Agreement + first-insert flip

- berth-tenancies.service.ts: autoCreatePendingTenancies(portId, interestId, opts)
  loops over interest_berths WHERE is_in_eoi_bundle=true and mints ONE
  pending tenancy per in-bundle berth. Wrapped in pg_advisory_xact_lock
  per port + idempotent skip when a (pending|active) tenancy already
  exists for the berth (webhook retry-safe). Each insert audit-logged
  + emits berth_tenancy:created socket event.
- createPending: same advisory-lock + tx pattern, additionally calls
  enableTenanciesModule(portId) so the FIRST manual tenancy in a port
  lazily flips tenancies_module_enabled=true (idempotent UPSERT, no-op
  on subsequent inserts).
- handleDocumentCompleted: branch on reservation_agreement completion
  gates on isTenanciesModuleEnabled, then calls autoCreatePendingTenancies
  with the just-committed signedFileId. Per design §"When disabled":
  stage advance + reservationDocStatus flip still fire when the module
  is off; only the tenancy mint is skipped.
- 5-case integration test covering bundle expansion, idempotent retry,
  empty-bundle no-op, missing-interest no-op, and the first-insert
  module-enable side effect.

Verified: tsc clean, 1485/1485 vitest (5 new cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:14:37 +02:00
parent ccc775dc66
commit 20549fb22e
3 changed files with 364 additions and 25 deletions

View File

@@ -1660,6 +1660,36 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
// Tenancies P3 — auto-create pending tenancies (one per in-bundle berth)
// when the module is enabled for this port. Gating is at the call site:
// disabled module = stage + docStatus updates still fire, only the
// tenancy mint is skipped (per docs/tenancies-design.md §"When disabled").
void (async () => {
try {
const { isTenanciesModuleEnabled } =
await import('@/lib/services/tenancies-module.service');
if (!(await isTenanciesModuleEnabled(doc.portId))) return;
const { autoCreatePendingTenancies } =
await import('@/lib/services/berth-tenancies.service');
// Re-read signedFileId from the post-commit row; the in-tx update
// above sets it, but doc was loaded before completion.
const fresh = await db.query.documents.findFirst({
where: eq(documents.id, doc.id),
columns: { signedFileId: true },
});
await autoCreatePendingTenancies(doc.portId, doc.interestId!, {
signedAt: new Date(),
sourceDocumentId: doc.id,
signedFileId: fresh?.signedFileId ?? null,
});
} catch (err) {
logger.error(
{ err, documentId: doc.id, interestId: doc.interestId, portId: doc.portId },
'autoCreatePendingTenancies failed during reservation_agreement completion',
);
}
})();
}
// Update interest if contract type. Outcome flip to 'won' is a separate