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

@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { systemSettings } from '@/lib/db/schema/system';
import { autoCreatePendingTenancies } from '@/lib/services/berth-tenancies.service';
import {
enableTenanciesModule,
isTenanciesModuleEnabled,
} from '@/lib/services/tenancies-module.service';
import { makeBerth, makeClient, makePort, makeYacht } from '../helpers/factories';
async function seedInterestWithBundleBerths(
portId: string,
bundleCount: number,
): Promise<{ interestId: string; clientId: string; yachtId: string; berthIds: string[] }> {
const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
const [interest] = await db
.insert(interests)
.values({
portId,
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'reservation',
outcome: 'open',
})
.returning();
const berthIds: string[] = [];
for (let i = 0; i < bundleCount; i++) {
const b = await makeBerth({ portId });
berthIds.push(b.id);
await db.insert(interestBerths).values({
interestId: interest!.id,
berthId: b.id,
isInEoiBundle: true,
isPrimary: i === 0,
isSpecificInterest: true,
});
}
return { interestId: interest!.id, clientId: client.id, yachtId: yacht.id, berthIds };
}
async function disableModule(portId: string): Promise<void> {
await db
.insert(systemSettings)
.values({ key: 'tenancies_module_enabled', portId, value: false })
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: { value: false, updatedAt: new Date() },
});
}
describe('autoCreatePendingTenancies', () => {
let portId: string;
beforeEach(async () => {
const port = await makePort();
portId = port.id;
// Start each test with the module explicitly disabled so the lazy
// auto-enable path doesn't taint the next test's port.
await disableModule(portId);
});
afterEach(async () => {
// Make sure no stale advisory locks from a failed test linger.
});
it('mints one pending tenancy per in-bundle berth', async () => {
await enableTenanciesModule(portId);
const { interestId, berthIds } = await seedInterestWithBundleBerths(portId, 3);
const result = await autoCreatePendingTenancies(portId, interestId, {
signedAt: new Date('2026-01-15'),
sourceDocumentId: 'doc-fixture-123',
signedFileId: null,
});
expect(result).toHaveLength(3);
expect(result.every((r) => r.status === 'pending')).toBe(true);
expect(result.every((r) => r.interestId === interestId)).toBe(true);
expect(result.every((r) => r.contractFileId === null)).toBe(true);
expect(result.map((r) => r.berthId).sort()).toEqual(berthIds.sort());
});
it('skips berths that already have a live tenancy (idempotent under retry)', async () => {
await enableTenanciesModule(portId);
const { interestId, berthIds } = await seedInterestWithBundleBerths(portId, 2);
const first = await autoCreatePendingTenancies(portId, interestId, {
signedAt: new Date(),
sourceDocumentId: 'doc-1',
signedFileId: null,
});
expect(first).toHaveLength(2);
const replay = await autoCreatePendingTenancies(portId, interestId, {
signedAt: new Date(),
sourceDocumentId: 'doc-1',
signedFileId: null,
});
expect(replay).toHaveLength(0);
const allRows = await db
.select()
.from(berthTenancies)
.where(and(eq(berthTenancies.portId, portId), eq(berthTenancies.interestId, interestId)));
expect(allRows).toHaveLength(2);
expect(allRows.map((r) => r.berthId).sort()).toEqual(berthIds.sort());
});
it('returns empty when the interest has no in-bundle berths', async () => {
await enableTenanciesModule(portId);
const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
const [interest] = await db
.insert(interests)
.values({
portId,
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'reservation',
outcome: 'open',
})
.returning();
const result = await autoCreatePendingTenancies(portId, interest!.id, {
signedAt: new Date(),
sourceDocumentId: 'doc-empty',
signedFileId: null,
});
expect(result).toEqual([]);
});
it('does not mint when the interest is missing (deleted before webhook fires)', async () => {
await enableTenanciesModule(portId);
const result = await autoCreatePendingTenancies(portId, 'nonexistent-interest', {
signedAt: new Date(),
sourceDocumentId: 'doc-x',
signedFileId: null,
});
expect(result).toEqual([]);
});
});
describe('isTenanciesModuleEnabled (P3 lazy auto-enable on first manual createPending)', () => {
it('flips to enabled after first manual createPending', async () => {
const port = await makePort();
const portId = port.id;
await disableModule(portId);
expect(await isTenanciesModuleEnabled(portId)).toBe(false);
const client = await makeClient({ portId });
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
const berth = await makeBerth({ portId });
const { createPending } = await import('@/lib/services/berth-tenancies.service');
await createPending(
portId,
{
berthId: berth.id,
clientId: client.id,
yachtId: yacht.id,
startDate: new Date(),
},
{ userId: 'system', portId, ipAddress: '0.0.0.0', userAgent: 'test' },
);
expect(await isTenanciesModuleEnabled(portId)).toBe(true);
});
});