Skip to content

Commit 272c333

Browse files
mpstatonclaude
andcommitted
feat(crm-starter-export): --enrich-links — the identity links and pulse streams reach Twenty after all
The operator's original ask ("include the identity links and pulse streams") made it into the CSVs but not the CRM — the v1 import mapped only domain + LinkedIn, and this instance's company object turned out to have NO native social fields at all. The enrichment pass mints six custom LINKS fields (xLink, youtubeLink, facebookLink, instagramLink, otherLinks, pulseStreams — the last two using LINKS' secondary-links capacity for multi-valued data, streams keeping their labels) and fills them per company with null-only patches: a link set in the app is never clobbered. First live run: 6 fields created, 63 companies patched, 19 already complete/empty, zero failures. Files changed: - scripts/import-crm-starter-to-twenty.mjs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent d366f15 commit 272c333

1 file changed

Lines changed: 86 additions & 0 deletions

File tree

scripts/import-crm-starter-to-twenty.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ for (let i = 2; i < process.argv.length; i += 1) {
4444
else if (k === '--base-url') args.baseUrl = process.argv[++i];
4545
else if (k === '--live') args.live = true;
4646
else if (k === '--with-opportunities') args.withOpportunities = true;
47+
else if (k === '--enrich-links') args.enrichLinks = true;
4748
else if (k === '--dry-run') args.live = false;
4849
}
4950

@@ -117,8 +118,10 @@ const peopleRows = parseCsv(`${DIR}/people.csv`);
117118

118119
// ---- shape companies (dedupe by external_id — multi-deal rows share one org)
119120
const companiesBySlug = new Map();
121+
const rawRowBySlug = new Map(); // full CSV row per slug — the links-enrichment pass reads it
120122
for (const r of orgRows) {
121123
if (!r.external_id || companiesBySlug.has(r.external_id)) continue;
124+
rawRowBySlug.set(r.external_id, r);
122125
// Guard: the canonical layer contains at least one literal "unknown"
123126
// website value — bad URLs surface as blank domains, never crashes.
124127
let domain = (r.domains || '').split(' | ')[0];
@@ -345,6 +348,89 @@ for (const p of people) {
345348
}
346349
if (repaired) console.log(`attach-repair: ${repaired} people gained their company (null-only fill)`);
347350
console.log('\nVERIFY: spot-check five companies for link fidelity, three multi-affiliation people, then re-run this script — it should report 0 to create (the external-id round-trip proof).');
351+
// ---- 5b. Links enrichment (--enrich-links, operator ask 2026-07-28) ---------
352+
// The starter CSVs carry the full identity-link set + pulse streams; the v1
353+
// import mapped only domain + LinkedIn. This pass mints custom LINKS fields
354+
// (this instance's company object has NO native social fields) and fills
355+
// them per company — ONLY when the live field is empty (additive; a link
356+
// someone set in the app is never clobbered).
357+
if (args.enrichLinks) {
358+
const companyObj2 = findObj('company');
359+
const LINK_FIELDS = [
360+
['xLink', 'X'],
361+
['youtubeLink', 'YouTube'],
362+
['facebookLink', 'Facebook'],
363+
['instagramLink', 'Instagram'],
364+
['otherLinks', 'Other Links'],
365+
['pulseStreams', 'Pulse Streams'],
366+
];
367+
for (const [name, label] of LINK_FIELDS) {
368+
if (!hasField(companyObj2, name)) {
369+
if (!args.live) { console.log(` would create company LINKS field: ${name}`); continue; }
370+
const r = await api('POST', '/rest/metadata/fields', {
371+
objectMetadataId: companyObj2.id, name, label, type: 'LINKS',
372+
});
373+
console.log(` custom field ${name}: ${r.status < 300 ? 'CREATED' : 'FAILED ' + r.status + ' ' + r.text.slice(0, 120)}`);
374+
if (r.status >= 300) process.exit(1);
375+
}
376+
}
377+
378+
const parseMultiline = (cell, style) =>
379+
String(cell ?? '').split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
380+
const url = /(https?:\/\/\S+)/.exec(line)?.[1] ?? null;
381+
if (!url) return null;
382+
let label = line.replace(url, '').replace(/[:()]+/g, ' ').replace(/\s+/g, ' ').trim();
383+
if (style === 'streams') label = label || 'stream';
384+
return { url, label: label.slice(0, 60) };
385+
}).filter(Boolean);
386+
const linksValue = (entries) => {
387+
if (!entries.length) return null;
388+
const [first, ...rest] = entries;
389+
return {
390+
primaryLinkUrl: first.url,
391+
primaryLinkLabel: first.label ?? '',
392+
secondaryLinks: rest.map((e) => ({ url: e.url, label: e.label ?? '' })),
393+
};
394+
};
395+
const single = (url) => (url ? { primaryLinkUrl: url } : null);
396+
397+
// Fresh company fetch — the create pass may have just run.
398+
const liveNow = await fetchAll('companies');
399+
const liveBySlugNow = new Map(liveNow.filter((c) => c.augmentItSlug).map((c) => [c.augmentItSlug, c]));
400+
let patched = 0, skippedFull = 0;
401+
for (const [slug, row] of rawRowBySlug) {
402+
const live = liveBySlugNow.get(slug);
403+
if (!live) continue;
404+
const want = {
405+
xLink: single(row.x),
406+
youtubeLink: single(row.youtube),
407+
facebookLink: single(row.facebook),
408+
instagramLink: single(row.instagram),
409+
otherLinks: linksValue([
410+
...(row.wikipedia ? [{ url: row.wikipedia, label: 'wikipedia' }] : []),
411+
...(row.bluesky ? [{ url: row.bluesky, label: 'bluesky' }] : []),
412+
...(row.substack ? [{ url: row.substack, label: 'substack' }] : []),
413+
...(row.team_page ? [{ url: row.team_page, label: 'team page' }] : []),
414+
...parseMultiline(row.other_links, 'links'),
415+
]),
416+
pulseStreams: linksValue(parseMultiline(row.streams, 'streams')),
417+
};
418+
const patch = {};
419+
for (const [field, value] of Object.entries(want)) {
420+
if (!value) continue;
421+
const cur = live[field];
422+
if (cur?.primaryLinkUrl) continue; // already set — never clobber
423+
patch[field] = value;
424+
}
425+
if (Object.keys(patch).length === 0) { skippedFull += 1; continue; }
426+
if (!args.live) { patched += 1; continue; }
427+
const r = await api('PATCH', `/rest/companies/${live.id}`, patch);
428+
if (r.status < 300) patched += 1;
429+
else console.log(` ✗ enrich ${slug}: ${r.status} ${r.text.slice(0, 140)}`);
430+
}
431+
console.log(`links enrichment: ${args.live ? 'patched' : 'would patch'} ${patched} companies, ${skippedFull} already complete/empty`);
432+
}
433+
348434
// ---- 6. Opportunities (operator rulings 2026-07-28) -------------------------
349435
// name: "Pipeline Export April 2026" for single-deal orgs; the tracker's
350436
// own row name for multi-row orgs AND fully-unattached rows (an

0 commit comments

Comments
 (0)