Skip to content

Commit 87f6e0c

Browse files
mpstatonclaude
andcommitted
fix(crm-starter-export): scope is the PIPELINE, not the roster — operator correction applied
The export's default flips to one row per pipeline row (96, tracker order — multi-deal orgs like Accelerate the Future keep their three rows), enrichment joined where a canonical org matched, blanks where not. The event-based org creations stay in the DB as kept noise; the all-roster shape survives behind --scope all. People scope accordingly: --orgs-csv reads the Phase-1 file's external_id column, exports only persons with an edge into the included set, and guarantees the primary attach is an included org. Second run: 96 org rows (67 matched: 29 exact + 38 fuzzy; 29 unmatched to the sidecar = the to-capture list), 61 people across 64 distinct pipeline orgs. Plan open decisions 2 and 3 recorded as settled. Files changed: - scripts/export-crm-orgs-csv.mjs - scripts/export-crm-people-csv.mjs - context-v/plans/CRM-Starter-Export-Orgs-Then-People.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 86a4520 commit 87f6e0c

3 files changed

Lines changed: 108 additions & 41 deletions

File tree

context-v/plans/CRM-Starter-Export-Orgs-Then-People.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,15 @@ external_id has somewhere to land.
201201
**opportunity** records per company (Twenty's native pipeline
202202
object)? Recommendation: opportunities — that's what they are — but
203203
the operator rules at dry-run review.
204-
2. **Org scope confirmation** — recommendation above is all-364 +
205-
filter-in-sheet; alternative is pre-filtering to `funders/` bucket
206-
(+ pipeline matches) if the CRM should only ever see funders.
207-
3. **Person floor** — export all 417, or only persons with a rated
208-
edge (`relevance` set)? Recommendation: all, with `relevance` as a
209-
sheet-filterable column.
204+
2. ~~Org scope~~ — SETTLED (operator ruling 2026-07-27, overriding the
205+
plan's all-364 recommendation): **pipeline rows only**. The export is
206+
one row per PIPELINE row (96 — multi-deal orgs stay multi-row, exactly
207+
like the tracker), enrichment joined where a canonical org matched.
208+
Event-based org creations are kept in the DB but are noise for the
209+
CRM; the all-roster shape survives behind `--scope all`.
210+
3. ~~Person floor~~ — SETTLED by the same ruling: people scope to the
211+
imported orgs (`--orgs-csv` filter; primary attach always an included
212+
org). The all-persons export remains available by omitting the flag.
210213

211214
## Out of scope
212215

scripts/export-crm-orgs-csv.mjs

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ const { Surreal } = requireScripts('surrealdb');
3737
const { connect } = requireServices('@nats-io/transport-node');
3838

3939
// ---- args -------------------------------------------------------------------
40-
const args = { client: 'reach-edu', match: 'Master-Pipeline-Tracker' };
40+
const args = { client: 'reach-edu', match: 'Master-Pipeline-Tracker', scope: 'pipeline' };
4141
for (let i = 2; i < process.argv.length; i += 1) {
4242
const k = process.argv[i];
4343
if (k === '--client') args.client = process.argv[++i];
4444
else if (k === '--record-set-name-match') args.match = process.argv[++i];
4545
else if (k === '--out-dir') args.outDir = process.argv[++i];
46+
// 'pipeline' (default, operator ruling 2026-07-27): one row per PIPELINE
47+
// row — the tracker's shape, enriched. Multi-deal orgs stay multi-row.
48+
// 'all': one row per canonical org (the event-based long tail included).
49+
else if (k === '--scope') args.scope = process.argv[++i];
4650
}
4751
const today = new Date().toISOString().slice(0, 10);
4852
const OUT_DIR = resolve(args.outDir ?? `clients/${args.client}/outputs/${today}_crm-starter`);
@@ -161,47 +165,44 @@ console.log(`pipeline rows: ${pipelineRows.length}`);
161165

162166
// 6. Join pipeline → orgs: exact by corpus_funder_slug, then name/alias.
163167
const orgBySlug = new Map(orgs.map((o) => [o.slug, o]));
164-
const byNorm = new Map(); // normalized name/alias → slug (first wins; collisions logged)
168+
const byNorm = new Map(); // normalized name/alias → slug (first wins)
165169
for (const o of orgs) {
166170
for (const cand of [o.complete_name, o.conventional_name, o.slug.replace(/-/g, ' '), ...(o.aliases ?? [])]) {
167171
const n = normName(cand);
168172
if (n && !byNorm.has(n)) byNorm.set(n, o.slug);
169173
}
170174
}
171-
const pipelineByOrg = new Map(); // slug → { row, matched }
172-
const unmatched = [];
173-
for (const row of pipelineRows) {
175+
// Per-PIPELINE-row resolution — multiple rows may share one org (multi-deal
176+
// orgs like Accelerate the Future); each keeps its own row, tracker-style.
177+
const resolved = pipelineRows.map((row) => {
174178
const name = row['Prospect / Organization'] ?? '';
175179
const exact = row.corpus_funder_slug && orgBySlug.has(row.corpus_funder_slug) ? row.corpus_funder_slug : null;
176180
const fuzzy = exact ? null : byNorm.get(normName(name));
177-
const slug = exact ?? fuzzy;
178-
if (!slug) {
179-
unmatched.push(row);
180-
continue;
181-
}
182-
if (!pipelineByOrg.has(slug)) {
183-
pipelineByOrg.set(slug, { row, matched: exact ? 'exact' : 'fuzzy' });
184-
} else {
185-
console.warn(` ⚠ second pipeline row also matches ${slug}: "${name}" — kept the first, this one goes to the sidecar`);
186-
unmatched.push(row);
187-
}
188-
}
189-
console.log(`pipeline matched: ${pipelineByOrg.size} (exact+fuzzy) · unmatched: ${unmatched.length}`);
181+
return { row, slug: exact ?? fuzzy ?? null, matched: exact ? 'exact' : fuzzy ? 'fuzzy' : 'none' };
182+
});
183+
const matchedCount = resolved.filter((r) => r.slug).length;
184+
console.log(`pipeline rows matched to a canonical org: ${matchedCount}/${resolved.length}`);
190185

191-
// 7. Shape org rows.
186+
// 7. Shape rows — canonical-enrichment column block for one org.
192187
const exported_at = new Date().toISOString();
193-
const outRows = orgs.map((o) => {
194-
const links = o.org_links ?? [];
188+
const enrichmentFor = (o) => {
189+
if (!o) {
190+
return {
191+
external_id: '', name: '', conventional_name: '', aliases: '', domains: '',
192+
bucket: '', tags: '',
193+
...Object.fromEntries(Object.values(PROMOTED_KINDS).map((c) => [c, ''])),
194+
other_links: '', streams: '', stream_count: '', related_orgs: '',
195+
};
196+
}
195197
const flat = {};
196198
const other = [];
197-
for (const l of links) {
199+
for (const l of o.org_links ?? []) {
198200
const col = PROMOTED_KINDS[l.kind];
199201
if (col && !flat[col]) flat[col] = l.url;
200202
else other.push(`${l.kind ?? 'other'}: ${l.url}`);
201203
}
202204
const streams = (o.media_streams ?? []).map((s) => `${s.name ? s.name + ' — ' : ''}${s.url}${s.kind ? ` (${s.kind})` : ''}`);
203-
const p = pipelineByOrg.get(o.slug);
204-
const row = {
205+
return {
205206
external_id: o.slug,
206207
name: o.complete_name ?? o.conventional_name ?? o.slug,
207208
conventional_name: o.conventional_name ?? '',
@@ -214,17 +215,44 @@ const outRows = orgs.map((o) => {
214215
streams: streams.join('\n'),
215216
stream_count: streams.length,
216217
related_orgs: (relsByOrg.get(o.slug) ?? []).join('\n'),
217-
pipeline_org_name: p?.row['Prospect / Organization'] ?? '',
218-
pipeline_matched: p?.matched ?? 'none',
219-
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, p?.row[c] ?? ''])),
220-
exported_at,
221218
};
222-
return row;
223-
});
219+
};
224220

225-
// Pipeline-matched rows first (they're the review priority), then by name.
226-
outRows.sort((a, b) =>
227-
(a.pipeline_matched === 'none') - (b.pipeline_matched === 'none') || a.name.localeCompare(b.name));
221+
let outRows;
222+
let unmatched = [];
223+
if (args.scope === 'pipeline') {
224+
// The tracker's shape: one row per pipeline row, in tracker order,
225+
// enrichment blank where no canonical org matched. Rows with no match
226+
// ALSO land in the sidecar as the to-capture list.
227+
outRows = resolved.map(({ row, slug, matched }) => ({
228+
...enrichmentFor(slug ? orgBySlug.get(slug) : null),
229+
pipeline_org_name: row['Prospect / Organization'] ?? '',
230+
pipeline_matched: matched,
231+
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, row[c] ?? ''])),
232+
exported_at,
233+
}));
234+
unmatched = resolved.filter((r) => !r.slug).map((r) => r.row);
235+
} else {
236+
// --scope all: one row per canonical org (first matching pipeline row
237+
// attached), the event-based long tail included.
238+
const pipelineByOrg = new Map();
239+
for (const r of resolved) {
240+
if (r.slug && !pipelineByOrg.has(r.slug)) pipelineByOrg.set(r.slug, r);
241+
}
242+
outRows = orgs.map((o) => {
243+
const p = pipelineByOrg.get(o.slug);
244+
return {
245+
...enrichmentFor(o),
246+
pipeline_org_name: p?.row['Prospect / Organization'] ?? '',
247+
pipeline_matched: p?.matched ?? 'none',
248+
...Object.fromEntries(PIPELINE_COLS.map((c) => [c, p?.row[c] ?? ''])),
249+
exported_at,
250+
};
251+
});
252+
outRows.sort((a, b) =>
253+
(a.pipeline_matched === 'none') - (b.pipeline_matched === 'none') || a.name.localeCompare(b.name));
254+
unmatched = resolved.filter((r) => !r.slug).map((r) => r.row);
255+
}
228256

