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