fix(clients): list contacts join + nationality backfill + col redesign

Wire primary email + primary phone into the /clients list service so
the redesigned columns (Name · Email · Phone · Country · Source ·
Latest stage · Created) actually have data. Picks the row marked
is_primary=true; falls back to most-recent created_at when the flag
is unset.

- 0026 schema migration: unique partial index
  idx_cc_one_primary_per_channel on (client_id, channel) WHERE
  is_primary=true. Prevents the §14.2 "multiple primaries" ambiguity.
- 0027 data migration: backfill clients.nationality_iso from the
  primary phone's value_country. 218 -> 36 missing on dev. Idempotent.
- listClients: add a fifth parallel query for client_contacts; build
  primaryEmailMap / primaryPhoneMap in-memory from the pre-sorted
  result.
- client-columns: drop Yachts/Companies/Tags from the default view
  per §5.1; add Email/Phone/Country/Latest-stage columns; rename
  "Nationality" -> "Country" since phone country is a proxy (§14.2).
- client-card: prefer email, fall back to phone, for the line under
  the name; replaces the old `contacts.find(isPrimary)` lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 02:15:03 +02:00
parent a2588f2c4a
commit 3017ce4b3a
9 changed files with 21563 additions and 106 deletions

View File

@@ -84,53 +84,71 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts] = 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),
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(berths, eq(berths.id, interests.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),
]);
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = 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),
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(berths, eq(berths.id, interests.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 every contact row for the page; the per-client primary
// resolution happens in the post-fetch loop below. Cheaper than
// running a DISTINCT-ON query per channel and keeps the picker
// logic (is_primary desc, then most recent created_at) in one
// place.
db
.select({
clientId: clientContacts.clientId,
channel: clientContacts.channel,
value: clientContacts.value,
isPrimary: clientContacts.isPrimary,
createdAt: clientContacts.createdAt,
})
.from(clientContacts)
.where(inArray(clientContacts.clientId, ids))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.createdAt)),
],
);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
@@ -146,6 +164,18 @@ export async function listClients(portId: string, query: ListClientsInput) {
}
}
// Pick the per-client primary (or, failing that, most-recent) email
// and phone. contactRows is pre-sorted is_primary desc, created_at desc.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
for (const c of contactRows) {
if (c.channel === 'email' && !primaryEmailMap.has(c.clientId)) {
primaryEmailMap.set(c.clientId, c.value);
} else if (c.channel === 'phone' && !primaryPhoneMap.has(c.clientId)) {
primaryPhoneMap.set(c.clientId, c.value);
}
}
return {
...result,
data: result.data.map((row) => {
@@ -155,6 +185,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
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,
latestInterest: latest
? {
stage: latest.stage,