Skip to content

Commit d366f15

Browse files
mpstatonclaude
andcommitted
feat(crm-starter-export): opportunities land — 96 deals in "Fully Introduced", tracker truth preserved per-record
The gated stub becomes the real pass, per operator rulings: name is "Pipeline Export April 2026" for single-deal orgs, the tracker's own row name for multi-row orgs and unattached rows (an anonymous card with no company would be unfindable); amount blank by explicit ruling; company attached via augmentItSlug; pointOfContact only on the five person-anchored deals; stage = the operator-created "Fully Introduced" for everything — with the tracker's real stage (Money In / Declined / …) preserved in a pipelineStage custom TEXT field so deal-state survives the flattening. Round-trip key: augmentItRowName (tracker row names are unique across the 96). First live run: 96 created, 0 failed; companies and people idempotent (0 to create). The old ensure-stage-options diff message retired — the single-stage ruling superseded it. 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 d01f74c commit d366f15

1 file changed

Lines changed: 70 additions & 4 deletions

File tree

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

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,10 +244,11 @@ if (oppObj) {
244244
(s) => !liveOptions.some((o) => String(o).toLowerCase() === s.toLowerCase()),
245245
);
246246
console.log(` opportunity.stage options live: [${liveOptions.join(', ')}]`);
247+
// Operator ruling 2026-07-28: ALL imported opportunities land in the
248+
// operator-created "Fully Introduced" stage; the tracker's own stage is
249+
// preserved per-record in the pipelineStage custom field, NOT as options.
247250
if (missing.length) {
248-
console.log(` MISSING stage options (created ahead of opportunities when --live --with-opportunities): ${missing.join(' · ')}`);
249-
} else {
250-
console.log(' all tracker stages present as options');
251+
console.log(` tracker stages NOT mirrored as options (by ruling — preserved in pipelineStage): ${missing.join(' · ')}`);
251252
}
252253
}
253254

@@ -344,6 +345,71 @@ for (const p of people) {
344345
}
345346
if (repaired) console.log(`attach-repair: ${repaired} people gained their company (null-only fill)`);
346347
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).');
348+
// ---- 6. Opportunities (operator rulings 2026-07-28) -------------------------
349+
// name: "Pipeline Export April 2026" for single-deal orgs; the tracker's
350+
// own row name for multi-row orgs AND fully-unattached rows (an
351+
// anonymous card with no company would be unfindable).
352+
// amount: BLANK (explicit ruling). company: attached via augmentItSlug.
353+
// pointOfContact: only person-anchored rows. stage: "Fully Introduced"
354+
// (operator-created option) for ALL — the tracker's real stage survives
355+
// in the pipelineStage custom TEXT field so deal-state isn't flattened
356+
// away. Round-trip key: augmentItRowName (tracker row names are unique).
347357
if (args.withOpportunities) {
348-
console.log('⚠ --with-opportunities: NOT implemented until the operator rules opportunities-vs-company-fields at dry-run review.');
358+
const oppObj2 = findObj('opportunity');
359+
const stageField = (oppObj2?.fields ?? []).find((f) => f.name === 'stage');
360+
const fullyIntroduced = (stageField?.options ?? []).find((o) =>
361+
/fully.?introduced/i.test(String(o.label ?? o.value)));
362+
if (!fullyIntroduced) {
363+
console.log('✗ stage option "Fully Introduced" not found on opportunity.stage — create it in the UI first. Aborting opportunities.');
364+
process.exit(1);
365+
}
366+
console.log(`\nopportunities: stage → ${fullyIntroduced.label ?? fullyIntroduced.value} (value ${fullyIntroduced.value})`);
367+
368+
for (const [name, label] of [['augmentItRowName', 'Augment-It Row Name'], ['pipelineStage', 'Pipeline Stage (tracker)']]) {
369+
if (!hasField(oppObj2, name)) {
370+
const r = await api('POST', '/rest/metadata/fields', {
371+
objectMetadataId: oppObj2.id, name, label, type: 'TEXT',
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+
// Person map for pointOfContact.
379+
const livePeople2 = await fetchAll('people');
380+
const personIdByUuid = new Map(livePeople2.filter((p) => p.augmentItPersonUuid).map((p) => [p.augmentItPersonUuid, p.id]));
381+
382+
// Existing opportunities by row-name key (idempotency).
383+
const liveOpps = await fetchAll('opportunities');
384+
const liveByRowName = new Set(liveOpps.map((o) => o.augmentItRowName).filter(Boolean));
385+
386+
// Multi-row orgs → row names.
387+
const rowsPerSlug = new Map();
388+
for (const o of opportunities) {
389+
if (o.company_slug) rowsPerSlug.set(o.company_slug, (rowsPerSlug.get(o.company_slug) ?? 0) + 1);
390+
}
391+
392+
let oCreated = 0, oFailed = 0, oSkipped = 0;
393+
for (const o of opportunities) {
394+
const rowKey = o.name; // tracker row name — unique across the 96
395+
if (liveByRowName.has(rowKey)) { oSkipped += 1; continue; }
396+
const multi = o.company_slug && (rowsPerSlug.get(o.company_slug) ?? 0) > 1;
397+
const bare = !o.company_slug && !o.person_uuid;
398+
const body = {
399+
name: multi || bare ? o.name : 'Pipeline Export April 2026',
400+
stage: fullyIntroduced.value,
401+
companyId: o.company_slug ? (slugToId.get(o.company_slug) ?? undefined) : undefined,
402+
pointOfContactId: o.person_uuid ? (personIdByUuid.get(o.person_uuid) ?? undefined) : undefined,
403+
augmentItRowName: rowKey,
404+
pipelineStage: o.stage || undefined,
405+
};
406+
const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined));
407+
const r = await api('POST', '/rest/opportunities', clean);
408+
if (r.status < 300) oCreated += 1;
409+
else {
410+
oFailed += 1;
411+
console.log(` ✗ opportunity \"${rowKey}\": ${r.status} ${r.text.slice(0, 140)}`);
412+
}
413+
}
414+
console.log(`opportunities: created ${oCreated}, failed ${oFailed}, pre-existing ${oSkipped}`);
349415
}

0 commit comments

Comments
 (0)