Skip to content

Commit 86a4520

Browse files
mpstatonclaude
andcommitted
feat(crm-starter-export): Phase 1-2 export scripts land — orgs.csv + people.csv from the canonical layer
Executes the plan's export half: one row per org (identity, flattened links by kind, streams as long-text, tags/bucket/relations, the Master Pipeline Tracker's human columns joined exact-first via corpus_funder_slug then name/alias fuzzy — flagged, never trusted), and one row per PERSON (relevance-ranked primary org attach, extras in additional_orgs, orphans kept with blank org). External ids (slug / person_uuid) on every row. No corpora, per the plan invariant. First run (reach-edu): 364 orgs (64 pipeline-matched: 28 exact, 36 fuzzy; 32 unmatched pipeline rows to the sidecar), 417 people (354 attached, 63 orphans, 18 multi-affiliation). The sidecar's three "second row also matches" warnings are the same org with multiple pipeline deals — evidence for the opportunities-not-company-fields call at dry-run sign-off. Per context-v/plans/CRM-Starter-Export-Orgs-Then-People.md (Phase 3 — the Twenty importer — awaits the fresh API key + operator review of these files). Files changed: - scripts/export-crm-orgs-csv.mjs (new) - scripts/export-crm-people-csv.mjs (new) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 4b1f7c7 commit 86a4520

2 files changed

Lines changed: 387 additions & 0 deletions

File tree

