Files
pn-new-crm/src/lib/services/interests.service.ts
Matt Ciaccio a767652d74 feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB
Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6,
#7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real
assignee column on interests, defer until ownership model lands) and #12
(keyboard shortcuts — explicit user call).

  Interest list (the rep's main triage surface):

    - Last activity column replaces Created (sortable by
      dateLastContact). Postgres NULLs-last on DESC means
      never-contacted leads sort to the bottom — exactly the right
      triage default.
    - Comment-icon next to client name when notesCount > 0, with a
      tooltip showing the count. Cheap, glanceable signal that the
      lead has correspondence to peek at.
    - Urgency badges under stage when criteria fire: "Silent Nd"
      for mid-funnel interests with no contact in 7+ days,
      "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd"
      for eoi_signed interests with no deposit after 21 days.
      Pure derived — no extra fetch, computed from the dates the
      row already returns.
    - Bulk select checkbox column with bulk-archive (existing
      DataTable.bulkActions API; just wired with a confirm-dialog
      and a Promise.all fan-out).
    - Mobile FAB (+) for new interest, anchored above the bottom-tab
      bar with safe-area inset awareness.

    All four signals mirrored on the mobile InterestCard (comment
    icon, urgency badges, last-activity footer).

  Interest detail:

    - Reminder bell badge in the header showing pending/snoozed
      reminder count linked to the interest. Surfaced via
      getInterestById's new `activeReminderCount`.
    - "Latest note" teaser on the Overview tab — truncated 3-line
      preview of the most recent threaded note + relative time +
      "View all" link to the Notes tab. Saves a click for the
      common "what was discussed last?" peek.
    - Color-block swatches in InlineStagePicker dropdown (rounded-sm
      mini-bars in the stage's progressive saturation color, replacing
      the previous tiny dots). Reads as a visual scan instead of a
      list.

  Dashboard:

    - MyRemindersRail on the right sidebar above the existing
      AlertRail. Shows pending+snoozed reminders for the current
      user (overdue first), each with priority pill, relative due
      time, and click-through to the linked interest/client/berth.

  Berth detail:

    - BerthInterestPulse card at the top of the Overview tab,
      replacing the old "buried in tab" pattern. Shows up to 5
      active interests with avatar, stage pill, urgency badges, and
      last-activity. Mirrors the old Nuxt CRM's beloved "Interested
      Parties" panel but with the new triage signals.

  Realtime toasts:

    - New <RealtimeToasts /> mounted inside SocketProvider in the
      dashboard layout. Subscribes to interest:stageChanged,
      document:completed, document:signer:signed, and
      interest:outcomeSet — fires sonner toasts so reps watching any
      page learn about pipeline events without refreshing.

  Service layer:

    - listInterests: notesCount per row (left join + count + groupBy).
    - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164
      (for the Email/Call/WhatsApp buttons added last commit; phone
      pieces were missing), notesCount, recentNote, activeReminderCount.
    - sortColumn switch handles 'dateLastContact' explicitly; default
      stays 'updatedAt'.

tsc clean. vitest 835/835 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00

950 lines
31 KiB
TypeScript

import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, 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 { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
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<void> {
const checks: Array<Promise<void>> = [];
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<void> {
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<string | undefined> {
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) {
filters.push(eq(interests.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 and berth mooring numbers
const interestIds = (
result.data as Array<{ id: string; clientId: string; berthId: string | null }>
).map((i) => i.id);
const clientIds = [
...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)),
];
const berthIds = [
...new Set(
(result.data as Array<{ berthId: string | null }>)
.map((i) => i.berthId)
.filter(Boolean) as string[],
),
];
let clientsMap: Record<string, string> = {};
let berthsMap: Record<string, string> = {};
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
const notesCountByInterestId: Record<string, number> = {};
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]));
}
if (berthIds.length > 0) {
const berthRows = await db
.select({ id: berths.id, mooringNumber: berths.mooringNumber })
.from(berths)
.where(inArray(berths.id, berthIds));
berthsMap = Object.fromEntries(berthRows.map((b) => [b.id, b.mooringNumber]));
}
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<number>`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<Record<string, unknown>>).map((i) => ({
...i,
clientName: clientsMap[i.clientId as string] ?? null,
berthMooringNumber: i.berthId ? (berthsMap[i.berthId 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);
let berthMooringNumber: string | null = null;
if (interest.berthId) {
const [berthRow] = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(eq(berths.id, interest.berthId));
berthMooringNumber = berthRow?.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<number>`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<number>`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,
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, ...interestData } = data;
// BR-011: auto-promote leadCategory
const resolvedLeadCategory = await resolveLeadCategory(
data.clientId,
data.leadCategory,
data.yachtId,
);
const result = await withTransaction(async (tx) => {
const [interest] = await tx
.insert(interests)
.values({
portId,
...interestData,
leadCategory: resolvedLeadCategory,
})
.returning();
if (tagIds && tagIds.length > 0) {
await tx
.insert(interestTags)
.values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId })));
}
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: result.berthId ?? 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');
}
await assertInterestFksInPort(portId, {
berthId: data.berthId && data.berthId !== existing.berthId ? 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;
}
const updateData = { ...data, leadCategory: resolvedLeadCategory };
const { diff } = diffEntity(
existing as Record<string, unknown>,
updateData as Record<string, unknown>,
);
const [updated] = await db
.update(interests)
.set({ ...updateData, 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: diff as Record<string, unknown>,
newValue: updateData as Record<string, unknown>,
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.
if (!canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
throw new ValidationError(
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}".`,
);
}
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<string, unknown> = {};
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<boolean> {
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 [updated] = await db
.update(interests)
.set({ berthId, 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: existing.berthId },
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 oldBerthId = existing.berthId;
const [updated] = await db
.update(interests)
.set({ berthId: null, 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<number>`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]));
}