/** * interest_berths junction helpers. * * The junction is the source of truth for which berths an interest is * linked to. Callers should resolve "the berth for this deal" through * `getPrimaryBerth(interestId)` rather than reading the legacy * `interests.berth_id` column (slated for removal once every caller * is migrated - see plan §3.4). * * Role-flag semantics (see plan §1): * - is_primary : at most one row per interest. Templates, * forms, and "the berth for this deal" * UIs resolve through this row. * - is_specific_interest : the berth shows as "Under Offer" on the * public map. False = legal/EOI-only link. * - is_in_eoi_bundle : covered by the interest's EOI signature. */ import { and, desc, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interestBerths, type InterestBerth } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; // ─── Reads ────────────────────────────────────────────────────────────────── export interface PrimaryBerthRef { berthId: string; mooringNumber: string | null; isInEoiBundle: boolean; isSpecificInterest: boolean; } /** * The primary berth for an interest, if any. Resolves the row marked * `is_primary=true`; falls back to the most recently added berth row * when no row is flagged primary (defensive — the unique partial index * guarantees ≤1 primary, but reads should never throw on data drift). */ export async function getPrimaryBerth(interestId: string): Promise { const rows = await db .select({ berthId: interestBerths.berthId, isPrimary: interestBerths.isPrimary, isSpecificInterest: interestBerths.isSpecificInterest, isInEoiBundle: interestBerths.isInEoiBundle, addedAt: interestBerths.addedAt, mooringNumber: berths.mooringNumber, }) .from(interestBerths) .innerJoin(berths, eq(berths.id, interestBerths.berthId)) .where(eq(interestBerths.interestId, interestId)) .orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt)); const first = rows[0]; if (!first) return null; return { berthId: first.berthId, mooringNumber: first.mooringNumber, isInEoiBundle: first.isInEoiBundle, isSpecificInterest: first.isSpecificInterest, }; } /** * Map { interestId → primary berth ref } for a batch of interest ids. * One round-trip; preferred for list pages over a per-row helper. */ export async function getPrimaryBerthsForInterests( interestIds: string[], ): Promise> { if (interestIds.length === 0) return new Map(); const rows = await db .select({ interestId: interestBerths.interestId, berthId: interestBerths.berthId, isPrimary: interestBerths.isPrimary, isSpecificInterest: interestBerths.isSpecificInterest, isInEoiBundle: interestBerths.isInEoiBundle, addedAt: interestBerths.addedAt, mooringNumber: berths.mooringNumber, }) .from(interestBerths) .innerJoin(berths, eq(berths.id, interestBerths.berthId)) .where(sql`${interestBerths.interestId} = ANY(${interestIds})`) .orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt)); const out = new Map(); for (const r of rows) { if (out.has(r.interestId)) continue; out.set(r.interestId, { berthId: r.berthId, mooringNumber: r.mooringNumber, isInEoiBundle: r.isInEoiBundle, isSpecificInterest: r.isSpecificInterest, }); } return out; } /** All berth links for a single interest, ordered with primary first. */ export async function listBerthsForInterest( interestId: string, ): Promise> { return db .select({ id: interestBerths.id, interestId: interestBerths.interestId, berthId: interestBerths.berthId, isPrimary: interestBerths.isPrimary, isSpecificInterest: interestBerths.isSpecificInterest, isInEoiBundle: interestBerths.isInEoiBundle, eoiBypassReason: interestBerths.eoiBypassReason, eoiBypassedBy: interestBerths.eoiBypassedBy, eoiBypassedAt: interestBerths.eoiBypassedAt, addedBy: interestBerths.addedBy, addedAt: interestBerths.addedAt, notes: interestBerths.notes, mooringNumber: berths.mooringNumber, }) .from(interestBerths) .innerJoin(berths, eq(berths.id, interestBerths.berthId)) .where(eq(interestBerths.interestId, interestId)) .orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt)); } /** All interest links for a single berth (used by the recommender + admin UI). */ export async function listInterestsForBerth(berthId: string): Promise> { return db .select() .from(interestBerths) .where(eq(interestBerths.berthId, berthId)) .orderBy(desc(interestBerths.addedAt)); } // ─── Writes ───────────────────────────────────────────────────────────────── interface AddOrUpdateOpts { isPrimary?: boolean; isSpecificInterest?: boolean; isInEoiBundle?: boolean; addedBy?: string; notes?: string; } /** * Idempotently link a berth to an interest. If the row already exists, * provided flags are merged; otherwise a fresh row is inserted. * * When `isPrimary=true` is requested, the previous primary (if any) is * demoted in the same transaction so the unique partial index is never * violated. */ export async function upsertInterestBerth( interestId: string, berthId: string, opts: AddOrUpdateOpts = {}, ): Promise { return db.transaction(async (tx) => { if (opts.isPrimary === true) { await tx .update(interestBerths) .set({ isPrimary: false }) .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.isPrimary, true))); } const setForUpdate: Partial = {}; if (opts.isPrimary !== undefined) setForUpdate.isPrimary = opts.isPrimary; if (opts.isSpecificInterest !== undefined) setForUpdate.isSpecificInterest = opts.isSpecificInterest; if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle; if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy; if (opts.notes !== undefined) setForUpdate.notes = opts.notes; const [row] = await tx .insert(interestBerths) .values({ interestId, berthId, isPrimary: opts.isPrimary ?? false, isSpecificInterest: opts.isSpecificInterest ?? true, isInEoiBundle: opts.isInEoiBundle ?? false, addedBy: opts.addedBy, notes: opts.notes, }) .onConflictDoUpdate({ target: [interestBerths.interestId, interestBerths.berthId], set: setForUpdate, }) .returning(); return row!; }); } /** Promote a single berth to primary for the interest. Demotes any prior primary. */ export async function setPrimaryBerth(interestId: string, berthId: string): Promise { await upsertInterestBerth(interestId, berthId, { isPrimary: true }); } /** Remove a berth from an interest. */ export async function removeInterestBerth(interestId: string, berthId: string): Promise { await db .delete(interestBerths) .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))); }