import { and, desc, eq, exists, gte, inArray, isNull, ne, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests'; import { reminders, interestContactLog } from '@/lib/db/schema/operations'; import { clients, clientAddresses, clientContacts, clientNotes } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { documents, documentEvents } from '@/lib/db/schema/documents'; import { berthTenancies } from '@/lib/db/schema/tenancies'; import { yachts, yachtNotes } from '@/lib/db/schema/yachts'; import { companyMemberships, companyNotes } from '@/lib/db/schema/companies'; import { tags } from '@/lib/db/schema/system'; import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { activeInterestsWhere } from '@/lib/services/active-interest'; import { getPortReminderConfig } from '@/lib/services/port-config'; import { getSetting } from '@/lib/services/settings.service'; import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service'; import { logger } from '@/lib/logger'; import { getAllBerthMooringsForInterests, getPrimaryBerth, getPrimaryBerthsForInterests, listBerthsForInterest, removeInterestBerth, upsertInterestBerth, upsertInterestBerthTx, } from '@/lib/services/interest-berths.service'; import { formatBerthRange } from '@/lib/templates/berth-range'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore, withTransaction } from '@/lib/db/utils'; import { PIPELINE_STAGES, STAGE_LABELS, canTransitionStage, canonicalizeStage, type PipelineStage, } from '@/lib/constants'; import type { CreateInterestInput, UpdateInterestInput, ChangeStageInput, ListInterestsInput, SetOutcomeInput, ClearOutcomeInput, } from '@/lib/validators/interests'; // ─── Types ──────────────────────────────────────────────────────────────────── // ─── Port-scope FK validator ───────────────────────────────────────────────── // Tenant scope: every FK referenced from an interest body - clientId, berthId, // and yachtId - must belong to the caller's port. Without this, a body-supplied // foreign-port id would create an interest that joins through these FKs and // surfaces foreign-tenant data on subsequent reads (clientName, berth mooring // number, yacht ownership). assertYachtBelongsToClient still runs separately to // enforce the additional ownership invariant. async function assertInterestFksInPort( portId: string, fks: { clientId?: string | null; berthId?: string | null; yachtId?: string | null }, ): Promise { const checks: Array> = []; if (fks.clientId) { checks.push( db.query.clients .findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) }) .then((row) => { if (!row) throw new ValidationError('clientId not found in this port'); }), ); } if (fks.berthId) { checks.push( db.query.berths .findFirst({ where: and(eq(berths.id, fks.berthId), eq(berths.portId, portId)) }) .then((row) => { if (!row) throw new ValidationError('berthId not found in this port'); }), ); } if (fks.yachtId) { checks.push( db.query.yachts .findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) }) .then((row) => { if (!row) throw new ValidationError('yachtId not found in this port'); }), ); } await Promise.all(checks); } // ─── Yacht ownership validator ─────────────────────────────────────────────── async function assertYachtBelongsToClient( portId: string, yachtId: string, clientId: string, ): Promise { const yacht = await db.query.yachts.findFirst({ where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)), }); if (!yacht) throw new ValidationError('yacht not found'); // Direct ownership by client if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) { return; } // Company-represented: client has active membership in the owning company if (yacht.currentOwnerType === 'company') { const membership = await db.query.companyMemberships.findFirst({ where: and( eq(companyMemberships.companyId, yacht.currentOwnerId), eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate), ), }); if (membership) return; } throw new ValidationError('yacht does not belong to this client'); } // ─── BR-011: Auto-promote leadCategory ─────────────────────────────────────── async function resolveLeadCategory( clientId: string, leadCategory: string | undefined | null, yachtId?: string | null, ): Promise { if (leadCategory && leadCategory !== 'general_interest') { return leadCategory; } if (yachtId) { const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) }); if (yacht && (yacht.lengthFt || yacht.lengthM)) { return 'specific_qualified'; } } return leadCategory ?? undefined; } // ─── Board (kanban) ─────────────────────────────────────────────────────────── /** * Soft cap on board rows. The kanban legitimately needs every active * interest in one shot - paginating would split deals across pages and * break drag-drop semantics - but unbounded SELECTs are a footgun if a * port suddenly has tens of thousands of stale interests. At 5000 the * payload is still well under a megabyte (≈50 bytes per minimal row), * and any port near that ceiling needs virtualization in the kanban UI * anyway, so failing loud here is the right escalation. */ const BOARD_MAX_ROWS = 5000; export interface BoardInterestRow { id: string; clientName: string | null; berthMooringNumber: string | null; /** Every linked berth's mooring on this interest (sorted). Consumers * pass this through `deriveInterestBerthLabel` for the header / card * display so multi-berth interests render as `A1-A3, B5` rather than * just the primary mooring. */ berthMoorings: string[]; leadCategory: string | null; pipelineStage: string; updatedAt: Date; } export interface BoardFilters { /** Free-text search against client name. */ search?: string; leadCategory?: string; source?: string; eoiStatus?: string; /** Tag IDs the interest must be tagged with (any-of). */ tagIds?: string[]; } /** * Minimal-projection list for the kanban board. Skips the validator's * `max(100)` page cap since the board renders the entire pipeline at * once. Returns only the fields PipelineCard renders - no tags-list, no * notes-count, no EOI status badges, no urgency joins. Always filters * out archived interests (the kanban is for active deals; the list view * has the includeArchived toggle for history). * * Filters are intentionally a SUBSET of listInterests - `pipelineStage` * is omitted because the columns ARE the stages, and `includeArchived` * is omitted because the kanban shouldn't surface archived deals. * * One round-trip for the interests + clientName join, one batched * round-trip via getPrimaryBerthsForInterests for the mooring numbers, * and one batched lookup for tag-id filtering when supplied. */ export async function listInterestsForBoard( portId: string, filters: BoardFilters = {}, ): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> { // Kanban shows only active deals - terminal (outcome-set) rows have // their own /closed views. Pre-2026-05-14 this filter was just // `archivedAt IS NULL`, which worked because setOutcome moved the // stage to the 'completed' sentinel and the kanban only renders the // 7-stage canon. With the sentinel-stage cleanup, terminal rows now // keep their actual stage value, so we filter outcome explicitly. const conditions = [ eq(interests.portId, portId), isNull(interests.archivedAt), isNull(interests.outcome), ]; if (filters.leadCategory) { conditions.push(eq(interests.leadCategory, filters.leadCategory)); } if (filters.source) { conditions.push(eq(interests.source, filters.source)); } if (filters.eoiStatus) { conditions.push(eq(interests.eoiStatus, filters.eoiStatus)); } // Tag-id filter resolves through the join table first so the main // query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT // with LEFT JOIN - keeps Postgres' planner happy at scale. if (filters.tagIds && filters.tagIds.length > 0) { const tagMatches = await db .selectDistinct({ interestId: interestTags.interestId }) .from(interestTags) .where(inArray(interestTags.tagId, filters.tagIds)); const matchingIds = tagMatches.map((r) => r.interestId); if (matchingIds.length === 0) { return { data: [], truncated: false, total: 0 }; } conditions.push(inArray(interests.id, matchingIds)); } // Search hits client name via the LEFT JOIN. ILIKE is correct here - // the kanban list is small (≤5000 rows) so an index scan isn't // required, and pg_trgm would be overkill for the board surface. if (filters.search && filters.search.trim().length > 0) { const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`; conditions.push(sql`${clients.fullName} ILIKE ${term}`); } const rows = await db .select({ id: interests.id, clientName: clients.fullName, leadCategory: interests.leadCategory, pipelineStage: interests.pipelineStage, updatedAt: interests.updatedAt, }) .from(interests) .leftJoin(clients, eq(interests.clientId, clients.id)) .where(and(...conditions)) .orderBy(desc(interests.updatedAt)) .limit(BOARD_MAX_ROWS + 1); const truncated = rows.length > BOARD_MAX_ROWS; const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows; // Primary-berth resolution stays in the junction-aware service so the // board sees the same "the berth for this deal" as every other surface. // All-berth aggregator runs in parallel; both come from the same // interest_berths table so the round-trips are independent. const interestIds = data.map((r) => r.id); const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([ getPrimaryBerthsForInterests(interestIds), getAllBerthMooringsForInterests(interestIds), ]); return { data: data.map((r) => ({ id: r.id, clientName: r.clientName ?? null, berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null, berthMoorings: allBerthMooringsMap.get(r.id) ?? [], leadCategory: r.leadCategory ?? null, pipelineStage: r.pipelineStage, updatedAt: r.updatedAt, })), truncated, total: data.length, }; } // ─── List ───────────────────────────────────────────────────────────────────── export async function listInterests(portId: string, query: ListInterestsInput) { const { page, limit, sort, order, search, includeArchived, clientId, yachtId, berthId, pipelineStage, leadCategory, eoiStatus, tagIds, } = query; const filters = []; if (clientId) { filters.push(eq(interests.clientId, clientId)); } if (yachtId) { filters.push(eq(interests.yachtId, yachtId)); } if (berthId) { // EXISTS subquery against the junction: matches whether or not the // berth is the interest's primary, mirroring "this berth is linked // to this interest in any role" semantics from plan §3.4. filters.push( exists( db .select({ one: sql`1` }) .from(interestBerths) .where( and(eq(interestBerths.interestId, interests.id), eq(interestBerths.berthId, berthId)), ), ), ); } if (pipelineStage && pipelineStage.length > 0) { filters.push(inArray(interests.pipelineStage, pipelineStage)); } if (leadCategory) { filters.push(eq(interests.leadCategory, leadCategory)); } if (eoiStatus) { filters.push(eq(interests.eoiStatus, eoiStatus)); } if (tagIds && tagIds.length > 0) { const interestsWithTags = await db .selectDistinct({ interestId: interestTags.interestId }) .from(interestTags) .where(inArray(interestTags.tagId, tagIds)); const matchingIds = interestsWithTags.map((r) => r.interestId); if (matchingIds.length > 0) { filters.push(inArray(interests.id, matchingIds)); } else { return { data: [], total: 0 }; } } const sortColumn = (() => { switch (sort) { case 'pipelineStage': return interests.pipelineStage; case 'leadCategory': return interests.leadCategory; case 'createdAt': return interests.createdAt; case 'dateLastContact': // Postgres sorts NULLs last on DESC by default, which is the right // behaviour for triage (recently-contacted first, never-contacted // at the bottom). return interests.dateLastContact; default: return interests.updatedAt; } })(); const result = await buildListQuery({ table: interests, portIdColumn: interests.portId, portId, idColumn: interests.id, updatedAtColumn: interests.updatedAt, filters, sort: { column: sortColumn, direction: order }, page, pageSize: limit, searchColumns: [], searchTerm: search, includeArchived, archivedAtColumn: interests.archivedAt, }); // Join client names, primary-berth mooring numbers, and yacht names. const interestIds = (result.data as Array<{ id: string; clientId: string }>).map((i) => i.id); const clientIds = [ ...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)), ]; const yachtIds = [ ...new Set( (result.data as Array<{ yachtId: string | null }>) .map((i) => i.yachtId) .filter(Boolean) as string[], ), ]; let clientsMap: Record = {}; let yachtsMap: Record = {}; const tagsByInterestId: Record> = {}; const notesCountByInterestId: Record = {}; if (clientIds.length > 0) { const clientRows = await db .select({ id: clients.id, fullName: clients.fullName }) .from(clients) .where(inArray(clients.id, clientIds)); clientsMap = Object.fromEntries(clientRows.map((c) => [c.id, c.fullName])); } // Primary-berth lookup via the interest_berths junction. Single round-trip // by interestId list - see plan §3.4: every "the berth for this interest" // surface resolves through getPrimaryBerth(...) rather than a column read. // Sibling all-mooring aggregator runs in parallel so the list endpoint // can surface multi-berth labels (A1-A3, B5) without a second waterfall. const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([ getPrimaryBerthsForInterests(interestIds), getAllBerthMooringsForInterests(interestIds), ]); if (yachtIds.length > 0) { const yachtRows = await db .select({ id: yachts.id, name: yachts.name }) .from(yachts) .where(inArray(yachts.id, yachtIds)); yachtsMap = Object.fromEntries(yachtRows.map((y) => [y.id, y.name])); } if (interestIds.length > 0) { const tagRows = await db .select({ interestId: interestTags.interestId, id: tags.id, name: tags.name, color: tags.color, }) .from(interestTags) .innerJoin(tags, eq(interestTags.tagId, tags.id)) .where(inArray(interestTags.interestId, interestIds)); for (const row of tagRows) { if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = []; tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color }); } // Note counts per interest, for the comment-icon row affordance. const noteCountRows = await db .select({ interestId: interestNotes.interestId, count: sql`count(*)::int`, }) .from(interestNotes) .where(inArray(interestNotes.interestId, interestIds)) .groupBy(interestNotes.interestId); for (const row of noteCountRows) { notesCountByInterestId[row.interestId] = row.count; } } const data = (result.data as Array>).map((i) => { const primary = primaryBerthMap.get(i.id as string) ?? null; return { ...i, clientName: clientsMap[i.clientId as string] ?? null, berthId: primary?.berthId ?? null, berthMooringNumber: primary?.mooringNumber ?? null, berthMoorings: allBerthMooringsMap.get(i.id as string) ?? [], yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null, tags: tagsByInterestId[i.id as string] ?? [], notesCount: notesCountByInterestId[i.id as string] ?? 0, }; }); return { data, total: result.total }; } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getInterestById(id: string, portId: string) { const interest = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!interest || interest.portId !== portId) { throw new NotFoundError('Interest'); } const [clientRow] = await db .select({ fullName: clients.fullName }) .from(clients) .where(eq(clients.id, interest.clientId)); // EOI prerequisites + interest-detail header contact actions: surface the // linked client's primary email/phone (and the canonical E.164 form for // wa.me) so the header can render Email / Call / WhatsApp buttons without // a second fetch, and the Documents tab can show the EOI prereq checklist. const [emailContact] = await db .select({ id: clientContacts.id, value: clientContacts.value }) .from(clientContacts) .where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email'))) .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)) .limit(1); const [phoneContact] = await db .select({ id: clientContacts.id, value: clientContacts.value, valueE164: clientContacts.valueE164, valueCountry: clientContacts.valueCountry, }) .from(clientContacts) .where( and( eq(clientContacts.clientId, interest.clientId), inArray(clientContacts.channel, ['phone', 'whatsapp']), ), ) .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)) .limit(1); const [addressRow] = await db .select({ id: clientAddresses.id }) .from(clientAddresses) .where( and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)), ) .limit(1); // Primary berth comes from the interest_berths junction (plan §3.4). // All linked moorings come from the same junction in one go - powers // the multi-berth label rendered on every "interest header" surface. const [primaryBerth, allMooringsMap] = await Promise.all([ getPrimaryBerth(interest.id), getAllBerthMooringsForInterests([interest.id]), ]); const berthId = primaryBerth?.berthId ?? null; const berthMooringNumber = primaryBerth?.mooringNumber ?? null; const berthMoorings = allMooringsMap.get(interest.id) ?? []; // Total linked-berth count powers the "Berth Interest" milestone on // the OverviewTab - first thing the rep needs to capture, especially // for general_interest leads. Resolved here (not from the join above) // so the count includes berths the rep added without marking primary. const [{ count: linkedBerthCount } = { count: 0 }] = await db .select({ count: sql`count(*)::int` }) .from(interestBerths) .where(eq(interestBerths.interestId, id)); const tagRows = await db .select({ id: tags.id, name: tags.name, color: tags.color }) .from(interestTags) .innerJoin(tags, eq(interestTags.tagId, tags.id)) .where(eq(interestTags.interestId, id)); // Most-recent note preview for the Overview tab (the "do you have anything // outstanding on this lead?" peek). Returns the latest note's truncated // content + author/timestamp so the UI can render a one-line teaser. // Left-joins userProfiles so the teaser can show the author's display name // instead of leaking the raw user-id UUID. const [recentNote] = await db .select({ id: interestNotes.id, content: interestNotes.content, authorId: interestNotes.authorId, authorName: userProfiles.displayName, createdAt: interestNotes.createdAt, }) .from(interestNotes) .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId)) .where(eq(interestNotes.interestId, id)) .orderBy(desc(interestNotes.createdAt)) .limit(1); const [{ count: notesCount } = { count: 0 }] = await db .select({ count: sql`count(*)::int` }) .from(interestNotes) .where(eq(interestNotes.interestId, id)); // Aggregated note count = direct interest notes + notes attached to // the linked client, yacht (if any), and any companies the linked // client is an active member of. Surfaces as a separate field so the // existing notesCount badge stays accurate to "this interest only", // while the Notes tab can render the broader total when reps want // the full picture at a glance. const clientNotesP = db .select({ count: sql`count(*)::int` }) .from(clientNotes) .where(eq(clientNotes.clientId, interest.clientId)); const yachtNotesP = interest.yachtId ? db .select({ count: sql`count(*)::int` }) .from(yachtNotes) .where(eq(yachtNotes.yachtId, interest.yachtId)) : Promise.resolve([{ count: 0 }]); const companyMembershipsP = db .select({ companyId: companyMemberships.companyId }) .from(companyMemberships) .where( and(eq(companyMemberships.clientId, interest.clientId), isNull(companyMemberships.endDate)), ); const [clientNotesRow, yachtNotesRow, memberships] = await Promise.all([ clientNotesP, yachtNotesP, companyMembershipsP, ]); const companyIds = memberships.map((m) => m.companyId); let companyNotesCount = 0; if (companyIds.length > 0) { const [row] = await db .select({ count: sql`count(*)::int` }) .from(companyNotes) .where(inArray(companyNotes.companyId, companyIds)); companyNotesCount = row?.count ?? 0; } const notesCountAggregated = notesCount + (clientNotesRow[0]?.count ?? 0) + (yachtNotesRow[0]?.count ?? 0) + companyNotesCount; // Active reminder count for the interest's bell badge. Counts reminders // directly linked via interestId - `pending` and `snoozed` only; // completed/dismissed don't surface. const [{ count: activeReminderCount } = { count: 0 }] = await db .select({ count: sql`count(*)::int` }) .from(reminders) .where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed']))); // Activity log entries in the last 7 days - surfaces "rep is engaged" // as a separate signal in the deal-health pulse beyond the coarse // dateLastContact bump. const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000); const [{ count: recentActivityCount } = { count: 0 }] = await db .select({ count: sql`count(*)::int` }) .from(interestContactLog) .where( and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)), ); // Phase 2 - risk-signal derivation. Three dates feed `computeDealHealth` // off the existing event tables so the pulse chip surfaces document // declines / cancelled reservations / berth-resold-to-other without // adding bespoke timestamp columns on `interests`. Each query runs in // parallel; all return `null` when no matching event exists. const [declinedRow, cancelledReservationRow, berthResoldRow] = await Promise.all([ // Latest 'rejected' / 'declined' document event whose document is // linked to this interest. db .select({ at: documentEvents.createdAt }) .from(documentEvents) .innerJoin(documents, eq(documents.id, documentEvents.documentId)) .where( and( eq(documents.interestId, id), inArray(documentEvents.eventType, ['rejected', 'declined']), ), ) .orderBy(desc(documentEvents.createdAt)) .limit(1), // Latest cancelled berth_tenancy row pointing at this interest. // berth_tenancies has no cancelled_at column; updatedAt is set when // the row flips to status='cancelled', so it tracks the same moment. db .select({ at: berthTenancies.updatedAt }) .from(berthTenancies) .where(and(eq(berthTenancies.interestId, id), eq(berthTenancies.status, 'cancelled'))) .orderBy(desc(berthTenancies.updatedAt)) .limit(1), // "Berth sold to another deal" - any of this interest's linked berths // has at least one OTHER interest with a `won` outcome. Take the // latest such outcome timestamp. archivedAt is a close proxy for the // moment the win was finalised on the conflicting deal. // // The inner subquery resolves *this* interest's berth_ids; the outer // query joins interestBerths to the won other-interest and filters // its berth_id against that set. Using raw `sql` avoids the alias // collision a Drizzle `exists()` would create with the same table on // both sides of the correlation. db.execute( sql`SELECT MAX(other.archived_at) AS at FROM interests other JOIN interest_berths ob ON ob.interest_id = other.id WHERE other.id <> ${id} AND other.outcome = 'won' AND ob.berth_id IN ( SELECT berth_id FROM interest_berths WHERE interest_id = ${id} )`, ), ]); const dateDocumentDeclined = declinedRow[0]?.at ?? null; const dateReservationCancelled = cancelledReservationRow[0]?.at ?? null; // db.execute(sql`...`) returns either an array (postgres-js driver) or // a `{rows: []}` object depending on driver build - match the dual // shape used by src/lib/storage/migrate.ts. const berthResoldRaw = berthResoldRow as unknown as | Array<{ at: Date | null }> | { rows?: Array<{ at: Date | null }> }; const berthResoldRows = Array.isArray(berthResoldRaw) ? berthResoldRaw : (berthResoldRaw.rows ?? []); const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null; // Yacht dimensions for inheritance display in OverviewTab. When the // interest has a linked yacht we ship the yacht's length/width/draft // alongside the interest record so the Berth Requirements section can // render a "from yacht" pill in place of an empty value. This is a // display-only inheritance - the actual recommender source switch is // still governed by `interests.useYachtDimensions`. let yachtDimensions: { lengthFt: string | null; widthFt: string | null; draftFt: string | null; lengthM: string | null; widthM: string | null; draftM: string | null; } | null = null; if (interest.yachtId) { const [yachtRow] = await db .select({ lengthFt: yachts.lengthFt, widthFt: yachts.widthFt, draftFt: yachts.draftFt, lengthM: yachts.lengthM, widthM: yachts.widthM, draftM: yachts.draftM, }) .from(yachts) .where(eq(yachts.id, interest.yachtId)) .limit(1); if (yachtRow) { const anyDim = yachtRow.lengthFt || yachtRow.widthFt || yachtRow.draftFt || yachtRow.lengthM || yachtRow.widthM || yachtRow.draftM; if (anyDim) yachtDimensions = yachtRow; } } // Resolve the assignee's display name for the header chip - falling back // to the raw ID is fine if the user record is missing (deleted/disabled). let assignedToName: string | null = null; if (interest.assignedTo) { const [profile] = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, interest.assignedTo)) .limit(1); assignedToName = profile?.displayName ?? null; } return { ...interest, clientName: clientRow?.fullName ?? null, clientPrimaryEmail: emailContact?.value ?? null, /** Contact-row id for the primary email - surfaces so the interest UI * can inline-edit through PATCH /api/v1/clients/[id]/contacts/[contactId]. */ clientPrimaryEmailContactId: emailContact?.id ?? null, clientPrimaryPhone: phoneContact?.value ?? null, clientPrimaryPhoneContactId: phoneContact?.id ?? null, clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null, clientPrimaryPhoneCountry: phoneContact?.valueCountry ?? null, clientHasAddress: !!addressRow, berthId, berthMooringNumber, berthMoorings, linkedBerthCount, tags: tagRows, notesCount, notesCountAggregated, recentNote: recentNote ?? null, activeReminderCount, assignedToName, recentActivityCount, // Phase 2 - risk-signal dates derived from event tables. Feed // computeDealHealth so the pulse chip surfaces document declines, // cancelled reservations, and "berth resold to another deal" without // bespoke timestamp columns on the interest record. dateDocumentDeclined, dateReservationCancelled, dateBerthSoldToOther, yachtDimensions, }; } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createInterest(portId: string, data: CreateInterestInput, meta: AuditMeta) { await assertInterestFksInPort(portId, { clientId: data.clientId, berthId: data.berthId, yachtId: data.yachtId, }); if (data.yachtId) { await assertYachtBelongsToClient(portId, data.yachtId, data.clientId); } const { tagIds, berthId: inputBerthId, ...interestData } = data; // BR-011: auto-promote leadCategory const resolvedLeadCategory = await resolveLeadCategory( data.clientId, data.leadCategory, data.yachtId, ); // Per-port reminder defaults - applied only when the caller omitted // reminderEnabled / reminderDays. Honors the /admin/reminders page. const reminderConfig = await getPortReminderConfig(portId); const resolvedReminderEnabled = interestData.reminderEnabled ?? reminderConfig.defaultEnabled; const resolvedReminderDays = interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null); // Resolve the deal owner. Three-tier chain: // 1. Explicit `data.assignedTo` from the caller (rep picked an // assignee in the create form). // 2. Port's `default_new_interest_owner` setting (used for round- // robin / "front desk owns all new leads" rules). // 3. Auto-assign to the creating user when they're a regular role // (sales rep, sales manager, etc.). Skipped for super-admins who // often create on behalf of other reps - they'd otherwise hijack // every new lead. Falls back to null (Unassigned) when none of // the above resolve. let resolvedAssignedTo = interestData.assignedTo ?? null; // CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run // when the per-port assignment feature is enabled. Tier 1 (an explicit // assignedTo from the caller) is always honored. Default is OFF. const assignmentSetting = await getSetting('assignment_enabled', portId); const assignmentEnabled = assignmentSetting?.value === true; if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) { const defaultOwner = await getSetting('default_new_interest_owner', portId); const v = defaultOwner?.value as { userId?: string } | null | undefined; if (v?.userId) { resolvedAssignedTo = v.userId; } else { // Tier 3: auto-assign to creator only when their role is a working // sales rep — super_admin / director / residential_partner / viewer // intentionally skip (they create on behalf of others or shouldn't // own interests at all). const AUTO_ASSIGN_ROLES = new Set(['sales_agent', 'sales_manager']); const [profile] = await db .select({ isSuperAdmin: userProfiles.isSuperAdmin }) .from(userProfiles) .where(eq(userProfiles.userId, meta.userId)) .limit(1); if (profile && !profile.isSuperAdmin) { const userRoles = await db .select({ name: roles.name }) .from(userPortRoles) .innerJoin(roles, eq(userPortRoles.roleId, roles.id)) .where(and(eq(userPortRoles.userId, meta.userId), eq(userPortRoles.portId, portId))); if (userRoles.some((r) => AUTO_ASSIGN_ROLES.has(r.name))) { resolvedAssignedTo = meta.userId; } } } } const result = await withTransaction(async (tx) => { const [interest] = await tx .insert(interests) .values({ portId, ...interestData, assignedTo: resolvedAssignedTo, reminderEnabled: resolvedReminderEnabled, reminderDays: resolvedReminderDays, leadCategory: resolvedLeadCategory, }) .returning(); if (tagIds && tagIds.length > 0) { await tx .insert(interestTags) .values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId }))); } // Plan §3.4: when berthId is provided we materialise it as a junction // row inside the same transaction so an interest is never created // without its primary-berth link surviving rollback. if (inputBerthId) { await upsertInterestBerthTx(tx, interest!.id, inputBerthId, { isPrimary: true, isSpecificInterest: true, isInEoiBundle: false, addedBy: meta.userId, }); } return interest!; }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'interest', entityId: result.id, newValue: { clientId: result.clientId, pipelineStage: result.pipelineStage }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:created', { interestId: result.id, clientId: result.clientId, berthId: inputBerthId ?? null, source: result.source ?? '', }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'interest:created', { interestId: result.id, clientId: result.clientId, }), ); // Phase 6 - CRM → Umami attribution. Fire an inbound-lead event so // marketing can correlate inquiry volume with website traffic by // source / referrer. void import('@/lib/services/umami.service').then(({ trackEvent }) => trackEvent(portId, 'interest-created', { interestId: result.id, source: result.source ?? null, }), ); return result; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateInterest( id: string, portId: string, data: UpdateInterestInput, meta: AuditMeta, ) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } // berthId no longer lives on the interests row - resolve current primary // via the junction so we know whether the caller is asking for a change. const currentPrimary = await getPrimaryBerth(id); const currentBerthId = currentPrimary?.berthId ?? null; await assertInterestFksInPort(portId, { berthId: data.berthId && data.berthId !== currentBerthId ? data.berthId : null, yachtId: data.yachtId && data.yachtId !== existing.yachtId ? data.yachtId : null, }); if (data.yachtId && data.yachtId !== existing.yachtId) { await assertYachtBelongsToClient(portId, data.yachtId, existing.clientId); } // BR-011: auto-promote leadCategory if provided let resolvedLeadCategory = data.leadCategory; if ('leadCategory' in data) { resolvedLeadCategory = (await resolveLeadCategory( existing.clientId, data.leadCategory, data.yachtId ?? existing.yachtId, )) as typeof data.leadCategory; } // Strip berthId out of the row write - the column was removed by the // junction-migration. We keep the value for diff/audit purposes and // dispatch the junction write separately. const { berthId: incomingBerthId, ...rowData } = data; const updateData = { ...rowData, leadCategory: resolvedLeadCategory }; const { diff } = diffEntity( { ...(existing as Record), berthId: currentBerthId }, { ...(updateData as Record), berthId: incomingBerthId ?? currentBerthId }, ); const [updated] = await db .update(interests) .set({ ...updateData, updatedAt: new Date() }) .where(and(eq(interests.id, id), eq(interests.portId, portId))) .returning(); // Apply primary-berth change through the junction so the unique // partial index is respected and the previous primary is demoted. if ('berthId' in data && incomingBerthId !== currentBerthId) { if (incomingBerthId) { await upsertInterestBerth(id, incomingBerthId, { isPrimary: true, isSpecificInterest: true, addedBy: meta.userId, }); } else if (currentBerthId) { await removeInterestBerth(id, currentBerthId, portId, meta); } } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest', entityId: id, oldValue: diff as Record, newValue: updateData as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: Object.keys(diff), }); // Owner change → notify the new assignee. We skip self-reassign so a rep // re-claiming their own deal doesn't get a noise notification. if ( 'assignedTo' in data && data.assignedTo && data.assignedTo !== existing.assignedTo && data.assignedTo !== meta.userId ) { const [clientRow] = await db .select({ fullName: clients.fullName }) .from(clients) .where(eq(clients.id, existing.clientId)) .limit(1); const clientLabel = clientRow?.fullName ?? 'a client'; void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: data.assignedTo!, type: 'interest_assigned', title: 'New deal assigned to you', description: `${clientLabel} - ${existing.pipelineStage.replace(/_/g, ' ')}`, link: `/interests/${id}` as never, entityType: 'interest', entityId: id, dedupeKey: `interest_assigned:${id}:${data.assignedTo}`, }), ); } return updated!; } // ─── Change Stage ───────────────────────────────────────────────────────────── /** * Sentinel returned by changeInterestStage when the requested target * matches the current stage (no audit row, no socket emit, no DB update). * The route handler translates this to a 204 No Content response. */ export const STAGE_NOOP = Symbol('stage-noop'); export async function changeInterestStage( id: string, portId: string, data: ChangeStageInput, meta: AuditMeta, // M3: distinguishes a manual/UI stage move (the /stage route + bulk route // call this directly) from a lifecycle/signing-driven advance routed // through advanceStageIfBehind (EOI sent/signed, deposit met, contract // signed, reservation signed, custom-doc upload). For signing-driven // advances the milestone date is owned by the doc-send/sign flow that // already stamped the real event timestamp - auto-stamping `now` here on // top of it back-dates "sent → signed" to ~0. So we only auto-populate // milestone columns when `lifecycleDriven` is false (manual/UI move). options?: { lifecycleDriven?: boolean }, ) { const lifecycleDriven = options?.lifecycleDriven ?? false; const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } // F27 / A19: same-stage write is a no-op. The service signals this via // the sentinel `STAGE_NOOP` so the route handler can return 204 No // Content instead of 200 + full body. Pre-fix every re-submit (e.g. // accidental double-click) wrote a "Same → Same" audit entry and // triggered downstream invalidations. if (existing.pipelineStage === data.pipelineStage) { return STAGE_NOOP; } // Plan: yachtId required to leave the initial enquiry stage if ( existing.pipelineStage === 'enquiry' && data.pipelineStage !== 'enquiry' && !existing.yachtId ) { // F21: user-readable; was "yachtId is required before leaving stage=enquiry" throw new ValidationError('A yacht must be linked before leaving the Enquiry stage.'); } // Block egregious skips. The transition table allows reasonable forward // jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry. // Same-stage no-ops are allowed. // Override (sales-rep manual fix) bypasses the table - the route handler // gates this on the `interests.override_stage` permission and requires // a reason, recorded in the audit log below. if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) { // F21: use the human-readable stage labels in error copy. throw new ValidationError( `Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages - requires a reason.`, ); } if (data.override && (!data.reason || data.reason.trim().length < 5)) { throw new ValidationError( 'Override requires a reason (min 5 chars) explaining the manual stage change.', ); } // T65 (Bucket 4 bug #2): block advancing past Qualified onto a // primary berth whose `price` is NULL. EOI / Reservation / Deposit / // Contract docs all render the price in templates / merge fields and // generating a $0 contract is a real production gotcha. Allow the // skip when `override` is true so a sales-manager fix path stays // open (recorded with a reason in audit log). const PRICED_STAGES = new Set(['eoi', 'reservation', 'deposit_paid', 'contract']); if (!data.override && PRICED_STAGES.has(data.pipelineStage)) { const primaryBerth = await db .select({ price: berths.price }) .from(interestBerths) .innerJoin(berths, eq(interestBerths.berthId, berths.id)) .where(and(eq(interestBerths.interestId, id), eq(interestBerths.isPrimary, true))) .limit(1); const price = primaryBerth[0]?.price; if (!price || parseFloat(String(price)) === 0) { throw new ValidationError( `Primary berth has no price set. Set the berth price before advancing past Qualified, or override with a reason.`, ); } } const oldStage = existing.pipelineStage; // BR-133: Auto-populate milestones based on stage. The rep can override the // stamp via `milestoneDate` when they're back-dating a real event (e.g. // "deposit landed yesterday"); we still default to now when omitted. // // M3: only stamp milestone columns for manual/UI moves. Lifecycle/signing- // driven advances (routed through advanceStageIfBehind) get their milestone // dates from the doc-send/sign flow that already recorded the true event // timestamp; auto-stamping `now` here on top of that back-dates intervals // like "contract sent → signed" to ~0. Folding this into the same UPDATE as // the stage change also removes the previous non-transactional double-write. const milestoneUpdates: Record = {}; if (!lifecycleDriven) { const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date(); if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate; if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate; if (data.pipelineStage === 'deposit_paid') milestoneUpdates.dateDepositReceived = milestoneDate; if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSent = milestoneDate; } const [updated] = await db .update(interests) .set({ pipelineStage: data.pipelineStage, ...milestoneUpdates, updatedAt: new Date() }) .where(and(eq(interests.id, id), eq(interests.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest', entityId: id, oldValue: { pipelineStage: oldStage }, newValue: { pipelineStage: data.pipelineStage, reason: data.reason }, metadata: { type: 'stage_change', reason: data.reason }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:stageChanged', { interestId: id, oldStage: oldStage ?? '', newStage: data.pipelineStage, clientName: '', berthNumber: '', }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'interest:stageChanged', { interestId: id, oldStage: oldStage ?? null, newStage: data.pipelineStage, }), ); // Phase 6 - CRM → Umami attribution for pipeline movement. void import('@/lib/services/umami.service').then(({ trackEvent }) => trackEvent(portId, 'interest-stage-changed', { interestId: id, oldStage: oldStage ?? null, newStage: data.pipelineStage, }), ); // Fire-and-forget notification to the acting user. Resolve a friendly // label (client full name → primary mooring number → "this interest") so // the inbox doesn't surface a raw UUID; stage names go through the // canonical STAGE_LABELS dictionary so "deposit_10pct" reads as // "10% Deposit" everywhere. void (async () => { const [{ createNotification }, clientRow, allBerths] = await Promise.all([ import('@/lib/services/notifications.service'), db.query.clients.findFirst({ where: eq(clients.id, existing.clientId), columns: { fullName: true }, }), listBerthsForInterest(id).catch( () => [] as Awaited>, ), ]); const primaryBerth = allBerths[0] ?? null; const moorings = allBerths.map((b) => b.mooringNumber).filter((m): m is string => Boolean(m)); const berthSuffix = moorings.length > 0 ? ` [${formatBerthRange(moorings)}]` : ''; const subject = clientRow?.fullName ?? (primaryBerth?.mooringNumber ? `Berth ${primaryBerth.mooringNumber}` : 'this interest'); const fromLabel = oldStage ? (STAGE_LABELS[oldStage as PipelineStage] ?? oldStage.replace(/_/g, ' ')) : 'unknown'; const toLabel = STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage.replace(/_/g, ' '); await createNotification({ portId, userId: meta.userId, type: 'interest_stage_changed', title: `${subject} moved to ${toLabel}${berthSuffix}`, description: `Stage changed from ${fromLabel} to ${toLabel}.`, link: `/interests/${id}`, entityType: 'interest', entityId: id, dedupeKey: `interest:${id}:stage:${data.pipelineStage}`, cooldownMs: 300_000, }); })(); return updated!; } // ─── Advance Stage If Behind ───────────────────────────────────────────────── // // Moves an interest forward to `target` if (and only if) it is currently behind // it in the pipeline order. Used by lifecycle events (EOI sent, EOI signed, // deposit recorded, contract signed) so the user-visible stage tracks reality // without overwriting a more advanced state - e.g. a late-arriving signed-EOI // webhook on an interest that has already moved on to `contract_sent` is a // no-op rather than a regression. // // Returns true when the stage was changed. export async function advanceStageIfBehind( interestId: string, portId: string, target: PipelineStage, meta: AuditMeta, reason?: string, ): Promise { const existing = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); if (!existing) return false; const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage); const targetIdx = PIPELINE_STAGES.indexOf(target); if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) { return false; } // yachtId gate: changeInterestStage requires a yacht before leaving the // initial enquiry stage. EOI events imply a yacht is in the picture, but // if the data is missing we bail rather than throw - the EOI itself // shouldn't fail because of this. if (existing.pipelineStage === 'enquiry' && !existing.yachtId) { return false; } // M3: this helper is the single funnel for every lifecycle/signing-driven // advance (EOI sent/signed, deposit met, contract signed, reservation // signed, custom-doc upload). Flag the move so changeInterestStage does not // auto-stamp milestone dates - those are owned by the doc-send/sign flow, // which already recorded the real event timestamp. await changeInterestStage(interestId, portId, { pipelineStage: target, reason }, meta, { lifecycleDriven: true, }); return true; } /** * Gated variant: reads the per-port `stage_advance_rules` setting for the * given trigger and either: * - 'auto' → calls advanceStageIfBehind (same behaviour as the base helper) * - 'suggest' → emits an in-CRM notification with an Approve link so the * rep can advance with one click (no auto-move) * - 'off' → no-op (audit log of the event still fires upstream) * * Use this from every lifecycle event handler that wants admin-controlled * cadence - the bare `advanceStageIfBehind` stays available for paths * where the move is unconditional (manual rep action, completion of a * doc the admin can't disable). */ export async function advanceStageIfBehindGated( interestId: string, portId: string, target: PipelineStage, meta: AuditMeta, reason: string | undefined, trigger: | 'eoi_sent' | 'eoi_signed' | 'reservation_signed' | 'deposit_received' | 'contract_signed', ): Promise { const { getStageAdvanceMode } = await import('@/lib/services/port-config'); const mode = await getStageAdvanceMode(portId, trigger); if (mode === 'off') return false; if (mode === 'auto') return advanceStageIfBehind(interestId, portId, target, meta, reason); // 'suggest' - notify the rep with an Approve link, no auto-move. The // rep can click the notification to fire the same advance manually. const existing = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), columns: { pipelineStage: true, assignedTo: true }, }); if (!existing) return false; const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage); const targetIdx = PIPELINE_STAGES.indexOf(target); if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) return false; if (existing.assignedTo) { void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: existing.assignedTo!, type: 'stage_advance_suggested', title: `Advance to ${target}?`, description: reason ?? `${trigger} fired - suggested advance from ${existing.pipelineStage} to ${target}.`, link: `/interests/${interestId}`, entityType: 'interest', entityId: interestId, dedupeKey: `interest:${interestId}:advance-suggest:${trigger}`, }).catch(() => { // Notification failure shouldn't block the parent webhook handler. }), ); } return false; } // ─── Set Outcome (Won / Lost) ──────────────────────────────────────────────── // // Records a terminal outcome for the interest. The `outcome` column is the // canonical terminal-state signal; `pipelineStage` stays where it was so // reports can answer "what stage was this deal at when it closed?". Prior to // 2026-05-14 this method forced pipelineStage='completed' - a sentinel // outside the 7-stage canon that broke type narrowing + downstream stage // label lookups. Active-interest queries filter by `outcome IS NULL` so // the rep-facing kanban still hides closed deals. export async function setInterestOutcome( id: string, portId: string, data: SetOutcomeInput, meta: AuditMeta, ) { const existing = await db.query.interests.findFirst({ where: and(eq(interests.id, id), eq(interests.portId, portId)), }); if (!existing) throw new NotFoundError('Interest'); // M1: terminal-state guard. Once an outcome is set this method must not run // again - re-firing it (won→lost flip, double-submit, an idempotent webhook // retry) would re-evaluate the berth rule, rename the document folder, write // a duplicate audit row, re-emit the socket event and re-fire the Umami // event. Mirrors clearInterestOutcome's `if (!existing.outcome)` guard: // changing a recorded outcome requires clearing it first (which reopens the // deal), so the side effects only ever run on a genuine close transition. if (existing.outcome) { throw new ConflictError( 'Interest already has an outcome. Clear the current outcome before setting a new one.', ); } const oldOutcome = existing.outcome; const stageAtOutcome = existing.pipelineStage; const now = new Date(); await db .update(interests) .set({ outcome: data.outcome, outcomeReason: data.reason ?? null, outcomeAt: now, updatedAt: now, }) .where(and(eq(interests.id, id), eq(interests.portId, portId))); void createAuditLog({ userId: meta.userId, portId, // M-AU04: distinct verb so the audit filter / FTS surface it directly. action: 'outcome_set', entityType: 'interest', entityId: id, oldValue: { outcome: oldOutcome, pipelineStage: stageAtOutcome }, newValue: { outcome: data.outcome, pipelineStage: stageAtOutcome, reason: data.reason }, metadata: { stageAtOutcome }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:outcomeSet', { interestId: id, outcome: data.outcome, stageAtOutcome, }); // Berth-rule on deal close (audit C2). Only a WON deal should drive the // berth to 'sold' (via interest_completed). Lost/cancelled deals fire // `deal_lost`, which frees the berth — previously every outcome reused // interest_completed and silently flipped the berth to 'sold'. void evaluateRule(data.outcome === 'won' ? 'interest_completed' : 'deal_lost', id, portId, meta); // Phase 2 nested-subfolders - rename the interest's document folder // to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic // import avoids the circular dep with document-folders.service which // already pulls from interests.service for the primary-berth label. void import('@/lib/services/document-folders.service') .then((m) => m.renameInterestFolderForOutcome ? m.renameInterestFolderForOutcome(id, portId, data.outcome) : null, ) .catch(() => { // Folder may not exist yet (first upload happens later) - silent. }); // Phase 6 - CRM → Umami attribution. Fire a custom Umami event so // marketing can correlate inbound website traffic with the resulting // deal outcome. Dynamic import to avoid a circular service dep at // module-load time. void import('@/lib/services/umami.service').then(({ trackEvent }) => trackEvent(portId, 'interest-outcome-set', { interestId: id, outcome: data.outcome, stageAtOutcome, }), ); return { ok: true as const }; } // Clears a terminal outcome and reopens the interest. Used when an outcome // was set in error or a "lost" deal comes back to life. export async function clearInterestOutcome( id: string, portId: string, data: ClearOutcomeInput, meta: AuditMeta, ) { const existing = await db.query.interests.findFirst({ where: and(eq(interests.id, id), eq(interests.portId, portId)), }); if (!existing) throw new NotFoundError('Interest'); if (!existing.outcome) { throw new ValidationError('Interest has no outcome to clear'); } // Reopen-stage logic: // - If the caller passed `data.reopenStage`, honor it (rep override). // - Else preserve the current stage - post-refactor setOutcome stops // touching pipelineStage, so the deal already knows where it was // when the rep closed it. Reopening should drop the rep back into // that same column on the kanban. // L1: the dead `pipelineStage === 'completed' ? 'qualified'` branch is // removed (the 'completed' sentinel was dropped in the 9→7 migration). // Any legacy stage value still on the row is folded to its canonical // 7-stage equivalent via canonicalizeStage so a pre-migration // 'completed' row reopens to `contract` (its true pre-close stage) and // never re-enters the kanban with a non-canonical value. const reopenStage = data.reopenStage ?? canonicalizeStage(existing.pipelineStage); const now = new Date(); await db .update(interests) .set({ outcome: null, outcomeReason: null, outcomeAt: null, pipelineStage: reopenStage, updatedAt: now, }) .where(and(eq(interests.id, id), eq(interests.portId, portId))); void createAuditLog({ userId: meta.userId, portId, // M-AU04: distinct verb so the audit filter / FTS surface it directly. action: 'outcome_cleared', entityType: 'interest', entityId: id, oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage }, newValue: { outcome: null, pipelineStage: reopenStage }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:outcomeCleared', { interestId: id }); return { ok: true as const }; } // ─── Archive / Restore ──────────────────────────────────────────────────────── export async function archiveInterest(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } // BR-014: Block archive if pending EOI/contract if (existing.eoiStatus === 'waiting_for_signatures' || existing.contractStatus === 'pending') { throw new ConflictError( 'Cannot archive interest with pending documents. Cancel documents first.', ); } // Resolve the primary berth BEFORE the soft-delete so the berth-rule and // next-in-line lookups still see the interest's junction rows. softDelete // toggles archivedAt; the junction isn't archived alongside it, but the // rule reads the primary via the junction which is unaffected. const primaryBerth = await getPrimaryBerth(id); await softDelete(interests, interests.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'interest', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id }); // G-C4: fire the berth-rule (default mode 'suggest' for interest_archived). // G-I2: notify sales of the next-in-line interests on the released berth so // they can follow up - mirrors the client-archive flow but scoped to a // single interest's primary berth. if (primaryBerth) { void evaluateRule('interest_archived', id, portId, meta); // Build the next-in-line dossier: any other active interest linked to // the same berth that doesn't belong to this archived interest. void (async () => { try { const others = await db .select({ interestId: interests.id, clientId: interests.clientId, clientName: clients.fullName, pipelineStage: interests.pipelineStage, }) .from(interestBerths) .innerJoin(interests, eq(interestBerths.interestId, interests.id)) .leftJoin(clients, eq(interests.clientId, clients.id)) .where( and( activeInterestsWhere(portId), eq(interestBerths.berthId, primaryBerth.berthId), ne(interests.id, id), ), ) .orderBy(desc(interests.updatedAt)) .limit(10); const archivedClient = existing.clientId ? await db.query.clients.findFirst({ where: eq(clients.id, existing.clientId) }) : null; await notifyNextInLine({ portId, berthId: primaryBerth.berthId, mooringNumber: primaryBerth.mooringNumber ?? '', archivedClientName: archivedClient?.fullName ?? '(unknown client)', nextInLineInterests: others.map((o) => ({ interestId: o.interestId, clientName: o.clientName, pipelineStage: o.pipelineStage, })), }); } catch (err) { logger.error( { err, interestId: id, berthId: primaryBerth.berthId }, 'Failed to fire next-in-line notification on interest archive', ); } })(); } } export async function restoreInterest(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } await restore(interests, interests.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'restore', entityType: 'interest', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: [] }); } // ─── Set Tags ───────────────────────────────────────────────────────────────── export async function setInterestTags( id: string, portId: string, tagIds: string[], meta: AuditMeta, ) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } const result = await setEntityTags({ joinTable: interestTags, entityColumn: interestTags.interestId, tagColumn: interestTags.tagId, entityId: id, portId, tagIds, meta, entityType: 'interest', }); return { interestId: result.entityId, tagIds: result.tagIds }; } // ─── Link / Unlink Berth ────────────────────────────────────────────────────── export async function linkBerth(id: string, portId: string, berthId: string, meta: AuditMeta) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } await assertInterestFksInPort(portId, { berthId }); const previousPrimary = await getPrimaryBerth(id); const oldBerthId = previousPrimary?.berthId ?? null; await upsertInterestBerth(id, berthId, { isPrimary: true, isSpecificInterest: true, addedBy: meta.userId, }); // Touch updatedAt so list/sort surfaces still reflect the change. const [updated] = await db .update(interests) .set({ updatedAt: new Date() }) .where(and(eq(interests.id, id), eq(interests.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest', entityId: id, oldValue: { berthId: oldBerthId }, newValue: { berthId }, metadata: { type: 'berth_linked' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:berthLinked', { interestId: id, berthId }); void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'interest:berthLinked', { interestId: id, berthId }), ); return updated!; } export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } const previousPrimary = await getPrimaryBerth(id); const oldBerthId = previousPrimary?.berthId ?? null; if (oldBerthId) { await removeInterestBerth(id, oldBerthId, portId, meta); } const [updated] = await db .update(interests) .set({ updatedAt: new Date() }) .where(and(eq(interests.id, id), eq(interests.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest', entityId: id, oldValue: { berthId: oldBerthId }, newValue: { berthId: null }, metadata: { type: 'berth_unlinked' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:berthUnlinked', { interestId: id, berthId: oldBerthId ?? '', }); return updated!; } // ─── Stage Counts (for board) ──────────────────────────────────────────────── export async function getInterestStageCounts(portId: string) { // Kanban / board counts surface active deals only (no terminal // outcomes) - terminal rows belong on a separate /closed surface. const rows = await db .select({ stage: interests.pipelineStage, count: sql`count(*)::int` }) .from(interests) .where(activeInterestsWhere(portId)) .groupBy(interests.pipelineStage); return Object.fromEntries(rows.map((r) => [r.stage, r.count])); }