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:
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "idx_cc_one_primary_per_channel" ON "client_contacts" USING btree ("client_id","channel") WHERE "client_contacts"."is_primary" = true;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Backfill clients.nationality_iso from the primary phone's parsed
|
||||
-- value_country. Idempotent (only runs on rows where nationality_iso
|
||||
-- is null), safe to re-execute. Phone country is a *proxy* for
|
||||
-- nationality - the client-list UI labels the column "Country" rather
|
||||
-- than "Nationality" to avoid implying it's authoritative (see §14.2).
|
||||
--
|
||||
-- Pattern: prefer the row marked `is_primary=true`; fall back to the
|
||||
-- most recently created phone contact when no row is flagged primary.
|
||||
WITH primary_phone AS (
|
||||
SELECT DISTINCT ON (cc.client_id)
|
||||
cc.client_id,
|
||||
cc.value_country
|
||||
FROM client_contacts cc
|
||||
WHERE cc.channel = 'phone'
|
||||
AND cc.value_country IS NOT NULL
|
||||
ORDER BY cc.client_id,
|
||||
cc.is_primary DESC,
|
||||
cc.created_at DESC
|
||||
)
|
||||
UPDATE clients c
|
||||
SET nationality_iso = primary_phone.value_country
|
||||
FROM primary_phone
|
||||
WHERE c.nationality_iso IS NULL
|
||||
AND c.id = primary_phone.client_id;
|
||||
10697
src/lib/db/migrations/meta/0026_snapshot.json
Normal file
10697
src/lib/db/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
10697
src/lib/db/migrations/meta/0027_snapshot.json
Normal file
10697
src/lib/db/migrations/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,20 @@
|
||||
"when": 1777939212954,
|
||||
"tag": "0025_berth_pricing_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1777939906731,
|
||||
"tag": "0026_client_contacts_one_primary_per_channel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1777939914252,
|
||||
"tag": "0027_backfill_nationality_iso_from_phone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ export const clientContacts = pgTable(
|
||||
index('idx_cc_phone')
|
||||
.on(table.channel, table.value)
|
||||
.where(sql`${table.channel} = 'phone'`),
|
||||
// At most one is_primary=true per (client_id, channel). Prevents
|
||||
// ambiguity when the /clients list pulls "the" primary phone/email.
|
||||
uniqueIndex('idx_cc_one_primary_per_channel')
|
||||
.on(table.clientId, table.channel)
|
||||
.where(sql`${table.isPrimary} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user