229257
const HEADERS = [
230258
'external_id', 'name', 'conventional_name', 'aliases', 'domains', 'bucket', 'tags',

scripts/export-crm-people-csv.mjs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ for (let i = 2; i < process.argv.length; i += 1) {
3131
const k = process.argv[i];
3232
if (k === '--client') args.client = process.argv[++i];
3333
else if (k === '--out-dir') args.outDir = process.argv[++i];
34+
// Scope people to the orgs actually being imported (operator ruling
35+
// 2026-07-27: pipeline orgs only; event-based creations are kept noise).
36+
// Reads the external_id column of a Phase-1 orgs.csv; only persons with
37+
// at least one edge to an included org export, and their PRIMARY attach
38+
// is always an included org. Omit for the all-persons export.
39+
else if (k === '--orgs-csv') args.orgsCsv = process.argv[++i];
3440
}
3541
const today = new Date().toISOString().slice(0, 10);
3642
const OUT_DIR = resolve(args.outDir ?? `clients/${args.client}/outputs/${today}_crm-starter`);
@@ -53,6 +59,30 @@ const RELEVANCE_RANK = {
5359
};
5460
const rankOf = (r) => RELEVANCE_RANK[r ?? ''] ?? 2;
5561

62+
// Minimal CSV parse (quoted multiline cells) — only to pull external_id.
63+
let includedSlugs = null;
64+
if (args.orgsCsv) {
65+
const { readFileSync } = await import('node:fs');
66+
const text = readFileSync(resolve(args.orgsCsv), 'utf8');
67+
const rows = [];
68+
let row = [], cell = '', inQ = false;
69+
for (let i = 0; i < text.length; i += 1) {
70+
const c = text[i];
71+
if (inQ) {
72+
if (c === '"' && text[i + 1] === '"') { cell += '"'; i += 1; }
73+
else if (c === '"') inQ = false;
74+
else cell += c;
75+
} else if (c === '"') inQ = true;
76+
else if (c === ',') { row.push(cell); cell = ''; }
77+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
78+
else if (c !== '\r') cell += c;
79+
}
80+
const [h, ...data] = rows;
81+
const col = h.indexOf('external_id');
82+
includedSlugs = new Set(data.map((r) => r[col]).filter(Boolean));
83+
console.log(`scoping to ${includedSlugs.size} orgs from ${args.orgsCsv}`);
84+
}
85+
5686
const db = new Surreal();
5787
await db.connect(process.env.SURREAL_URL);
5888
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
@@ -85,10 +115,16 @@ console.log(`person→org edges: ${edges.length}`);
85115

86116
// 3. Shape one row per person.
87117
const exported_at = new Date().toISOString();
88-
const rows = persons.map((p) => {
118+
const rows = persons.flatMap((p) => {
89119
const my = (edgesByPerson.get(String(p.person_uuid)) ?? [])
90120
.slice()
91-
.sort((a, b) => rankOf(b.relevance) - rankOf(a.relevance));
121+
// Included-org edges outrank everything (the primary attach must be an
122+
// org that exists in the import), then relevance.
123+
.sort((a, b) =>
124+
(includedSlugs ? includedSlugs.has(b.org_slug) - includedSlugs.has(a.org_slug) : 0) ||
125+
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 [];
92128
const primary = my[0];
93129
const extras = my.slice(1);
94130
const links = p.personal_links ?? [];

0 commit comments

Comments
 (0)