|
| 1 | +#!/usr/bin/env node |
| 2 | +// ============================================================================ |
| 3 | +// push-mega-gift-targets-to-twenty.mjs |
| 4 | +// |
| 5 | +// Operator ask 2026-07-28: every funder created by the mega-gifts ingest |
| 6 | +// becomes a Twenty OPPORTUNITY at stage "Target". Selector: SurrealDB orgs |
| 7 | +// with source = 'mega-gifts-by-topic' (the ingest's stamp — mints + the |
| 8 | +// ruled vehicle orgs). Companies are created first (idempotent by |
| 9 | +// augmentItSlug), then one opportunity per funder, named "Mega Gifts July |
| 10 | +// 2026", keyed augmentItRowName = "mega-gifts:<slug>". The "Target" stage |
| 11 | +// option is created on opportunity.stage if absent. |
| 12 | +// |
| 13 | +// Usage: set -a; source ./.env; set +a |
| 14 | +// node scripts/push-mega-gift-targets-to-twenty.mjs [--live] |
| 15 | +// ============================================================================ |
| 16 | + |
| 17 | +import { createRequire } from 'node:module'; |
| 18 | +import { readFileSync, existsSync } from 'node:fs'; |
| 19 | + |
| 20 | +const requireScripts = createRequire(new URL('./package.json', import.meta.url)); |
| 21 | +const { Surreal } = requireScripts('surrealdb'); |
| 22 | + |
| 23 | +const args = { live: false, envFile: '/Users/mpstaton/code/lossless-monorepo/self-host-stack/client-stacks/reach-edu/twenty/.env', baseUrl: 'https://twenty-server-production-7c98.up.railway.app' }; |
| 24 | +for (let i = 2; i < process.argv.length; i += 1) { |
| 25 | + if (process.argv[i] === '--live') args.live = true; |
| 26 | +} |
| 27 | + |
| 28 | +function readKey() { |
| 29 | + if (process.env.TWENTY_MCP_API_KEY) return process.env.TWENTY_MCP_API_KEY; |
| 30 | + if (existsSync(args.envFile)) { |
| 31 | + const m = /^TWENTY_MCP_API_KEY=(.+)$/m.exec(readFileSync(args.envFile, 'utf8')); |
| 32 | + if (m) return m[1].trim().replace(/^["']|["']$/g, ''); |
| 33 | + } |
| 34 | + return null; |
| 35 | +} |
| 36 | +const KEY = readKey(); |
| 37 | +const PACE_MS = 650; |
| 38 | +let lastCall = 0; |
| 39 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 40 | +async function api(method, path, body) { |
| 41 | + for (let attempt = 0; attempt < 4; attempt += 1) { |
| 42 | + const wait = lastCall + PACE_MS - Date.now(); |
| 43 | + if (wait > 0) await sleep(wait); |
| 44 | + lastCall = Date.now(); |
| 45 | + const res = await fetch(`${args.baseUrl}${path}`, { |
| 46 | + method, |
| 47 | + headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, |
| 48 | + body: body ? JSON.stringify(body) : undefined, |
| 49 | + }); |
| 50 | + const text = await res.text(); |
| 51 | + let json = null; |
| 52 | + try { json = JSON.parse(text); } catch { /* */ } |
| 53 | + if (res.status === 429 && attempt < 3) { await sleep(61_000); continue; } |
| 54 | + return { status: res.status, json, text }; |
| 55 | + } |
| 56 | +} |
| 57 | +async function fetchAll(object) { |
| 58 | + const out = []; |
| 59 | + let after = ''; |
| 60 | + for (let page = 0; page < 50; page += 1) { |
| 61 | + const r = await api('GET', `/rest/${object}?limit=60${after}`); |
| 62 | + const arr = r.json?.data?.[object] ?? []; |
| 63 | + out.push(...arr); |
| 64 | + const cursor = r.json?.pageInfo?.endCursor; |
| 65 | + if (!cursor || arr.length < 60) break; |
| 66 | + after = `&starting_after=${encodeURIComponent(cursor)}`; |
| 67 | + } |
| 68 | + return out; |
| 69 | +} |
| 70 | + |
| 71 | +// ---- 1. The funder set from SurrealDB ---------------------------------------- |
| 72 | +const db = new Surreal(); |
| 73 | +await db.connect(process.env.SURREAL_URL); |
| 74 | +await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS }); |
| 75 | +await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB }); |
| 76 | +const funders = (await db.query( |
| 77 | + `SELECT slug, complete_name, conventional_name, domains, org_links FROM organizations |
| 78 | + WHERE source = 'mega-gifts-by-topic' AND client_access CONTAINS 'reach-edu';`, |
| 79 | +))?.[0] ?? []; |
| 80 | +await db.close(); |
| 81 | +console.log(`mega-gifts funders in SurrealDB: ${funders.length}`); |
| 82 | +for (const f of funders) console.log(' -', f.slug); |
| 83 | + |
| 84 | +// ---- 2. Twenty state --------------------------------------------------------- |
| 85 | +const probe = await api('GET', '/rest/companies?limit=1'); |
| 86 | +if (probe.status === 401) { console.log('✗ API key rejected — mint/update the key first.'); process.exit(1); } |
| 87 | + |
| 88 | +const meta = await api('GET', '/rest/metadata/objects'); |
| 89 | +const objects = meta.json?.data?.objects ?? meta.json?.data ?? []; |
| 90 | +const oppObj = objects.find((o) => (o.nameSingular ?? o.name) === 'opportunity'); |
| 91 | +const stageField = (oppObj?.fields ?? []).find((f) => f.name === 'stage'); |
| 92 | +let target = (stageField?.options ?? []).find((o) => /^target$/i.test(String(o.label ?? o.value))); |
| 93 | +console.log(`stage options: [${(stageField?.options ?? []).map((o) => o.label ?? o.value).join(', ')}]`); |
| 94 | +if (!target) { |
| 95 | + console.log(' "Target" option missing — will create it on opportunity.stage'); |
| 96 | + if (args.live) { |
| 97 | + const maxPos = Math.max(0, ...(stageField.options ?? []).map((o) => o.position ?? 0)); |
| 98 | + const newOptions = [ |
| 99 | + ...(stageField.options ?? []), |
| 100 | + { value: 'TARGET', label: 'Target', position: maxPos + 1, color: 'blue' }, |
| 101 | + ]; |
| 102 | + const r = await api('PATCH', `/rest/metadata/fields/${stageField.id}`, { options: newOptions }); |
| 103 | + if (r.status >= 300) { console.log(`✗ could not add stage option: ${r.status} ${r.text.slice(0, 200)}\n → add "Target" in Settings → Data model → Opportunity → Stage, then re-run.`); process.exit(1); } |
| 104 | + target = { value: 'TARGET', label: 'Target' }; |
| 105 | + console.log(' created stage option Target'); |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +const liveCompanies = await fetchAll('companies'); |
| 110 | +const companyBySlug = new Map(liveCompanies.filter((c) => c.augmentItSlug).map((c) => [c.augmentItSlug, c])); |
| 111 | +const liveOpps = await fetchAll('opportunities'); |
| 112 | +const oppKeys = new Set(liveOpps.map((o) => o.augmentItRowName).filter(Boolean)); |
| 113 | + |
| 114 | +const missingCompanies = funders.filter((f) => !companyBySlug.has(f.slug)); |
| 115 | +const missingOpps = funders.filter((f) => !oppKeys.has(`mega-gifts:${f.slug}`)); |
| 116 | +console.log(`companies to create: ${missingCompanies.length} · opportunities to create: ${missingOpps.length}`); |
| 117 | + |
| 118 | +if (!args.live) { console.log('\nDRY-RUN — nothing written. Re-run with --live.'); process.exit(0); } |
| 119 | + |
| 120 | +// ---- 3. Companies ------------------------------------------------------------ |
| 121 | +let cCreated = 0; |
| 122 | +for (const f of missingCompanies) { |
| 123 | + const website = (f.org_links ?? []).find((l) => l.kind === 'website')?.url; |
| 124 | + const domain = (f.domains ?? []).map((d) => d.domain).filter(Boolean)[0] |
| 125 | + ?? (website ? (() => { try { return new URL(website).hostname.replace(/^www\./, ''); } catch { return null; } })() : null); |
| 126 | + const linkedin = (f.org_links ?? []).find((l) => l.kind === 'linkedin_company')?.url; |
| 127 | + const body = { |
| 128 | + name: f.complete_name ?? f.conventional_name ?? f.slug, |
| 129 | + augmentItSlug: f.slug, |
| 130 | + ...(domain ? { domainName: { primaryLinkUrl: `https://${domain}` } } : {}), |
| 131 | + ...(linkedin ? { linkedinLink: { primaryLinkUrl: linkedin } } : {}), |
| 132 | + }; |
| 133 | + let r = await api('POST', '/rest/companies', body); |
| 134 | + if (r.status === 400 && /duplicate/i.test(r.text) && body.domainName) { |
| 135 | + const { domainName, ...noDomain } = body; |
| 136 | + r = await api('POST', '/rest/companies', noDomain); |
| 137 | + } |
| 138 | + const rec = r.json?.data?.createCompany ?? r.json?.data ?? null; |
| 139 | + if (r.status < 300 && rec?.id) { companyBySlug.set(f.slug, rec); cCreated += 1; } |
| 140 | + else console.log(` ✗ company ${f.slug}: ${r.status} ${r.text.slice(0, 120)}`); |
| 141 | +} |
| 142 | +console.log(`companies: created ${cCreated}, pre-existing ${funders.length - missingCompanies.length}`); |
| 143 | + |
| 144 | +// ---- 4. Opportunities at Target ---------------------------------------------- |
| 145 | +let oCreated = 0, oFailed = 0; |
| 146 | +for (const f of missingOpps) { |
| 147 | + const companyId = companyBySlug.get(f.slug)?.id; |
| 148 | + const r = await api('POST', '/rest/opportunities', { |
| 149 | + name: 'Mega Gifts July 2026', |
| 150 | + stage: target.value, |
| 151 | + ...(companyId ? { companyId } : {}), |
| 152 | + augmentItRowName: `mega-gifts:${f.slug}`, |
| 153 | + }); |
| 154 | + if (r.status < 300) oCreated += 1; |
| 155 | + else { oFailed += 1; console.log(` ✗ opportunity ${f.slug}: ${r.status} ${r.text.slice(0, 120)}`); } |
| 156 | +} |
| 157 | +console.log(`opportunities: created ${oCreated}, failed ${oFailed}, pre-existing ${funders.length - missingOpps.length}`); |
0 commit comments