rspace-online/scripts/twenty-setup.js

296 lines
9.7 KiB
JavaScript

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