Files
pn-new-crm/src/lib/services/clients.service.ts
Matt cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:42:37 +02:00

1141 lines
38 KiB
TypeScript

import { and, count, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
clients,
clientContacts,
clientRelationships,
clientTags,
clientAddresses,
} from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { restore, withTransaction } from '@/lib/db/utils';
import { logger } from '@/lib/logger';
import {
syncEntityFolderName,
applyEntityArchivedSuffix,
applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
import type {
CreateClientInput,
UpdateClientInput,
ListClientsInput,
} from '@/lib/validators/clients';
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listClients(portId: string, query: ListClientsInput) {
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
const filters = [];
if (source) {
filters.push(eq(clients.source, source));
}
if (nationality) {
// Filter accepts an ISO-3166-1 alpha-2 code.
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
}
if (tagIds && tagIds.length > 0) {
const clientsWithTags = await db
.selectDistinct({ clientId: clientTags.clientId })
.from(clientTags)
.where(inArray(clientTags.tagId, tagIds));
const matchingIds = clientsWithTags.map((r) => r.clientId);
if (matchingIds.length > 0) {
filters.push(inArray(clients.id, matchingIds));
} else {
// No clients match these tags - return empty
return { data: [], total: 0 };
}
}
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
clients.updatedAt;
if (sort === 'fullName') sortColumn = clients.fullName;
else if (sort === 'createdAt') sortColumn = clients.createdAt;
const result = await buildListQuery<typeof clients.$inferSelect>({
table: clients,
portIdColumn: clients.portId,
portId,
idColumn: clients.id,
updatedAtColumn: clients.updatedAt,
searchColumns: [clients.fullName],
searchTerm: search,
filters,
sort: sort ? { column: sortColumn, direction: order } : undefined,
page,
pageSize: limit,
includeArchived,
archivedAtColumn: clients.archivedAt,
});
if (result.data.length === 0) return result;
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
inArray(yachts.currentOwnerId, ids),
isNull(yachts.archivedAt),
),
)
.groupBy(yachts.currentOwnerId),
db
.select({ clientId: companyMemberships.clientId, count: count() })
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
// Latest interest per client + its primary-berth mooring (resolved via
// interest_berths join, plan §3.4). The is_primary filter narrows the
// join to ≤1 berth row per interest; non-primary links never surface
// through this list-page derivation.
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.orderBy(desc(interests.updatedAt)),
db
.select({ clientId: interests.clientId, count: count() })
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.groupBy(interests.clientId),
// Pull at most ONE contact per (client_id, channel) for the page.
// DISTINCT ON sorted by `is_primary DESC, created_at DESC` keeps
// the picker logic identical to the in-memory version it replaced
// while bounding the row count to ~2 per client (one email, one
// phone) regardless of how many contacts the client has.
db.execute<{
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
}>(sql`
SELECT DISTINCT ON (client_id, channel)
client_id AS "clientId",
channel,
value,
value_e164 AS "valueE164",
is_primary AS "isPrimary",
created_at AS "createdAt"
FROM client_contacts
WHERE ${inArray(clientContacts.clientId, ids)}
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),
// Berths each client has interests in, with the (most-active)
// interest's stage attached so the list-view chip can self-describe
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
// collapses (client, berth) when the client has had multiple
// historical interests in the same berth - we keep the open-outcome
// one if any, otherwise the most recently updated. Excludes archived
// interests so closed deals don't crowd the chip row.
db.execute<{
clientId: string;
berthId: string;
mooringNumber: string;
interestId: string;
pipelineStage: string;
outcome: string | null;
}>(sql`
SELECT DISTINCT ON (i.client_id, b.id)
i.client_id AS "clientId",
b.id AS "berthId",
b.mooring_number AS "mooringNumber",
i.id AS "interestId",
i.pipeline_stage AS "pipelineStage",
i.outcome
FROM interests i
JOIN interest_berths ib ON ib.interest_id = i.id
JOIN berths b ON b.id = ib.berth_id
WHERE i.port_id = ${portId}
AND i.client_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
AND i.archived_at IS NULL
ORDER BY
i.client_id,
b.id,
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
i.updated_at DESC
`),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
for (const row of interestRows) {
if (!latestInterestMap.has(row.clientId)) {
latestInterestMap.set(row.clientId, {
stage: row.pipelineStage,
mooringNumber: row.mooringNumber,
});
}
}
// Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice.
// We also keep the E.164 form of the phone so the UI can build a
// wa.me/<digits> link that doesn't need re-parsing.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
const primaryPhoneE164Map = new Map<string, string>();
type ContactRow = {
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
};
const contactRowList: ContactRow[] =
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
else if (c.channel === 'phone') {
primaryPhoneMap.set(c.clientId, c.value);
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
}
}
// Aggregate berths per client, sorted so the most-action-worthy
// interest floats to the top of the chip row. Priority:
// 1. open outcome (active deal) before closed (won/lost/cancelled)
// 2. within open: most progressed stage first (contract > … > enquiry)
// 3. tie-breaker: mooring number alphabetical for stable ordering
// The list-view UI shows the top 2 with full labels; the rest fall
// through into a "+N more" popover.
//
// L-001 fix: pre-refactor this map used the 9-stage legacy names
// (contract_signed, deposit_10pct, …) and every modern 7-stage value
// fell through to rank 0, making the sort effectively random for any
// post-refactor interest. Modern values now own the canonical ranks
// and legacy keys map to their 7-stage equivalents so historical data
// continues to sort correctly.
const stageRank: Record<string, number> = {
// modern (post 9→7 refactor)
contract: 1,
deposit_paid: 2,
reservation: 3,
eoi: 4,
nurturing: 5,
qualified: 6,
enquiry: 7,
// legacy aliases - kept so audit-log + soft-archive data sorts the same
contract_signed: 1,
contract_sent: 1,
completed: 1,
deposit_10pct: 2,
eoi_signed: 4,
eoi_sent: 4,
in_communication: 6,
details_sent: 7,
open: 7,
};
type LinkedBerth = {
id: string;
mooringNumber: string;
interestId: string;
stage: string;
outcome: string | null;
};
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
const linkedBerthList: LinkedBerthRow[] =
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
(linkedBerthRows as unknown as LinkedBerthRow[]);
for (const r of linkedBerthList) {
const list = linkedBerthsMap.get(r.clientId) ?? [];
list.push({
id: r.berthId,
mooringNumber: r.mooringNumber,
interestId: r.interestId,
stage: r.pipelineStage,
outcome: r.outcome,
});
linkedBerthsMap.set(r.clientId, list);
}
for (const list of linkedBerthsMap.values()) {
list.sort((a, b) => {
// Open before closed.
const openA = a.outcome === null ? 0 : 1;
const openB = b.outcome === null ? 0 : 1;
if (openA !== openB) return openA - openB;
// Within bucket, most-progressed stage first.
const rankA = stageRank[a.stage] ?? 99;
const rankB = stageRank[b.stage] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return a.mooringNumber.localeCompare(b.mooringNumber);
});
}
return {
...result,
data: result.data.map((row) => {
const latest = latestInterestMap.get(row.id);
return {
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest
? {
stage: latest.stage,
mooringNumber: latest.mooringNumber,
}
: null,
};
}),
};
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getClientById(id: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!client || client.portId !== portId) {
throw new NotFoundError('Client');
}
const contacts = await db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, id),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const addresses = await db.query.clientAddresses.findMany({
where: eq(clientAddresses.clientId, id),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const clientTagRows = await db
.select({ tag: tags })
.from(clientTags)
.innerJoin(tags, eq(clientTags.tagId, tags.id))
.where(eq(clientTags.clientId, id));
const yachtRows = await db.query.yachts.findMany({
where: and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, id),
isNull(yachts.archivedAt),
),
columns: {
id: true,
name: true,
hullNumber: true,
registration: true,
lengthFt: true,
widthFt: true,
status: true,
},
});
const membershipRows = await db
.select({
membershipId: companyMemberships.id,
role: companyMemberships.role,
isPrimary: companyMemberships.isPrimary,
startDate: companyMemberships.startDate,
company: {
id: companies.id,
name: companies.name,
legalName: companies.legalName,
status: companies.status,
},
})
.from(companyMemberships)
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
.where(
and(
eq(companyMemberships.clientId, id),
eq(companies.portId, portId),
isNull(companyMemberships.endDate),
),
);
// Include pending tenancies alongside active ones — a tenancy starts
// in `pending` (auto-created from a signed Reservation Agreement, or
// manually created via the "Create tenancy" button) and stays pending
// until the rep confirms start date + tenure type via the
// pending→active activation flow. Reps need to SEE pending rows on
// the client tab to act on them; only filtering to `active` hid the
// freshly-created tenancy entirely (UAT 2026-05-26).
const activeTenancies = await db.query.berthTenancies.findMany({
where: and(
eq(berthTenancies.clientId, id),
eq(berthTenancies.portId, portId),
inArray(berthTenancies.status, ['pending', 'active']),
),
columns: {
id: true,
berthId: true,
yachtId: true,
startDate: true,
tenureType: true,
status: true,
},
});
const portalEnabled = await isPortalEnabledForPort(portId);
// Counts surfaced for tab badges (Interests + Notes - Yachts/Companies/etc
// get their counts from the corresponding row arrays we already fetched).
const [interestCountRow] = await db
.select({ count: count() })
.from(interests)
.where(
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
);
// Aggregated note count — matches what `NotesList` renders below
// (direct client notes + interest_notes + yacht_notes for owned
// yachts + company_notes for active memberships). Bare clientNotes
// count would understate when the rep adds notes to linked entities.
const { countForClientAggregated } = await import('@/lib/services/notes.service');
const aggregatedNoteCount = await countForClientAggregated(portId, id);
return {
...client,
contacts,
addresses,
tags: clientTagRows.map((r) => r.tag),
yachts: yachtRows,
companies: membershipRows,
activeTenancies,
interestCount: interestCountRow?.count ?? 0,
noteCount: aggregatedNoteCount,
clientPortalEnabled: portalEnabled,
};
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
const result = await withTransaction(async (tx) => {
const { contacts: contactsInput, tagIds, ...clientData } = data;
const [client] = await tx
.insert(clients)
.values({ portId, ...clientData })
.returning();
if (contactsInput.length > 0) {
await tx
.insert(clientContacts)
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
}
if (tagIds && tagIds.length > 0) {
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
}
return client!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'client',
entityId: result.id,
newValue: { fullName: result.fullName },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:created', {
clientId: result.id,
clientName: result.fullName ?? '',
source: result.source ?? '',
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
);
return result;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateClient(
id: string,
portId: string,
data: UpdateClientInput,
meta: AuditMeta,
) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(clients)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'client',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', {
clientId: id,
changedFields: Object.keys(diff),
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
);
if (data.fullName !== undefined) {
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
logger.warn({ err, clientId: id }, 'Failed to sync client folder name');
});
}
return updated;
}
// ─── Archive / Restore ────────────────────────────────────────────────────────
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
// F10: cascade-archive the client's open interests so they don't
// dangle in active queries with a shadowed client. Won/lost interests
// (outcome IS NOT NULL) are kept as historical records - only IN-FLIGHT
// deals get archived. Wrapped in a single transaction so a partial
// archive can't leave the system half-cascaded.
const archivedInterestIds: string[] = await db.transaction(async (tx) => {
await tx
.update(clients)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(eq(clients.id, id));
const cascaded = await tx
.update(interests)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(interests.clientId, id),
eq(interests.portId, portId),
isNull(interests.archivedAt),
isNull(interests.outcome),
),
)
.returning({ id: interests.id });
return cascaded.map((r) => r.id);
});
// fire-and-forget: archive UI does not depend on the folder suffix
// being stamped before the HTTP response returns. Task 5 (rename
// hook) uses await because the rename should be visible to the
// next read; archive does not.
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'client',
entityId: id,
// Surface the cascade in the audit trail so /admin/audit shows
// exactly which interests got swept up.
newValue:
archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
// H-07: emit per-interest archive rows so an auditor searching for a
// specific archived interest finds it directly - the client-level row's
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
for (const interestId of archivedInterestIds) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'interest',
entityId: interestId,
metadata: { cascadeSource: 'client_archive', clientId: id },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
for (const interestId of archivedInterestIds) {
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
}
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
);
}
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.clients.findFirst({
where: eq(clients.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Client');
}
await restore(clients, clients.id, id);
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'client',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function listContacts(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
}
export async function addContact(
clientId: string,
portId: string,
data: {
channel: string;
value: string;
valueE164?: string | null;
valueCountry?: string | null;
label?: string;
isPrimary?: boolean;
notes?: string;
},
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const [contact] = await db
.insert(clientContacts)
.values({ clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientContact',
entityId: contact!.id,
newValue: { clientId, channel: contact!.channel },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return contact!;
}
export async function updateContact(
contactId: string,
clientId: string,
portId: string,
data: Partial<{
channel: string;
value: string;
valueE164: string | null;
valueCountry: string | null;
label: string;
isPrimary: boolean;
notes: string;
}>,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
const [updated] = await db
.update(clientContacts)
.set({ ...data, updatedAt: new Date() })
// M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
.returning();
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return updated;
}
/**
* Phase 3d - promote a non-primary client_contacts row to primary,
* demoting the prior primary for the same channel inside one
* transaction. Throws when the contact is already primary or the row
* does not exist on the targeted client.
*
* Used by the EOI dialog's "Set as default for future docs" toggle
* (via the eoi-overrides service) and by the client-detail "[EOI] Set
* as primary" action.
*/
export async function promoteContactToPrimary(
contactId: string,
clientId: string,
portId: string,
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
if (contact.isPrimary) {
// No-op - return the row as-is so callers can be idempotent.
return contact;
}
const updated = await withTransaction(async (tx) => {
// Demote the prior primary for the same channel so the partial
// unique index doesn't reject the promotion.
await tx
.update(clientContacts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(clientContacts.clientId, clientId),
eq(clientContacts.channel, contact.channel),
eq(clientContacts.isPrimary, true),
),
);
const [row] = await tx
.update(clientContacts)
.set({ isPrimary: true, updatedAt: new Date() })
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
.returning();
return row!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'promote_to_primary',
entityType: 'client_contact',
entityId: contactId,
newValue: { clientId, channel: contact.channel, value: contact.value },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
return updated;
}
export async function removeContact(
contactId: string,
clientId: string,
portId: string,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const contact = await db.query.clientContacts.findFirst({
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
});
if (!contact) throw new NotFoundError('Contact');
// M-MT03: pin (id, clientId) for defense-in-depth.
await db
.delete(clientContacts)
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}
// ─── Addresses ────────────────────────────────────────────────────────────────
interface AddressInput {
label?: string;
streetAddress?: string | null;
city?: string | null;
subdivisionIso?: string | null;
postalCode?: string | null;
countryIso?: string | null;
isPrimary?: boolean;
}
export async function listClientAddresses(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientAddresses.findMany({
where: eq(clientAddresses.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
}
export async function addClientAddress(
clientId: string,
portId: string,
data: AddressInput,
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
// The unique partial index requires us to demote any existing primary
// before inserting a new one. We grab a row lock on the client to
// serialize concurrent primary-toggle requests against the same client -
// without this, two simultaneous "isPrimary=true" inserts can both
// observe "no existing primary" and one trips the unique index with a
// 5xx instead of being safely ordered.
const address = await withTransaction(async (tx) => {
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
const wantsPrimary = data.isPrimary ?? false;
if (wantsPrimary) {
await tx
.update(clientAddresses)
.set({ isPrimary: false })
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
}
const [row] = await tx
.insert(clientAddresses)
.values({
clientId,
portId,
label: data.label ?? 'Primary',
streetAddress: data.streetAddress ?? null,
city: data.city ?? null,
subdivisionIso: data.subdivisionIso ?? null,
postalCode: data.postalCode ?? null,
countryIso: data.countryIso ?? null,
isPrimary: wantsPrimary,
})
.returning();
return row!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientAddress',
entityId: address.id,
newValue: { clientId, label: address.label, countryIso: address.countryIso },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
return address;
}
export async function updateClientAddress(
addressId: string,
clientId: string,
portId: string,
data: AddressInput,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const existing = await db.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
});
if (!existing) throw new NotFoundError('Address');
const updated = await withTransaction(async (tx) => {
// Lock the client row to serialize primary-toggle changes - see addClientAddress.
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
if (data.isPrimary === true && !existing.isPrimary) {
await tx
.update(clientAddresses)
.set({ isPrimary: false })
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
}
const [row] = await tx
.update(clientAddresses)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientAddresses.id, addressId))
.returning();
return row!;
});
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
return updated;
}
export async function removeClientAddress(
addressId: string,
clientId: string,
portId: string,
_meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
const address = await db.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
});
if (!address) throw new NotFoundError('Address');
await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
}
// ─── Tags ─────────────────────────────────────────────────────────────────────
export async function setClientTags(
clientId: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
await setEntityTags({
joinTable: clientTags,
entityColumn: clientTags.clientId,
tagColumn: clientTags.tagId,
entityId: clientId,
portId,
tagIds,
meta,
entityType: 'client',
});
}
// ─── Relationships ────────────────────────────────────────────────────────────
export async function listRelationships(clientId: string, portId: string) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
return db.query.clientRelationships.findMany({
where: (r, { and, or, eq }) =>
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
});
}
export async function createRelationship(
clientId: string,
portId: string,
data: { clientBId: string; relationshipType: string; description?: string },
meta: AuditMeta,
) {
if (data.clientBId === clientId) {
throw new ValidationError('A client cannot have a relationship to themselves');
}
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) throw new NotFoundError('Client');
// Tenant scope: clientBId arrives from the request body. Without this check
// a port-A caller could splice a port-B client UUID onto their own client's
// relationship row; the GET handler joins clientRelationships → clients with
// no port filter and would surface the foreign client's name + email.
const otherClient = await db.query.clients.findFirst({
where: and(eq(clients.id, data.clientBId), eq(clients.portId, portId)),
});
if (!otherClient) throw new ValidationError('clientBId not found in this port');
const [rel] = await db
.insert(clientRelationships)
.values({ portId, clientAId: clientId, ...data })
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'clientRelationship',
entityId: rel!.id,
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rel!;
}
export async function deleteRelationship(
relId: string,
clientId: string,
portId: string,
meta: AuditMeta,
) {
const rel = await db.query.clientRelationships.findFirst({
where: eq(clientRelationships.id, relId),
});
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
await db
.delete(clientRelationships)
.where(and(eq(clientRelationships.id, relId), eq(clientRelationships.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'clientRelationship',
entityId: relId,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Find Duplicates ──────────────────────────────────────────────────────────
export async function findDuplicates(portId: string, fullName: string) {
return db.query.clients.findMany({
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
limit: 5,
});
}
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
export async function listClientOptions(portId: string, search?: string) {
// Pickers only surface active rows. Archived clients are still resolvable
// by id (e.g. history views) but should not appear in dropdowns.
const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
if (search) {
conditions.push(ilike(clients.fullName, `%${search}%`));
}
return db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(and(...conditions))
.orderBy(clients.fullName)
.limit(50);
}