#!/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 ==="); })();