270 lines
8.1 KiB
JavaScript
270 lines
8.1 KiB
JavaScript
#!/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 ===");
|
|
})();
|