Skip to content

Commit cde8cf4

Browse files
mpstatonclaude
andcommitted
feat(crm-starter-export): persons are first-class pipeline prospects — deals match people directly
Operator ruling: a pipeline row can BE a person ("sometimes there are people who have several things going on") — no forced org aliases, no invented family-fund rows. The export now runs a person-match pass on rows no org claims: exact normalized name (including the parenthetical — "Toolbox Family Fund (Joshua Biber)" can anchor to Joshua Biber), never fuzzy. Matched rows carry person_external_id / person_name with pipeline_matched 'person', and the people export includes those persons even without an org edge — they're going to Twenty as contacts with opportunities. First effect: Blair Miller's Major Gift row matches her persons row and leaves the sidecar (19 → 18). Bob Campbell, Bob Zorich, James Patterson, and Joshua Biber will auto-match the moment their person rows exist — no org creation required. Files changed: - scripts/export-crm-orgs-csv.mjs - scripts/export-crm-people-csv.mjs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 034c1b3 commit cde8cf4

2 files changed

Lines changed: 58 additions & 7 deletions

File tree

scripts/export-crm-orgs-csv.mjs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,30 @@ const BUCKETS = ['funders', 'gov-entities', 'think-tanks', 'associations-network
153153
const CORPUS_ROOT = resolve(`clients/${args.client}/corpus`);
154154
const bucketOf = (slug) => BUCKETS.find((b) => existsSync(join(CORPUS_ROOT, b, slug))) ?? '';
155155

156+
// 4b. Persons — pipeline prospects are allowed to BE people (operator
157+
// ruling 2026-07-28: "sometimes there are people who have several things
158+
// going on" — a Major Gift row attaches to the person, not a forced org).
159+
// Exact-normalized name match only; person names are too risky to fuzz.
160+
const persons = (await db.query(
161+
`SELECT person_uuid, name, full_name FROM persons WHERE client_access CONTAINS $client;`,
162+
{ client: args.client },
163+
))?.[0] ?? [];
164+
const personByNorm = new Map();
165+
for (const p of persons) {
166+
for (const cand of [p.name, p.full_name]) {
167+
const n = normName(cand);
168+
if (n && !personByNorm.has(n)) personByNorm.set(n, p);
169+
}
170+
}
171+
const personMatch = (name) => {
172+
const direct = personByNorm.get(normName(name));
173+
if (direct) return direct;
174+
// "Toolbox Family Fund (Joshua Biber)" — the parenthetical often names
175+
// the person the deal actually anchors to.
176+
const paren = /\(([^)]+)\)/.exec(String(name))?.[1];
177+
return paren ? (personByNorm.get(normName(paren)) ?? null) : null;
178+
};
179+
156180
// 5. Pipeline record set over NATS — newest record set whose name matches.
157181
const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' });
158182
const req = async (s, b, t = 30_000) =>
@@ -226,10 +250,17 @@ const resolved = pipelineRows.map((row) => {
226250
const name = row['Prospect / Organization'] ?? '';
227251
const exact = row.corpus_funder_slug && orgBySlug.has(row.corpus_funder_slug) ? row.corpus_funder_slug : null;
228252
const fuzzy = exact ? null : fuzzyMatch(name);
229-
return { row, slug: exact ?? fuzzy ?? null, matched: exact ? 'exact' : fuzzy ? 'fuzzy' : 'none' };
253+
const person = exact || fuzzy ? null : personMatch(name);
254+
return {
255+
row,
256+
slug: exact ?? fuzzy ?? null,
257+
person,
258+
matched: exact ? 'exact' : fuzzy ? 'fuzzy' : person ? 'person' : 'none',
259+
};
230260
});
231261
const matchedCount = resolved.filter((r) => r.slug).length;
232-
console.log(`pipeline rows matched to a canonical org: ${matchedCount}/${resolved.length}`);
262+
const personCount = resolved.filter((r) => r.person).length;
263+
console.log(`pipeline rows matched: ${matchedCount} org + ${personCount} person / ${resolved.length}`);
233264

