import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests'; import { reminders } from '@/lib/db/schema/operations'; import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients'; import { berths } from '@/lib/db/schema/berths'; import { yachts } from '@/lib/db/schema/yachts'; import { companyMemberships } from '@/lib/db/schema/companies'; import { tags } from '@/lib/db/schema/system'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { getPortReminderConfig } from '@/lib/services/port-config'; import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { getPrimaryBerth, getPrimaryBerthsForInterests, removeInterestBerth, upsertInterestBerth, upsertInterestBerthTx, } from '@/lib/services/interest-berths.service'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore, withTransaction } from '@/lib/db/utils'; import { PIPELINE_STAGES, canTransitionStage, 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; } // ─── 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. const primaryBerthMap = await getPrimaryBerthsForInterests(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, 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({ 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({ value: clientContacts.value, valueE164: clientContacts.valueE164 }) .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). const primaryBerth = await getPrimaryBerth(interest.id); const berthId = primaryBerth?.berthId ?? null; const berthMooringNumber = primaryBerth?.mooringNumber ?? null; 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. const [recentNote] = await db .select({ id: interestNotes.id, content: interestNotes.content, authorId: interestNotes.authorId, createdAt: interestNotes.createdAt, }) .from(interestNotes) .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)); // 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']))); return { ...interest, clientName: clientRow?.fullName ?? null, clientPrimaryEmail: emailContact?.value ?? null, clientPrimaryPhone: phoneContact?.value ?? null, clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null, clientHasAddress: !!addressRow, berthId, berthMooringNumber, tags: tagRows, notesCount, recentNote: recentNote ?? null, activeReminderCount, }; } // ─── 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); const result = await withTransaction(async (tx) => { const [interest] = await tx .insert(interests) .values({ portId, ...interestData, 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, }), ); 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); } } 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), }); return updated!; } // ─── Change Stage ───────────────────────────────────────────────────────────── export async function changeInterestStage( id: string, portId: string, data: ChangeStageInput, meta: AuditMeta, ) { const existing = await db.query.interests.findFirst({ where: eq(interests.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Interest'); } // Plan: yachtId required to leave stage=open if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) { throw new ValidationError('yachtId is required before leaving stage=open'); } // Block egregious skips. The transition table allows reasonable forward // jumps (e.g. open → eoi_sent) while rejecting things like completed → open // or open → contract_signed. 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)) { throw new ValidationError( `Cannot move interest from "${existing.pipelineStage}" directly to "${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.', ); } const oldStage = existing.pipelineStage; const [updated] = await db .update(interests) .set({ pipelineStage: data.pipelineStage, updatedAt: new Date() }) .where(and(eq(interests.id, id), eq(interests.portId, portId))) .returning(); // BR-133: Auto-populate milestones based on stage const milestoneUpdates: Record = {}; if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date(); if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date(); if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date(); if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date(); if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date(); if (Object.keys(milestoneUpdates).length > 0) { await db .update(interests) .set({ ...milestoneUpdates, updatedAt: new Date() }) .where(eq(interests.id, id)); } 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, }), ); // Fire-and-forget notification to the acting user void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: meta.userId, type: 'interest_stage_changed', title: `Interest moved to ${data.pipelineStage}`, description: `Interest ${id} stage changed from ${oldStage ?? 'unknown'} to ${data.pipelineStage}`, 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 `open`. // 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 === 'open' && !existing.yachtId) { return false; } await changeInterestStage(interestId, portId, { pipelineStage: target, reason }, meta); return true; } // ─── Set Outcome (Won / Lost) ──────────────────────────────────────────────── // // Records a terminal outcome for the interest and moves the pipelineStage to // `completed` so the funnel/kanban reflect the final state. The outcome // distinguishes won deals (they made it through) from lost variants - funnel // math and reports key off the `outcome` column to compute true conversion. // // Both the stage advance and the outcome write happen in one transaction so // the timeline doesn't end up showing one without the other. 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'); const oldOutcome = existing.outcome; const oldStage = existing.pipelineStage; const now = new Date(); await db .update(interests) .set({ outcome: data.outcome, outcomeReason: data.reason ?? null, outcomeAt: now, pipelineStage: 'completed', updatedAt: now, }) .where(and(eq(interests.id, id), eq(interests.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest', entityId: id, oldValue: { outcome: oldOutcome, pipelineStage: oldStage }, newValue: { outcome: data.outcome, pipelineStage: 'completed', reason: data.reason }, metadata: { type: 'outcome_set' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:outcomeSet', { interestId: id, outcome: data.outcome, oldStage, }); 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'); } const reopenStage = data.reopenStage ?? 'in_communication'; 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, action: 'update', entityType: 'interest', entityId: id, oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage }, newValue: { outcome: null, pipelineStage: reopenStage }, metadata: { type: 'outcome_cleared' }, 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.', ); } 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 }); } 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); } 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) { const rows = await db .select({ stage: interests.pipelineStage, count: sql`count(*)::int` }) .from(interests) .where(and(eq(interests.portId, portId), isNull(interests.archivedAt))) .groupBy(interests.pipelineStage); return Object.fromEntries(rows.map((r) => [r.stage, r.count])); }