From bfb1e3d0d715edb59847887ae6623bff43a366e2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Mar 2026 14:06:52 -0800 Subject: [PATCH] feat: add Twenty CRM setup scripts for pipeline, fields, and views Instance-agnostic scripts to configure standalone Twenty CRM instances (crypto-commons and VOTC) with 7-stage pipeline, custom opportunity/company fields, and saved views (My Pipeline kanban, Needs Follow-up, Stale Leads). Co-Authored-By: Claude Opus 4.6 --- scripts/twenty-setup.js | 295 ++++++++++++++++++++++++++++++++++++++++ scripts/twenty-views.js | 269 ++++++++++++++++++++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 scripts/twenty-setup.js create mode 100644 scripts/twenty-views.js diff --git a/scripts/twenty-setup.js b/scripts/twenty-setup.js new file mode 100644 index 0000000..fcd459b --- /dev/null +++ b/scripts/twenty-setup.js @@ -0,0 +1,295 @@ +#!/usr/bin/env bun +/** + * Twenty CRM setup script — configure pipeline stages and custom fields. + * Run inside the rspace container: + * docker exec rspace-online bun /app/scripts/twenty-setup.js + */ + +const TOKEN = process.env.TWENTY_API_TOKEN; +const BASE = process.env.TWENTY_API_URL || "http://twenty-ch-server:3000"; + +if (!TOKEN) { + // Fallback: read from /proc/1/environ + const fs = require("fs"); + try { + const env = fs.readFileSync("/proc/1/environ", "utf8"); + const match = env.split("\0").find(e => e.startsWith("TWENTY_API_TOKEN=")); + if (match) { + var FALLBACK_TOKEN = match.split("=").slice(1).join("="); + } + } catch {} + if (!FALLBACK_TOKEN) { + console.error("TWENTY_API_TOKEN not set"); + process.exit(1); + } + var API_TOKEN = FALLBACK_TOKEN; +} else { + var API_TOKEN = TOKEN; +} + +async function gqlMetadata(query, variables) { + const r = await fetch(BASE + "/metadata", { + method: "POST", + headers: { + Authorization: "Bearer " + API_TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + const d = await r.json(); + if (d.errors) { + console.error("GraphQL error:", JSON.stringify(d.errors, null, 2)); + } + return d.data; +} + +async function gqlApi(query, variables) { + const r = await fetch(BASE + "/api/graphql", { + method: "POST", + headers: { + Authorization: "Bearer " + API_TOKEN, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + const d = await r.json(); + if (d.errors) { + console.error("API GraphQL error:", JSON.stringify(d.errors, null, 2)); + } + return d.data; +} + +// ── Step 1: Find object IDs ── +async function getObjectId(nameSingular) { + const data = await gqlMetadata(`{ + objects(paging: { first: 100 }) { + edges { + node { id nameSingular } + } + } + }`); + const obj = data.objects.edges.find(e => e.node.nameSingular === nameSingular); + return obj ? obj.node.id : null; +} + +// ── Step 2: Get existing fields for an object ── +async function getFields(objectId) { + const data = await gqlMetadata(` + query GetFields($id: UUID!) { + object(id: $id) { + id + nameSingular + fields { + edges { + node { + id + name + label + type + isCustom + options + defaultValue + } + } + } + } + } + `, { id: objectId }); + return data.object; +} + +// ── Step 3: Update stage field options ── +async function updateFieldOptions(fieldId, options, defaultValue) { + const update = { options }; + if (defaultValue !== undefined) { + update.defaultValue = defaultValue; + } + const data = await gqlMetadata(` + mutation UpdateField($input: UpdateOneFieldMetadataInput!) { + updateOneField(input: $input) { + id + name + options + defaultValue + } + } + `, { + input: { + id: fieldId, + update, + }, + }); + return data ? data.updateOneField : null; +} + +// ── Step 4: Create custom field ── +async function createField(objectId, fieldDef) { + const data = await gqlMetadata(` + mutation CreateField($input: CreateOneFieldMetadataInput!) { + createOneField(input: $input) { + id + name + label + type + } + } + `, { + input: { + field: { + objectMetadataId: objectId, + ...fieldDef, + }, + }, + }); + return data.createOneField; +} + +// ══════════════════════════════════════════ +// PIPELINE STAGES +// ══════════════════════════════════════════ +const PIPELINE_STAGES = [ + { label: "New Lead", value: "NEW_LEAD", color: "#1E90FF", position: 0 }, + { label: "Contacted", value: "CONTACTED", color: "#EAB308", position: 1 }, + { label: "Qualified", value: "QUALIFIED", color: "#F97316", position: 2 }, + { label: "Offer Sent", value: "OFFER_SENT", color: "#8B5CF6", position: 3 }, + { label: "Confirmed", value: "CONFIRMED", color: "#14B8A6", position: 4 }, + { label: "Won", value: "WON", color: "#22C55E", position: 5 }, + { label: "Lost / Not Now", value: "LOST_NOT_NOW", color: "#EF4444", position: 6 }, +]; + +// ══════════════════════════════════════════ +// CUSTOM FIELDS +// ══════════════════════════════════════════ +const OPPORTUNITY_FIELDS = [ + { name: "eventDatesPreferred", label: "Event Dates (preferred)", type: "DATE" }, + { name: "eventDatesFlexible", label: "Event Dates (flexible range)", type: "TEXT" }, + { name: "groupSize", label: "Group Size", type: "NUMBER" }, + { name: "needsAccommodation", label: "Needs: Accommodation", type: "BOOLEAN", defaultValue: false }, + { name: "needsCatering", label: "Needs: Catering", type: "BOOLEAN", defaultValue: false }, + { name: "needsRooms", label: "Needs: Rooms", type: "BOOLEAN", defaultValue: false }, + { name: "needsAV", label: "Needs: AV", type: "BOOLEAN", defaultValue: false }, + { name: "nextActionDate", label: "Next Action Date", type: "DATE" }, + { name: "followUpDate", label: "Follow-up Date", type: "DATE" }, + { name: "lostReason", label: "Lost Reason", type: "TEXT" }, +]; + +const COMPANY_FIELDS = [ + { + name: "leadSource", + label: "Lead Source", + type: "SELECT", + options: [ + { label: "Website", value: "WEBSITE", color: "#3B82F6", position: 0 }, + { label: "Referral", value: "REFERRAL", color: "#22C55E", position: 1 }, + { label: "Event", value: "EVENT", color: "#8B5CF6", position: 2 }, + { label: "Cold Outreach", value: "COLD_OUTREACH", color: "#F97316", position: 3 }, + { label: "Social Media", value: "SOCIAL_MEDIA", color: "#06B6D4", position: 4 }, + { label: "Other", value: "OTHER", color: "#6B7280", position: 5 }, + ], + }, + { name: "lastTouchDate", label: "Last Touch Date", type: "DATE" }, +]; + +// ══════════════════════════════════════════ +// MAIN +// ══════════════════════════════════════════ +(async () => { + console.log("=== Twenty CRM Setup ===\n"); + + // 1. Get object IDs + const oppId = await getObjectId("opportunity"); + const compId = await getObjectId("company"); + console.log("Opportunity object:", oppId); + console.log("Company object:", compId); + + if (!oppId || !compId) { + console.error("Could not find opportunity or company objects"); + process.exit(1); + } + + // 2. Get current opportunity fields + const oppObj = await getFields(oppId); + const existingOppFields = oppObj.fields.edges.map(e => e.node); + console.log("\nExisting opportunity fields:", existingOppFields.map(f => f.name).join(", ")); + + // 3. Find and update the stage field + const stageField = existingOppFields.find(f => f.name === "stage"); + if (stageField) { + console.log("\nCurrent stage options:", JSON.stringify(stageField.options, null, 2)); + console.log("\nUpdating pipeline stages..."); + // Default value must match one of the new option values (Twenty wraps in quotes) + const updated = await updateFieldOptions(stageField.id, PIPELINE_STAGES, "'NEW_LEAD'"); + if (updated) { + console.log("Pipeline stages updated:", updated.options.map(o => o.label).join(" → ")); + } + } else { + console.log("\nWARNING: No 'stage' field found on opportunity object"); + } + + // 4. Create custom fields on Opportunity + console.log("\n--- Creating Opportunity custom fields ---"); + for (const fieldDef of OPPORTUNITY_FIELDS) { + const exists = existingOppFields.find(f => f.name === fieldDef.name); + if (exists) { + console.log(" SKIP (exists): " + fieldDef.name); + continue; + } + const input = { + name: fieldDef.name, + label: fieldDef.label, + type: fieldDef.type, + description: "", + }; + if (fieldDef.defaultValue !== undefined) { + input.defaultValue = fieldDef.defaultValue; + } + if (fieldDef.options) { + input.options = fieldDef.options; + } + try { + const created = await createField(oppId, input); + if (created) { + console.log(" CREATED: " + created.name + " (" + created.type + ")"); + } else { + console.log(" FAILED: " + fieldDef.name); + } + } catch (e) { + console.log(" ERROR: " + fieldDef.name + " - " + e.message); + } + } + + // 5. Create custom fields on Company + console.log("\n--- Creating Company custom fields ---"); + const compObj = await getFields(compId); + const existingCompFields = compObj.fields.edges.map(e => e.node); + console.log("Existing company fields:", existingCompFields.map(f => f.name).join(", ")); + + for (const fieldDef of COMPANY_FIELDS) { + const exists = existingCompFields.find(f => f.name === fieldDef.name); + if (exists) { + console.log(" SKIP (exists): " + fieldDef.name); + continue; + } + const input = { + name: fieldDef.name, + label: fieldDef.label, + type: fieldDef.type, + description: "", + }; + if (fieldDef.options) { + input.options = fieldDef.options; + } + try { + const created = await createField(compId, input); + if (created) { + console.log(" CREATED: " + created.name + " (" + created.type + ")"); + } else { + console.log(" FAILED: " + fieldDef.name); + } + } catch (e) { + console.log(" ERROR: " + fieldDef.name + " - " + e.message); + } + } + + console.log("\n=== Setup complete ==="); +})(); diff --git a/scripts/twenty-views.js b/scripts/twenty-views.js new file mode 100644 index 0000000..bf2db82 --- /dev/null +++ b/scripts/twenty-views.js @@ -0,0 +1,269 @@ +#!/usr/bin/env bun +/** + * Twenty CRM — create saved views for the lead funnel. + * Run inside the rspace container: + * docker exec -e TWENTY_API_TOKEN=... -e TWENTY_API_URL=... rspace-online bun /app/scripts/twenty-views.js + * + * Field IDs are discovered dynamically via metadata introspection, + * so this script works on any Twenty instance. + */ + +const TOKEN = process.env.TWENTY_API_TOKEN; +const BASE = process.env.TWENTY_API_URL || "http://twenty-ch-server:3000"; + +if (!TOKEN) { + const fs = require("fs"); + try { + const env = fs.readFileSync("/proc/1/environ", "utf8"); + const match = env.split("\0").find(e => e.startsWith("TWENTY_API_TOKEN=")); + if (match) var FALLBACK = match.split("=").slice(1).join("="); + } catch {} + if (!FALLBACK) { console.error("TWENTY_API_TOKEN not set"); process.exit(1); } + var API_TOKEN = FALLBACK; +} else { + var API_TOKEN = TOKEN; +} + +const HEADERS = { + Authorization: "Bearer " + API_TOKEN, + "Content-Type": "application/json", +}; + +async function gql(query, variables) { + const r = await fetch(BASE + "/metadata", { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ query, variables }), + }); + const d = await r.json(); + if (d.errors) console.error("GQL error:", JSON.stringify(d.errors, null, 2)); + return d.data; +} + +// ── Discover object and field IDs dynamically ── +async function discoverIds() { + // Step 1: Find the opportunity object ID + const objData = await gql(`{ + objects(paging: { first: 100 }) { + edges { node { id nameSingular } } + } + }`); + + const oppObj = objData.objects.edges.find(e => e.node.nameSingular === "opportunity"); + if (!oppObj) throw new Error("Opportunity object not found"); + + const opp = oppObj.node; + + // Step 2: Query fields separately with proper paging + const fieldData = await gql(` + query GetFields($id: UUID!) { + object(id: $id) { + fields(paging: { first: 200 }) { edges { node { id name } } } + } + } + `, { id: opp.id }); + + const fields = fieldData.object.fields.edges.map(e => e.node); + const fieldMap = {}; + for (const f of fields) fieldMap[f.name] = f.id; + + console.log("Discovered opportunity object:", opp.id); + console.log("Fields:", Object.keys(fieldMap).join(", ")); + + const required = ["stage", "name", "closeDate", "amount"]; + for (const name of required) { + if (!fieldMap[name]) throw new Error(`Required field '${name}' not found on opportunity object`); + } + + return { + oppObjectId: opp.id, + stageFieldId: fieldMap.stage, + nameFieldId: fieldMap.name, + closeDateFieldId: fieldMap.closeDate, + amountFieldId: fieldMap.amount, + nextActionDateFieldId: fieldMap.nextActionDate || null, + }; +} + +(async () => { + console.log("=== Creating Twenty CRM Saved Views ===\n"); + + const ids = await discoverIds(); + + // ── View 1: "My Pipeline" — Kanban grouped by stage ── + console.log("\nCreating 'My Pipeline' kanban view..."); + const v1 = await gql(` + mutation CreateView($input: CreateViewInput!) { + createCoreView(input: $input) { id name type } + } + `, { + input: { + name: "My Pipeline", + objectMetadataId: ids.oppObjectId, + type: "KANBAN", + icon: "IconLayoutKanban", + position: 0, + key: "INDEX", + visibility: "WORKSPACE", + mainGroupByFieldMetadataId: ids.stageFieldId, + }, + }); + + if (v1 && v1.createCoreView) { + const viewId = v1.createCoreView.id; + console.log(" Created view:", viewId); + + const viewFields = [ + { fieldMetadataId: ids.nameFieldId, position: 0, isVisible: true }, + { fieldMetadataId: ids.stageFieldId, position: 1, isVisible: true }, + { fieldMetadataId: ids.amountFieldId, position: 2, isVisible: true }, + { fieldMetadataId: ids.closeDateFieldId, position: 3, isVisible: true }, + ]; + if (ids.nextActionDateFieldId) { + viewFields.push({ fieldMetadataId: ids.nextActionDateFieldId, position: 4, isVisible: true }); + } + for (const vf of viewFields) { + await gql(` + mutation CreateViewField($input: CreateViewFieldInput!) { + createCoreViewField(input: $input) { id } + } + `, { input: { viewId, ...vf } }); + } + console.log(" View fields added"); + } else { + console.log(" FAILED to create My Pipeline view"); + } + + // ── View 2: "Needs Follow-up" — Table filtered: nextActionDate <= today ── + if (ids.nextActionDateFieldId) { + console.log("\nCreating 'Needs Follow-up' table view..."); + const v2 = await gql(` + mutation CreateView($input: CreateViewInput!) { + createCoreView(input: $input) { id name type } + } + `, { + input: { + name: "Needs Follow-up", + objectMetadataId: ids.oppObjectId, + type: "TABLE", + icon: "IconAlarm", + position: 1, + key: "INDEX", + visibility: "WORKSPACE", + }, + }); + + if (v2 && v2.createCoreView) { + const viewId = v2.createCoreView.id; + console.log(" Created view:", viewId); + + console.log(" Adding filter: nextActionDate <= today..."); + const fg = await gql(` + mutation CreateFilterGroup($input: CreateViewFilterGroupInput!) { + createCoreViewFilterGroup(input: $input) { id } + } + `, { + input: { + viewId: viewId, + logicalOperator: "AND", + positionInViewFilterGroup: 0, + }, + }); + + if (fg && fg.createCoreViewFilterGroup) { + await gql(` + mutation CreateFilter($input: CreateViewFilterInput!) { + createCoreViewFilter(input: $input) { id } + } + `, { + input: { + viewId: viewId, + viewFilterGroupId: fg.createCoreViewFilterGroup.id, + fieldMetadataId: ids.nextActionDateFieldId, + operand: "IS_BEFORE", + value: "TODAY", + positionInViewFilterGroup: 0, + }, + }); + console.log(" Filter added"); + } + + console.log(" Adding sort: nextActionDate ASC..."); + await gql(` + mutation CreateSort($input: CreateViewSortInput!) { + createCoreViewSort(input: $input) { id } + } + `, { + input: { + viewId: viewId, + fieldMetadataId: ids.nextActionDateFieldId, + direction: "ASC", + }, + }); + console.log(" Sort added"); + } else { + console.log(" FAILED to create Needs Follow-up view"); + } + + // ── View 3: "Stale Leads" — Table filtered: nextActionDate is empty ── + console.log("\nCreating 'Stale Leads' table view..."); + const v3 = await gql(` + mutation CreateView($input: CreateViewInput!) { + createCoreView(input: $input) { id name type } + } + `, { + input: { + name: "Stale Leads", + objectMetadataId: ids.oppObjectId, + type: "TABLE", + icon: "IconAlertTriangle", + position: 2, + key: "INDEX", + visibility: "WORKSPACE", + }, + }); + + if (v3 && v3.createCoreView) { + const viewId = v3.createCoreView.id; + console.log(" Created view:", viewId); + + console.log(" Adding filter: nextActionDate is empty..."); + const fg = await gql(` + mutation CreateFilterGroup($input: CreateViewFilterGroupInput!) { + createCoreViewFilterGroup(input: $input) { id } + } + `, { + input: { + viewId: viewId, + logicalOperator: "AND", + positionInViewFilterGroup: 0, + }, + }); + + if (fg && fg.createCoreViewFilterGroup) { + await gql(` + mutation CreateFilter($input: CreateViewFilterInput!) { + createCoreViewFilter(input: $input) { id } + } + `, { + input: { + viewId: viewId, + viewFilterGroupId: fg.createCoreViewFilterGroup.id, + fieldMetadataId: ids.nextActionDateFieldId, + operand: "IS_EMPTY", + value: "", + positionInViewFilterGroup: 0, + }, + }); + console.log(" Filter added"); + } + } else { + console.log(" FAILED to create Stale Leads view"); + } + } else { + console.log("\nSKIPPING follow-up/stale views — nextActionDate field not found."); + console.log("Run twenty-setup.js first to create custom fields, then re-run this script."); + } + + console.log("\n=== Views setup complete ==="); +})();