diff --git a/src/app/api/public/berths/[mooringNumber]/route.ts b/src/app/api/public/berths/[mooringNumber]/route.ts index 81a409b3..126d019e 100644 --- a/src/app/api/public/berths/[mooringNumber]/route.ts +++ b/src/app/api/public/berths/[mooringNumber]/route.ts @@ -5,9 +5,11 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { berths, berthMapData } from '@/lib/db/schema/berths'; import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { berthTenancies } from '@/lib/db/schema/tenancies'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; -import { toPublicBerth } from '@/lib/services/public-berths'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; +import { isPermanentTenureType, toPublicBerth } from '@/lib/services/public-berths'; /** * GET /api/public/berths/[mooringNumber] @@ -105,7 +107,32 @@ export async function GET( .limit(1), ]); - const out = toPublicBerth(berth, mapData[0] ?? null, specificInterestRows.length > 0); + // Tenancies-module status flip: an active permanent-class tenancy + // pushes the berth to "Sold" when the module is enabled for this port. + let hasActivePermanentTenancy = false; + if (await isTenanciesModuleEnabled(port.id)) { + const activeTenancy = await db + .select({ tenureType: berthTenancies.tenureType }) + .from(berthTenancies) + .where( + and( + eq(berthTenancies.portId, port.id), + eq(berthTenancies.berthId, berth.id), + eq(berthTenancies.status, 'active'), + ), + ) + .limit(1); + hasActivePermanentTenancy = activeTenancy.some((row) => + isPermanentTenureType(row.tenureType), + ); + } + + const out = toPublicBerth( + berth, + mapData[0] ?? null, + specificInterestRows.length > 0, + hasActivePermanentTenancy, + ); if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') { logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range'); diff --git a/src/app/api/public/berths/route.ts b/src/app/api/public/berths/route.ts index ef57a9ac..770d5719 100644 --- a/src/app/api/public/berths/route.ts +++ b/src/app/api/public/berths/route.ts @@ -5,9 +5,15 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { berths, berthMapData } from '@/lib/db/schema/berths'; import { interestBerths, interests } from '@/lib/db/schema/interests'; +import { berthTenancies } from '@/lib/db/schema/tenancies'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; -import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths'; +import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; +import { + isPermanentTenureType, + toPublicBerth, + type PublicBerth, +} from '@/lib/services/public-berths'; /** * GET /api/public/berths @@ -109,8 +115,35 @@ export async function GET(request: Request): Promise { const mapByBerth = new Map(mapRows.map((m) => [m.berthId, m])); const specificInterestSet = new Set(specificInterestRows.map((r) => r.berthId)); + // Tenancies module: per-port flag. When enabled, an active tenancy + // with a permanent-class tenure type pushes a berth to "Sold" in the + // public feed (per docs/tenancies-design.md §"Public map status flip"). + // When disabled, the lookup is skipped entirely — preserves the + // pre-module behaviour for ports that haven't opted in. + const permanentTenancyBerthIds = new Set(); + if (await isTenanciesModuleEnabled(port.id)) { + const activeTenancyRows = await db + .select({ berthId: berthTenancies.berthId, tenureType: berthTenancies.tenureType }) + .from(berthTenancies) + .where( + and( + eq(berthTenancies.portId, port.id), + eq(berthTenancies.status, 'active'), + inArray(berthTenancies.berthId, berthIds), + ), + ); + for (const row of activeTenancyRows) { + if (isPermanentTenureType(row.tenureType)) permanentTenancyBerthIds.add(row.berthId); + } + } + const list = berthRows.map((b) => - toPublicBerth(b, mapByBerth.get(b.id) ?? null, specificInterestSet.has(b.id)), + toPublicBerth( + b, + mapByBerth.get(b.id) ?? null, + specificInterestSet.has(b.id), + permanentTenancyBerthIds.has(b.id), + ), ); // Validate the response enum before returning - any unknown status diff --git a/src/lib/services/public-berths.ts b/src/lib/services/public-berths.ts index 5ac18b2f..426664a9 100644 --- a/src/lib/services/public-berths.ts +++ b/src/lib/services/public-berths.ts @@ -78,20 +78,41 @@ function toString(value: unknown): string | null { } /** - * Derive the public-facing status string from the CRM's internal status - * + the per-berth `is_specific_interest` flag. Per plan §1: + * Tenure types that are treated as "permanent-class" by the public-map + * status resolver — an active tenancy with one of these tenure types + * pushes a berth into "Sold" even when `berths.status` is something + * weaker (`available`, `under_offer`). Per docs/tenancies-design.md § + * "Public map status flip". Seasonal / fixed-term tenancies do NOT flip + * the public status (they fall through to the existing precedence). + */ +const PERMANENT_TENURE_TYPES = new Set(['permanent', 'fee_simple', 'strata_lot']); + +export function isPermanentTenureType(tenureType: string | null | undefined): boolean { + return typeof tenureType === 'string' && PERMANENT_TENURE_TYPES.has(tenureType); +} + +/** + * Derive the public-facing status string. Precedence: * - * Public "Under Offer" = berth.status = 'sold'? No - that's "Sold". - * berth.status = 'under_offer' OR - * at least one interest_berths row marked - * is_specific_interest = true ON an active - * (non-archived) interest. + * Sold > Under Offer > Available + * + * Sold can come from: + * 1. berths.status = 'sold' (explicit admin set), OR + * 2. an active tenancy with tenure_type in PERMANENT_TENURE_TYPES + * exists for this berth (only when the tenancies module is enabled). + * + * Under Offer fires when berth.status='under_offer' OR at least one + * interest_berths row marked is_specific_interest=true exists on an + * active (non-archived, non-closed) interest. Seasonal / fixed-term + * active tenancies do not flip the public status — they fall through + * to the existing under-offer / available precedence. */ export function derivePublicStatus( internalStatus: string, hasSpecificInterest: boolean, + hasActivePermanentTenancy = false, ): PublicStatus { - if (internalStatus === 'sold') return 'Sold'; + if (internalStatus === 'sold' || hasActivePermanentTenancy) return 'Sold'; if (internalStatus === 'under_offer' || hasSpecificInterest) return 'Under Offer'; return 'Available'; } @@ -117,6 +138,7 @@ export function toPublicBerth( berth: Berth, mapData: BerthMapData | null | undefined, hasSpecificInterest: boolean, + hasActivePermanentTenancy = false, ): PublicBerth { return { Id: berth.id, @@ -127,7 +149,7 @@ export function toPublicBerth( 'Side Pontoon': toString(berth.sidePontoon), 'Power Capacity': toNumber(berth.powerCapacity), Voltage: toNumber(berth.voltage), - Status: derivePublicStatus(berth.status, hasSpecificInterest), + Status: derivePublicStatus(berth.status, hasSpecificInterest, hasActivePermanentTenancy), Area: toString(berth.area), 'Mooring Type': toString(berth.mooringType), 'Bow Facing': toString(berth.bowFacing), diff --git a/tests/unit/services/public-berths.test.ts b/tests/unit/services/public-berths.test.ts index b2dae69f..de5f6b23 100644 --- a/tests/unit/services/public-berths.test.ts +++ b/tests/unit/services/public-berths.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { derivePublicStatus, toPublicBerth } from '@/lib/services/public-berths'; +import { + derivePublicStatus, + isPermanentTenureType, + toPublicBerth, +} from '@/lib/services/public-berths'; import type { Berth, BerthMapData } from '@/lib/db/schema/berths'; function makeBerth(overrides: Partial = {}): Berth { @@ -93,6 +97,43 @@ describe('derivePublicStatus', () => { it('plain available stays available', () => { expect(derivePublicStatus('available', false)).toBe('Available'); }); + + // ─── Tenancies P4: permanent-tenancy flip ───────────────────────────── + describe('hasActivePermanentTenancy flag', () => { + it('flips an "available" berth to "Sold" when an active permanent tenancy exists', () => { + expect(derivePublicStatus('available', false, true)).toBe('Sold'); + }); + it('flips an "under_offer" berth to "Sold" (permanent tenancy is the strongest signal)', () => { + expect(derivePublicStatus('under_offer', true, true)).toBe('Sold'); + }); + it('explicit berths.status="sold" still resolves to "Sold" (idempotent)', () => { + expect(derivePublicStatus('sold', false, true)).toBe('Sold'); + }); + it('false flag falls through to existing precedence', () => { + expect(derivePublicStatus('available', true, false)).toBe('Under Offer'); + expect(derivePublicStatus('available', false, false)).toBe('Available'); + }); + it('default-omitted flag preserves pre-module behaviour', () => { + expect(derivePublicStatus('available', true)).toBe('Under Offer'); + }); + }); +}); + +describe('isPermanentTenureType', () => { + it('returns true for permanent-class tenure types', () => { + expect(isPermanentTenureType('permanent')).toBe(true); + expect(isPermanentTenureType('fee_simple')).toBe(true); + expect(isPermanentTenureType('strata_lot')).toBe(true); + }); + it('returns false for seasonal / fixed-term', () => { + expect(isPermanentTenureType('seasonal')).toBe(false); + expect(isPermanentTenureType('fixed_term')).toBe(false); + }); + it('returns false for null / undefined / unknown values', () => { + expect(isPermanentTenureType(null)).toBe(false); + expect(isPermanentTenureType(undefined)).toBe(false); + expect(isPermanentTenureType('garbage')).toBe(false); + }); }); describe('toPublicBerth', () => {