|
| 1 | +#!/usr/bin/env node |
| 2 | +// ============================================================================ |
| 3 | +// tag-funder-strategies-from-mega-gifts.mjs |
| 4 | +// |
| 5 | +// Backfills strategy tags onto funder orgs from the mega-gifts CSV |
| 6 | +// (operator ask 2026-07-28): each funder gets the Train-Case form of every |
| 7 | +// strategy its gift rows referenced (ballmer-group ← Workforce-Development). |
| 8 | +// |
| 9 | +// Resolution is derived from what the ingest ACTUALLY wrote — an org is a |
| 10 | +// row's funder iff the row's source_url sits on that org's corpus — so no |
| 11 | +// name re-matching and no drift from the reviewed OVERRIDES table. |
| 12 | +// organization.tag.add is idempotent (created:false on repeats); re-runs |
| 13 | +// are free. Tags land in tag_vocab, the workbench card, and the CRM |
| 14 | +// export's tags column. |
| 15 | +// |
| 16 | +// Usage: set -a; source ./.env; set +a |
| 17 | +// node scripts/tag-funder-strategies-from-mega-gifts.mjs [--live] |
| 18 | +// ============================================================================ |
| 19 | + |
| 20 | +import { createRequire } from 'node:module'; |
| 21 | +import { readFileSync } from 'node:fs'; |
| 22 | +import { resolve } from 'node:path'; |
| 23 | + |
| 24 | +const requireScripts = createRequire(new URL('./package.json', import.meta.url)); |
| 25 | +const requireServices = createRequire(new URL('../services/social-search/package.json', import.meta.url)); |
| 26 | +const { Surreal } = requireScripts('surrealdb'); |
| 27 | +const { connect } = requireServices('@nats-io/transport-node'); |
| 28 | + |
| 29 | +const args = { csv: 'clients/reach-edu/outputs/2026-07-28_mega-gifts-by-topic/mega-gifts-by-topic.csv', client: 'reach-edu', live: false }; |
| 30 | +for (let i = 2; i < process.argv.length; i += 1) { |
| 31 | + if (process.argv[i] === '--live') args.live = true; |
| 32 | + else if (process.argv[i] === '--csv') args.csv = process.argv[++i]; |
| 33 | +} |
| 34 | + |
| 35 | +// slug → Train-Case tag (acronyms uppercase per the house tagging rule). |
| 36 | +const ACRONYMS = new Set(['ncad', 'ai']); |
| 37 | +const trainCase = (slug) => |
| 38 | + slug.split('-').map((w) => (ACRONYMS.has(w) ? w.toUpperCase() : w[0].toUpperCase() + w.slice(1))).join('-'); |
| 39 | + |
| 40 | +function parseCsv(path) { |
| 41 | + const text = readFileSync(path, 'utf8'); |
| 42 | + const rows = []; |
| 43 | + let row = [], cell = '', inQ = false; |
| 44 | + for (let i = 0; i < text.length; i += 1) { |
| 45 | + const c = text[i]; |
| 46 | + if (inQ) { |
| 47 | + if (c === '"' && text[i + 1] === '"') { cell += '"'; i += 1; } |
| 48 | + else if (c === '"') inQ = false; |
| 49 | + else cell += c; |
| 50 | + } else if (c === '"') inQ = true; |
| 51 | + else if (c === ',') { row.push(cell); cell = ''; } |
| 52 | + else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } |
| 53 | + else if (c !== '\r') cell += c; |
| 54 | + } |
| 55 | + if (row.length > 1) rows.push(row); |
| 56 | + const [h, ...data] = rows; |
| 57 | + return data.filter((r) => r.length > 1).map((r) => Object.fromEntries(h.map((c, i) => [c, (r[i] ?? '').trim()]))); |
| 58 | +} |
| 59 | +const rows = parseCsv(resolve(args.csv)); |
| 60 | + |
| 61 | +// url → org slugs, from the live corpus (what the ingest wrote). |
| 62 | +const db = new Surreal(); |
| 63 | +await db.connect(process.env.SURREAL_URL); |
| 64 | +await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS }); |
| 65 | +await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB }); |
| 66 | +const orgs = (await db.query( |
| 67 | + `SELECT slug, org_corpus FROM organizations WHERE client_access CONTAINS $client;`, |
| 68 | + { client: args.client }, |
| 69 | +))?.[0] ?? []; |
| 70 | +await db.close(); |
| 71 | +const slugsByUrl = new Map(); |
| 72 | +for (const o of orgs) { |
| 73 | + for (const e of o.org_corpus ?? []) { |
| 74 | + if (!e.url) continue; |
| 75 | + slugsByUrl.set(e.url, [...(slugsByUrl.get(e.url) ?? []), o.slug]); |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +// funder slug → set of strategy tags. |
| 80 | +const tagsBySlug = new Map(); |
| 81 | +for (const r of rows) { |
| 82 | + const slugs = slugsByUrl.get(r.source_url) ?? []; |
| 83 | + const tags = r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean).map(trainCase); |
| 84 | + for (const slug of slugs) { |
| 85 | + const set = tagsBySlug.get(slug) ?? new Set(); |
| 86 | + for (const t of tags) set.add(t); |
| 87 | + tagsBySlug.set(slug, set); |
| 88 | + } |
| 89 | +} |
| 90 | +console.log(`funders to tag: ${tagsBySlug.size}`); |
| 91 | +for (const [slug, tags] of [...tagsBySlug.entries()].sort()) console.log(` ${slug} ← ${[...tags].join(', ')}`); |
| 92 | + |
| 93 | +if (!args.live) { console.log('\nDRY-RUN — nothing written. Re-run with --live.'); process.exit(0); } |
| 94 | + |
| 95 | +const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' }); |
| 96 | +const req = async (s, b) => JSON.parse(new TextDecoder().decode((await nc.request(s, JSON.stringify(b), { timeout: 20_000 })).data)); |
| 97 | +let added = 0, existed = 0, failed = 0; |
| 98 | +for (const [slug, tags] of tagsBySlug) { |
| 99 | + for (const tag of tags) { |
| 100 | + const r = await req('organization.tag.add.requested', { org_slug: slug, tag, client: args.client }); |
| 101 | + if (r.ok && r.created) added += 1; |
| 102 | + else if (r.ok) existed += 1; |
| 103 | + else { failed += 1; console.log(` ✗ ${slug} ← ${tag}: ${r.error}`); } |
| 104 | + } |
| 105 | +} |
| 106 | +console.log(`\ntags added: ${added} · already present: ${existed} · failures: ${failed}`); |
| 107 | +await nc.drain(); |
0 commit comments