|
| 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(); |
0 commit comments