234265
// 7. Shape rows — canonical-enrichment column block for one org.
235266
const exported_at = new Date().toISOString();
@@ -272,14 +303,17 @@ if (args.scope === 'pipeline') {
272303
// The tracker's shape: one row per pipeline row, in tracker order,
273304
// enrichment blank where no canonical org matched. Rows with no match
274305
// ALSO land in the sidecar as the to-capture list.
275-
outRows = resolved.map(({ row, slug, matched }) => ({
306+
outRows = resolved.map(({ row, slug, person, matched }) => ({
276307
...enrichmentFor(slug ? orgBySlug.get(slug) : null),
308+
person_external_id: person?.person_uuid ?? '',
309+
person_name: person ? (person.name ?? person.full_name ?? '') : '',
277310
pipeline_org_name: row['Prospect / Organization'] ?? '',
278311
pipeline_matched: matched,
279312
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, row[c] ?? ''])),
280313
exported_at,
281314
}));
282-
unmatched = resolved.filter((r) => !r.slug).map((r) => r.row);
315+
// Sidecar = rows matching NEITHER an org nor a person.
316+
unmatched = resolved.filter((r) => !r.slug && !r.person).map((r) => r.row);
283317
// Operator-directed inclusions land after the pipeline rows.
284318
const already = new Set(outRows.map((r) => r.external_id).filter(Boolean));
285319
for (const slug of args.include ?? []) {
@@ -291,6 +325,8 @@ if (args.scope === 'pipeline') {
291325
}
292326
outRows.push({
293327
...enrichmentFor(o),
328+
person_external_id: '',
329+
person_name: '',
294330
pipeline_org_name: '',
295331
pipeline_matched: 'manual',
296332
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, ''])),
@@ -309,6 +345,8 @@ if (args.scope === 'pipeline') {
309345
const p = pipelineByOrg.get(o.slug);
310346
return {
311347
...enrichmentFor(o),
348+
person_external_id: '',
349+
person_name: '',
312350
pipeline_org_name: p?.row['Prospect / Organization'] ?? '',
313351
pipeline_matched: p?.matched ?? 'none',
314352
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, p?.row[c] ?? ''])),
@@ -323,6 +361,7 @@ if (args.scope === 'pipeline') {
323361
const HEADERS = [
324362
'external_id', 'name', 'conventional_name', 'aliases', 'domains', 'bucket', 'tags',
325363
...Object.values(PROMOTED_KINDS), 'other_links', 'streams', 'stream_count', 'related_orgs',
364+
'person_external_id', 'person_name',
326365
'pipeline_org_name', 'pipeline_matched', ...PIPELINE_COLS, 'exported_at',
327366
];
328367

scripts/export-crm-people-csv.mjs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const rankOf = (r) => RELEVANCE_RANK[r ?? ''] ?? 2;
6161

6262
// Minimal CSV parse (quoted multiline cells) — only to pull external_id.
6363
let includedSlugs = null;
64+
let includedPersons = null;
6465
if (args.orgsCsv) {
6566
const { readFileSync } = await import('node:fs');
6667
const text = readFileSync(resolve(args.orgsCsv), 'utf8');
@@ -80,7 +81,13 @@ if (args.orgsCsv) {
8081
const [h, ...data] = rows;
8182
const col = h.indexOf('external_id');
8283
includedSlugs = new Set(data.map((r) => r[col]).filter(Boolean));
83-
console.log(`scoping to ${includedSlugs.size} orgs from ${args.orgsCsv}`);
84+
// Person-anchored pipeline rows (operator ruling 2026-07-28: people are
85+
// first-class prospects) — those persons export even with no org edge.
86+
const pcol = h.indexOf('person_external_id');
87+
if (pcol >= 0) {
88+
includedPersons = new Set(data.map((r) => r[pcol]).filter(Boolean));
89+
}
90+
console.log(`scoping to ${includedSlugs.size} orgs + ${includedPersons?.size ?? 0} pipeline persons from ${args.orgsCsv}`);
8491
}
8592

8693
const db = new Surreal();
@@ -123,8 +130,13 @@ const rows = persons.flatMap((p) => {
123130
.sort((a, b) =>
124131
(includedSlugs ? includedSlugs.has(b.org_slug) - includedSlugs.has(a.org_slug) : 0) ||
125132
rankOf(b.relevance) - rankOf(a.relevance));
126-
// Scoped run: skip persons with no edge into the included org set.
127-
if (includedSlugs && !my.some((e) => includedSlugs.has(e.org_slug))) return [];
133+
// Scoped run: keep persons with an edge into the included org set OR
134+
// named directly by a pipeline row (person-anchored deals).
135+
if (
136+
includedSlugs &&
137+
!my.some((e) => includedSlugs.has(e.org_slug)) &&
138+
!includedPersons?.has(String(p.person_uuid))
139+
) return [];
128140
const primary = my[0];
129141
const extras = my.slice(1);
130142
const links = p.personal_links ?? [];

0 commit comments

Comments
 (0)