Skip to content

Commit c0d82d4

Browse files
mpstatonclaude
andcommitted
feat(mega-gifts-ingest): the 51 mega-gift funders land in Twenty as Target-stage opportunities
Operator ask: every funder the mega-gifts ingest created becomes an opportunity at stage Target. Selector is the ingest's own provenance stamp (SurrealDB orgs with source mega-gifts-by-topic — the 44 mints plus the ruled vehicles: Yield Giving, Declaration Partners, the Gottesmans, Stryker Johnston, NCTR, College Board, CFAT). Companies created first (idempotent by augmentItSlug, domain/linkedin carried from the canonical rows, domain-dupe retry inherited), then one opportunity per funder named "Mega Gifts July 2026", keyed augmentItRowName mega-gifts:<slug>. Found on arrival: the operator has rebuilt opportunity.stage into a real grant pipeline (Target → Assessing → Seeking Meeting → Fully Introduced → Proposal → Application Due/Submitted → Won/Lost) — "Target" existed, so the option-creation path stayed dormant. Files changed: - scripts/push-mega-gift-targets-to-twenty.mjs (new) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 71e9772 commit c0d82d4

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)