scripts/export-crm-orgs-csv.mjs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
// ============================================================================
3+
// export-crm-orgs-csv.mjs
4+
//
5+
// Phase 1 of context-v/plans/CRM-Starter-Export-Orgs-Then-People.md:
6+
// one row per canonical organization visible to the client, carrying
7+
// - identity (slug as external_id, names, aliases, domains, bucket, tags)
8+
// - identity links flattened by kind (website/linkedin/x/… + other_links)
9+
// - pulse streams (one long-text column — multi-valued by nature)
10+
// - org↔org relations summary
11+
// - the Master Pipeline Tracker's human columns, joined exact-first via
12+
// the pipeline row's corpus_funder_slug, then by name/alias matching
13+
// - NO corpora (plan invariant 2)
14+
//
15+
// Unmatched pipeline rows are reported to a sidecar CSV, never dropped —
16+
// they may name orgs never captured (themselves a to-capture list).
17+
//
18+
// Reads: SurrealDB direct (orgs/tags/relations — SURREAL_* env) + NATS
19+
// row-store (the pipeline record set). Writes CSVs; touches nothing.
20+
//
21+
// Usage:
22+
// set -a; source ./.env; set +a
23+
// node scripts/export-crm-orgs-csv.mjs \
24+
// [--client reach-edu] \
25+
// [--record-set-name-match Master-Pipeline-Tracker] \
26+
// [--out-dir clients/<client>/outputs/<YYYY-MM-DD>_crm-starter/]
27+
// ============================================================================
28+
29+
import { createRequire } from 'node:module';
30+
import { mkdir, writeFile } from 'node:fs/promises';
31+
import { existsSync } from 'node:fs';
32+
import { resolve, join } from 'node:path';
33+
34+
const requireScripts = createRequire(new URL('./package.json', import.meta.url));
35+
const requireServices = createRequire(new URL('../services/social-search/package.json', import.meta.url));
36+
const { Surreal } = requireScripts('surrealdb');
37+
const { connect } = requireServices('@nats-io/transport-node');
38+
39+
// ---- args -------------------------------------------------------------------
40+
const args = { client: 'reach-edu', match: 'Master-Pipeline-Tracker' };
41+
for (let i = 2; i < process.argv.length; i += 1) {
42+
const k = process.argv[i];
43+
if (k === '--client') args.client = process.argv[++i];
44+
else if (k === '--record-set-name-match') args.match = process.argv[++i];
45+
else if (k === '--out-dir') args.outDir = process.argv[++i];
46+
}
47+
const today = new Date().toISOString().slice(0, 10);
48+
const OUT_DIR = resolve(args.outDir ?? `clients/${args.client}/outputs/${today}_crm-starter`);
49+
50+
// ---- csv helpers --------------------------------------------------------------
51+
const csvEscape = (s) => {
52+
const v = String(s ?? '');
53+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
54+
};
55+
const writeCsv = async (path, headers, rows) => {
56+
const lines = [headers.join(',')];
57+
for (const r of rows) lines.push(headers.map((h) => csvEscape(r[h])).join(','));
58+
await writeFile(path, lines.join('\n') + '\n', 'utf8');
59+
};
60+
61+
// ---- link-kind flattening map --------------------------------------------------
62+
// First URL of each promoted kind gets its own column; the rest spill into
63+
// other_links. Kinds observed live 2026-07-27 (19 in use).
64+
const PROMOTED_KINDS = {
65+
website: 'website',
66+
linkedin_company: 'linkedin',
67+
x_profile: 'x',
68+
facebook_profile: 'facebook',
69+
instagram_profile: 'instagram',
70+
youtube: 'youtube',
71+
wikipedia: 'wikipedia',
72+
bluesky_profile: 'bluesky',
73+
substack: 'substack',
74+
team_page: 'team_page',
75+
};
76+
77+
// ---- pipeline columns replicated (the plan's enumerated human set) -------------
78+
const PIPELINE_COLS = [
79+
'Type', 'Owner', 'Stage', 'Total Commitment ($)', 'FY26 Revenue ($)',
80+
'FY27 Revenue ($)', 'Probability (auto)', 'Weighted FY26 (auto)',
81+
'Weighted FY27 (auto)', 'Last Contact/Update', 'Notes/Context', 'Next Step',
82+
'Next Step Due', 'Next Step Owner', 'Next Step Status', 'Upcoming Event',
83+
'RSVP Status',
84+
];
85+
86+
// ---- name normalization for the fuzzy join --------------------------------------
87+
const normName = (s) =>
88+
String(s ?? '')
89+
.replace(/\(.*?\)/g, ' ') // strip parentheticals: "Accelerate the Future (ACH, GW Match)"
90+
.toLowerCase()
91+
.replace(/[^a-z0-9]+/g, ' ')
92+
.trim();
93+
94+
// ---- main -----------------------------------------------------------------------
95+
const db = new Surreal();
96+
await db.connect(process.env.SURREAL_URL);
97+
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
98+
await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB });
99+
100+
// 1. Canonical orgs.
101+
const orgs = (await db.query(
102+
`SELECT id, slug, complete_name, conventional_name, aliases, domains,
103+
org_links, media_streams
104+
FROM organizations WHERE client_access CONTAINS $client;`,
105+
{ client: args.client },
106+
))?.[0] ?? [];
107+
console.log(`orgs: ${orgs.length}`);
108+
109+
// 2. Org tags (per-client has_tag observations), grouped by subject id.
110+
const tagRows = (await db.query(
111+
`SELECT subject, object FROM observations
112+
WHERE predicate = 'has_tag' AND client = $client
113+
AND record::tb(subject) = 'organizations';`,
114+
{ client: args.client },
115+
))?.[0] ?? [];
116+
const tagsByOrg = new Map();
117+
for (const t of tagRows) {
118+
const k = String(t.subject);
119+
tagsByOrg.set(k, [...(tagsByOrg.get(k) ?? []), String(t.object)]);
120+
}
121+
122+
// 3. Org↔org relations, projected per org (same semantics as
123+
// organization.relations: inbound child_of = child, outbound = parent).
124+
const relRows = (await db.query(
125+
`SELECT rel, kind, in.slug AS in_slug, out.slug AS out_slug FROM affiliations
126+
WHERE edge_type = 'org_org' AND client_access CONTAINS $client;`,
127+
{ client: args.client },
128+
))?.[0] ?? [];
129+
const relsByOrg = new Map();
130+
const addRel = (slug, line) => relsByOrg.set(slug, [...(relsByOrg.get(slug) ?? []), line]);
131+
for (const r of relRows) {
132+
const kind = r.kind ? ` (${r.kind})` : '';
133+
if (r.rel === 'peer') {
134+
addRel(r.in_slug, `peer: ${r.out_slug}${kind}`);
135+
addRel(r.out_slug, `peer: ${r.in_slug}${kind}`);
136+
} else {
137+
addRel(r.in_slug, `parent: ${r.out_slug}${kind}`);
138+
addRel(r.out_slug, `child: ${r.in_slug}${kind}`);
139+
}
140+
}
141+
142+
// 4. Bucket derivation from the client corpus's disk folders.
143+
const BUCKETS = ['funders', 'gov-entities', 'think-tanks', 'associations-networks', 'academic-institutions', 'data-services'];
144+
const CORPUS_ROOT = resolve(`clients/${args.client}/corpus`);
145+
const bucketOf = (slug) => BUCKETS.find((b) => existsSync(join(CORPUS_ROOT, b, slug))) ?? '';
146+
147+
// 5. Pipeline record set over NATS — newest record set whose name matches.
148+
const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' });
149+
const req = async (s, b, t = 30_000) =>
150+
JSON.parse(new TextDecoder().decode((await nc.request(s, JSON.stringify(b), { timeout: t })).data));
151+
const list = await req('record_set.list.requested', {});
152+
const candidates = (list.record_sets ?? []).filter((s) => (s.name ?? '').includes(args.match));
153+
candidates.sort((a, b) => String(b.name).localeCompare(String(a.name)));
154+
const chosen = candidates[0];
155+
if (!chosen) throw new Error(`no record set matching "${args.match}"`);
156+
console.log(`pipeline record set: ${chosen.name}`);
157+
const got = await req('record_set.get.requested', { record_set_id: chosen.record_set_id ?? chosen.id });
158+
const pipelineRows = (got.rows ?? []).map((r) => r.fields ?? r);
159+
await nc.drain();
160+
console.log(`pipeline rows: ${pipelineRows.length}`);
161+
162+
// 6. Join pipeline → orgs: exact by corpus_funder_slug, then name/alias.
163+
const orgBySlug = new Map(orgs.map((o) => [o.slug, o]));
164+
const byNorm = new Map(); // normalized name/alias → slug (first wins; collisions logged)
165+
for (const o of orgs) {
166+
for (const cand of [o.complete_name, o.conventional_name, o.slug.replace(/-/g, ' '), ...(o.aliases ?? [])]) {
167+
const n = normName(cand);
168+
if (n && !byNorm.has(n)) byNorm.set(n, o.slug);
169+
}
170+
}
171+
const pipelineByOrg = new Map(); // slug → { row, matched }
172+
const unmatched = [];
173+
for (const row of pipelineRows) {
174+
const name = row['Prospect / Organization'] ?? '';
175+
const exact = row.corpus_funder_slug && orgBySlug.has(row.corpus_funder_slug) ? row.corpus_funder_slug : null;
176+
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}`);
190+
191+
// 7. Shape org rows.
192+
const exported_at = new Date().toISOString();
193+
const outRows = orgs.map((o) => {
194+
const links = o.org_links ?? [];
195+
const flat = {};
196+
const other = [];
197+
for (const l of links) {
198+
const col = PROMOTED_KINDS[l.kind];
199+
if (col && !flat[col]) flat[col] = l.url;
200+
else other.push(`${l.kind ?? 'other'}: ${l.url}`);
201+
}
202+
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+
external_id: o.slug,
206+
name: o.complete_name ?? o.conventional_name ?? o.slug,
207+
conventional_name: o.conventional_name ?? '',
208+
aliases: (o.aliases ?? []).join(' | '),
209+
domains: (o.domains ?? []).map((d) => d.domain).filter(Boolean).join(' | '),
210+
bucket: bucketOf(o.slug),
211+
tags: (tagsByOrg.get(String(o.id)) ?? []).join(' | '),
212+
...Object.fromEntries(Object.values(PROMOTED_KINDS).map((c) => [c, flat[c] ?? ''])),
213+
other_links: other.join('\n'),
214+
streams: streams.join('\n'),
215+
stream_count: streams.length,
216+
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,
221+
};
222+
return row;
223+
});
224+
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));
228+
229+
const HEADERS = [
230+
'external_id', 'name', 'conventional_name', 'aliases', 'domains', 'bucket', 'tags',
231+
...Object.values(PROMOTED_KINDS), 'other_links', 'streams', 'stream_count', 'related_orgs',
232+
'pipeline_org_name', 'pipeline_matched', ...PIPELINE_COLS, 'exported_at',
233+
];
234+
235+
await mkdir(OUT_DIR, { recursive: true });
236+
await writeCsv(join(OUT_DIR, 'orgs.csv'), HEADERS, outRows);
237+
console.log(`wrote ${outRows.length} rows → ${join(OUT_DIR, 'orgs.csv')}`);
238+
239+
if (unmatched.length > 0) {
240+
const uHeaders = Object.keys(unmatched[0]);
241+
await writeCsv(join(OUT_DIR, 'unmatched-pipeline-rows.csv'), uHeaders, unmatched);
242+
console.log(`wrote ${unmatched.length} unmatched pipeline rows → ${join(OUT_DIR, 'unmatched-pipeline-rows.csv')}`);
243+
}
244+
245+
const fuzzyCount = outRows.filter((r) => r.pipeline_matched === 'fuzzy').length;
246+
console.log(`review: ${fuzzyCount} fuzzy pipeline matches flagged in pipeline_matched`);
247+
await db.close();

scripts/export-crm-people-csv.mjs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env node
2+
// ============================================================================
3+
// export-crm-people-csv.mjs
4+
//
5+
// Phase 2 of context-v/plans/CRM-Starter-Export-Orgs-Then-People.md:
6+
// one row per PERSON (deliberately NOT per affiliation edge — a person with
7+
// two affiliations must not become two CRM contacts; the inversion of
8+
// export-affiliation-ratings-csv.mjs's shape, reasoned in the plan).
9+
//
10+
// The strongest affiliation (relevance-ranked) supplies the org attach
11+
// (org_external_id = the org slug, matching orgs.csv's external_id); the
12+
// remaining edges ride in additional_orgs. Persons with zero affiliations
13+
// still export with blank org columns — the CRM decides about orphans.
14+
//
15+
// Usage:
16+
// set -a; source ./.env; set +a
17+
// node scripts/export-crm-people-csv.mjs \
18+
// [--client reach-edu] \
19+
// [--out-dir clients/<client>/outputs/<YYYY-MM-DD>_crm-starter/]
20+
// ============================================================================
21+
22+
import { createRequire } from 'node:module';
23+
import { mkdir, writeFile } from 'node:fs/promises';
24+
import { resolve, join } from 'node:path';
25+
26+
const requireScripts = createRequire(new URL('./package.json', import.meta.url));
27+
const { Surreal } = requireScripts('surrealdb');
28+
29+
const args = { client: 'reach-edu' };
30+
for (let i = 2; i < process.argv.length; i += 1) {
31+
const k = process.argv[i];
32+
if (k === '--client') args.client = process.argv[++i];
33+
else if (k === '--out-dir') args.outDir = process.argv[++i];
34+
}
35+
const today = new Date().toISOString().slice(0, 10);
36+
const OUT_DIR = resolve(args.outDir ?? `clients/${args.client}/outputs/${today}_crm-starter`);
37+
38+
const csvEscape = (s) => {
39+
const v = String(s ?? '');
40+
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
41+
};
42+
43+
// Relevance ranking — the operator's labels outrank unrated; skip/irrelevant
44+
// sink below unrated so a person's "best" org is never one they were rated
45+
// irrelevant for when an unrated alternative exists.
46+
const RELEVANCE_RANK = {
47+
very_relevant: 5,
48+
highly_relevant: 4,
49+
relevant: 3,
50+
'': 2,
51+
skip: 1,
52+
irrelevant: 0,
53+
};
54+
const rankOf = (r) => RELEVANCE_RANK[r ?? ''] ?? 2;
55+
56+
const db = new Surreal();
57+
await db.connect(process.env.SURREAL_URL);
58+
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
59+
await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB });
60+
61+
// 1. Persons visible to the client.
62+
const persons = (await db.query(
63+
`SELECT id, person_uuid, name, full_name, first_name, surname, email,
64+
linkedin_profile_url, headline, personal_links
65+
FROM persons WHERE client_access CONTAINS $client;`,
66+
{ client: args.client },
67+
))?.[0] ?? [];
68+
console.log(`persons: ${persons.length}`);
69+
70+
// 2. Person→org edges in one pass (org_org edges excluded by shape: their
71+
// `in` is an organizations row, so in.person_uuid is absent — the same
72+
// guard the People Reveal relies on).
73+
const edges = (await db.query(
74+
`SELECT in.person_uuid AS person_uuid, out.slug AS org_slug,
75+
out.complete_name AS org_complete, out.conventional_name AS org_conventional,
76+
kind, relevance
77+
FROM affiliations WHERE in.person_uuid != NONE;`,
78+
))?.[0] ?? [];
79+
const edgesByPerson = new Map();
80+
for (const e of edges) {
81+
const k = String(e.person_uuid);
82+
edgesByPerson.set(k, [...(edgesByPerson.get(k) ?? []), e]);
83+
}
84+
console.log(`person→org edges: ${edges.length}`);
85+
86+
// 3. Shape one row per person.
87+
const exported_at = new Date().toISOString();
88+
const rows = persons.map((p) => {
89+
const my = (edgesByPerson.get(String(p.person_uuid)) ?? [])
90+
.slice()
91+
.sort((a, b) => rankOf(b.relevance) - rankOf(a.relevance));
92+
const primary = my[0];
93+
const extras = my.slice(1);
94+
const links = p.personal_links ?? [];
95+
const linkedin =
96+
p.linkedin_profile_url ??
97+
links.find((l) => /linkedin\.com\/in\//i.test(l.url ?? ''))?.url ??
98+
'';
99+
const other = links
100+
.filter((l) => (l.url ?? '') !== linkedin)
101+
.map((l) => `${l.kind ?? 'other'}: ${l.url}`);
102+
return {
103+
external_id: p.person_uuid,
104+
name: p.name ?? p.full_name ?? '',
105+
first_name: p.first_name ?? '',
106+
surname: p.surname ?? '',
107+
email: p.email ?? '',
108+
linkedin,
109+
headline: p.headline ?? '',
110+
org_external_id: primary?.org_slug ?? '',
111+
org_name: primary ? (primary.org_complete ?? primary.org_conventional ?? primary.org_slug) : '',
112+
role: primary?.kind ?? '',
113+
relevance: primary?.relevance ?? '',
114+
additional_orgs: extras.map((e) => `${e.org_slug}${e.kind ?? '-'}${e.relevance ? ` (${e.relevance})` : ''}`).join('\n'),
115+
other_links: other.join('\n'),
116+
exported_at,
117+
};
118+
});
119+
120+
// Attached-and-rated first, then attached, then orphans; alphabetical within.
121+
rows.sort((a, b) =>
122+
(a.org_external_id === '') - (b.org_external_id === '') ||
123+
rankOf(b.relevance) - rankOf(a.relevance) ||
124+
a.name.localeCompare(b.name));
125+
126+
const HEADERS = [
127+
'external_id', 'name', 'first_name', 'surname', 'email', 'linkedin', 'headline',
128+
'org_external_id', 'org_name', 'role', 'relevance', 'additional_orgs',
129+
'other_links', 'exported_at',
130+
];
131+
await mkdir(OUT_DIR, { recursive: true });
132+
const lines = [HEADERS.join(',')];
133+
for (const r of rows) lines.push(HEADERS.map((h) => csvEscape(r[h])).join(','));
134+
await writeFile(join(OUT_DIR, 'people.csv'), lines.join('\n') + '\n', 'utf8');
135+
136+
const orphans = rows.filter((r) => !r.org_external_id).length;
137+
const multi = rows.filter((r) => r.additional_orgs).length;
138+
console.log(`wrote ${rows.length} rows → ${join(OUT_DIR, 'people.csv')}`);
139+
console.log(`attached: ${rows.length - orphans} · orphans (no affiliation): ${orphans} · multi-affiliation: ${multi}`);
140+
await db.close();

0 commit comments

Comments
 (0)