Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1174 lines
40 KiB
TypeScript
1174 lines
40 KiB
TypeScript
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<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;
|
|
}
|
|
|
|
// ─── 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;
|
|
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 }> {
|
|
const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
|
|
|
|
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.
|
|
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
clientName: r.clientName ?? null,
|
|
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
|
|
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<string, string> = {};
|
|
let yachtsMap: 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]));
|
|
}
|
|
|
|
// 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<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) => {
|
|
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;
|
|
|
|
// 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<number>`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.
|
|
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,
|
|
berthId,
|
|
berthMooringNumber,
|
|
linkedBerthCount,
|
|
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<string, unknown>), berthId: currentBerthId },
|
|
{ ...(updateData as Record<string, unknown>), 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<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.
|
|
// 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<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 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<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]));
|
|
}
|