@elitedcs/ghl-mcp 3.75.0 → 3.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -1
- package/README.md +1 -1
- package/dist/index.js +761 -63
- package/guide/guide.html +59 -3
- package/package.json +1 -1
- package/templates/action-schemas.json +31 -8
package/dist/index.js
CHANGED
|
@@ -918,6 +918,129 @@ var init_id_shape = __esm({
|
|
|
918
918
|
}
|
|
919
919
|
});
|
|
920
920
|
|
|
921
|
+
// src/parked-guard.ts
|
|
922
|
+
function stable(value) {
|
|
923
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
924
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
925
|
+
const keys = Object.keys(value).sort();
|
|
926
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
|
|
927
|
+
}
|
|
928
|
+
function isRecord(v) {
|
|
929
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
930
|
+
}
|
|
931
|
+
function referencedStepIds(action) {
|
|
932
|
+
const out = [];
|
|
933
|
+
const attrs = isRecord(action.attributes) ? action.attributes : void 0;
|
|
934
|
+
const extras = attrs && isRecord(attrs.extras) ? attrs.extras : void 0;
|
|
935
|
+
if (!extras) return out;
|
|
936
|
+
const list2 = extras.stepIds;
|
|
937
|
+
if (Array.isArray(list2)) {
|
|
938
|
+
for (const id of list2) if (typeof id === "string" && id) out.push(id);
|
|
939
|
+
}
|
|
940
|
+
const invoice = extras.invoiceStepId;
|
|
941
|
+
if (typeof invoice === "string" && invoice) out.push(invoice);
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
function branchTargetStepIds(action) {
|
|
945
|
+
const out = [];
|
|
946
|
+
const attrs = isRecord(action.attributes) ? action.attributes : void 0;
|
|
947
|
+
if (!attrs) return out;
|
|
948
|
+
const quick = isRecord(attrs.quickReplies) ? attrs.quickReplies.transitions : void 0;
|
|
949
|
+
for (const table of [attrs.transitions, attrs.nonBranchingTransitions, quick]) {
|
|
950
|
+
if (!Array.isArray(table)) continue;
|
|
951
|
+
for (const entry of table) {
|
|
952
|
+
if (isRecord(entry) && typeof entry.id === "string" && entry.id) out.push(entry.id);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
function protectedClosure(parkedIds, byId2) {
|
|
958
|
+
const seen = /* @__PURE__ */ new Set();
|
|
959
|
+
const queue = [...parkedIds];
|
|
960
|
+
while (queue.length > 0) {
|
|
961
|
+
const id = queue.shift();
|
|
962
|
+
if (seen.has(id)) continue;
|
|
963
|
+
seen.add(id);
|
|
964
|
+
const node = byId2.get(id);
|
|
965
|
+
if (!node) continue;
|
|
966
|
+
for (const ref of referencedStepIds(node)) if (!seen.has(ref)) queue.push(ref);
|
|
967
|
+
}
|
|
968
|
+
return seen;
|
|
969
|
+
}
|
|
970
|
+
function disturbedStepIds(current, next) {
|
|
971
|
+
const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
|
|
972
|
+
const out = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const before of current.filter(hasId)) {
|
|
974
|
+
const after = nextById.get(before.id);
|
|
975
|
+
if (!after || stable(before) !== stable(after)) out.add(before.id);
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
}
|
|
979
|
+
function parkedRefusal(current, next, parked) {
|
|
980
|
+
const occupied = parked.filter((p) => p.total > 0 && typeof p.currentStepId === "string" && p.currentStepId);
|
|
981
|
+
if (occupied.length === 0) return null;
|
|
982
|
+
const currentById = new Map(current.filter(hasId).map((a) => [a.id, a]));
|
|
983
|
+
const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
|
|
984
|
+
const contactsByStep = new Map(occupied.map((p) => [p.currentStepId, p.total]));
|
|
985
|
+
const guarded = protectedClosure([...contactsByStep.keys()], currentById);
|
|
986
|
+
const deleted = [];
|
|
987
|
+
const altered = [];
|
|
988
|
+
for (const id of guarded) {
|
|
989
|
+
const before = currentById.get(id);
|
|
990
|
+
if (!before) continue;
|
|
991
|
+
const after = nextById.get(id);
|
|
992
|
+
if (!after) {
|
|
993
|
+
deleted.push(id);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (stable(before) !== stable(after)) altered.push(id);
|
|
997
|
+
}
|
|
998
|
+
for (const id of guarded) {
|
|
999
|
+
const head2 = currentById.get(id);
|
|
1000
|
+
if (!head2) continue;
|
|
1001
|
+
for (const target of branchTargetStepIds(head2)) {
|
|
1002
|
+
if (guarded.has(target) || deleted.includes(target)) continue;
|
|
1003
|
+
if (currentById.has(target) && !nextById.has(target)) deleted.push(target);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (deleted.length === 0 && altered.length === 0) return null;
|
|
1007
|
+
const touched = /* @__PURE__ */ new Set([...deleted, ...altered]);
|
|
1008
|
+
let contacts = 0;
|
|
1009
|
+
for (const [stepId, total] of contactsByStep) {
|
|
1010
|
+
const own = protectedClosure([stepId], currentById);
|
|
1011
|
+
let affected = false;
|
|
1012
|
+
for (const id of own) {
|
|
1013
|
+
if (touched.has(id)) {
|
|
1014
|
+
affected = true;
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
const node = currentById.get(id);
|
|
1018
|
+
if (node && branchTargetStepIds(node).some((t) => touched.has(t))) {
|
|
1019
|
+
affected = true;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (affected) contacts += total;
|
|
1024
|
+
}
|
|
1025
|
+
if (contacts === 0) for (const total of contactsByStep.values()) contacts += total;
|
|
1026
|
+
const parts = [];
|
|
1027
|
+
if (deleted.length > 0) parts.push(`${deleted.length} would be removed (${deleted.join(", ")})`);
|
|
1028
|
+
if (altered.length > 0) parts.push(`${altered.length} would be changed (${altered.join(", ")})`);
|
|
1029
|
+
return {
|
|
1030
|
+
deleted,
|
|
1031
|
+
altered,
|
|
1032
|
+
contacts,
|
|
1033
|
+
message: `Refused: ${contacts} contact(s) are part-way through this workflow, and this change would disturb the step they are waiting on \u2014 ${parts.join(", and ")}. They would be dropped from the sequence with no way to put them back, so nothing was written. Every other step in this workflow can still be edited. Wait until nobody is mid-sequence, or move those contacts on deliberately first.`
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
var hasId;
|
|
1037
|
+
var init_parked_guard = __esm({
|
|
1038
|
+
"src/parked-guard.ts"() {
|
|
1039
|
+
"use strict";
|
|
1040
|
+
hasId = (a) => typeof a.id === "string" && a.id.length > 0;
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
921
1044
|
// src/trigger-schemas.ts
|
|
922
1045
|
function describe(status, knownFields) {
|
|
923
1046
|
switch (status) {
|
|
@@ -1328,6 +1451,64 @@ var init_trigger_schemas = __esm({
|
|
|
1328
1451
|
});
|
|
1329
1452
|
|
|
1330
1453
|
// src/workflow-builder-client.ts
|
|
1454
|
+
function isParkedContactsError(e) {
|
|
1455
|
+
return typeof e === "object" && e !== null && e.code === "PARKED_CONTACTS";
|
|
1456
|
+
}
|
|
1457
|
+
function rewriteActionIdRefs(action, remap) {
|
|
1458
|
+
const a = action;
|
|
1459
|
+
const swap = (v) => typeof v === "string" && remap.has(v) ? remap.get(v) : v;
|
|
1460
|
+
if (typeof a.id === "string" && remap.has(a.id)) a.id = remap.get(a.id);
|
|
1461
|
+
for (const key of ["parent", "parentKey"]) if (typeof a[key] === "string") a[key] = swap(a[key]);
|
|
1462
|
+
if (typeof a.next === "string") a.next = swap(a.next);
|
|
1463
|
+
else if (Array.isArray(a.next)) a.next = a.next.map(swap);
|
|
1464
|
+
if (Array.isArray(a.sibling)) a.sibling = a.sibling.map(swap);
|
|
1465
|
+
const attrs = a.attributes;
|
|
1466
|
+
if (typeof attrs !== "object" || attrs === null || Array.isArray(attrs)) return;
|
|
1467
|
+
const at = attrs;
|
|
1468
|
+
if (Array.isArray(at.branches)) {
|
|
1469
|
+
for (const b of at.branches) {
|
|
1470
|
+
if (!isRecord2(b)) continue;
|
|
1471
|
+
if (typeof b.id === "string") b.id = swap(b.id);
|
|
1472
|
+
if (!Array.isArray(b.segments)) continue;
|
|
1473
|
+
for (const seg of b.segments) {
|
|
1474
|
+
if (!isRecord2(seg) || !Array.isArray(seg.conditions)) continue;
|
|
1475
|
+
for (const cond of seg.conditions) {
|
|
1476
|
+
if (isRecord2(cond) && typeof cond.ifElseNodeId === "string") cond.ifElseNodeId = swap(cond.ifElseNodeId);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (typeof at.targetNodeId === "string") at.targetNodeId = swap(at.targetNodeId);
|
|
1482
|
+
const quick = isRecord2(at.quickReplies) ? at.quickReplies.transitions : void 0;
|
|
1483
|
+
for (const table of [at.transitions, at.nonBranchingTransitions, quick]) {
|
|
1484
|
+
if (!Array.isArray(table)) continue;
|
|
1485
|
+
for (const entry of table) {
|
|
1486
|
+
if (isRecord2(entry) && typeof entry.id === "string") entry.id = swap(entry.id);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
const extras = at.extras;
|
|
1490
|
+
if (typeof extras === "object" && extras !== null && !Array.isArray(extras)) {
|
|
1491
|
+
const ex = extras;
|
|
1492
|
+
if (Array.isArray(ex.stepIds)) ex.stepIds = ex.stepIds.map(swap);
|
|
1493
|
+
if (typeof ex.invoiceStepId === "string") ex.invoiceStepId = swap(ex.invoiceStepId);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function coerceLegacyWriteShapes(action) {
|
|
1497
|
+
const attrs = action.attributes;
|
|
1498
|
+
if (typeof attrs !== "object" || attrs === null || Array.isArray(attrs)) return action;
|
|
1499
|
+
const a = attrs;
|
|
1500
|
+
const startAfter = a.startAfter;
|
|
1501
|
+
if (typeof startAfter === "object" && startAfter !== null && !Array.isArray(startAfter)) {
|
|
1502
|
+
const sa = startAfter;
|
|
1503
|
+
if (sa.type === "day") {
|
|
1504
|
+
return { ...action, attributes: { ...a, startAfter: { ...sa, type: "days" } } };
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (action.type === "internal_notification" && a.type === "inapp") {
|
|
1508
|
+
return { ...action, attributes: { ...a, type: "notification" } };
|
|
1509
|
+
}
|
|
1510
|
+
return action;
|
|
1511
|
+
}
|
|
1331
1512
|
function normalizeRemoveFromWorkflowAction(action) {
|
|
1332
1513
|
if (action.type !== "remove_from_workflow") return action;
|
|
1333
1514
|
const attrs = action.attributes;
|
|
@@ -1399,13 +1580,13 @@ function normalizeInternalUpdateOpportunityAction(action) {
|
|
|
1399
1580
|
delete attributes.workflowsActionType;
|
|
1400
1581
|
return { ...action, workflowsActionType: "INTERNAL", attributes };
|
|
1401
1582
|
}
|
|
1402
|
-
function
|
|
1583
|
+
function hasId2(action) {
|
|
1403
1584
|
return typeof action.id === "string" && action.id.length > 0;
|
|
1404
1585
|
}
|
|
1405
1586
|
function hasTriggerId(trigger) {
|
|
1406
1587
|
return typeof trigger.id === "string" && trigger.id.length > 0;
|
|
1407
1588
|
}
|
|
1408
|
-
function
|
|
1589
|
+
function isRecord2(value) {
|
|
1409
1590
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1410
1591
|
}
|
|
1411
1592
|
function getStringArray(value) {
|
|
@@ -1415,9 +1596,20 @@ function getStringArray(value) {
|
|
|
1415
1596
|
return value;
|
|
1416
1597
|
}
|
|
1417
1598
|
function validateActionChain(actions, existingIds) {
|
|
1418
|
-
const
|
|
1599
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
1600
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1601
|
+
for (const action of actions.filter(hasId2)) {
|
|
1602
|
+
if (seenIds.has(action.id)) duplicates.add(action.id);
|
|
1603
|
+
seenIds.add(action.id);
|
|
1604
|
+
}
|
|
1605
|
+
if (duplicates.size > 0) {
|
|
1606
|
+
throw new Error(
|
|
1607
|
+
`Two or more steps share the same id: ${[...duplicates].join(", ")}. Ids identify a step to GoHighLevel and to any contact waiting on it, so they must be unique. Give each step its own id, or omit the id and one will be generated.`
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
const byId2 = new Map(actions.filter(hasId2).map((action) => [action.id, action]));
|
|
1419
1611
|
for (const action of actions) {
|
|
1420
|
-
const attr =
|
|
1612
|
+
const attr = isRecord2(action.attributes) ? action.attributes : void 0;
|
|
1421
1613
|
if (!attr) continue;
|
|
1422
1614
|
switch (action.type) {
|
|
1423
1615
|
case "sms":
|
|
@@ -1438,7 +1630,7 @@ function validateActionChain(actions, existingIds) {
|
|
|
1438
1630
|
}
|
|
1439
1631
|
break;
|
|
1440
1632
|
case "internal_update_opportunity": {
|
|
1441
|
-
const isRoundTripped =
|
|
1633
|
+
const isRoundTripped = hasId2(action) && (existingIds ? existingIds.has(action.id) : true);
|
|
1442
1634
|
if (!isRoundTripped) {
|
|
1443
1635
|
const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : null;
|
|
1444
1636
|
if (!cif) {
|
|
@@ -1459,7 +1651,7 @@ function validateActionChain(actions, existingIds) {
|
|
|
1459
1651
|
break;
|
|
1460
1652
|
}
|
|
1461
1653
|
case "internal_create_opportunity": {
|
|
1462
|
-
const isRoundTripped =
|
|
1654
|
+
const isRoundTripped = hasId2(action) && (existingIds ? existingIds.has(action.id) : true);
|
|
1463
1655
|
if (!isRoundTripped) {
|
|
1464
1656
|
const cif = Array.isArray(attr.__customInputFields__) ? attr.__customInputFields__ : [];
|
|
1465
1657
|
const listed = (ff) => cif.find((f) => f && f.filterField === ff);
|
|
@@ -1505,8 +1697,8 @@ function validateActionChain(actions, existingIds) {
|
|
|
1505
1697
|
throw new Error(`Webhook action "${action.name}" needs '${field}' to be an array of {key, value} pairs.`);
|
|
1506
1698
|
}
|
|
1507
1699
|
for (const entry of entries) {
|
|
1508
|
-
const key =
|
|
1509
|
-
const value =
|
|
1700
|
+
const key = isRecord2(entry) ? entry.key : void 0;
|
|
1701
|
+
const value = isRecord2(entry) ? entry.value : void 0;
|
|
1510
1702
|
if (typeof key !== "string" || !key.trim() || value === void 0 || value === null) {
|
|
1511
1703
|
throw new Error(
|
|
1512
1704
|
`Webhook action "${action.name}" has an empty '${field}' pair. Every entry needs a non-empty key and a value.`
|
|
@@ -1543,7 +1735,7 @@ function validateActionChain(actions, existingIds) {
|
|
|
1543
1735
|
}
|
|
1544
1736
|
for (const action of actions) {
|
|
1545
1737
|
if (action.type !== "find_opportunity") continue;
|
|
1546
|
-
if (existingIds &&
|
|
1738
|
+
if (existingIds && hasId2(action) && existingIds.has(action.id)) continue;
|
|
1547
1739
|
if (!action.id) throw new Error(`find_opportunity "${action.name}" missing id.`);
|
|
1548
1740
|
const next = getStringArray(action.next);
|
|
1549
1741
|
if (!next || next.length !== 2) {
|
|
@@ -1567,7 +1759,7 @@ function validateActionChain(actions, existingIds) {
|
|
|
1567
1759
|
}
|
|
1568
1760
|
}
|
|
1569
1761
|
}
|
|
1570
|
-
var fs4, path4, dotenv, import_zod4, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, WorkflowBuilderClient;
|
|
1762
|
+
var fs4, path4, dotenv, import_zod4, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, UUID_V4, WorkflowBuilderClient;
|
|
1571
1763
|
var init_workflow_builder_client = __esm({
|
|
1572
1764
|
"src/workflow-builder-client.ts"() {
|
|
1573
1765
|
"use strict";
|
|
@@ -1580,6 +1772,7 @@ var init_workflow_builder_client = __esm({
|
|
|
1580
1772
|
init_retry();
|
|
1581
1773
|
init_firebase_claims();
|
|
1582
1774
|
init_id_shape();
|
|
1775
|
+
init_parked_guard();
|
|
1583
1776
|
init_trigger_schemas();
|
|
1584
1777
|
BACKEND_BASE = "https://backend.leadconnectorhq.com/workflow";
|
|
1585
1778
|
FIREBASE_TOKEN_URL = "https://securetoken.googleapis.com/v1/token";
|
|
@@ -1625,6 +1818,16 @@ var init_workflow_builder_client = __esm({
|
|
|
1625
1818
|
version: typeof data.version === "number" ? data.version : 1
|
|
1626
1819
|
});
|
|
1627
1820
|
});
|
|
1821
|
+
ParkedContactsError = class extends Error {
|
|
1822
|
+
constructor(message, contacts) {
|
|
1823
|
+
super(message);
|
|
1824
|
+
this.contacts = contacts;
|
|
1825
|
+
this.name = "ParkedContactsError";
|
|
1826
|
+
}
|
|
1827
|
+
contacts;
|
|
1828
|
+
code = "PARKED_CONTACTS";
|
|
1829
|
+
};
|
|
1830
|
+
UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1628
1831
|
WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
1629
1832
|
// Active Firebase auth — swapped when operating in another company's GHL.
|
|
1630
1833
|
firebaseApiKey;
|
|
@@ -1946,6 +2149,56 @@ var init_workflow_builder_client = __esm({
|
|
|
1946
2149
|
Version: "2021-07-28"
|
|
1947
2150
|
};
|
|
1948
2151
|
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Who is part-way through this workflow, and which step they are sitting on.
|
|
2154
|
+
*
|
|
2155
|
+
* This is the read the parked-contact guard is built on. It is the endpoint
|
|
2156
|
+
* GoHighLevel's own Automation UI uses for its Enrolment view — found by
|
|
2157
|
+
* reading that module's public JS bundle, because the UI runs in a
|
|
2158
|
+
* cross-origin iframe and neither a network log nor a page-level interceptor
|
|
2159
|
+
* can see its calls (2026-09-01).
|
|
2160
|
+
*
|
|
2161
|
+
* GET {backend}/workflows/status/search/count-per-step
|
|
2162
|
+
* ?workflowId={wf}&locationId={loc}
|
|
2163
|
+
* → [{ "total": 1, "currentStepId": "f065696d-…" }]
|
|
2164
|
+
*
|
|
2165
|
+
* Verified live, read-only, on two accounts. Two behaviours worth keeping:
|
|
2166
|
+
* - `workflowId` is SINGULAR and a string. Passing `workflowIds` returns 422
|
|
2167
|
+
* "property workflowIds should not exist" — which only appears AFTER auth
|
|
2168
|
+
* passes, so it doubles as the quickest auth check.
|
|
2169
|
+
* - `[]` means nobody is MID-SEQUENCE. A workflow can show enrolments in the
|
|
2170
|
+
* UI and still return `[]`; those are finished, and they must not block a
|
|
2171
|
+
* write.
|
|
2172
|
+
*
|
|
2173
|
+
* FAILS CLOSED. If this read cannot be completed we do not know whether anyone
|
|
2174
|
+
* is parked, and a guard that assumes "probably nobody" is not a guard.
|
|
2175
|
+
*/
|
|
2176
|
+
async parkedContacts(workflowId) {
|
|
2177
|
+
const url = `https://backend.leadconnectorhq.com/workflows/status/search/count-per-step?workflowId=${encodeURIComponent(workflowId)}&locationId=${encodeURIComponent(this.locationId)}`;
|
|
2178
|
+
const res = await fetch(url, { headers: await this.buildHeaders() });
|
|
2179
|
+
if (!res.ok) {
|
|
2180
|
+
throw new Error(
|
|
2181
|
+
`Could not check whether anyone is part-way through this workflow (HTTP ${res.status}). Nothing was written. This check has to succeed before a step can be changed, because changing a step somebody is waiting on drops them from the sequence permanently.`
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2184
|
+
const body = await res.json();
|
|
2185
|
+
if (!Array.isArray(body)) {
|
|
2186
|
+
throw new Error(
|
|
2187
|
+
`The check for contacts part-way through this workflow returned something unexpected, so it is not possible to tell whether anyone would be dropped. Nothing was written.`
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
return body.map((row) => {
|
|
2191
|
+
const r = typeof row === "object" && row !== null && !Array.isArray(row) ? row : null;
|
|
2192
|
+
const total = r && typeof r.total === "number" ? r.total : null;
|
|
2193
|
+
const currentStepId = r && typeof r.currentStepId === "string" && r.currentStepId ? r.currentStepId : null;
|
|
2194
|
+
if (total === null || currentStepId === null) {
|
|
2195
|
+
throw new Error(
|
|
2196
|
+
`The check for contacts part-way through this workflow returned a row that could not be read, so it is not possible to tell whether anyone would be dropped. Nothing was written.`
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
return { total, currentStepId };
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
1949
2202
|
/**
|
|
1950
2203
|
* Make a request to the internal workflow API.
|
|
1951
2204
|
* Retries on 429, 5xx, and transient network errors with exponential backoff.
|
|
@@ -2027,9 +2280,9 @@ ${errorBody}`
|
|
|
2027
2280
|
*/
|
|
2028
2281
|
async getWorkflow(workflowId) {
|
|
2029
2282
|
const raw = await this.request("GET", `/${this.locationId}/${workflowId}?includeTriggers=true`);
|
|
2030
|
-
const flat =
|
|
2283
|
+
const flat = isRecord2(raw) && isRecord2(raw.workflowData) ? {
|
|
2031
2284
|
...raw.workflowData,
|
|
2032
|
-
workflowData:
|
|
2285
|
+
workflowData: isRecord2(raw.workflowData.workflowData) ? raw.workflowData.workflowData : { templates: [] },
|
|
2033
2286
|
triggers: raw.triggers
|
|
2034
2287
|
} : raw;
|
|
2035
2288
|
const parsed = WorkflowFullSchema.parse(flat);
|
|
@@ -2065,17 +2318,22 @@ ${errorBody}`
|
|
|
2065
2318
|
async updateWorkflow(workflowId, updates) {
|
|
2066
2319
|
const current = await this.getWorkflow(workflowId);
|
|
2067
2320
|
const effectiveStatus = updates.status ?? current.status;
|
|
2068
|
-
if (updates.triggers !== void 0) {
|
|
2069
|
-
await this.syncTriggers(workflowId, current.triggers || [], updates.triggers, effectiveStatus);
|
|
2070
|
-
}
|
|
2071
2321
|
const currentActions = current.workflowData?.templates || [];
|
|
2072
2322
|
const newActions = updates.actions ?? currentActions;
|
|
2073
|
-
const existingActionIds = new Set(currentActions.filter(
|
|
2323
|
+
const existingActionIds = new Set(currentActions.filter(hasId2).map((a) => a.id));
|
|
2074
2324
|
const linkedActions = this.buildActionChain(newActions, existingActionIds);
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2325
|
+
if (currentActions.length > 0 && disturbedStepIds(currentActions, linkedActions).size > 0) {
|
|
2326
|
+
const parked = await this.parkedContacts(workflowId);
|
|
2327
|
+
const refusal = parkedRefusal(currentActions, linkedActions, parked);
|
|
2328
|
+
if (refusal) throw new Error(refusal.message);
|
|
2329
|
+
}
|
|
2330
|
+
if (updates.triggers !== void 0) {
|
|
2331
|
+
await this.syncTriggers(workflowId, current.triggers || [], updates.triggers, effectiveStatus);
|
|
2332
|
+
}
|
|
2333
|
+
const currentIds = new Set(currentActions.filter(hasId2).map((a) => a.id));
|
|
2334
|
+
const newIds = new Set(linkedActions.filter(hasId2).map((a) => a.id));
|
|
2335
|
+
const createdSteps = linkedActions.filter(hasId2).filter((a) => !currentIds.has(a.id)).map((a) => a.id);
|
|
2336
|
+
const deletedSteps = currentActions.filter(hasId2).filter((a) => !newIds.has(a.id)).map((a) => a.id);
|
|
2079
2337
|
const body = {
|
|
2080
2338
|
name: updates.name ?? current.name,
|
|
2081
2339
|
isRestoreRequest: true,
|
|
@@ -2111,7 +2369,15 @@ ${errorBody}`
|
|
|
2111
2369
|
/**
|
|
2112
2370
|
* Delete a workflow
|
|
2113
2371
|
*/
|
|
2114
|
-
async deleteWorkflow(workflowId) {
|
|
2372
|
+
async deleteWorkflow(workflowId, opts) {
|
|
2373
|
+
const parked = await this.parkedContacts(workflowId);
|
|
2374
|
+
const contacts = parked.reduce((sum, p) => sum + p.total, 0);
|
|
2375
|
+
if (contacts > 0 && opts?.acknowledgeContacts !== contacts) {
|
|
2376
|
+
throw new ParkedContactsError(
|
|
2377
|
+
`Refused: ${contacts} contact(s) are part-way through this workflow right now. Deleting it drops every one of them out of the sequence, and there is no way to put them back. Nothing was deleted. If that is what you intend, re-run it acknowledging ${contacts} contact(s).`,
|
|
2378
|
+
contacts
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2115
2381
|
return this.request("DELETE", `/${this.locationId}/${workflowId}`);
|
|
2116
2382
|
}
|
|
2117
2383
|
/**
|
|
@@ -2142,7 +2408,7 @@ ${errorBody}`
|
|
|
2142
2408
|
returned = typeof after.status === "string" ? after.status : null;
|
|
2143
2409
|
} catch (e) {
|
|
2144
2410
|
readBackFailed = e instanceof Error ? e.message : String(e);
|
|
2145
|
-
returned =
|
|
2411
|
+
returned = isRecord2(result) && typeof result.status === "string" ? result.status : null;
|
|
2146
2412
|
}
|
|
2147
2413
|
const publish_check = {
|
|
2148
2414
|
published: returned === "published",
|
|
@@ -2160,7 +2426,7 @@ ${errorBody}`
|
|
|
2160
2426
|
if (triggers.length === 0) {
|
|
2161
2427
|
publish_check.note = "This workflow has NO TRIGGER, so nothing starts it on its own. That is only correct if another workflow adds contacts to it; otherwise its trigger did not save.";
|
|
2162
2428
|
}
|
|
2163
|
-
return
|
|
2429
|
+
return isRecord2(result) ? { ...result, publish_check } : { result, publish_check };
|
|
2164
2430
|
}
|
|
2165
2431
|
/**
|
|
2166
2432
|
* Create a workflow trigger. GHL generates the Firestore id and returns it.
|
|
@@ -2170,7 +2436,7 @@ ${errorBody}`
|
|
|
2170
2436
|
async createTrigger(workflowId, trigger, workflowStatus) {
|
|
2171
2437
|
const payload = this.buildTriggerPayload(workflowId, trigger, workflowStatus);
|
|
2172
2438
|
const raw = await this.request("POST", `/${this.locationId}/trigger`, payload);
|
|
2173
|
-
if (
|
|
2439
|
+
if (isRecord2(raw) && typeof raw.id === "string") {
|
|
2174
2440
|
return raw.id;
|
|
2175
2441
|
}
|
|
2176
2442
|
throw new Error(`Trigger creation did not return an id. Response: ${JSON.stringify(raw)}`);
|
|
@@ -2251,7 +2517,7 @@ ${errorBody}`
|
|
|
2251
2517
|
*/
|
|
2252
2518
|
buildActionChain(actions, existingIds) {
|
|
2253
2519
|
validateActionChain(actions, existingIds);
|
|
2254
|
-
const linked = actions.map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map(normalizeInternalCreateOpportunityAction).map((action, i) => {
|
|
2520
|
+
const linked = actions.map(coerceLegacyWriteShapes).map(normalizeRemoveFromWorkflowAction).map(normalizeInternalUpdateOpportunityAction).map(normalizeInternalCreateOpportunityAction).map((action, i) => {
|
|
2255
2521
|
const copy = { ...action };
|
|
2256
2522
|
if (!copy.id) {
|
|
2257
2523
|
copy.id = crypto.randomUUID();
|
|
@@ -2261,6 +2527,17 @@ ${errorBody}`
|
|
|
2261
2527
|
}
|
|
2262
2528
|
return copy;
|
|
2263
2529
|
});
|
|
2530
|
+
const remap = /* @__PURE__ */ new Map();
|
|
2531
|
+
for (const action of linked) {
|
|
2532
|
+
const id = action.id;
|
|
2533
|
+
if (typeof id !== "string" || !id) continue;
|
|
2534
|
+
if (existingIds?.has(id)) continue;
|
|
2535
|
+
if (UUID_V4.test(id)) continue;
|
|
2536
|
+
remap.set(id, crypto.randomUUID());
|
|
2537
|
+
}
|
|
2538
|
+
if (remap.size > 0) {
|
|
2539
|
+
for (const action of linked) rewriteActionIdRefs(action, remap);
|
|
2540
|
+
}
|
|
2264
2541
|
for (let i = 0; i < linked.length; i++) {
|
|
2265
2542
|
const action = linked[i];
|
|
2266
2543
|
if (action.parent || action.nodeType) continue;
|
|
@@ -3553,8 +3830,8 @@ function registerContactTools(server2, client) {
|
|
|
3553
3830
|
taskId: import_zod9.z.string().min(1).describe("The ID of the task (from get_contact_tasks or create_contact_task)."),
|
|
3554
3831
|
completed: import_zod9.z.boolean().optional().describe("true (default) marks the task completed; false reopens a completed task.")
|
|
3555
3832
|
},
|
|
3556
|
-
async ({ contactId, taskId, completed }) => {
|
|
3557
|
-
return client.put(`/contacts/${contactId}/tasks/${
|
|
3833
|
+
async ({ contactId, taskId: taskId2, completed }) => {
|
|
3834
|
+
return client.put(`/contacts/${contactId}/tasks/${taskId2}/completed`, {
|
|
3558
3835
|
body: { completed: completed ?? true }
|
|
3559
3836
|
});
|
|
3560
3837
|
}
|
|
@@ -3572,7 +3849,7 @@ function registerContactTools(server2, client) {
|
|
|
3572
3849
|
completed: import_zod9.z.boolean().optional().describe("Set the completed state of the task."),
|
|
3573
3850
|
assignedTo: import_zod9.z.string().optional().describe("User ID to reassign the task to.")
|
|
3574
3851
|
},
|
|
3575
|
-
async ({ contactId, taskId, title: title2, body: taskBody, dueDate, completed, assignedTo }) => {
|
|
3852
|
+
async ({ contactId, taskId: taskId2, title: title2, body: taskBody, dueDate, completed, assignedTo }) => {
|
|
3576
3853
|
if (dueDate !== void 0 && !isDateOnly(dueDate) && !hasTimezoneOffset(dueDate)) {
|
|
3577
3854
|
throw new Error(
|
|
3578
3855
|
`dueDate "${dueDate}" carries no timezone offset, so it does not say which 9am it means. GHL reads it as UTC and stores a different local hour \u2014 a task set for 9am in US/Arizona lands at 2am. Add the offset, e.g. "2026-09-15T09:00:00-07:00", or "...Z" for UTC. Nothing was changed.`
|
|
@@ -3584,7 +3861,7 @@ function registerContactTools(server2, client) {
|
|
|
3584
3861
|
"Nothing to update: pass at least one of title, body, dueDate, completed, assignedTo."
|
|
3585
3862
|
);
|
|
3586
3863
|
}
|
|
3587
|
-
return client.put(`/contacts/${contactId}/tasks/${
|
|
3864
|
+
return client.put(`/contacts/${contactId}/tasks/${taskId2}`, { body: reqBody });
|
|
3588
3865
|
}
|
|
3589
3866
|
);
|
|
3590
3867
|
safeTool(
|
|
@@ -3596,8 +3873,8 @@ function registerContactTools(server2, client) {
|
|
|
3596
3873
|
taskId: import_zod9.z.string().min(1).describe("The ID of the task to delete (from get_contact_tasks)."),
|
|
3597
3874
|
confirm: import_zod9.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action.")
|
|
3598
3875
|
},
|
|
3599
|
-
async ({ contactId, taskId }) => {
|
|
3600
|
-
return client.delete(`/contacts/${contactId}/tasks/${
|
|
3876
|
+
async ({ contactId, taskId: taskId2 }) => {
|
|
3877
|
+
return client.delete(`/contacts/${contactId}/tasks/${taskId2}`);
|
|
3601
3878
|
}
|
|
3602
3879
|
);
|
|
3603
3880
|
safeTool(
|
|
@@ -3784,7 +4061,7 @@ function registerConversationTools(server2, client) {
|
|
|
3784
4061
|
if (emailBcc !== void 0) body.emailBcc = emailBcc;
|
|
3785
4062
|
if (attachments !== void 0) body.attachments = attachments;
|
|
3786
4063
|
const result = await client.post("/conversations/messages", { body });
|
|
3787
|
-
const messageId =
|
|
4064
|
+
const messageId = isRecord3(result) && typeof result.messageId === "string" ? result.messageId : null;
|
|
3788
4065
|
if (!messageId) return result;
|
|
3789
4066
|
const TERMINAL = /* @__PURE__ */ new Set(["delivered", "sent", "failed", "undelivered", "rejected", "opted_out"]);
|
|
3790
4067
|
let status = null;
|
|
@@ -3794,7 +4071,7 @@ function registerConversationTools(server2, client) {
|
|
|
3794
4071
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 1200 * attempt));
|
|
3795
4072
|
try {
|
|
3796
4073
|
const raw = await client.get(`/conversations/messages/${messageId}`);
|
|
3797
|
-
const msg3 =
|
|
4074
|
+
const msg3 = isRecord3(raw) && isRecord3(raw.message) ? raw.message : isRecord3(raw) ? raw : null;
|
|
3798
4075
|
status = typeof msg3?.status === "string" ? msg3.status : null;
|
|
3799
4076
|
from = typeof msg3?.from === "string" ? msg3.from : from;
|
|
3800
4077
|
readError = null;
|
|
@@ -3815,7 +4092,7 @@ function registerConversationTools(server2, client) {
|
|
|
3815
4092
|
} else if (!delivered) {
|
|
3816
4093
|
delivery_check.warning = `NOT DELIVERED. GHL reports status "${status ?? "(none)"}" for this message. Do not report it as sent.`;
|
|
3817
4094
|
}
|
|
3818
|
-
return
|
|
4095
|
+
return isRecord3(result) ? { ...result, delivery_check } : { result, delivery_check };
|
|
3819
4096
|
}
|
|
3820
4097
|
);
|
|
3821
4098
|
safeTool(
|
|
@@ -3861,13 +4138,13 @@ function registerConversationTools(server2, client) {
|
|
|
3861
4138
|
}
|
|
3862
4139
|
);
|
|
3863
4140
|
}
|
|
3864
|
-
var import_zod10,
|
|
4141
|
+
var import_zod10, isRecord3;
|
|
3865
4142
|
var init_conversations = __esm({
|
|
3866
4143
|
"src/tools/conversations.ts"() {
|
|
3867
4144
|
"use strict";
|
|
3868
4145
|
import_zod10 = require("zod");
|
|
3869
4146
|
init_tool_helpers();
|
|
3870
|
-
|
|
4147
|
+
isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3871
4148
|
}
|
|
3872
4149
|
});
|
|
3873
4150
|
|
|
@@ -7577,9 +7854,10 @@ function registerWebhookTools(server2, builderClient) {
|
|
|
7577
7854
|
{
|
|
7578
7855
|
webhookId: import_zod34.z.string().describe("The webhook ID (its workflow ID)."),
|
|
7579
7856
|
confirm: import_zod34.z.literal("DELETE").describe("Must be 'DELETE' to confirm this destructive action."),
|
|
7580
|
-
force: import_zod34.z.boolean().optional().describe("Delete even when the workflow contains other actions that would go with it.")
|
|
7857
|
+
force: import_zod34.z.boolean().optional().describe("Delete even when the workflow contains other actions that would go with it."),
|
|
7858
|
+
acknowledgeContacts: import_zod34.z.number().optional().describe("Only needed if contacts are part-way through this workflow. Deleting drops them permanently and they cannot be put back. The first attempt tells you the number; pass it here to proceed.")
|
|
7581
7859
|
},
|
|
7582
|
-
async ({ webhookId, confirm: confirm2, force }) => {
|
|
7860
|
+
async ({ webhookId, confirm: confirm2, force, acknowledgeContacts }) => {
|
|
7583
7861
|
try {
|
|
7584
7862
|
const client = requireClient();
|
|
7585
7863
|
if (confirm2 !== "DELETE") throw new Error("Pass confirm: 'DELETE' to delete a webhook.");
|
|
@@ -7594,7 +7872,7 @@ function registerWebhookTools(server2, builderClient) {
|
|
|
7594
7872
|
`Workflow "${summary.name}" does more than fire a webhook \u2014 deleting it would delete those steps too. Pass force: true if that is what you want, or use update_webhook with status 'draft' to just stop it firing.`
|
|
7595
7873
|
);
|
|
7596
7874
|
}
|
|
7597
|
-
await client.deleteWorkflow(webhookId);
|
|
7875
|
+
await client.deleteWorkflow(webhookId, { acknowledgeContacts });
|
|
7598
7876
|
return jsonResponse({ deleted: true, webhookId, name: summary.name });
|
|
7599
7877
|
} catch (error) {
|
|
7600
7878
|
return errorResponse(error);
|
|
@@ -8190,14 +8468,15 @@ function registerWorkflowBuilderTools(server2, client) {
|
|
|
8190
8468
|
);
|
|
8191
8469
|
server2.tool(
|
|
8192
8470
|
"delete_workflow_full",
|
|
8193
|
-
"Permanently delete a workflow. IRREVERSIBLE.",
|
|
8471
|
+
"Permanently delete a workflow. IRREVERSIBLE. If contacts are part-way through it, the first attempt refuses and tells you how many would be dropped \u2014 deleting removes them from the sequence permanently and they cannot be restored.",
|
|
8194
8472
|
{
|
|
8195
8473
|
workflowId: import_zod36.z.string().describe("The workflow ID to delete."),
|
|
8196
|
-
confirm: import_zod36.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action.")
|
|
8474
|
+
confirm: import_zod36.z.literal("DELETE").describe("Must pass 'DELETE' to confirm this destructive action."),
|
|
8475
|
+
acknowledgeContacts: import_zod36.z.number().optional().describe("Only needed if contacts are part-way through this workflow. Deleting drops them out of the sequence permanently and they cannot be put back. The first attempt tells you the number; pass it here to proceed.")
|
|
8197
8476
|
},
|
|
8198
|
-
async ({ workflowId }) => {
|
|
8477
|
+
async ({ workflowId, acknowledgeContacts }) => {
|
|
8199
8478
|
try {
|
|
8200
|
-
const result = await client.deleteWorkflow(workflowId);
|
|
8479
|
+
const result = await client.deleteWorkflow(workflowId, { acknowledgeContacts });
|
|
8201
8480
|
return jsonResponse(result);
|
|
8202
8481
|
} catch (error) {
|
|
8203
8482
|
return errorResponse(error);
|
|
@@ -10964,7 +11243,31 @@ var init_attestation = __esm({
|
|
|
10964
11243
|
});
|
|
10965
11244
|
|
|
10966
11245
|
// src/setup-tool.ts
|
|
10967
|
-
function
|
|
11246
|
+
function stableFallbackId() {
|
|
11247
|
+
let user;
|
|
11248
|
+
try {
|
|
11249
|
+
user = os2.userInfo().username;
|
|
11250
|
+
} catch {
|
|
11251
|
+
user = process.env.USER || process.env.USERNAME || process.env.LOGNAME || "no-user";
|
|
11252
|
+
}
|
|
11253
|
+
const raw = `${user}:${os2.platform()}:${os2.arch()}`;
|
|
11254
|
+
return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
11255
|
+
}
|
|
11256
|
+
function warnNoDurableId() {
|
|
11257
|
+
if (warnedNoDurableId) return;
|
|
11258
|
+
warnedNoDurableId = true;
|
|
11259
|
+
let where = "the config folder";
|
|
11260
|
+
try {
|
|
11261
|
+
const store = (init_credentials_store(), __toCommonJS(credentials_store_exports));
|
|
11262
|
+
where = store.appDataDir();
|
|
11263
|
+
} catch {
|
|
11264
|
+
}
|
|
11265
|
+
process.stderr.write(
|
|
11266
|
+
`[ghl-mcp] Warning: this machine's identity could not be stored in ${where}, so a derived one is being used. Your licence still works and no extra install slot is consumed. If this keeps appearing, that folder is not writable \u2014 set GHL_MCP_CONFIG_DIR to a folder you own.
|
|
11267
|
+
`
|
|
11268
|
+
);
|
|
11269
|
+
}
|
|
11270
|
+
function licenseDeviceId() {
|
|
10968
11271
|
try {
|
|
10969
11272
|
const store = (init_credentials_store(), __toCommonJS(credentials_store_exports));
|
|
10970
11273
|
const creds = store.readCredentials();
|
|
@@ -10974,7 +11277,14 @@ function deviceFingerprint2() {
|
|
|
10974
11277
|
const att = (init_attestation(), __toCommonJS(attestation_exports));
|
|
10975
11278
|
adopted = att.parseAttestationPayload(creds.signed_attestation)?.device_fingerprint;
|
|
10976
11279
|
}
|
|
10977
|
-
|
|
11280
|
+
let id = adopted;
|
|
11281
|
+
if (!id) {
|
|
11282
|
+
id = telemetryDeviceId() ?? void 0;
|
|
11283
|
+
if (!id) {
|
|
11284
|
+
warnNoDurableId();
|
|
11285
|
+
id = stableFallbackId();
|
|
11286
|
+
}
|
|
11287
|
+
}
|
|
10978
11288
|
if (creds) {
|
|
10979
11289
|
try {
|
|
10980
11290
|
store.writeCredentials({ ...creds, device_id: id });
|
|
@@ -10983,8 +11293,7 @@ function deviceFingerprint2() {
|
|
|
10983
11293
|
}
|
|
10984
11294
|
return id;
|
|
10985
11295
|
} catch {
|
|
10986
|
-
|
|
10987
|
-
return crypto4.createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
11296
|
+
return stableFallbackId();
|
|
10988
11297
|
}
|
|
10989
11298
|
}
|
|
10990
11299
|
async function validateLicense(email, licenseKey) {
|
|
@@ -10996,7 +11305,7 @@ async function validateLicense(email, licenseKey) {
|
|
|
10996
11305
|
body: JSON.stringify({
|
|
10997
11306
|
email: email.trim(),
|
|
10998
11307
|
license_key: licenseKey.trim(),
|
|
10999
|
-
device_fingerprint:
|
|
11308
|
+
device_fingerprint: licenseDeviceId()
|
|
11000
11309
|
}),
|
|
11001
11310
|
signal: AbortSignal.timeout(1e4)
|
|
11002
11311
|
});
|
|
@@ -11553,7 +11862,7 @@ function registerLeadCaptureTool(server2) {
|
|
|
11553
11862
|
}
|
|
11554
11863
|
);
|
|
11555
11864
|
}
|
|
11556
|
-
var os2, crypto4, import_zod43, LICENSE_API, CAPTURE_API, GHL_API, FIREBASE_TOKEN_API, LICENSE_REASON_MAP, setupPkgVersion;
|
|
11865
|
+
var os2, crypto4, import_zod43, LICENSE_API, CAPTURE_API, GHL_API, FIREBASE_TOKEN_API, warnedNoDurableId, LICENSE_REASON_MAP, setupPkgVersion;
|
|
11557
11866
|
var init_setup_tool = __esm({
|
|
11558
11867
|
"src/setup-tool.ts"() {
|
|
11559
11868
|
"use strict";
|
|
@@ -11571,6 +11880,7 @@ var init_setup_tool = __esm({
|
|
|
11571
11880
|
CAPTURE_API = "https://elitedcs.com/api/capture-lead";
|
|
11572
11881
|
GHL_API = "https://services.leadconnectorhq.com";
|
|
11573
11882
|
FIREBASE_TOKEN_API = "https://securetoken.googleapis.com/v1/token";
|
|
11883
|
+
warnedNoDurableId = false;
|
|
11574
11884
|
LICENSE_REASON_MAP = {
|
|
11575
11885
|
unreachable: "license_unreachable",
|
|
11576
11886
|
install_limit: "license_install_limit",
|
|
@@ -14587,6 +14897,12 @@ function traceOpportunityContext(node, byId2) {
|
|
|
14587
14897
|
}
|
|
14588
14898
|
return "dangling";
|
|
14589
14899
|
}
|
|
14900
|
+
function isRecord4(v) {
|
|
14901
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
14902
|
+
}
|
|
14903
|
+
function isUuidV4(id) {
|
|
14904
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
|
|
14905
|
+
}
|
|
14590
14906
|
function checkActionShapes(workflow) {
|
|
14591
14907
|
const findings = [];
|
|
14592
14908
|
const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
|
|
@@ -14659,9 +14975,79 @@ function checkActionShapes(workflow) {
|
|
|
14659
14975
|
}
|
|
14660
14976
|
return findings;
|
|
14661
14977
|
}
|
|
14978
|
+
function checkSaveBlockers(workflow) {
|
|
14979
|
+
const findings = [];
|
|
14980
|
+
const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
|
|
14981
|
+
for (const a of actions) {
|
|
14982
|
+
const type = typeof a.type === "string" ? a.type : "unknown";
|
|
14983
|
+
const name = typeof a.name === "string" ? a.name : "unnamed action";
|
|
14984
|
+
const id = typeof a.id === "string" ? a.id : name;
|
|
14985
|
+
const where = `action "${name}" (${type})`;
|
|
14986
|
+
if (typeof a.id === "string" && a.id && !isUuidV4(a.id)) {
|
|
14987
|
+
findings.push({
|
|
14988
|
+
severity: "warning",
|
|
14989
|
+
category: "legacy_step_id",
|
|
14990
|
+
id,
|
|
14991
|
+
where,
|
|
14992
|
+
message: `${where} carries a step id GoHighLevel's validator now rejects when the workflow is saved ("${a.id}"). The workflow RUNS normally \u2014 it cannot be EDITED until the id is re-minted. Re-minting drops anyone currently waiting on that step, so it is refused while somebody is part-way through.`
|
|
14993
|
+
});
|
|
14994
|
+
}
|
|
14995
|
+
{
|
|
14996
|
+
const attrs = isRecord4(a.attributes) ? a.attributes : void 0;
|
|
14997
|
+
const startAfter = attrs && isRecord4(attrs.startAfter) ? attrs.startAfter : void 0;
|
|
14998
|
+
if (startAfter && (startAfter.type === "day" || startAfter.type === "minute")) {
|
|
14999
|
+
findings.push({
|
|
15000
|
+
severity: "warning",
|
|
15001
|
+
category: "legacy_wait_unit",
|
|
15002
|
+
id,
|
|
15003
|
+
where,
|
|
15004
|
+
message: `${where} uses the singular wait unit "${String(startAfter.type)}"; GoHighLevel requires the plural on save. It runs fine and blocks saves. This one is corrected automatically on the next write \u2014 no ids change and nobody is ejected.`
|
|
15005
|
+
});
|
|
15006
|
+
}
|
|
15007
|
+
}
|
|
15008
|
+
if (type === "internal_notification") {
|
|
15009
|
+
const attrs = isRecord4(a.attributes) ? a.attributes : void 0;
|
|
15010
|
+
const notif = attrs && isRecord4(attrs.notification) ? attrs.notification : void 0;
|
|
15011
|
+
const selected = notif?.selectedUser;
|
|
15012
|
+
const blank = selected === void 0 || selected === null || typeof selected === "string" && selected.trim() === "";
|
|
15013
|
+
if (notif?.userType === "user" && blank) {
|
|
15014
|
+
findings.push({
|
|
15015
|
+
severity: "warning",
|
|
15016
|
+
category: "missing_required_attribute",
|
|
15017
|
+
id,
|
|
15018
|
+
where,
|
|
15019
|
+
message: `${where} is set to notify a specific person but nobody is selected, so it sends nothing. The workflow saves, publishes and runs \u2014 this step just never notifies anyone. Pick a user, or set it to notify everyone.`
|
|
15020
|
+
});
|
|
15021
|
+
}
|
|
15022
|
+
}
|
|
15023
|
+
}
|
|
15024
|
+
return findings;
|
|
15025
|
+
}
|
|
15026
|
+
function checkMissingIntegrations(workflow, connectedPlatforms) {
|
|
15027
|
+
const findings = [];
|
|
15028
|
+
const actions = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : [];
|
|
15029
|
+
for (const a of actions) {
|
|
15030
|
+
const type = typeof a.type === "string" ? a.type : "";
|
|
15031
|
+
const needs = SOCIAL_STEP_PLATFORMS.get(type);
|
|
15032
|
+
if (!needs) continue;
|
|
15033
|
+
if (needs.some((p) => connectedPlatforms.has(p))) continue;
|
|
15034
|
+
const name = typeof a.name === "string" ? a.name : "unnamed action";
|
|
15035
|
+
const id = typeof a.id === "string" ? a.id : name;
|
|
15036
|
+
const label = needs.length > 1 ? needs.join(" or ") : needs[0];
|
|
15037
|
+
findings.push({
|
|
15038
|
+
severity: "warning",
|
|
15039
|
+
category: "missing_integration",
|
|
15040
|
+
id,
|
|
15041
|
+
where: `action "${name}" (${type})`,
|
|
15042
|
+
message: `action "${name}" needs a connected ${label} account and this location has none. The workflow saves, publishes and runs \u2014 this step just does nothing when it is reached. Connect the account in Settings \u2192 Integrations. (This check only sees a MISSING account: if the account is connected but its messaging permission was never granted, the step fails the same way and nothing here can tell you.)`
|
|
15043
|
+
});
|
|
15044
|
+
}
|
|
15045
|
+
return findings;
|
|
15046
|
+
}
|
|
14662
15047
|
function summarizeFindings(findings) {
|
|
14663
15048
|
const issues = findings.filter((f) => f.severity === "error").length;
|
|
14664
|
-
const
|
|
15049
|
+
const WARNING_CATEGORIES = /* @__PURE__ */ new Set(["custom_field", "action_shape", "legacy_step_id", "legacy_wait_unit", "missing_required_attribute", "missing_integration"]);
|
|
15050
|
+
const warnings = findings.filter((f) => f.severity === "warning" && WARNING_CATEGORIES.has(f.category)).length;
|
|
14665
15051
|
return { status: issues > 0 ? "issues_found" : "ok", issues_count: issues, warnings_count: warnings };
|
|
14666
15052
|
}
|
|
14667
15053
|
function collectMergeTagKeys(value, out) {
|
|
@@ -15089,6 +15475,26 @@ function incompleteCatalogNote(stopReason, enumerated, reportedTotal) {
|
|
|
15089
15475
|
return "";
|
|
15090
15476
|
}
|
|
15091
15477
|
}
|
|
15478
|
+
async function connectedSocialPlatforms(client, locationId2) {
|
|
15479
|
+
try {
|
|
15480
|
+
const raw = await client.get(`/social-media-posting/${locationId2}/accounts`);
|
|
15481
|
+
const rec = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
15482
|
+
const root = rec(raw);
|
|
15483
|
+
const results = root && rec(root.results);
|
|
15484
|
+
const list2 = results?.accounts ?? root?.accounts;
|
|
15485
|
+
if (!Array.isArray(list2)) return null;
|
|
15486
|
+
const out = /* @__PURE__ */ new Set();
|
|
15487
|
+
for (const row of list2) {
|
|
15488
|
+
const r = rec(row);
|
|
15489
|
+
if (!r) continue;
|
|
15490
|
+
if (r.isExpired === true || r.deleted === true) continue;
|
|
15491
|
+
if (typeof r.platform === "string") out.add(r.platform.toLowerCase());
|
|
15492
|
+
}
|
|
15493
|
+
return out;
|
|
15494
|
+
} catch {
|
|
15495
|
+
return null;
|
|
15496
|
+
}
|
|
15497
|
+
}
|
|
15092
15498
|
function registerValidatorTools(server2, client, builderClient) {
|
|
15093
15499
|
if (!builderClient) {
|
|
15094
15500
|
const finishInstallStub = (asked) => ({
|
|
@@ -15128,7 +15534,12 @@ function registerValidatorTools(server2, client, builderClient) {
|
|
|
15128
15534
|
const workflow = await builderClient.getWorkflow(workflowId);
|
|
15129
15535
|
if (!workflow) return errorResponse(new Error(`Workflow ${workflowId} not found`));
|
|
15130
15536
|
const refs = [];
|
|
15131
|
-
const
|
|
15537
|
+
const socialPlatforms = await connectedSocialPlatforms(client, client.defaultLocationId);
|
|
15538
|
+
const shape = [
|
|
15539
|
+
...checkActionShapes(workflow),
|
|
15540
|
+
...checkSaveBlockers(workflow),
|
|
15541
|
+
...socialPlatforms ? checkMissingIntegrations(workflow, socialPlatforms) : []
|
|
15542
|
+
];
|
|
15132
15543
|
const actionsChecked = Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates.length : 0;
|
|
15133
15544
|
for (const t of Array.isArray(workflow.triggers) ? workflow.triggers : []) extractFromTrigger(t, refs);
|
|
15134
15545
|
for (const a of Array.isArray(workflow.workflowData?.templates) ? workflow.workflowData.templates : []) extractFromAction(a, refs);
|
|
@@ -15190,6 +15601,7 @@ function registerValidatorTools(server2, client, builderClient) {
|
|
|
15190
15601
|
notes: catalog.complete ? [] : [incompleteCatalogNote(catalog.stopReason, 0, catalog.reportedTotal)]
|
|
15191
15602
|
});
|
|
15192
15603
|
const lookups = await fetchAndBuildLookups(client, builderClient, locationId2, { ids: catalog.ids, complete: catalog.complete });
|
|
15604
|
+
const auditSocialPlatforms = await connectedSocialPlatforms(client, locationId2);
|
|
15193
15605
|
const SCAN_CAP = 300;
|
|
15194
15606
|
const toScan = catalog.rows.slice(0, SCAN_CAP);
|
|
15195
15607
|
const CONCURRENCY = 6;
|
|
@@ -15203,7 +15615,12 @@ function registerValidatorTools(server2, client, builderClient) {
|
|
|
15203
15615
|
const wf = await builderClient.getWorkflow(row.id);
|
|
15204
15616
|
const refs = auditOneWorkflow(wf, row.id, lookups);
|
|
15205
15617
|
if (refs.length === 0) zeroRefCount++;
|
|
15206
|
-
const findings = [
|
|
15618
|
+
const findings = [
|
|
15619
|
+
...checkActionShapes(wf),
|
|
15620
|
+
...checkSaveBlockers(wf),
|
|
15621
|
+
...auditSocialPlatforms ? checkMissingIntegrations(wf, auditSocialPlatforms) : [],
|
|
15622
|
+
...checkRefs(refs, row.id, lookups)
|
|
15623
|
+
];
|
|
15207
15624
|
results.push({ id: row.id, name: wf.name ?? row.name, status: wf.status, refs: refs.length, findings });
|
|
15208
15625
|
} catch (e) {
|
|
15209
15626
|
unscannable.push({ id: row.id, name: row.name, reason: e instanceof Error ? e.message : String(e) });
|
|
@@ -15262,7 +15679,7 @@ function registerValidatorTools(server2, client, builderClient) {
|
|
|
15262
15679
|
}
|
|
15263
15680
|
);
|
|
15264
15681
|
}
|
|
15265
|
-
var import_zod53, ALL_CATEGORIES, OPPORTUNITY_CONTEXT_TRIGGERS, OPPORTUNITY_CREATING_TYPES, KNOWN_UPDATE_OPP_FIELDS, MAX_TRACE_HOPS, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
|
|
15682
|
+
var import_zod53, ALL_CATEGORIES, OPPORTUNITY_CONTEXT_TRIGGERS, OPPORTUNITY_CREATING_TYPES, KNOWN_UPDATE_OPP_FIELDS, MAX_TRACE_HOPS, SOCIAL_STEP_PLATFORMS, STANDARD_CONTACT_FIELDS, MERGE_TAG_RE;
|
|
15266
15683
|
var init_validators = __esm({
|
|
15267
15684
|
"src/tools/validators.ts"() {
|
|
15268
15685
|
"use strict";
|
|
@@ -15281,6 +15698,12 @@ var init_validators = __esm({
|
|
|
15281
15698
|
OPPORTUNITY_CREATING_TYPES = /* @__PURE__ */ new Set(["internal_create_opportunity", "create_opportunity"]);
|
|
15282
15699
|
KNOWN_UPDATE_OPP_FIELDS = /* @__PURE__ */ new Set(["pipelineId", "pipelineStageId", "name", "status", "source", "monetaryValue"]);
|
|
15283
15700
|
MAX_TRACE_HOPS = 200;
|
|
15701
|
+
SOCIAL_STEP_PLATFORMS = /* @__PURE__ */ new Map([
|
|
15702
|
+
["instagram-dm", ["instagram"]],
|
|
15703
|
+
["fb_interactive_messenger", ["facebook"]],
|
|
15704
|
+
// A comment reply can be on either network; one connected account is enough.
|
|
15705
|
+
["respond_on_comment", ["facebook", "instagram"]]
|
|
15706
|
+
]);
|
|
15284
15707
|
STANDARD_CONTACT_FIELDS = /* @__PURE__ */ new Set([
|
|
15285
15708
|
"first_name",
|
|
15286
15709
|
"firstname",
|
|
@@ -23802,6 +24225,142 @@ var init_plain_outcome = __esm({
|
|
|
23802
24225
|
}
|
|
23803
24226
|
});
|
|
23804
24227
|
|
|
24228
|
+
// src/manual-tasks.ts
|
|
24229
|
+
function taskId(text) {
|
|
24230
|
+
const normalised = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
24231
|
+
let h = 0;
|
|
24232
|
+
for (let i = 0; i < normalised.length; i++) {
|
|
24233
|
+
h = h * 31 + normalised.charCodeAt(i) | 0;
|
|
24234
|
+
}
|
|
24235
|
+
return `t${(h >>> 0).toString(36)}`;
|
|
24236
|
+
}
|
|
24237
|
+
function findingsFor(outcome) {
|
|
24238
|
+
if (!outcome) return [];
|
|
24239
|
+
const list2 = outcome.plain?.issues?.length ? outcome.plain.issues : outcome.issues;
|
|
24240
|
+
return (list2 ?? []).map((s) => String(s).trim()).filter(Boolean);
|
|
24241
|
+
}
|
|
24242
|
+
function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
24243
|
+
const before = new Map(previous.map((t) => [t.id, t]));
|
|
24244
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24245
|
+
const out = [];
|
|
24246
|
+
for (const text of findings) {
|
|
24247
|
+
const id = taskId(text);
|
|
24248
|
+
if (seen.has(id)) continue;
|
|
24249
|
+
seen.add(id);
|
|
24250
|
+
const prior = before.get(id);
|
|
24251
|
+
if (prior?.claimed) {
|
|
24252
|
+
out.push({ id, text, reappeared: true });
|
|
24253
|
+
} else {
|
|
24254
|
+
out.push({ id, text, ...prior?.reappeared ? { reappeared: true } : {} });
|
|
24255
|
+
}
|
|
24256
|
+
}
|
|
24257
|
+
void now;
|
|
24258
|
+
return out;
|
|
24259
|
+
}
|
|
24260
|
+
function setTaskClaimed(tasks2, id, claimed, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
24261
|
+
return tasks2.map(
|
|
24262
|
+
(t) => t.id !== id ? t : claimed ? { ...t, claimed: true, claimedAt: now, reappeared: false } : { id: t.id, text: t.text, ...t.reappeared ? { reappeared: true } : {} }
|
|
24263
|
+
);
|
|
24264
|
+
}
|
|
24265
|
+
function manualProgress(tasks2, verifyHasRun) {
|
|
24266
|
+
const total = tasks2.length;
|
|
24267
|
+
const claimed = tasks2.filter((t) => t.claimed).length;
|
|
24268
|
+
const reappeared = tasks2.filter((t) => t.reappeared).length;
|
|
24269
|
+
const clear = verifyHasRun && total === 0;
|
|
24270
|
+
let line2;
|
|
24271
|
+
if (!verifyHasRun) {
|
|
24272
|
+
line2 = "Run the check first. It writes this list for you.";
|
|
24273
|
+
} else if (clear) {
|
|
24274
|
+
line2 = "Nothing left to do by hand. The check came back clean.";
|
|
24275
|
+
} else if (reappeared) {
|
|
24276
|
+
line2 = `${reappeared} of these ${reappeared === 1 ? "was" : "were"} ticked off, but the last check still found ${reappeared === 1 ? "it" : "them"} in the account. Worth a second look before signing off.`;
|
|
24277
|
+
} else if (claimed === total) {
|
|
24278
|
+
line2 = "All ticked. Run the check again to confirm the account agrees.";
|
|
24279
|
+
} else {
|
|
24280
|
+
line2 = `${total - claimed} of ${total} still to do. These are the jobs only you can do in the account.`;
|
|
24281
|
+
}
|
|
24282
|
+
return { total, claimed, reappeared, clear, line: line2 };
|
|
24283
|
+
}
|
|
24284
|
+
function manualSignOffRefusal(tasks2, verifyHasRun) {
|
|
24285
|
+
if (!verifyHasRun) {
|
|
24286
|
+
return "Run the check first. It is what writes the list of manual jobs, so there is nothing to sign off yet.";
|
|
24287
|
+
}
|
|
24288
|
+
const p = manualProgress(tasks2, verifyHasRun);
|
|
24289
|
+
if (p.clear) return null;
|
|
24290
|
+
const left = p.total - p.claimed;
|
|
24291
|
+
if (p.reappeared) {
|
|
24292
|
+
return `The last check still found ${p.reappeared} ${p.reappeared === 1 ? "thing" : "things"} that ${p.reappeared === 1 ? "was" : "were"} ticked off. Fix it in the account, then run the check again \u2014 signing off now would record something the account disagrees with.`;
|
|
24293
|
+
}
|
|
24294
|
+
if (left > 0) {
|
|
24295
|
+
return `${left} manual ${left === 1 ? "job is" : "jobs are"} still open. Do them in the account, tick them here, then run the check again.`;
|
|
24296
|
+
}
|
|
24297
|
+
return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
|
|
24298
|
+
}
|
|
24299
|
+
var init_manual_tasks = __esm({
|
|
24300
|
+
"src/manual-tasks.ts"() {
|
|
24301
|
+
"use strict";
|
|
24302
|
+
}
|
|
24303
|
+
});
|
|
24304
|
+
|
|
24305
|
+
// src/go-live.ts
|
|
24306
|
+
function parse3(t) {
|
|
24307
|
+
const n = t ? Date.parse(t) : NaN;
|
|
24308
|
+
return Number.isFinite(n) ? n : NaN;
|
|
24309
|
+
}
|
|
24310
|
+
function goLiveRefusal(e) {
|
|
24311
|
+
if (!e.verify) {
|
|
24312
|
+
return "This account has not been checked yet. Run the check, deal with anything it finds, then sign off.";
|
|
24313
|
+
}
|
|
24314
|
+
const manual = manualSignOffRefusal(e.manualTasks, true);
|
|
24315
|
+
if (manual) return manual;
|
|
24316
|
+
const findings = e.verify.plain?.issues?.length ? e.verify.plain.issues : e.verify.issues;
|
|
24317
|
+
if (findings && findings.length) {
|
|
24318
|
+
return `The last check found ${findings.length} ${findings.length === 1 ? "thing" : "things"} still wrong. Signing off now would tell your client an account works that we have just been told does not.`;
|
|
24319
|
+
}
|
|
24320
|
+
const v = parse3(e.verify.at);
|
|
24321
|
+
const b = parse3(e.build?.at);
|
|
24322
|
+
if (!Number.isFinite(b)) {
|
|
24323
|
+
return "There is no dated record of the last build for this account, so we cannot tell whether the clean check came before or after it. Run the build step and the check again, in that order, then sign off.";
|
|
24324
|
+
}
|
|
24325
|
+
if (!Number.isFinite(v)) {
|
|
24326
|
+
return "The last check has no usable date on it, so it cannot be shown to describe the account as it stands now. Run the check again, then sign off.";
|
|
24327
|
+
}
|
|
24328
|
+
if (b > v) {
|
|
24329
|
+
return "The account was built into again after the last check, so the clean result is out of date. Run the check once more, then sign off on what it says.";
|
|
24330
|
+
}
|
|
24331
|
+
if (!e.by || !e.by.trim()) {
|
|
24332
|
+
return "Put your name to it. A go-live signed by nobody is exactly the word-of-mouth 'done' this replaces.";
|
|
24333
|
+
}
|
|
24334
|
+
return null;
|
|
24335
|
+
}
|
|
24336
|
+
function signOff(e, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
24337
|
+
return { by: (e.by ?? "").trim(), at: now, verifiedAt: e.verify?.at ?? "" };
|
|
24338
|
+
}
|
|
24339
|
+
function liveClaim(stageIsDone, sig, lastBuildAt) {
|
|
24340
|
+
if (!stageIsDone) return { live: false };
|
|
24341
|
+
if (!sig || !sig.by || !sig.by.trim() || !Number.isFinite(parse3(sig.at))) {
|
|
24342
|
+
return {
|
|
24343
|
+
live: false,
|
|
24344
|
+
warning: "This client is ticked as live but there is no signed record of it, so it cannot be shown as live. That happens when the board was restored from a backup or edited by hand. Run the check and sign it off properly."
|
|
24345
|
+
};
|
|
24346
|
+
}
|
|
24347
|
+
const built = lastBuildAt ? Date.parse(lastBuildAt) : NaN;
|
|
24348
|
+
const verified = sig.verifiedAt ? Date.parse(sig.verifiedAt) : NaN;
|
|
24349
|
+
if (Number.isFinite(built) && Number.isFinite(verified) && built > verified) {
|
|
24350
|
+
return {
|
|
24351
|
+
live: true,
|
|
24352
|
+
note: "Signed off, but the account has been built into since then, so that sign-off no longer describes what is in there now. Run the check again if you need to stand behind it."
|
|
24353
|
+
};
|
|
24354
|
+
}
|
|
24355
|
+
return { live: true };
|
|
24356
|
+
}
|
|
24357
|
+
var init_go_live = __esm({
|
|
24358
|
+
"src/go-live.ts"() {
|
|
24359
|
+
"use strict";
|
|
24360
|
+
init_manual_tasks();
|
|
24361
|
+
}
|
|
24362
|
+
});
|
|
24363
|
+
|
|
23805
24364
|
// src/work-queue.ts
|
|
23806
24365
|
function markKey(locationId2, stageIndex) {
|
|
23807
24366
|
return `${locationId2}:${stageIndex}`;
|
|
@@ -23919,10 +24478,11 @@ function deriveQueue(input) {
|
|
|
23919
24478
|
const updatedAt = state.clients[locationId2]?.updated_at || void 0;
|
|
23920
24479
|
if (stages.every((s) => s === "done")) {
|
|
23921
24480
|
const proof2 = lastProof(state, locationId2, QUEUE_STEPS.length);
|
|
24481
|
+
const claim = liveClaim(true, state.goLive?.[locationId2]);
|
|
23922
24482
|
finished.push({
|
|
23923
24483
|
locationId: locationId2,
|
|
23924
24484
|
clientName: name,
|
|
23925
|
-
title: `${name} is live`,
|
|
24485
|
+
title: claim.live ? `${name} is live` : `${name} is ticked live with no signed record`,
|
|
23926
24486
|
...updatedAt ? { finishedAt: updatedAt } : {},
|
|
23927
24487
|
...proof2 ? { proof: proof2 } : {}
|
|
23928
24488
|
});
|
|
@@ -24047,6 +24607,7 @@ var init_work_queue = __esm({
|
|
|
24047
24607
|
"use strict";
|
|
24048
24608
|
init_stage_runner();
|
|
24049
24609
|
init_plain_outcome();
|
|
24610
|
+
init_go_live();
|
|
24050
24611
|
QUEUE_STEPS = [
|
|
24051
24612
|
{ label: "Intake", todo: "Set up the intake questions for {client}", why: "Command OS puts the questionnaire into their account." },
|
|
24052
24613
|
{ label: "Brief", todo: "Write the build brief for {client}", why: "Command OS turns their answers into the plan." },
|
|
@@ -26779,9 +27340,10 @@ async function executeRevert(plan, deps) {
|
|
|
26779
27340
|
attempted.push(item);
|
|
26780
27341
|
} catch (e) {
|
|
26781
27342
|
const detail = errText(e);
|
|
27343
|
+
const parked = isParkedContactsError(e);
|
|
26782
27344
|
refused.push({
|
|
26783
27345
|
object: item.object,
|
|
26784
|
-
reason: `GoHighLevel refused to remove ${SINGULAR[item.object.type].toLowerCase()} "${item.object.name}". This is usually because something else in the account still uses it. Remove it by hand in ${WHERE[item.object.type]}, or remove whatever still points at it and undo again.`,
|
|
27346
|
+
reason: parked ? `Left alone: contacts are part-way through workflow "${item.object.name}", and removing it would drop them out of the sequence permanently. Everything else in this undo carried on. If you do want it gone, remove that one with delete_workflow_full \u2014 it will tell you how many people are affected and ask you to confirm that number.` : `GoHighLevel refused to remove ${SINGULAR[item.object.type].toLowerCase()} "${item.object.name}". This is usually because something else in the account still uses it. Remove it by hand in ${WHERE[item.object.type]}, or remove whatever still points at it and undo again.`,
|
|
26785
27347
|
detail
|
|
26786
27348
|
});
|
|
26787
27349
|
}
|
|
@@ -26964,6 +27526,7 @@ var BUILD_ORDER, REVERT_ORDER, ORDER_INDEX, PLURAL, SINGULAR, WHERE, BOUND_REASO
|
|
|
26964
27526
|
var init_revert = __esm({
|
|
26965
27527
|
"src/intake-to-build/revert.ts"() {
|
|
26966
27528
|
"use strict";
|
|
27529
|
+
init_workflow_builder_client();
|
|
26967
27530
|
BUILD_ORDER = [
|
|
26968
27531
|
"user",
|
|
26969
27532
|
"pipeline",
|
|
@@ -29097,6 +29660,12 @@ function makeExecuteDeps(client, builderClient, locationId2, registry2) {
|
|
|
29097
29660
|
const full = await builderClient.getWorkflow(workflowId);
|
|
29098
29661
|
return pickCalendarPins(full);
|
|
29099
29662
|
},
|
|
29663
|
+
// No acknowledgement threaded, deliberately. This is only used to roll back
|
|
29664
|
+
// an UNSAVED workflow shell — one created moments earlier that never had its
|
|
29665
|
+
// actions written. A shell has no steps, so nobody can be parked in it and
|
|
29666
|
+
// the guard returns "nobody" and proceeds. If the parked read itself fails,
|
|
29667
|
+
// the delete fails and `rollbackUnsavedShells` surfaces it in the halt message
|
|
29668
|
+
// rather than swallowing it, which is the behaviour we want anyway.
|
|
29100
29669
|
deleteWorkflow: async (workflowId) => {
|
|
29101
29670
|
await builderClient.deleteWorkflow(workflowId);
|
|
29102
29671
|
},
|
|
@@ -29113,6 +29682,17 @@ function makeRevertDeps(client, builderClient, locationId2) {
|
|
|
29113
29682
|
return {
|
|
29114
29683
|
remove: {
|
|
29115
29684
|
// Reverse build order is enforced by revert.ts; this map is just routes.
|
|
29685
|
+
// No acknowledgement is threaded here, and that is deliberate rather than
|
|
29686
|
+
// an omission (Codex review, 2026-09-01 — my first fix passed
|
|
29687
|
+
// `o.acknowledgeContacts`, which is always undefined because `o` is a
|
|
29688
|
+
// ledger row and never carried one; it looked like a fix and did nothing).
|
|
29689
|
+
//
|
|
29690
|
+
// A revert can remove several workflows at once with different numbers of
|
|
29691
|
+
// contacts parked in each, so a single acknowledgement number cannot
|
|
29692
|
+
// honestly cover them. Rather than invent one, a workflow with somebody
|
|
29693
|
+
// part-way through is REFUSED and reported, the rest of the revert
|
|
29694
|
+
// continues, and the operator removes that one deliberately with
|
|
29695
|
+
// `delete_workflow_full`, which asks for the count for that workflow alone.
|
|
29116
29696
|
workflow: async (o) => {
|
|
29117
29697
|
await builderClient.deleteWorkflow(o.ghlId);
|
|
29118
29698
|
},
|
|
@@ -30608,7 +31188,7 @@ var require_package = __commonJS({
|
|
|
30608
31188
|
"package.json"(exports2, module2) {
|
|
30609
31189
|
module2.exports = {
|
|
30610
31190
|
name: "@elitedcs/ghl-mcp",
|
|
30611
|
-
version: "3.
|
|
31191
|
+
version: "3.76.0",
|
|
30612
31192
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
30613
31193
|
description: "GoHighLevel MCP Server for Claude. 248 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
30614
31194
|
main: "dist/index.js",
|
|
@@ -32504,6 +33084,13 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
32504
33084
|
run.lines.push(`Could not write this run to the cost record: ${e instanceof Error ? e.message : String(e)}. The step itself is unaffected.`);
|
|
32505
33085
|
}
|
|
32506
33086
|
for (const line2 of costLines(outcome)) run.lines.push(line2);
|
|
33087
|
+
if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
|
|
33088
|
+
try {
|
|
33089
|
+
const st = readCockpitState();
|
|
33090
|
+
writeCockpitState({ ...st, outcomes: { ...st.outcomes ?? {}, [`${locationId2}:3`]: { at: (/* @__PURE__ */ new Date()).toISOString() } } });
|
|
33091
|
+
} catch {
|
|
33092
|
+
}
|
|
33093
|
+
}
|
|
32507
33094
|
if (outcome.summary || outcome.issues?.length) {
|
|
32508
33095
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
32509
33096
|
const save = (plain) => {
|
|
@@ -32517,6 +33104,17 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
32517
33104
|
save(plain);
|
|
32518
33105
|
}
|
|
32519
33106
|
}
|
|
33107
|
+
if (stage === 4) {
|
|
33108
|
+
try {
|
|
33109
|
+
const st = readCockpitState();
|
|
33110
|
+
const fresh = findingsFor(st.outcomes?.[`${locationId2}:4`] ?? { issues: outcome.issues, plain: outcome.plain });
|
|
33111
|
+
const merged = mergeTasks(st.manualTasks?.[locationId2] ?? [], fresh);
|
|
33112
|
+
writeCockpitState({ ...st, manualTasks: { ...st.manualTasks ?? {}, [locationId2]: merged } });
|
|
33113
|
+
const back = merged.filter((t) => t.reappeared).length;
|
|
33114
|
+
if (back) run.lines.push(`${back} job${back === 1 ? "" : "s"} you had ticked off came back in this check. Worth a second look.`);
|
|
33115
|
+
} catch {
|
|
33116
|
+
}
|
|
33117
|
+
}
|
|
32520
33118
|
run.ok = outcome.ok;
|
|
32521
33119
|
const before = readCockpitState().clients[locationId2]?.stages;
|
|
32522
33120
|
onDone(outcome.ok);
|
|
@@ -33270,6 +33868,47 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
|
|
|
33270
33868
|
if(!r.ok)alert(r.error);load()};
|
|
33271
33869
|
ab.appendChild(w);ab.appendChild(btn);ab.appendChild(protectBtn(c))}
|
|
33272
33870
|
div.appendChild(ab)}
|
|
33871
|
+
// A "live" claim is re-derived from the signature, never trusted from the
|
|
33872
|
+
// stage array: the gate runs on the write path, and this file can be restored
|
|
33873
|
+
// or hand-edited (Codex round 2).
|
|
33874
|
+
// The server decides whether a client is live; this only draws the answer.
|
|
33875
|
+
{const _claim=(lastState.liveClaims||{})[c.locationId]||{live:false};
|
|
33876
|
+
const _sig=(lastState.goLive||{})[c.locationId];
|
|
33877
|
+
if(_claim.warning){const wb=document.createElement("div");
|
|
33878
|
+
wb.style.cssText="margin-top:.5rem;padding:.5rem .6rem;border-radius:8px;font-size:.75rem;background:var(--warn-soft);color:var(--warn)";
|
|
33879
|
+
wb.textContent=_claim.warning;div.appendChild(wb)}
|
|
33880
|
+
else if(_claim.live&&_sig){const lb=document.createElement("div");
|
|
33881
|
+
lb.style.cssText="margin-top:.5rem;font-size:.75rem";
|
|
33882
|
+
lb.textContent="Live. Signed off by "+_sig.by+" on "+String(_sig.at).slice(0,10)+(_sig.verifiedAt?", on a check that came back clean "+String(_sig.verifiedAt).slice(0,10):"")+".";
|
|
33883
|
+
div.appendChild(lb);
|
|
33884
|
+
if(_claim.note){const sb=document.createElement("div");sb.style.cssText="margin-top:.3rem;font-size:.74rem;color:var(--warn)";
|
|
33885
|
+
sb.textContent=_claim.note;div.appendChild(sb)}}}
|
|
33886
|
+
// Stage 5: the jobs only a person can do, written by Verify itself. Shown as
|
|
33887
|
+
// soon as Verify has run, because that is the moment the list exists.
|
|
33888
|
+
{const mt=(lastState.manualTasks||{})[c.locationId]||[];
|
|
33889
|
+
const ranVerify=Boolean((lastState.outcomes||{})[c.locationId+":4"]);
|
|
33890
|
+
if(ranVerify){
|
|
33891
|
+
const box=document.createElement("div");box.style.cssText="margin-top:.6rem;padding:.6rem .7rem;border:1px solid var(--line);border-radius:8px;background:var(--card-soft,transparent)";
|
|
33892
|
+
const h=document.createElement("div");h.style.cssText="font-size:.78rem;font-weight:700;margin-bottom:.35rem";
|
|
33893
|
+
h.textContent=mt.length?"Your jobs in the account ("+mt.filter(t=>!t.claimed).length+" to do)":"Your jobs in the account";
|
|
33894
|
+
box.appendChild(h);
|
|
33895
|
+
const note=document.createElement("div");note.className="muted";note.style.cssText="font-size:.74rem;margin-bottom:.45rem";
|
|
33896
|
+
note.textContent=mt.length===0
|
|
33897
|
+
?"Nothing left to do by hand. The last check came back clean."
|
|
33898
|
+
:(mt.some(t=>t.reappeared)
|
|
33899
|
+
?"Some of these were ticked off, but the last check still found them in the account. Worth a second look."
|
|
33900
|
+
:"Ticking a box records that you did it. The check is what proves it, so run the check again when you are done.");
|
|
33901
|
+
box.appendChild(note);
|
|
33902
|
+
mt.forEach(t=>{
|
|
33903
|
+
const row=document.createElement("label");
|
|
33904
|
+
row.style.cssText="display:flex;gap:.5rem;align-items:flex-start;font-size:.76rem;padding:.22rem 0;cursor:pointer"+(t.reappeared?";color:var(--warn)":"");
|
|
33905
|
+
const cb=document.createElement("input");cb.type="checkbox";cb.checked=Boolean(t.claimed);cb.style.marginTop=".15rem";
|
|
33906
|
+
cb.onchange=async(ev)=>{ev.stopPropagation();
|
|
33907
|
+
await postJSON("/api/manual-task",{locationId:c.locationId,id:t.id,claimed:cb.checked});load()};
|
|
33908
|
+
const txt=document.createElement("span");
|
|
33909
|
+
txt.textContent=(t.reappeared?"Came back after you ticked it: ":"")+t.text;
|
|
33910
|
+
row.appendChild(cb);row.appendChild(txt);box.appendChild(row)});
|
|
33911
|
+
div.appendChild(box)}}
|
|
33273
33912
|
const _intakeIn=(iData[c.locationId]&&iData[c.locationId].status==="received");
|
|
33274
33913
|
const _writing=reviewBusy(c.locationId,run);
|
|
33275
33914
|
if(_intakeIn||_writing||st[1]==="done"||st[2]==="done"||(rData[c.locationId]&&rData[c.locationId].ok)){
|
|
@@ -33304,7 +33943,15 @@ function whereItStands(c,state,run){
|
|
|
33304
33943
|
if(c.pending)return {pill:"Needs connecting",cls:"state warn",text:"One 2-minute step left"};
|
|
33305
33944
|
if(run&&run.running&&run.locationId===c.locationId)return {pill:"Building now",cls:"state live",text:LABELS[run.stage]||""};
|
|
33306
33945
|
const st=(state.clients[c.locationId]||{stages:[]}).stages||[];
|
|
33307
|
-
|
|
33946
|
+
// "Live" comes from the SIGNATURE, never from the stage array \u2014 the same rule
|
|
33947
|
+
// the card uses, through the same server-computed answer. This row used to say
|
|
33948
|
+
// "Live" from the checkboxes alone, so a restored or hand-edited board showed a
|
|
33949
|
+
// client live in the roster while the card said there was no signed record
|
|
33950
|
+
// (Codex, round 5 \u2014 and I had told it this renderer did not exist).
|
|
33951
|
+
if(st.length===STAGES.length&&st.every(s=>s==="done")){
|
|
33952
|
+
const _cl=(state.liveClaims||{})[c.locationId]||{};
|
|
33953
|
+
if(_cl.live)return {pill:"Live",cls:"state ok",text:_cl.note?"Signed off, but the account changed since":"All 7 steps done"};
|
|
33954
|
+
return {pill:"Needs sign-off",cls:"state warn",text:"Ticked live with no signed record"}}
|
|
33308
33955
|
if(!st.some(s=>s!=="pending"))return {pill:"Not started",cls:"chip",text:""};
|
|
33309
33956
|
let i=0;while(i<st.length&&st[i]==="done")i++;
|
|
33310
33957
|
return {pill:"Step "+Math.min(i+1,STAGES.length)+" of "+STAGES.length,cls:"chip space",text:(LABELS[Math.min(i,STAGES.length-1)]||"")+" is next"}}
|
|
@@ -33692,7 +34339,14 @@ async function runDashboard(argv) {
|
|
|
33692
34339
|
}
|
|
33693
34340
|
if (req.method === "GET" && url === "/api/state") {
|
|
33694
34341
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
33695
|
-
|
|
34342
|
+
{
|
|
34343
|
+
const st = readCockpitState();
|
|
34344
|
+
const liveClaims = {};
|
|
34345
|
+
for (const [loc, c] of Object.entries(st.clients ?? {})) {
|
|
34346
|
+
liveClaims[loc] = liveClaim(c.stages?.[6] === "done", st.goLive?.[loc], st.outcomes?.[`${loc}:3`]?.at);
|
|
34347
|
+
}
|
|
34348
|
+
res.end(JSON.stringify({ ...st, protectedAccounts: readAccountRulesSafe().protected, liveClaims }));
|
|
34349
|
+
}
|
|
33696
34350
|
return;
|
|
33697
34351
|
}
|
|
33698
34352
|
if (req.method === "GET" && url === "/api/queue") {
|
|
@@ -34004,6 +34658,29 @@ async function runDashboard(argv) {
|
|
|
34004
34658
|
});
|
|
34005
34659
|
return;
|
|
34006
34660
|
}
|
|
34661
|
+
if (req.method === "POST" && url === "/api/manual-task") {
|
|
34662
|
+
let body = "";
|
|
34663
|
+
req.on("data", (c) => {
|
|
34664
|
+
body += c;
|
|
34665
|
+
if (body.length > 5e3) req.destroy();
|
|
34666
|
+
});
|
|
34667
|
+
req.on("end", () => {
|
|
34668
|
+
try {
|
|
34669
|
+
const { locationId: locationId2, id, claimed } = JSON.parse(body);
|
|
34670
|
+
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
34671
|
+
const st = readCockpitState();
|
|
34672
|
+
const tasks2 = st.manualTasks?.[locationId2] ?? [];
|
|
34673
|
+
if (!tasks2.some((t) => t.id === id)) throw new Error("That job is not on this client's list any more \u2014 run the check again.");
|
|
34674
|
+
writeCockpitState({ ...st, manualTasks: { ...st.manualTasks ?? {}, [locationId2]: setTaskClaimed(tasks2, id, Boolean(claimed)) } });
|
|
34675
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
34676
|
+
res.end(JSON.stringify({ ok: true }));
|
|
34677
|
+
} catch (e) {
|
|
34678
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
34679
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
|
|
34680
|
+
}
|
|
34681
|
+
});
|
|
34682
|
+
return;
|
|
34683
|
+
}
|
|
34007
34684
|
if (req.method === "GET" && url === "/api/agency-profile") {
|
|
34008
34685
|
const prof = readAgencyProfile();
|
|
34009
34686
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -34213,8 +34890,27 @@ async function runDashboard(argv) {
|
|
|
34213
34890
|
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
34214
34891
|
const refusal = handMarkRefusal(stage, status);
|
|
34215
34892
|
if (refusal) throw new Error(refusal);
|
|
34893
|
+
if (stage === 6 && status === "done") {
|
|
34894
|
+
const st6 = readCockpitState();
|
|
34895
|
+
const refusal6 = goLiveRefusal({
|
|
34896
|
+
verify: st6.outcomes?.[`${locationId2}:4`],
|
|
34897
|
+
build: st6.outcomes?.[`${locationId2}:3`],
|
|
34898
|
+
manualTasks: st6.manualTasks?.[locationId2] ?? [],
|
|
34899
|
+
by
|
|
34900
|
+
});
|
|
34901
|
+
if (refusal6) throw new Error(refusal6);
|
|
34902
|
+
}
|
|
34903
|
+
if (stage === 5 && status === "done") {
|
|
34904
|
+
const st5 = readCockpitState();
|
|
34905
|
+
const gate3 = manualSignOffRefusal(
|
|
34906
|
+
st5.manualTasks?.[locationId2] ?? [],
|
|
34907
|
+
Boolean(st5.outcomes?.[`${locationId2}:4`])
|
|
34908
|
+
);
|
|
34909
|
+
if (gate3) throw new Error(gate3);
|
|
34910
|
+
}
|
|
34216
34911
|
const marked = recordHandMark(setStage(readCockpitState(), locationId2, stage, status), locationId2, stage, by, status);
|
|
34217
|
-
|
|
34912
|
+
const withSig = stage === 6 && status === "done" ? { ...marked, goLive: { ...marked.goLive ?? {}, [locationId2]: signOff({ verify: marked.outcomes?.[`${locationId2}:4`], manualTasks: marked.manualTasks?.[locationId2] ?? [], by }) } } : stage === 6 ? { ...marked, goLive: Object.fromEntries(Object.entries(marked.goLive ?? {}).filter(([k]) => k !== locationId2)) } : marked;
|
|
34913
|
+
writeCockpitState(withSig);
|
|
34218
34914
|
pushShared(locationId2);
|
|
34219
34915
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
34220
34916
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -34278,6 +34974,8 @@ var init_dashboard = __esm({
|
|
|
34278
34974
|
init_stage_runner();
|
|
34279
34975
|
init_account_rules();
|
|
34280
34976
|
init_run_cost();
|
|
34977
|
+
init_manual_tasks();
|
|
34978
|
+
init_go_live();
|
|
34281
34979
|
init_provisioning();
|
|
34282
34980
|
init_customization();
|
|
34283
34981
|
init_question_set();
|
|
@@ -34824,7 +35522,7 @@ function confirmSaved(registry2) {
|
|
|
34824
35522
|
}
|
|
34825
35523
|
return true;
|
|
34826
35524
|
}
|
|
34827
|
-
function
|
|
35525
|
+
function parse4(argv, options, required) {
|
|
34828
35526
|
let parsed;
|
|
34829
35527
|
try {
|
|
34830
35528
|
parsed = (0, import_node_util2.parseArgs)({ args: argv, options, strict: true, allowPositionals: false });
|
|
@@ -34840,7 +35538,7 @@ function parse3(argv, options, required) {
|
|
|
34840
35538
|
return parsed;
|
|
34841
35539
|
}
|
|
34842
35540
|
async function cmdRegisterLocation(argv, registry2) {
|
|
34843
|
-
const p =
|
|
35541
|
+
const p = parse4(
|
|
34844
35542
|
argv,
|
|
34845
35543
|
{
|
|
34846
35544
|
"location-id": { type: "string" },
|
|
@@ -34890,7 +35588,7 @@ async function cmdRegisterLocation(argv, registry2) {
|
|
|
34890
35588
|
return EXIT_OK2;
|
|
34891
35589
|
}
|
|
34892
35590
|
async function cmdRegisterCompanyFirebase(argv, registry2) {
|
|
34893
|
-
const p =
|
|
35591
|
+
const p = parse4(
|
|
34894
35592
|
argv,
|
|
34895
35593
|
{
|
|
34896
35594
|
"company-id": { type: "string" },
|
|
@@ -34957,7 +35655,7 @@ async function cmdRegisterCompanyFirebase(argv, registry2) {
|
|
|
34957
35655
|
return EXIT_OK2;
|
|
34958
35656
|
}
|
|
34959
35657
|
async function cmdRegisterAgencyKey(argv, registry2) {
|
|
34960
|
-
const p =
|
|
35658
|
+
const p = parse4(
|
|
34961
35659
|
argv,
|
|
34962
35660
|
{ "api-key": { type: "string" }, "no-validate": { type: "boolean" } },
|
|
34963
35661
|
["api-key"]
|