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 <noreply@anthropic.com>
This commit is contained in:
parent
ce3a3ae0c0
commit
bfb1e3d0d7
|
|
@ -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 ===");
|
||||
})();
|
||||
|
|
@ -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 ===");
|
||||
})();
|
||||
Loading…
Reference in New Issue