@elitedcs/ghl-mcp 3.92.2 → 3.93.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 +18 -0
- package/README.md +1 -1
- package/dist/index.js +1719 -1292
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11562,7 +11562,7 @@ function registerAiEmployeeDemoTools(server2, shared, deps = {}) {
|
|
|
11562
11562
|
if (!tagsBack.includes(DEMO_PROSPECT_TAG)) throw new Error(`The prospect's contact (${contactId}) read back without the ${DEMO_PROSPECT_TAG} tag, so the next demo on this phone would refuse it as a real person. The bot was NOT touched. Tag it and run again.`);
|
|
11563
11563
|
persist({ contactId });
|
|
11564
11564
|
const host = new URL(website).hostname;
|
|
11565
|
-
const kb = unwrapEnvelope(await client.post("/knowledge-bases/", { ...AI, noRetry: true, body: { name: `Demo
|
|
11565
|
+
const kb = unwrapEnvelope(await client.post("/knowledge-bases/", { ...AI, noRetry: true, body: { name: `Demo: ${businessName} (${run.runId})`, locationId: loc, description: `AI employee demo for ${host}. Created by prepare_ai_employee_demo; end_ai_employee_demo deletes it.` } }));
|
|
11566
11566
|
const kbId = isRecord7(kb) && typeof kb.id === "string" ? kb.id : "";
|
|
11567
11567
|
if (!kbId) throw new Error("GoHighLevel did not return the knowledge base id; the bot was NOT touched.");
|
|
11568
11568
|
persist({ kbId });
|
|
@@ -39601,7 +39601,7 @@ function expandTrigger(trigger, idx, idMap) {
|
|
|
39601
39601
|
});
|
|
39602
39602
|
}
|
|
39603
39603
|
const out = native({
|
|
39604
|
-
name: `Appointment Status
|
|
39604
|
+
name: `Appointment Status: ${status}`,
|
|
39605
39605
|
type: "appointment",
|
|
39606
39606
|
conditions
|
|
39607
39607
|
}, pending);
|
|
@@ -39809,7 +39809,7 @@ function scanSection(section2, planObjects, existing) {
|
|
|
39809
39809
|
return hit ? { ref: o.ref, type: section2, name: o.name, status: "existing", existingId: hit.id } : { ref: o.ref, type: section2, name: o.name, status: "would_create" };
|
|
39810
39810
|
});
|
|
39811
39811
|
}
|
|
39812
|
-
function computeWorkflowGates(plan, metHandoffs, liveCustomValues, liveUnreadable = false) {
|
|
39812
|
+
function computeWorkflowGates(plan, metHandoffs, liveCustomValues, liveUnreadable = false, bookingLinkFilled = false) {
|
|
39813
39813
|
const gates = /* @__PURE__ */ new Map();
|
|
39814
39814
|
const addGate = (wfRef, handoffRef) => {
|
|
39815
39815
|
const cur = gates.get(wfRef) ?? [];
|
|
@@ -39854,6 +39854,7 @@ function computeWorkflowGates(plan, metHandoffs, liveCustomValues, liveUnreadabl
|
|
|
39854
39854
|
continue;
|
|
39855
39855
|
}
|
|
39856
39856
|
const value = live ? live.value : cv.value;
|
|
39857
|
+
if (!live && key2 === "booking_link" && bookingLinkFilled) continue;
|
|
39857
39858
|
if (!(value ?? "").trim()) addGate(w.ref, emptyCustomValueGate(cv.name));
|
|
39858
39859
|
}
|
|
39859
39860
|
}
|
|
@@ -39870,6 +39871,26 @@ function heldWorkflowReason(name2, gates) {
|
|
|
39870
39871
|
function emptyCustomValueGate(name2) {
|
|
39871
39872
|
return `custom value ${name2} is empty (fill it in Settings > Custom Values; its messages would go out with a blank)`;
|
|
39872
39873
|
}
|
|
39874
|
+
function bookingWidgetUrl(calendarId) {
|
|
39875
|
+
return `https://api.leadconnectorhq.com/widget/booking/${encodeURIComponent(calendarId)}`;
|
|
39876
|
+
}
|
|
39877
|
+
function bookingLinkCalendarRef(plan) {
|
|
39878
|
+
const cv = (plan.customValues ?? []).find((v) => customValueKey(v.name) === "booking_link");
|
|
39879
|
+
if (!cv || (cv.value ?? "").trim()) return void 0;
|
|
39880
|
+
const cals = plan.calendars ?? [];
|
|
39881
|
+
if (cals.length === 1) return cals[0].ref;
|
|
39882
|
+
const says = (plan.handoffs ?? []).filter((h) => cv.ref && (h.produces === cv.ref || (h.blocks ?? []).includes(cv.ref))).map((h) => (h.instruction ?? "").toLowerCase());
|
|
39883
|
+
const named = (name2) => {
|
|
39884
|
+
const words2 = name2.toLowerCase().split(/\s+/).filter(Boolean);
|
|
39885
|
+
for (let i = 0; i <= Math.max(0, words2.length - 2); i++) {
|
|
39886
|
+
const tail = words2.slice(i).join(" ");
|
|
39887
|
+
if (says.some((t) => t.includes(tail))) return true;
|
|
39888
|
+
}
|
|
39889
|
+
return false;
|
|
39890
|
+
};
|
|
39891
|
+
const hits = cals.filter((c) => named(c.name));
|
|
39892
|
+
return hits.length === 1 ? hits[0].ref : void 0;
|
|
39893
|
+
}
|
|
39873
39894
|
function customValueKey(name2) {
|
|
39874
39895
|
return name2.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
39875
39896
|
}
|
|
@@ -39990,7 +40011,8 @@ function resolvePlan(plan, existing, opts = {}) {
|
|
|
39990
40011
|
}
|
|
39991
40012
|
}
|
|
39992
40013
|
idx.calendarPendingRefs = new Set(calendarsManual.map((c) => c.ref));
|
|
39993
|
-
const
|
|
40014
|
+
const bookingCal = bookingLinkCalendarRef(plan);
|
|
40015
|
+
const gates = computeWorkflowGates(plan, metHandoffs, existing.customValues, existing.customValuesUnreadable === true, Boolean(bookingCal && !idx.calendarPendingRefs?.has(bookingCal)));
|
|
39994
40016
|
const workflows = (plan.workflows ?? []).map((w) => expandWorkflow(w, idx, idMap, gates.get(w.ref) ?? []));
|
|
39995
40017
|
const handoffs = (plan.handoffs ?? []).map((h) => ({
|
|
39996
40018
|
ref: h.ref,
|
|
@@ -45756,7 +45778,7 @@ var require_package = __commonJS({
|
|
|
45756
45778
|
"package.json"(exports2, module2) {
|
|
45757
45779
|
module2.exports = {
|
|
45758
45780
|
name: "@elitedcs/ghl-mcp",
|
|
45759
|
-
version: "3.
|
|
45781
|
+
version: "3.93.0",
|
|
45760
45782
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
45761
45783
|
description: "GoHighLevel MCP Server for Claude. 250 tools for CRM, automation and marketing, an account-wide workflow audit, live funnel-capture verification and a workflow builder, across client accounts.",
|
|
45762
45784
|
main: "dist/index.js",
|
|
@@ -45978,6 +46000,11 @@ function cockpitSidebarHtml(active, capability) {
|
|
|
45978
46000
|
<div class="cds-side-foot" title="What you type stays here. What Claude reads for you goes to your own Claude account; a link or a call recording is fetched from its own site or recorder first. No copy comes to us.">Runs on this computer. Version <span class="version">${esc(pkg.version)}</span></div>
|
|
45979
46001
|
</aside>`;
|
|
45980
46002
|
}
|
|
46003
|
+
function homeCrumb(active, opts) {
|
|
46004
|
+
const home = sidebarPlan === "pro" ? "Your clients" : "Board";
|
|
46005
|
+
if (active === "board" && !opts.capability) return `<span aria-current="page">${home}</span>`;
|
|
46006
|
+
return `<a href="/" title="${esc(productName())} home">${home}</a><span aria-hidden="true">/</span>${crumbLabel(active, opts.capability, opts.trail)}`;
|
|
46007
|
+
}
|
|
45981
46008
|
function crumbLabel(active, capability, trail) {
|
|
45982
46009
|
if (trail?.length) return trail.map((t, i) => i === trail.length - 1 ? `<span aria-current="page">${esc(t)}</span>` : `<span>${esc(t)}</span><span aria-hidden="true">/</span>`).join("");
|
|
45983
46010
|
const cap2 = capability ? CAPABILITIES.find((c) => c.key === capability) : void 0;
|
|
@@ -45993,7 +46020,7 @@ function cockpitHeaderHtml(active, opts = {}) {
|
|
|
45993
46020
|
${cockpitSidebarHtml(active, opts.capability)}
|
|
45994
46021
|
<div class="cds-app-main">
|
|
45995
46022
|
<header class="cds-top">
|
|
45996
|
-
<nav class="cds-crumbs" aria-label="You are here"
|
|
46023
|
+
<nav class="cds-crumbs" aria-label="You are here">${homeCrumb(active, opts)}</nav>
|
|
45997
46024
|
<div class="cds-top-actions">
|
|
45998
46025
|
<div class="cds-size" role="group" aria-label="Text size"><button id="sz-down" type="button" aria-label="Smaller text">A−</button><output id="sz-val" aria-live="polite">${TYPE_DEFAULT}px</output><button id="sz-up" type="button" aria-label="Larger text">A+</button></div>
|
|
45999
46026
|
<a class="cds-btn ghost" href="${HUB_URL}#do">Members Hub</a>
|
|
@@ -46020,7 +46047,7 @@ function cockpitHeadHtml(title2, opts = {}) {
|
|
|
46020
46047
|
<script data-access-boot>${COCKPIT_ACCESS_JS}</script>
|
|
46021
46048
|
<style>${cockpitTokensCss(opts)}${cockpitChromeCss()}${opts.extraCss ?? ""}</style></head>`;
|
|
46022
46049
|
}
|
|
46023
|
-
var esc, HUB_URL, COCKPIT_DATE_JS, COCKPIT_MONEY_JS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, THEME_STORAGE_KEY, COCKPIT_THEME_HEAD_JS, DS_UI_PX, NAV, sidebarPlan, SIDEBAR, link, recoveryBanner, COCKPIT_TYPE_JS, RECOVERY_BEFORE, RECOVERY_AFTER, COCKPIT_ACCESS_JS;
|
|
46050
|
+
var esc, HUB_URL, COCKPIT_DATE_JS, COCKPIT_MONEY_JS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, THEME_STORAGE_KEY, COCKPIT_THEME_HEAD_JS, DS_UI_PX, NAV, sidebarPlan, SIDEBAR, link, recoveryBanner, COCKPIT_NEW_TAB_JS, COCKPIT_TYPE_JS, RECOVERY_BEFORE, RECOVERY_AFTER, COCKPIT_ACCESS_JS;
|
|
46024
46051
|
var init_cockpit_shell = __esm({
|
|
46025
46052
|
"src/command-os/cockpit-shell.ts"() {
|
|
46026
46053
|
"use strict";
|
|
@@ -46104,6 +46131,20 @@ function cockpitMoney(amount,currency){
|
|
|
46104
46131
|
];
|
|
46105
46132
|
link = (label2, href, current, title2, sub = false, badgeId) => `<a class="cds-side-link${sub ? " sub" : ""}" href="${href}"${current ? ' aria-current="page"' : ""}${title2 ? ` title="${esc(title2)}"` : ""}><span>${label2}</span>${badgeId ? `<span class="cds-side-badge" id="${badgeId}" hidden></span>` : ""}</a>`;
|
|
46106
46133
|
recoveryBanner = () => "";
|
|
46134
|
+
COCKPIT_NEW_TAB_JS = `(function(){if(window.__cmdNewTab)return;window.__cmdNewTab=true;
|
|
46135
|
+
var SAY=" (opens in a new tab)";
|
|
46136
|
+
function mark(a){if(a.getAttribute("data-nt")||a.hidden)return;a.setAttribute("data-nt","1");if((a.textContent||"").indexOf("opens in a new tab")<0){var s=document.createElement("span");s.className="nt";s.textContent=SAY;a.appendChild(s)}}
|
|
46137
|
+
function markAll(root){var list=(root||document).querySelectorAll?((root||document).querySelectorAll('a[target="_blank"]')):[];for(var i=0;i<list.length;i++)mark(list[i])}
|
|
46138
|
+
document.addEventListener("click",function(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;
|
|
46139
|
+
var a=e.target&&e.target.closest?e.target.closest('a[target="_blank"]'):null;if(!a||!a.href)return;
|
|
46140
|
+
/* 8a LOW-5: only web addresses are opened by hand; anything else (javascript:, data:) is left to the browser. */
|
|
46141
|
+
if(a.protocol!=="http:"&&a.protocol!=="https:")return;
|
|
46142
|
+
/* "noopener" in the features makes window.open answer null even when it worked, so the opener is cut by hand. */
|
|
46143
|
+
e.preventDefault();var w=null;try{w=window.open(a.href,"_blank")}catch(x){w=null}
|
|
46144
|
+
if(w){try{w.opener=null;w.focus()}catch(x){}}else{var same=document.createElement("a");same.href=a.href;same.click()}},false);
|
|
46145
|
+
markAll(document);
|
|
46146
|
+
try{new MutationObserver(function(ms){for(var i=0;i<ms.length;i++){var n=ms[i].addedNodes;for(var j=0;j<n.length;j++){var x=n[j];if(x.nodeType!==1)continue;if(x.matches&&x.matches('a[target="_blank"]'))mark(x);markAll(x)}}}).observe(document.documentElement,{childList:true,subtree:true})}catch(e){}
|
|
46147
|
+
})();`;
|
|
46107
46148
|
COCKPIT_TYPE_JS = `/* TextSize: ${TYPE_MIN}-${TYPE_MAX}px of body text, remembered; the screen scales as a whole. */
|
|
46108
46149
|
(function(){var MIN=${TYPE_MIN},MAX=${TYPE_MAX},STEP=2,DEF=${TYPE_DEFAULT},UI=${DS_UI_PX},KEY=${scriptJson(TYPE_STORAGE_KEY)};
|
|
46109
46150
|
var down=document.getElementById("sz-down"),up=document.getElementById("sz-up"),val=document.getElementById("sz-val");
|
|
@@ -46138,7 +46179,8 @@ paintQueueBadge();
|
|
|
46138
46179
|
var box=side.getBoundingClientRect(),link=c.getBoundingClientRect();
|
|
46139
46180
|
var height=box.bottom-box.top;
|
|
46140
46181
|
if(height>0)side.scrollTop+=(link.top+link.bottom-box.top-box.bottom)/2*side.clientHeight/height;
|
|
46141
|
-
})()
|
|
46182
|
+
})();
|
|
46183
|
+
${COCKPIT_NEW_TAB_JS}`;
|
|
46142
46184
|
RECOVERY_BEFORE = "The cockpit's saved progress could not be read, so it started empty. Nothing was deleted: the old file was kept as ";
|
|
46143
46185
|
RECOVERY_AFTER = ". Before approving anything again, email support@ghlcommand.com and we'll help you restore it.";
|
|
46144
46186
|
COCKPIT_ACCESS_JS = `(function(){if(window.__cmdosAccess)return;window.__cmdosAccess=true;
|
|
@@ -47213,6 +47255,299 @@ function ageLine(v,now){if(!v)return "";if(v.cold||!v.loadedAt){const f=v.lastRe
|
|
|
47213
47255
|
}
|
|
47214
47256
|
});
|
|
47215
47257
|
|
|
47258
|
+
// src/command-os/shopper/analyze.ts
|
|
47259
|
+
function excerpt(text3, start, end, radius = 60) {
|
|
47260
|
+
const flat = (s) => s.replace(/\s+/g, " ");
|
|
47261
|
+
let a = Math.max(0, start - radius);
|
|
47262
|
+
let b = Math.min(text3.length, end + radius);
|
|
47263
|
+
const back = text3.slice(a, start);
|
|
47264
|
+
const cut = Math.max(back.lastIndexOf("\n"), back.lastIndexOf(". "));
|
|
47265
|
+
if (cut >= 0) a += cut + 1;
|
|
47266
|
+
const fwd = text3.slice(end, b);
|
|
47267
|
+
const stop3 = fwd.search(/\n|[.!?](\s|$)/);
|
|
47268
|
+
if (stop3 >= 0) b = end + stop3 + 1;
|
|
47269
|
+
return (a > 0 ? "\u2026" : "") + flat(text3.slice(a, b)).trim() + (b < text3.length ? "\u2026" : "");
|
|
47270
|
+
}
|
|
47271
|
+
function blankGapIsIntended(text3, index, matched) {
|
|
47272
|
+
const after = text3.slice(index + matched.length, index + matched.length + 2);
|
|
47273
|
+
if (/^[.:;]/.test(after)) return true;
|
|
47274
|
+
if (/[:;][)(DPp]/.test(matched + after.slice(0, 1))) return true;
|
|
47275
|
+
return false;
|
|
47276
|
+
}
|
|
47277
|
+
function usableForeignNames(own, others) {
|
|
47278
|
+
const ownL = own.trim().toLowerCase();
|
|
47279
|
+
const out = /* @__PURE__ */ new Set();
|
|
47280
|
+
for (const raw of others) {
|
|
47281
|
+
const name2 = raw.trim();
|
|
47282
|
+
const l = name2.toLowerCase();
|
|
47283
|
+
if (name2.length < 6 || !l || l === ownL) continue;
|
|
47284
|
+
if (ownL.includes(l) || l.includes(ownL)) continue;
|
|
47285
|
+
const words2 = l.split(/[^a-z0-9]+/).filter(Boolean);
|
|
47286
|
+
if (words2.every((w) => GENERIC.has(w))) continue;
|
|
47287
|
+
out.add(name2);
|
|
47288
|
+
}
|
|
47289
|
+
return [...out];
|
|
47290
|
+
}
|
|
47291
|
+
function messageText3(m) {
|
|
47292
|
+
return m.subject ? `${m.subject}
|
|
47293
|
+
${m.body}` : m.body;
|
|
47294
|
+
}
|
|
47295
|
+
function checkMessage(m, ctx, foreign) {
|
|
47296
|
+
const problems = [];
|
|
47297
|
+
const text3 = messageText3(m);
|
|
47298
|
+
const minute2 = minuteOf2(m, ctx.startedAt);
|
|
47299
|
+
const base = { messageId: m.id, channel: m.channel, minute: minute2 };
|
|
47300
|
+
const gaps = [];
|
|
47301
|
+
for (const g of text3.matchAll(BLANK_GAP)) {
|
|
47302
|
+
const index = g.index ?? 0;
|
|
47303
|
+
if (blankGapIsIntended(text3, index, g[0])) continue;
|
|
47304
|
+
if (/[!?;:]/.test(g[3]) && !GREETING.test(g[1])) continue;
|
|
47305
|
+
gaps.push({ index, match: g[0], word: g[1] });
|
|
47306
|
+
}
|
|
47307
|
+
if (gaps.length) {
|
|
47308
|
+
const first2 = gaps[0];
|
|
47309
|
+
const sms = m.channel === "sms";
|
|
47310
|
+
problems.push({
|
|
47311
|
+
...base,
|
|
47312
|
+
kind: "blank_field",
|
|
47313
|
+
title: gaps.length > 1 ? `${gaps.length} places where a name or detail came out blank` : "A name or detail came out blank",
|
|
47314
|
+
quote: excerpt(text3, first2.index, first2.index + first2.match.length),
|
|
47315
|
+
highlight: first2.match,
|
|
47316
|
+
fix: sms ? `Texts can't fill in a missing name, so reword it so it reads right without one, for example "Hi! Thanks for\u2026".` : 'Set a fallback on that merge field in the email editor (for example "there", so it reads "Hi there,"), or reword it.'
|
|
47317
|
+
});
|
|
47318
|
+
}
|
|
47319
|
+
const code = [...text3.matchAll(CODE_SHOWING)];
|
|
47320
|
+
if (code.length) {
|
|
47321
|
+
const c = code[0];
|
|
47322
|
+
const index = c.index ?? 0;
|
|
47323
|
+
problems.push({
|
|
47324
|
+
...base,
|
|
47325
|
+
kind: "code_showing",
|
|
47326
|
+
title: code.length > 1 ? `${code.length} pieces of template code went out as text` : "A piece of template code went out as text",
|
|
47327
|
+
quote: excerpt(text3, index, index + c[0].length),
|
|
47328
|
+
highlight: c[0],
|
|
47329
|
+
fix: "GoHighLevel printed this as-is because the field doesn't exist or is misspelled for this lead. Pick the field again from the editor's merge-field list, or remove it."
|
|
47330
|
+
});
|
|
47331
|
+
}
|
|
47332
|
+
for (const name2 of foreign) {
|
|
47333
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe4(name2)}(?![\\p{L}\\p{N}])`, "iu");
|
|
47334
|
+
const hit = re.exec(text3);
|
|
47335
|
+
if (!hit) continue;
|
|
47336
|
+
problems.push({
|
|
47337
|
+
...base,
|
|
47338
|
+
kind: "wrong_business",
|
|
47339
|
+
title: "The message names a different business",
|
|
47340
|
+
quote: excerpt(text3, hit.index, hit.index + hit[0].length),
|
|
47341
|
+
highlight: hit[0],
|
|
47342
|
+
fix: `Left over from the account it was copied from. Replace it with ${ctx.businessName ? `"${ctx.businessName}"` : "this client's name"}, or better, the business-name merge field so it can't drift again.`
|
|
47343
|
+
});
|
|
47344
|
+
break;
|
|
47345
|
+
}
|
|
47346
|
+
for (const link2 of ctx.links ?? []) {
|
|
47347
|
+
if (link2.state !== "broken") continue;
|
|
47348
|
+
const at = text3.indexOf(link2.url);
|
|
47349
|
+
if (at < 0) continue;
|
|
47350
|
+
problems.push({
|
|
47351
|
+
...base,
|
|
47352
|
+
kind: "broken_link",
|
|
47353
|
+
title: "A link doesn't open",
|
|
47354
|
+
quote: excerpt(text3, at, at + link2.url.length, 40),
|
|
47355
|
+
highlight: link2.url,
|
|
47356
|
+
fix: `${link2.detail} Fix or replace the link.`
|
|
47357
|
+
});
|
|
47358
|
+
}
|
|
47359
|
+
return problems;
|
|
47360
|
+
}
|
|
47361
|
+
function analyzeMessages(messages, ctx) {
|
|
47362
|
+
const sorted = [...messages].sort((a, b) => a.at - b.at);
|
|
47363
|
+
const foreign = usableForeignNames(ctx.businessName, ctx.otherBusinessNames);
|
|
47364
|
+
const problems = [];
|
|
47365
|
+
const seen = /* @__PURE__ */ new Map();
|
|
47366
|
+
for (const m of sorted) {
|
|
47367
|
+
problems.push(...checkMessage(m, ctx, foreign));
|
|
47368
|
+
const key2 = `${m.channel}|${normal(m.subject ?? "")}|${normal(m.body)}`;
|
|
47369
|
+
if (normal(m.body) && seen.has(key2)) {
|
|
47370
|
+
problems.push({
|
|
47371
|
+
kind: "duplicate",
|
|
47372
|
+
messageId: m.id,
|
|
47373
|
+
channel: m.channel,
|
|
47374
|
+
minute: minuteOf2(m, ctx.startedAt),
|
|
47375
|
+
title: "The same message went out twice",
|
|
47376
|
+
quote: excerpt(m.body, 0, Math.min(m.body.length, 80), 0),
|
|
47377
|
+
highlight: "",
|
|
47378
|
+
fix: "Two workflows (or one that re-enrolled the lead) send this. Keep one, or add a condition so it only goes once."
|
|
47379
|
+
});
|
|
47380
|
+
} else seen.set(key2, m.id);
|
|
47381
|
+
}
|
|
47382
|
+
const missing = [];
|
|
47383
|
+
const notYetDue = [];
|
|
47384
|
+
for (const e of ctx.expect ?? []) {
|
|
47385
|
+
const want = normal(e.match);
|
|
47386
|
+
const got = sorted.some((m) => (!e.channel || e.channel === m.channel) && want && normal(messageText3(m)).includes(want));
|
|
47387
|
+
if (got) continue;
|
|
47388
|
+
const row = { label: e.label, channel: e.channel, byMinute: e.byMinute, workflowId: e.workflowId, stepId: e.stepId, afterWaits: e.afterWaits };
|
|
47389
|
+
(e.byMinute <= ctx.windowMinutes ? missing : notYetDue).push(row);
|
|
47390
|
+
}
|
|
47391
|
+
const sentBy2 = /* @__PURE__ */ new Map();
|
|
47392
|
+
const sentByStep = /* @__PURE__ */ new Map();
|
|
47393
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
47394
|
+
const incomplete = /* @__PURE__ */ new Set();
|
|
47395
|
+
const list4 = ctx.messageSteps;
|
|
47396
|
+
const whole = !!list4 && list4.complete === true && Array.isArray(list4.steps);
|
|
47397
|
+
for (const m of sorted) {
|
|
47398
|
+
const text3 = normal(messageText3(m));
|
|
47399
|
+
const hit = (ctx.expect ?? []).find((e) => (!e.channel || e.channel === m.channel) && normal(e.match) && text3.includes(normal(e.match)));
|
|
47400
|
+
if (hit) sentBy2.set(m.id, hit.label);
|
|
47401
|
+
if (!list4) continue;
|
|
47402
|
+
if (!whole) {
|
|
47403
|
+
incomplete.add(m.id);
|
|
47404
|
+
continue;
|
|
47405
|
+
}
|
|
47406
|
+
const cands = list4.steps.filter((s) => s.channel === m.channel && normal(s.match) && text3.includes(normal(s.match)));
|
|
47407
|
+
const ids = new Set(cands.map((s) => `${s.workflowId ?? ""}|${s.stepId ?? ""}`));
|
|
47408
|
+
if (ids.size === 1 && cands[0].workflowId && cands[0].stepId) sentByStep.set(m.id, { label: hit?.label ?? "", match: cands[0].match, byMinute: 0, workflowId: cands[0].workflowId, stepId: cands[0].stepId });
|
|
47409
|
+
else if (ids.size > 1) ambiguous.add(m.id);
|
|
47410
|
+
}
|
|
47411
|
+
for (const p of problems) {
|
|
47412
|
+
if (sentBy2.has(p.messageId)) p.sentBy = sentBy2.get(p.messageId);
|
|
47413
|
+
const e = sentByStep.get(p.messageId);
|
|
47414
|
+
if (e?.workflowId && e.stepId) {
|
|
47415
|
+
p.sentByWorkflowId = e.workflowId;
|
|
47416
|
+
p.sentByStepId = e.stepId;
|
|
47417
|
+
}
|
|
47418
|
+
if (ambiguous.has(p.messageId)) p.sentByAmbiguous = true;
|
|
47419
|
+
if (incomplete.has(p.messageId)) p.sentByIncomplete = true;
|
|
47420
|
+
}
|
|
47421
|
+
problems.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.minute - b.minute);
|
|
47422
|
+
const links = ctx.links ?? [];
|
|
47423
|
+
const timeline = sorted.map((m) => ({
|
|
47424
|
+
messageId: m.id,
|
|
47425
|
+
channel: m.channel,
|
|
47426
|
+
minute: minuteOf2(m, ctx.startedAt),
|
|
47427
|
+
subject: m.subject,
|
|
47428
|
+
preview: excerpt(m.body, 0, 0, 90).replace(/^…/, ""),
|
|
47429
|
+
source: m.source,
|
|
47430
|
+
sentBy: sentBy2.get(m.id),
|
|
47431
|
+
problems: problems.filter((p) => p.messageId === m.id).map((p) => p.kind)
|
|
47432
|
+
}));
|
|
47433
|
+
return {
|
|
47434
|
+
sent: sorted.length,
|
|
47435
|
+
problems,
|
|
47436
|
+
missing,
|
|
47437
|
+
notYetDue,
|
|
47438
|
+
links: {
|
|
47439
|
+
ok: links.filter((l) => l.state === "ok").length,
|
|
47440
|
+
broken: links.filter((l) => l.state === "broken").length,
|
|
47441
|
+
skipped: links.filter((l) => l.state === "skipped").length,
|
|
47442
|
+
total: links.length
|
|
47443
|
+
},
|
|
47444
|
+
timeline
|
|
47445
|
+
};
|
|
47446
|
+
}
|
|
47447
|
+
var KIND_ORDER, minuteOf2, BLANK_GAP, GREETING, CODE_SHOWING, escapeRe4, GENERIC, normal;
|
|
47448
|
+
var init_analyze = __esm({
|
|
47449
|
+
"src/command-os/shopper/analyze.ts"() {
|
|
47450
|
+
"use strict";
|
|
47451
|
+
KIND_ORDER = { blank_field: 0, code_showing: 1, wrong_business: 2, broken_link: 3, duplicate: 4 };
|
|
47452
|
+
minuteOf2 = (m, startedAt) => Math.max(0, Math.round((m.at - startedAt) / 6e3) / 10);
|
|
47453
|
+
BLANK_GAP = /([\p{L}\p{N}'’]+)([ \t ]+)([,.!?;:])(?=[\s"'”)\]]|$)/gu;
|
|
47454
|
+
GREETING = /^(?:hi|hey|hello|dear|thanks|welcome|congrats|congratulations|morning|afternoon|evening)$/i;
|
|
47455
|
+
CODE_SHOWING = /\{\{[^{}\n]{0,80}\}\}|\{\{|\}\}|\{\s*(?:contact|user|location|appointment|custom_values)\.[a-z_]+\s*\}|%[A-Z][A-Z_]{2,}%|\[\[[^\]\n]{1,40}\]\]/g;
|
|
47456
|
+
escapeRe4 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47457
|
+
GENERIC = /* @__PURE__ */ new Set(["clinic", "demo", "test", "testing", "template", "sandbox", "account", "agency", "company", "llc", "inc"]);
|
|
47458
|
+
normal = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
47459
|
+
}
|
|
47460
|
+
});
|
|
47461
|
+
|
|
47462
|
+
// src/command-os/shopper/links.ts
|
|
47463
|
+
function extractUrls(text3, html2) {
|
|
47464
|
+
const out = /* @__PURE__ */ new Set();
|
|
47465
|
+
const add2 = (u) => {
|
|
47466
|
+
const clean5 = u.replace(/&/g, "&").replace(/[.,;:!?]+$/, "");
|
|
47467
|
+
if (/^https?:\/\//i.test(clean5)) out.add(clean5);
|
|
47468
|
+
};
|
|
47469
|
+
if (html2) for (const m of html2.matchAll(HREF_RE)) add2(m[1]);
|
|
47470
|
+
for (const m of text3.matchAll(URL_RE)) add2(m[0]);
|
|
47471
|
+
return [...out];
|
|
47472
|
+
}
|
|
47473
|
+
function siteHost(website) {
|
|
47474
|
+
if (!website?.trim()) return void 0;
|
|
47475
|
+
try {
|
|
47476
|
+
const u = new URL(/^https?:\/\//i.test(website) ? website : `https://${website.trim()}`);
|
|
47477
|
+
return u.hostname.toLowerCase().replace(/^www\./, "") || void 0;
|
|
47478
|
+
} catch {
|
|
47479
|
+
return void 0;
|
|
47480
|
+
}
|
|
47481
|
+
}
|
|
47482
|
+
function skipReason(url, ownHosts) {
|
|
47483
|
+
if (UNSUB.test(url)) return "Not opened: an unsubscribe or preference link would opt the test lead out.";
|
|
47484
|
+
let host;
|
|
47485
|
+
try {
|
|
47486
|
+
host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
47487
|
+
} catch {
|
|
47488
|
+
return "Not opened: not a readable address.";
|
|
47489
|
+
}
|
|
47490
|
+
const own = ownHosts.some((h) => host === h || host === `www.${h}`);
|
|
47491
|
+
if (!own) return "Not opened: it is not on the client's own website, so it may be a tracked link that counts as the lead clicking.";
|
|
47492
|
+
if (TRACKING_PATH.test(url)) return "Not opened: it looks like a tracked or trigger link, and opening it could fire the client's automations.";
|
|
47493
|
+
return null;
|
|
47494
|
+
}
|
|
47495
|
+
async function checkLinks(urls, ownHosts, fetcher, timeoutMs = 1e4) {
|
|
47496
|
+
const results = [];
|
|
47497
|
+
for (const url of urls) {
|
|
47498
|
+
const skip = skipReason(url, ownHosts);
|
|
47499
|
+
if (skip) {
|
|
47500
|
+
results.push({ url, state: "skipped", detail: skip });
|
|
47501
|
+
continue;
|
|
47502
|
+
}
|
|
47503
|
+
const ctrl = new AbortController();
|
|
47504
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
47505
|
+
try {
|
|
47506
|
+
let cur = url;
|
|
47507
|
+
let r = await fetcher(cur, { method: "GET", redirect: "manual", signal: ctrl.signal, headers: { "User-Agent": "Mozilla/5.0 (Command OS secret shopper)" } });
|
|
47508
|
+
let stopped = null;
|
|
47509
|
+
for (let hop = 0; r.status >= 300 && r.status < 400 && r.location; hop++) {
|
|
47510
|
+
let next;
|
|
47511
|
+
try {
|
|
47512
|
+
next = new URL(r.location, cur).toString();
|
|
47513
|
+
} catch {
|
|
47514
|
+
stopped = "It redirects to an address that is not readable, so it was not followed.";
|
|
47515
|
+
break;
|
|
47516
|
+
}
|
|
47517
|
+
const why = hop >= MAX_LINK_HOPS ? "it redirects more than 5 times" : skipReason(next, ownHosts)?.replace(/^Not opened: /, "") ?? null;
|
|
47518
|
+
if (why) {
|
|
47519
|
+
stopped = `It redirects to ${next}, which was not opened: ${why}`;
|
|
47520
|
+
break;
|
|
47521
|
+
}
|
|
47522
|
+
cur = next;
|
|
47523
|
+
r = await fetcher(cur, { method: "GET", redirect: "manual", signal: ctrl.signal, headers: { "User-Agent": "Mozilla/5.0 (Command OS secret shopper)" } });
|
|
47524
|
+
}
|
|
47525
|
+
results.push(stopped ? { url, state: "skipped", detail: stopped } : r.status < 400 ? { url, state: "ok", detail: `Opened (HTTP ${r.status}).` } : { url, state: "broken", detail: `The page answered HTTP ${r.status}.` });
|
|
47526
|
+
} catch (e) {
|
|
47527
|
+
const why = ctrl.signal.aborted ? `It did not answer within ${Math.round(timeoutMs / 1e3)} seconds.` : `It could not be reached (${e instanceof Error ? e.message : String(e)}).`;
|
|
47528
|
+
results.push({ url, state: "broken", detail: why });
|
|
47529
|
+
} finally {
|
|
47530
|
+
clearTimeout(timer);
|
|
47531
|
+
}
|
|
47532
|
+
}
|
|
47533
|
+
return results;
|
|
47534
|
+
}
|
|
47535
|
+
function htmlToText(body) {
|
|
47536
|
+
if (!/<(?:p|div|br|table|td|span|a|html|body|h\d|li|style|strong|em|b|i)(?:\s+[a-z][a-z0-9:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>=]+))?)*\s*\/?>/i.test(body)) return body;
|
|
47537
|
+
return body.replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(?:p|div|tr|li|h\d|table)>/gi, "\n").replace(/<\/(?:td|th)>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /gi, "\xA0").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d))).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
47538
|
+
}
|
|
47539
|
+
var URL_RE, HREF_RE, TRACKING_PATH, UNSUB, MAX_LINK_HOPS;
|
|
47540
|
+
var init_links2 = __esm({
|
|
47541
|
+
"src/command-os/shopper/links.ts"() {
|
|
47542
|
+
"use strict";
|
|
47543
|
+
URL_RE = /https?:\/\/[^\s<>"'\])]+/g;
|
|
47544
|
+
HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
47545
|
+
TRACKING_PATH = /\/(?:c|o|l|ls|tl|t|r|e|wf|track|click|open)\/|\/ls\/click|[?&](?:upn|trigger_link|utm_id|mc_cid|_hs(?:enc|mi))=/i;
|
|
47546
|
+
UNSUB = /unsubscribe|opt-?out|\/preferences|email-preferences|unsubscribe-view|\/emails\/builder\//i;
|
|
47547
|
+
MAX_LINK_HOPS = 5;
|
|
47548
|
+
}
|
|
47549
|
+
});
|
|
47550
|
+
|
|
47216
47551
|
// src/command-os/shopper/mask.ts
|
|
47217
47552
|
function maskSecrets(text3) {
|
|
47218
47553
|
let out = text3;
|
|
@@ -47600,1087 +47935,6 @@ var init_score = __esm({
|
|
|
47600
47935
|
}
|
|
47601
47936
|
});
|
|
47602
47937
|
|
|
47603
|
-
// src/command-os/shopper/page.ts
|
|
47604
|
-
function capped(rows2, wrap) {
|
|
47605
|
-
if (rows2.length <= 8) return wrap(rows2.join(""));
|
|
47606
|
-
return `${wrap(rows2.slice(0, 8).join(""))}<details><summary class="cds-link">Show all ${rows2.length}</summary>${wrap(rows2.slice(8).join(""))}</details>`;
|
|
47607
|
-
}
|
|
47608
|
-
function ringHtml(score2, caption, complete = true) {
|
|
47609
|
-
const s = Math.max(0, Math.min(100, Math.round(score2)));
|
|
47610
|
-
const r = 52, c = 2 * Math.PI * r, g = gradeOf(s), k = complete ? `cds-g-${g.toLowerCase()}` : "cds-g-incomplete";
|
|
47611
|
-
return `<div class="cds-ring" role="img" aria-label="Grade ${g}, ${s} out of 100${complete ? "" : ""}. ${esc2(caption)}"><svg width="160" height="160" viewBox="0 0 120 120" aria-hidden="true"><circle class="trk" cx="60" cy="60" r="${r}" stroke-width="10"></circle><circle class="arc ${k}" cx="60" cy="60" r="${r}" stroke-width="10" stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${(c * (1 - s / 100)).toFixed(2)}" transform="rotate(-90 60 60)"></circle><text class="letter ${k}" x="60" y="66" text-anchor="middle" font-size="44">${g}</text><text class="out" x="60" y="86" text-anchor="middle">${s}/100</text></svg><div class="cap">${esc2(caption)}</div></div>`;
|
|
47612
|
-
}
|
|
47613
|
-
function stepRow(r) {
|
|
47614
|
-
const [tone, word] = r.gaveUp ? ["bad", "Failed, gave up"] : OUTCOME[r.outcome] ?? ["", ""];
|
|
47615
|
-
return `<li><span class="t"><time data-ms="${r.at}">${esc2(new Date(r.at).toISOString().slice(11, 16))}</time></span>${dot(tone)}<span class="what">${esc2(r.stepName)}<small>${esc2(r.detail)}</small></span>${word ? chip(tone, word) : ""}</li>`;
|
|
47616
|
-
}
|
|
47617
|
-
function stepsHtml(a) {
|
|
47618
|
-
if (!a) return "";
|
|
47619
|
-
if (!a.read) return panel("Every step", "What each workflow step did", "", callout("warn", "", esc2(a.unreadable ?? "The step log could not be read.")));
|
|
47620
|
-
const rows2 = a.problems.map((p) => `<li><span class="ttl">${esc2(p.stepName)}<span class="sub">${esc2(p.detail)}</span><span class="sub"><b>The fix:</b> ${esc2(p.fix)}</span></span><span class="end">${/^The Checkup found: /.test(p.detail ?? "") && p.kind !== "failed_holding" && p.kind !== "failed_gave_up" ? chip("bad", "Failed") : chip(p.kind === "skipped" ? "warn" : "bad", PROBLEM_CHIP[p.kind])}${chip("", p.workflowName)}</span></li>`);
|
|
47621
|
-
const diagnosed = a.problems.filter((p) => p.kind !== "failed_holding" && p.kind !== "failed_gave_up" && /^The Checkup found: /.test(p.detail ?? "")).length;
|
|
47622
|
-
const byWf = /* @__PURE__ */ new Map();
|
|
47623
|
-
for (const r of a.rows) byWf.set(r.workflowName, [...byWf.get(r.workflowName) ?? [], r]);
|
|
47624
|
-
const notes = [
|
|
47625
|
-
...a.unreadWorkflows?.length ? [`Could not read the steps of: ${a.unreadWorkflows.join(", ")}. Their steps are not judged here.`] : [],
|
|
47626
|
-
...a.partialWorkflows?.length ? [`Very busy workflow, only partly read: ${a.partialWorkflows.join(", ")}. Some of the test lead's steps may be missing from this list.`] : [],
|
|
47627
|
-
...a.rows.length === 0 ? ["No steps were logged for the test lead. That is not a pass: the lead may not have entered, or GoHighLevel had not logged it yet."] : [],
|
|
47628
|
-
...a.counts.unclear ? [`${a.counts.unclear} step${a.counts.unclear === 1 ? " has" : "s have"} a status this version cannot read; check ${a.counts.unclear === 1 ? "it" : "them"} in GoHighLevel's Execution logs.`] : []
|
|
47629
|
-
];
|
|
47630
|
-
const noteHtml = notes.length ? callout("warn", "", notes.map(esc2).join("<br>")) : "";
|
|
47631
|
-
const lists = [...byWf.entries()].map(([name2, steps]) => `<details${a.problems.length ? " open" : ""}><summary class="cds-link">${esc2(name2)} (${steps.length} step${steps.length === 1 ? "" : "s"})</summary>${capped(steps.map(stepRow), tl)}</details>`).join("");
|
|
47632
|
-
return `${a.problems.length ? panel("Fix these too", "Steps that did not do their job", "From GoHighLevel's own execution log", capped(rows2, list2)) : ""}
|
|
47633
|
-
${panel("Every step", "What each workflow step did", `${a.counts.ran} ran \xB7 ${Math.max(0, a.counts.didNothing - diagnosed)} skipped or did nothing \xB7 ${a.counts.failed + diagnosed} failed`, noteHtml + lists)}`;
|
|
47634
|
-
}
|
|
47635
|
-
function marked(quote2, highlight) {
|
|
47636
|
-
if (!highlight) return esc2(quote2);
|
|
47637
|
-
const i = quote2.indexOf(highlight);
|
|
47638
|
-
if (i < 0) return esc2(quote2);
|
|
47639
|
-
return `${esc2(quote2.slice(0, i))}<mark>${esc2(highlight)}</mark>${esc2(quote2.slice(i + highlight.length))}`;
|
|
47640
|
-
}
|
|
47641
|
-
function problemRow(p) {
|
|
47642
|
-
return `<li><span class="ttl">${esc2(p.title)}<span class="sub">What the lead got: "${marked(p.quote, p.highlight)}"</span><span class="sub">Sent by: ${p.sentBy ? esc2(p.sentBy) : "Not matched to a planned step"}</span><span class="sub"><b>The fix:</b> ${esc2(p.fix)}</span></span><span class="end">${chip("bad", CHIP[p.kind])}${chip("info", CHANNEL[p.channel] ?? "Message")}${chip("", mins(p.minute))}</span></li>`;
|
|
47643
|
-
}
|
|
47644
|
-
function timelineRow(t) {
|
|
47645
|
-
const [tone, word] = t.problems.length === 0 ? ["ok", "OK"] : t.problems.length === 1 ? ["bad", CHIP[t.problems[0]]] : ["bad", `${t.problems.length} problems`];
|
|
47646
|
-
const what = t.channel === "email" ? `Email: "${esc2(t.subject ?? t.preview)}"` : `${CHANNEL[t.channel] ?? "Message"}: "${esc2(t.preview)}"`;
|
|
47647
|
-
return `<li><span class="t">${mins(t.minute)}</span>${dot(tone)}<span class="what">${what}<small>${esc2(t.sentBy ?? (t.source === "workflow" ? "Sent by a workflow" : t.source ? `Sent by ${t.source}` : ""))}</small></span>${chip(tone, word)}</li>`;
|
|
47648
|
-
}
|
|
47649
|
-
function aboutHtml(run) {
|
|
47650
|
-
const rows2 = [
|
|
47651
|
-
["Started", when(run.startedAt)],
|
|
47652
|
-
["Test lead", `${esc2(run.leadEmail)} (your address, not a customer's)`],
|
|
47653
|
-
...run.leadHasFirstName ? [] : [["First name", "No first name, on purpose"]],
|
|
47654
|
-
["Watching for", `${run.watchMinutes} minute${run.watchMinutes === 1 ? "" : "s"}${run.watchReason ? `. ${esc2(run.watchReason)}` : ""}`]
|
|
47655
|
-
];
|
|
47656
|
-
return panel("The test lead", "How this check ran", "", `<div class="cds-table-wrap"><table class="cds-table"><tbody>${rows2.map(([k, v]) => `<tr><th scope="row">${k}</th><td>${v}</td></tr>`).join("")}</tbody></table></div>`);
|
|
47657
|
-
}
|
|
47658
|
-
function resultHtml(run, now, clientName, action) {
|
|
47659
|
-
if (run.status === "failed") {
|
|
47660
|
-
return {
|
|
47661
|
-
head: pageHead("The Workflow Audit Pro could not run", esc2(run.error ?? "Unknown error"), action),
|
|
47662
|
-
rest: callout("warn", "", esc2(run.cleanup?.detail ?? "No test lead was sent."))
|
|
47663
|
-
};
|
|
47664
|
-
}
|
|
47665
|
-
const f = run.findings;
|
|
47666
|
-
const watching = run.status === "watching";
|
|
47667
|
-
const card = scoreRun(run);
|
|
47668
|
-
if (run.status === "done" && card.graded && f) {
|
|
47669
|
-
const href = reportHref(run);
|
|
47670
|
-
const charged2 = card.deductions.filter((d) => d.points > 0).length;
|
|
47671
|
-
const leftovers = `${run.fastForward && !run.fastForward.removed ? callout("warn", "Not cleaned up:", ` temporary copies are still in the account: ${esc2(run.fastForward.copies.map((c) => `"${c.copyName}"`).join(", "))}. Delete them in GoHighLevel.`) : ""}${run.cleanup && !run.cleanup.removed ? callout("warn", "Test lead:", ` ${esc2(run.cleanup.detail)}`) : ""}`;
|
|
47672
|
-
const summary = `<div class="sum"><div class="cds-stack"><p>Finished ${when(run.finishedAt ?? run.startedAt)}. ${esc2(card.coverage.split(". Not checked")[0])}.</p>${leftovers}<div class="cds-row cds-no-print"><a class="cds-btn ghost" href="${href}&print=1" target="_blank" rel="noopener">Save as PDF</a></div></div>
|
|
47673
|
-
${ringHtml(card.score, card.coverageLabel ?? card.coverage, true)}</div>`;
|
|
47674
|
-
return {
|
|
47675
|
-
head: pageHead(
|
|
47676
|
-
charged2 ? `${charged2} ${charged2 === 1 ? "thing" : "things"} would let a new lead down` : "No problems in what was checked",
|
|
47677
|
-
"The report card is ready: the grade, what to fix, how the score was reached, and every step. Open it to read, save as a PDF or hand to your client.",
|
|
47678
|
-
action
|
|
47679
|
-
),
|
|
47680
|
-
rest: panel("Report card", esc2(clientName) || "This check", "", summary)
|
|
47681
|
-
};
|
|
47682
|
-
}
|
|
47683
|
-
const minutesLeft = Math.max(0, Math.ceil((run.closesAt - now) / 6e4));
|
|
47684
|
-
const planned = run.planNote.source !== "none" && run.expect.length > 0;
|
|
47685
|
-
const held = f?.notYetDue.filter((m) => m.heldByWait) ?? [];
|
|
47686
|
-
const title2 = !f ? "Watching what the test lead receives" : f.sent === 0 ? !watching && nothingArrivedHeading(run.steps) || `Nothing arrived in ${watching ? "the time so far" : `${run.watchMinutes} minutes`}` : f.problems.length || f.missing.length ? "What a new lead actually received" : held.length ? "Not finished: GoHighLevel was still holding the test lead" : planned ? "A new lead got exactly what you planned" : "No problems in what the lead received";
|
|
47687
|
-
const head4 = pageHead(title2, `${productName()} made a test lead, then read every text and email this account sent it, word for word. ${esc2(run.entryNote)}`, action);
|
|
47688
|
-
const worst = f?.problems[0];
|
|
47689
|
-
const quote2 = (tone, text3, cite) => `<div class="cds-callout quote${tone ? ` ${tone}` : ""}">${text3}<cite>${cite}</cite></div>`;
|
|
47690
|
-
const quoteHtml = f && f.sent === 0 ? quote2("warn", "The test lead received no text and no email.", esc2(nothingArrivedWhy(run.steps, run.draftWorkflows))) : worst ? quote2("bad", `${worst.kind === "blank_field" ? "A new lead with no first name on file got" : "A new lead got"} "${marked(worst.quote, "")}"`, `${esc2(worst.title)}${f && f.problems.length > 1 ? `. The first of ${f.problems.length}.` : "."}`) : f && held.length && !f.missing.length ? quote2("warn", `${held.length} planned message${held.length === 1 ? " was" : "s were"} still waiting behind a wait GoHighLevel had not let go of.`, "Not judged. Run the check again.") : f && f.missing.length ? quote2("warn", `${f.missing.length} planned message${f.missing.length === 1 ? " never" : "s never"} arrived.`, "Everything that did arrive read correctly.") : f ? quote2("", `Every message read correctly${planned ? " and arrived when planned" : ""}.`, `Checked for the first ${run.watchMinutes} minutes. Later messages are listed below as not yet checked.`) : "";
|
|
47691
|
-
const stats = f ? `<div class="stats">
|
|
47692
|
-
${statTile(f.sent, "Messages sent")}
|
|
47693
|
-
${statTile(f.problems.length, "A customer would notice", f.problems.length ? "bad" : "")}
|
|
47694
|
-
${run.steps?.read ? statTile(run.steps.rows.length ? run.steps.problems.length : "\u2013", "Steps failed, skipped or did nothing", run.steps.problems.length ? "bad" : run.steps.rows.length && !run.steps.unreadWorkflows?.length && !run.steps.partialWorkflows?.length && !run.steps.counts.unclear ? "" : "warn") : ""}
|
|
47695
|
-
${statTile(run.planNote.source === "none" ? "\u2013" : f.missing.length, "Planned, never sent", f.missing.length || held.length ? "warn" : "")}
|
|
47696
|
-
${(() => {
|
|
47697
|
-
const opened = f.links.total - f.links.skipped;
|
|
47698
|
-
return opened ? statTile(`${f.links.ok} of ${opened}`, `Links open${f.links.skipped ? ` \xB7 ${f.links.skipped} not opened` : ""}`, f.links.broken ? "bad" : "") : statTile(0, `Links opened${f.links.skipped ? ` \xB7 ${f.links.skipped} not` : ""}`);
|
|
47699
|
-
})()}
|
|
47700
|
-
</div>` : "";
|
|
47701
|
-
const notGraded = !card.graded && run.status === "done" ? callout("warn", "Not graded.", ` ${esc2(card.reason)}`) : "";
|
|
47702
|
-
const watchNote = watching ? callout("info", "", `<span><b>Still watching.</b> ${minutesLeft ? `About ${minutesLeft} minute${minutesLeft === 1 ? "" : "s"} left; this page updates itself and finishes on its own.` : "Finishing now."}${f ? " What you see below is a partial look." : ""}</span><button class="cds-btn ghost" type="button" id="look-now">Look now</button>`, ` role="status" id="watching"`) : "";
|
|
47703
|
-
const draftNote = run.draftWorkflows?.length ? callout("bad", "Still a draft, so it did nothing:", ` ${esc2(run.draftWorkflows.join(", "))}. GoHighLevel accepts a lead added to a draft workflow and then runs none of its steps, with no error anywhere. Publish it, then run the Workflow Audit Pro again.`) : "";
|
|
47704
|
-
const top = notGraded + watchNote + stats + quoteHtml + draftNote;
|
|
47705
|
-
if (!f) return { head: head4, rest: top + aboutHtml(run) };
|
|
47706
|
-
const fixes = f.problems.length ? panel("Fix these", "What a customer would have seen wrong", `${f.problems.length} problem${f.problems.length === 1 ? "" : "s"} \xB7 most serious first`, capped(f.problems.map(problemRow), list2)) : "";
|
|
47707
|
-
const rows2 = [
|
|
47708
|
-
...f.timeline.map(timelineRow),
|
|
47709
|
-
...f.missing.map((m) => `<li><span class="t">by ${mins(m.byMinute)}</span>${dot("warn")}<span class="what">Planned but never sent: ${esc2(m.label)}<small>Its workflow may be a draft, its step may have failed, or a condition skipped it.</small></span>${chip("warn", "Missing")}</li>`)
|
|
47710
|
-
];
|
|
47711
|
-
const timeline = panel(
|
|
47712
|
-
"Timeline",
|
|
47713
|
-
"Everything the lead received, in order",
|
|
47714
|
-
"Minutes after the lead went in",
|
|
47715
|
-
rows2.length ? capped(rows2, tl) : `<div class="cds-empty"><h3>Nothing has arrived yet.</h3><p>Every text and email the account sends the test lead is listed here, in order.</p></div>`
|
|
47716
|
-
);
|
|
47717
|
-
const { coverage, cleanup } = coverageParts(run, f);
|
|
47718
|
-
return { head: head4, rest: top + fixes + stepsHtml(run.steps) + timeline + aboutHtml(run) + coverage + cleanup };
|
|
47719
|
-
}
|
|
47720
|
-
function coverageParts(run, f) {
|
|
47721
|
-
const plan = run.planNote.source === "workflows" ? `Planned messages were read from the workflows: ${run.expect.length}.${run.planNote.conditional ? ` ${run.planNote.conditional} left out because they depend on what the lead does (an if/else or a wait for a reply).` : ""}${run.planNote.noWording ? ` ${run.planNote.noWording} left out because their wording is all merge fields.` : ""}${run.planNote.unread?.length ? ` ${run.planNote.unread.length} workflow${run.planNote.unread.length === 1 ? "" : "s"} could not be read.` : ""}${run.planNote.noPhone ? ` ${run.planNote.noPhone} text-message step${run.planNote.noPhone === 1 ? " was" : "s were"} not checked: no test phone was given.` : ""}` : run.planNote.source === "given" ? `Planned messages were given for this run: ${run.expect.length}.` : "No plan was known for this run, so nothing is reported as missing.";
|
|
47722
|
-
const notYet = [
|
|
47723
|
-
...f.notYetDue.map((m) => m.heldByWait ? `${esc2(m.label)}: GoHighLevel was still holding the test lead at ${esc2(run.heldAt?.map((h) => `"${h.stepName}"`).join(", ") ?? "a wait")} after it should have ended${run.extendedMinutes ? `, even after the check waited ${run.extendedMinutes} extra minutes` : ""}. Not judged; run the check again.` : `${esc2(m.label)}, due after the window closed.`),
|
|
47724
|
-
`Messages after the ${run.watchMinutes}-minute window. Choose a longer watch on the next run.`,
|
|
47725
|
-
"Booking an actual time, or replying as the lead.",
|
|
47726
|
-
"Phone calls and voice agents.",
|
|
47727
|
-
"Whether the wording persuades. This checks that it's correct, not that it sells.",
|
|
47728
|
-
...f.links.skipped ? [`${f.links.skipped} link${f.links.skipped === 1 ? "" : "s"} deliberately not opened: only links on the client's own website are opened, because a tracked link counts as the lead clicking and an unsubscribe link opts them out.`] : [],
|
|
47729
|
-
...run.testPhone ? [] : ["Text messages: no test phone was given, so the lead could not receive any."],
|
|
47730
|
-
...run.fastForward ? [
|
|
47731
|
-
`Real timing: every wait was cut to 1 minute. In real life: ${run.fastForward.copies.flatMap((c) => c.waits).join("; ") || "no waits"}.`,
|
|
47732
|
-
...run.fastForward.copies.filter((c) => c.window).map((c) => `${c.sourceName} ${c.window} in real life; the copy ran at any hour.`),
|
|
47733
|
-
"That staff receive alerts, tasks and assigned leads: during the check they came to you instead.",
|
|
47734
|
-
...run.fastForward.copies.length ? ["Where a webhook's receiving service forwards the lead next: only the first address is checked."] : [],
|
|
47735
|
-
'Wording that depends on time ("see you tomorrow") reads oddly after a 1-minute wait; it is not flagged.'
|
|
47736
|
-
] : [],
|
|
47737
|
-
...run.websiteHost ? [] : ["Links: this account has no website on file, so no link was opened. Add the website in the account's business profile to have them checked."]
|
|
47738
|
-
];
|
|
47739
|
-
const checks = [
|
|
47740
|
-
...run.steps?.read ? ["Every workflow step: ran, did nothing, failed or waiting, from GoHighLevel's own log"] : [],
|
|
47741
|
-
"Blank names and empty fields in every message",
|
|
47742
|
-
"Template code left showing, like {{ \u2026 }}",
|
|
47743
|
-
"Another of your accounts' business names",
|
|
47744
|
-
...f.links.total - f.links.skipped ? ["Links on the client's own website, opened to see they load"] : [],
|
|
47745
|
-
"Messages the plan promised that never came",
|
|
47746
|
-
"The same message sent twice"
|
|
47747
|
-
];
|
|
47748
|
-
const coverage = panel(
|
|
47749
|
-
"Coverage",
|
|
47750
|
-
"What this check looked at",
|
|
47751
|
-
"So a clean result means something",
|
|
47752
|
-
`${list2(checks.map((c) => `<li><span>${esc2(c)}</span></li>`).join(""))}
|
|
47753
|
-
<p>${esc2(plan)}</p>
|
|
47754
|
-
<details><summary class="cds-link">Not checked (${notYet.length})</summary>${list2(notYet.map((n) => `<li><span>${n}</span></li>`).join(""))}</details>`
|
|
47755
|
-
);
|
|
47756
|
-
const ffLine = run.fastForward ? run.fastForward.removed ? " The temporary fast-forward copies were removed." : run.fastForward.problems?.length ? ` ${run.fastForward.problems.join(" ")}` : ` Temporary copies still in the account: ${run.fastForward.copies.map((c) => `"${c.copyName}"`).join(", ")}. The cockpit retries every 30 minutes while it runs; if they are still there tomorrow, delete them in GoHighLevel.` : "";
|
|
47757
|
-
const leftover = !!run.cleanup && (!run.cleanup.removed || !!run.fastForward && !run.fastForward.removed);
|
|
47758
|
-
const cleanup = run.cleanup ? callout(leftover ? "warn" : "", run.cleanup.removed ? "Cleaned up:" : "Test lead:", ` ${esc2(run.cleanup.detail + ffLine)} Anything that looked like a password or key in a message was hidden before it reached this page.`) : "";
|
|
47759
|
-
return { coverage, cleanup };
|
|
47760
|
-
}
|
|
47761
|
-
function startForm(d, inlineSubmit) {
|
|
47762
|
-
if (!d.client) return "";
|
|
47763
|
-
const form = `<form id="start-form" class="startform">
|
|
47764
|
-
<fieldset class="cds-field"><legend class="lbl">Workflows the test lead enters</legend><div class="wf" id="wf-list"><p class="hint">Loading this account's workflows\u2026</p></div></fieldset>
|
|
47765
|
-
<div class="cds-field"><label for="owner-email">Your email</label><div class="hint" id="owner-email-hint">The test lead uses it, so every message comes to you.</div><input class="cds-input" id="owner-email" name="ownerEmail" type="email" required value="${esc2(d.ownerEmail ?? "")}" autocomplete="email" aria-describedby="owner-email-hint"></div>
|
|
47766
|
-
<div class="cds-field"><label for="test-phone">Test phone, optional</label><div class="hint" id="test-phone-hint">A phone you own; texts will really send to it.</div><input class="cds-input" id="test-phone" name="testPhone" type="tel" autocomplete="tel" placeholder="+1 555 010 0100" aria-describedby="test-phone-hint"></div>
|
|
47767
|
-
<div class="cds-field"><label for="watch">Watch for</label><select class="cds-select" id="watch" name="watchMinutes"><option value="auto" selected>Automatic: until the last planned message, plus 2 minutes</option><option value="10">10 minutes</option><option value="30">30 minutes</option><option value="60">1 hour</option><option value="240">4 hours</option><option value="1440">24 hours</option></select></div>
|
|
47768
|
-
|
|
47769
|
-
<label class="cds-check"><input type="checkbox" id="ff" name="fastForward"><span>Fast-forward: test long workflows in minutes.<small>A temporary copy with every step kept and every wait cut to 1 minute; staff alerts, tasks and lead assignments go to one person you choose, marked TEST; the copy is deleted afterwards.</small></span></label>
|
|
47770
|
-
<div class="cds-field" id="alert-to-wrap" hidden><label for="alert-to">Staff alerts, tasks and assignments go to</label><div class="hint" id="alert-to-hint">Instead of your staff, marked TEST.</div><select class="cds-select" id="alert-to" name="alertUserId" aria-describedby="alert-to-hint"></select></div>
|
|
47771
|
-
<fieldset class="cds-field" id="hooks" hidden><legend class="lbl">Outside services these workflows send the lead to</legend><div class="wf" id="hooks-list"></div></fieldset>
|
|
47772
|
-
${callout("warn", "", "The test lead has no first name, on purpose. The workflows you tick will really send to your address and may alert your staff once.")}
|
|
47773
|
-
${inlineSubmit ? `<div class="cds-row"><button class="cds-btn ghost" type="submit" id="start-btn" disabled>Start the check</button></div>` : ""}
|
|
47774
|
-
<p class="cds-callout result" id="start-result" role="status" aria-live="polite"></p>
|
|
47775
|
-
</form>`;
|
|
47776
|
-
return panel("New check", `${d.run?.status === "done" ? "Run another check" : "Run the Workflow Audit Pro"} on ${esc2(d.client.name)}`, "Sends one test lead, then lists the contacts that carry its address", form, ` id="start"`, "cds-no-print");
|
|
47777
|
-
}
|
|
47778
|
-
function shopperPageHtml(d, seatToken, seatHeader) {
|
|
47779
|
-
const picker = `<form class="picker cds-no-print" method="get" action="/shopper"><div class="cds-field"><label for="client">Client account</label><select class="cds-select" id="client" name="client" data-picker="" onchange="this.form.submit()"><option value="">Choose a client\u2026</option>${d.accounts.map((a) => `<option value="${esc2(a.locationId)}"${d.client?.locationId === a.locationId ? ' selected=""' : ""}>${esc2(a.name)}</option>`).join("")}</select></div><noscript><button class="cds-btn ghost" type="submit">Open</button></noscript></form>`;
|
|
47780
|
-
const run = d.run;
|
|
47781
|
-
const graded2 = !!(run && run.status === "done" && run.findings && scoreRun(run).graded);
|
|
47782
|
-
const canStart = !!d.client && run?.status !== "watching";
|
|
47783
|
-
const action = graded2 && run ? `<a class="cds-btn" href="${reportHref(run)}" target="_blank" rel="noopener">Open the report</a>` : canStart ? `<button class="cds-btn" type="submit" form="start-form" id="start-btn" disabled>Start the check</button>` : "";
|
|
47784
|
-
let body;
|
|
47785
|
-
if (!d.client) {
|
|
47786
|
-
body = pageHead("See what a new lead actually receives", `Pick a client account. ${productName()} makes a test lead with your own email address, puts it through the workflows you choose, then reads every text and email the account sent it, word for word, and tells you what a real customer would have seen wrong.`) + picker;
|
|
47787
|
-
} else if (!run) {
|
|
47788
|
-
body = pageHead(`No check has run on ${esc2(d.client.name)} yet`, "Choose the workflows a new lead goes through, usually speed-to-lead and the welcome sequence, then start.", action) + picker + startForm(d, false);
|
|
47789
|
-
} else {
|
|
47790
|
-
const r = resultHtml(run, d.now, d.client.name, action);
|
|
47791
|
-
body = r.head + picker + r.rest + (canStart ? startForm(d, graded2) : "");
|
|
47792
|
-
}
|
|
47793
|
-
return `${cockpitHeadHtml(`Workflow Audit Pro \xB7 ${productName()}`, { extraCss: CSS })}${cockpitHeaderHtml("shopper")}
|
|
47794
|
-
<main class="shopper">${body}</main>${cockpitShellClose()}
|
|
47795
|
-
<script>
|
|
47796
|
-
${COCKPIT_TYPE_JS}
|
|
47797
|
-
(function(){
|
|
47798
|
-
var seat=${scriptJson(seatToken)}, seatHeader=${scriptJson(seatHeader)}, client=${scriptJson(d.client?.locationId ?? "")}, run=${scriptJson(d.run ? { runId: d.run.runId, status: d.run.status, due: d.now >= d.run.closesAt } : null)};
|
|
47799
|
-
document.querySelectorAll("time[data-ms]").forEach(function(t){var dt=new Date(Number(t.getAttribute("data-ms")));t.textContent=dt.toLocaleString([], {weekday:"short",hour:"numeric",minute:"2-digit"});});
|
|
47800
|
-
function esc(t){return String(t).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c]})}
|
|
47801
|
-
function hdrs(extra){var h=extra||{};h[seatHeader]=seat;return h}
|
|
47802
|
-
var list=document.getElementById("wf-list");
|
|
47803
|
-
// The list exactly as drawn: Start sends it back, and the server refuses when the account's list has changed since
|
|
47804
|
-
// (safety release 2026-10-14). drawWorkflows keeps the ticks of workflows still on the list.
|
|
47805
|
-
var shownWorkflows=null;
|
|
47806
|
-
function drawWorkflows(ws,keep){shownWorkflows=ws.map(function(w){return {id:w.id,name:w.name,status:w.status}});
|
|
47807
|
-
if(!ws.length){list.innerHTML='<p class="err">This account has no workflows to test.</p>';return}
|
|
47808
|
-
list.innerHTML=ws.map(function(w){return '<label class="cds-check"><input type="checkbox" name="wf" value="'+esc(w.id)+'"'+(keep&&keep[w.id]?' checked':'')+'><span>'+esc(w.name)+(w.status&&w.status!=="published"?' <span class="cds-chip warn">'+esc(w.status)+'</span>':'')+'</span></label>'}).join("");
|
|
47809
|
-
var b=document.getElementById("start-btn");if(b)b.disabled=false}
|
|
47810
|
-
// The cockpit keeps what it has loaded (cockpit-cache.ts): the list says how old it is, with one Refresh; a stale
|
|
47811
|
-
// list is re-read behind the page, and a look that found nothing new never redraws it.
|
|
47812
|
-
${AGE_LINE_JS}
|
|
47813
|
-
var wfAge=null,wfSig="",wfFollow=0,wfAsked=0;
|
|
47814
|
-
function paintWfAge(){if(!list)return;var p=document.getElementById("wf-age");if(!p){p=document.createElement("p");p.id="wf-age";p.className="hint cds-age";p.setAttribute("data-age","shopper");p.setAttribute("role","status");
|
|
47815
|
-
var t=document.createElement("span");t.className="cds-age-text";p.appendChild(t);var b=document.createElement("button");b.type="button";b.className="cds-btn-link cds-age-refresh";b.textContent="Refresh";
|
|
47816
|
-
b.onclick=function(){askWf(false)};p.appendChild(document.createTextNode(" "));p.appendChild(b);list.parentNode.insertBefore(p,list)}
|
|
47817
|
-
p.querySelector(".cds-age-text").textContent=ageLine(wfAge)}
|
|
47818
|
-
function askWf(staleOnly){if(staleOnly&&(!wfAge||!(wfAge.stale||wfAge.cold)||wfAge.refreshing||Date.now()-wfAsked<60000))return;wfAsked=Date.now();
|
|
47819
|
-
post("/api/cache/refresh-client",{page:"shopper",locationId:client,staleOnly:staleOnly}).then(function(j){if(j&&j.queued){wfAge=Object.assign({},wfAge,{refreshing:true});paintWfAge();wfFollow=0;setTimeout(lookWf,3000);
|
|
47820
|
-
/* The one Refresh covers the people list too (Codex MED 5, re-review): look again while it is re-read. */
|
|
47821
|
-
if(alertTo&&alertTo.getAttribute("data-loaded")==="1")setTimeout(function(){lookUsers(1)},3000)}}).catch(function(){});
|
|
47822
|
-
/* ...and the outside services of the ticked workflows, read again now (their own POST). */
|
|
47823
|
-
if(!staleOnly&&ff&&ff.checked)refreshHooks(true)}
|
|
47824
|
-
function lookWf(){fetch("/api/shopper/workflows?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){if(!r.ok)return;wfAge=r.cache||null;
|
|
47825
|
-
if(r.cold){paintWfAge();if(wfAge&&wfAge.refreshing&&wfFollow++<20)setTimeout(lookWf,3000);else if(!wfSig)list.innerHTML='<p class="err">'+esc("This account's workflows could not be read. Check its connection on the board.")+'</p>';return}
|
|
47826
|
-
var s=JSON.stringify(r.workflows);if(s!==wfSig){wfSig=s;var keep={};[].slice.call(list.querySelectorAll('input[name="wf"]:checked')).forEach(function(i){keep[i.value]=true});drawWorkflows(r.workflows,keep)}
|
|
47827
|
-
paintWfAge();if(wfAge&&wfAge.refreshing&&wfFollow++<20)setTimeout(lookWf,3000)}).catch(function(){})}
|
|
47828
|
-
if(list&&client){fetch("/api/shopper/workflows?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){
|
|
47829
|
-
if(!r.ok){list.innerHTML='<p class="err">'+esc(r.error||"This account's workflows could not be read.")+'</p>';return}
|
|
47830
|
-
// Nothing loaded yet (a GET never reads, Codex MED 3): say so, ask for the read by POST, and look until it lands.
|
|
47831
|
-
if(r.cold){list.innerHTML='<p class="hint">'+esc(ageLine(r.cache||{cold:true,refreshing:true}))+'</p>';wfAge=r.cache||{cold:true};paintWfAge();askWf(true);return}
|
|
47832
|
-
wfSig=JSON.stringify(r.workflows);drawWorkflows(r.workflows);
|
|
47833
|
-
if(r.cache){wfAge=r.cache;paintWfAge();askWf(true)}
|
|
47834
|
-
}).catch(function(){list.innerHTML='<p class="err">The workflows could not be loaded. Check that ${productName()} is still running.</p>'})}
|
|
47835
|
-
if(run&&run.status==="watching"&&run.due){fetch("/api/shopper/finish",{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify({client:client,runId:run.runId})}).then(function(r){return r.json()}).then(function(r){if(r.ok)location.reload()})}
|
|
47836
|
-
function post(path, body){return fetch(path,{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify(body)}).then(function(r){return r.json()})}
|
|
47837
|
-
var form=document.getElementById("start-form");
|
|
47838
|
-
if(form){form.addEventListener("submit",function(e){e.preventDefault();
|
|
47839
|
-
var ids=[].slice.call(form.querySelectorAll('input[name="wf"]:checked')).map(function(i){return i.value});
|
|
47840
|
-
var out=document.getElementById("start-result");
|
|
47841
|
-
// The start button can sit in the page header, far from this line: bring the answer into view.
|
|
47842
|
-
function say(t){out.textContent=t;if(out.scrollIntoView)out.scrollIntoView({block:"nearest"})}
|
|
47843
|
-
if(!ids.length){say("Tick at least one workflow for the test lead to enter.");return}
|
|
47844
|
-
if(document.getElementById("ff").checked&&!document.getElementById("alert-to").value&&document.getElementById("alert-to").getAttribute("data-loaded")==="1"){say("Choose who gets the staff alerts, tasks and assignments during the test.");return}
|
|
47845
|
-
if(document.getElementById("ff").checked&&form.querySelector('[data-hook-blocked]')){say("One of these workflows sends the lead to an address built from a merge field, so fast-forward cannot test it yet. Untick that workflow, or run without fast-forward.");return}
|
|
47846
|
-
var btn=document.getElementById("start-btn");btn.disabled=true;say("Starting\u2026");
|
|
47847
|
-
var hosts=[].slice.call(form.querySelectorAll('input[name="hook"]:checked')).map(function(i){return i.value});
|
|
47848
|
-
post("/api/shopper/start",{client:client,workflowIds:ids,ownerEmail:document.getElementById("owner-email").value,testPhone:document.getElementById("test-phone").value||undefined,watchMinutes:(document.getElementById("watch").value==="auto"?undefined:Number(document.getElementById("watch").value)),fastForward:document.getElementById("ff").checked,allowedHosts:hosts,alertUserId:(document.getElementById("alert-to").value||undefined),workflowsShown:shownWorkflows})
|
|
47849
|
-
.then(function(r){if(r.ok){location.reload();return}
|
|
47850
|
-
// Out of date: the list is drawn again as the account has it now, the ticks still there are kept, and the
|
|
47851
|
-
// refusal says what changed. Nothing started; pressing Start again sends the list now shown.
|
|
47852
|
-
if(r.stale&&r.workflows){var keep={};ids.forEach(function(id){keep[id]=true});drawWorkflows(r.workflows,keep)}
|
|
47853
|
-
btn.disabled=false;say(r.error||"That did not start.")})
|
|
47854
|
-
.catch(function(){btn.disabled=false;say("That did not start. Check that ${productName()} is still running.")});
|
|
47855
|
-
})}
|
|
47856
|
-
// Fast-forward sends the test lead to the workflows' outside services for real:
|
|
47857
|
-
// read them from the chosen workflows and let the owner tick each one.
|
|
47858
|
-
var hookSeq=0, hooks=document.getElementById("hooks"), hooksList=document.getElementById("hooks-list"), ff=document.getElementById("ff");
|
|
47859
|
-
function refreshHooks(force){
|
|
47860
|
-
if(!hooks||!form)return;
|
|
47861
|
-
var ids=[].slice.call(form.querySelectorAll('input[name="wf"]:checked')).map(function(i){return i.value});
|
|
47862
|
-
if(!ff.checked||!ids.length){hooks.hidden=true;hooksList.innerHTML="";return}
|
|
47863
|
-
var kept={};[].slice.call(hooksList.querySelectorAll('input[name="hook"]:checked')).forEach(function(i){kept[i.value]=true});
|
|
47864
|
-
var mine=++hookSeq;
|
|
47865
|
-
hooks.hidden=false;hooksList.innerHTML='<p class="hint">Reading the chosen workflows\u2026</p>';
|
|
47866
|
-
fetch("/api/shopper/webhooks",{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify({client:client,ids:ids,refresh:force===true})}).then(function(r){return r.json()}).then(function(r){
|
|
47867
|
-
if(mine!==hookSeq)return; // an older answer arriving late: the newer one wins
|
|
47868
|
-
if(!r.ok){hooksList.innerHTML='<p class="err">'+esc(r.error||"The workflows could not be read.")+'</p>';return}
|
|
47869
|
-
// How old this list is (Codex MED 5, re-review): the services as last read, with its age.
|
|
47870
|
-
var hooksAge='<p class="hint cds-age" data-age="shopper-hooks" role="status">'+esc(ageLine(r.cache||null))+'</p>';
|
|
47871
|
-
if(!r.destinations.length){hooksList.innerHTML='<p class="hint">None. These workflows do not send the lead to any outside service, so nothing leaves GoHighLevel during the test.</p>'+hooksAge;return}
|
|
47872
|
-
var byHost={};r.destinations.forEach(function(d){var k=d.host||("!"+(d.problem||"merge_field"));(byHost[k]=byHost[k]||[]).push(d)});
|
|
47873
|
-
hooksList.innerHTML='<p class="hint">During a fast-forward check these steps run for real, so the test lead (no first name, your email) is sent to each service below and may appear there as a new record. Tick each one you are happy to receive it. Leave one unticked and the check stops before it starts and names the step.</p>'+Object.keys(byHost).map(function(h){var steps=byHost[h].map(function(d){return '"'+esc(d.stepName)+'" in '+esc(d.workflowName)}).join(", ");
|
|
47874
|
-
return h.charAt(0)!=="!"?'<label class="cds-check"><input type="checkbox" name="hook" value="'+esc(h)+'"'+(kept[h]?" checked":"")+'><span><b>'+esc(h)+'</b>, used by '+steps+'</span></label>':'<p class="err" data-hook-blocked>'+steps+(h==="!no_address"?' has no address filled in, so it cannot run. Fix the step, or untick this workflow.':' sends the lead to an address filled in from a merge field, so where it goes cannot be checked in advance. Fast-forward cannot test this workflow yet.')+'</p>'}).join("")+hooksAge;
|
|
47875
|
-
}).catch(function(){hooksList.innerHTML='<p class="err">The workflows could not be read. Check that ${productName()} is still running.</p>'});
|
|
47876
|
-
}
|
|
47877
|
-
if(ff&&list){ff.addEventListener("change",refreshHooks);list.addEventListener("change",refreshHooks)}
|
|
47878
|
-
// Who stands in for the staff: this account's users, the owner's own login pre-selected when the email matches.
|
|
47879
|
-
var alertWrap=document.getElementById("alert-to-wrap"), alertTo=document.getElementById("alert-to"), usersLoaded=false;
|
|
47880
|
-
function base(e){e=String(e||"").trim().toLowerCase();var p=e.split("@");return p[0].split("+")[0]+"@"+(p[1]||"")}
|
|
47881
|
-
function refreshAlertTo(){
|
|
47882
|
-
if(!alertWrap)return;
|
|
47883
|
-
alertWrap.hidden=!ff.checked;
|
|
47884
|
-
if(!ff.checked||usersLoaded)return;usersLoaded=true;
|
|
47885
|
-
alertTo.innerHTML='<option value="">Loading the people in this account\u2026</option>';
|
|
47886
|
-
lookUsers(0)}
|
|
47887
|
-
// Cache only (Codex MED 3): a list not loaded yet is asked for by POST, then looked at every 3 seconds, a minute at most.
|
|
47888
|
-
function lookUsers(n){
|
|
47889
|
-
fetch("/api/shopper/users?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){
|
|
47890
|
-
if(r.ok&&r.cold){var v=r.cache||{};
|
|
47891
|
-
if(n===0&&!v.refreshing)post("/api/cache/refresh-client",{page:"shopper",locationId:client,staleOnly:true}).catch(function(){});
|
|
47892
|
-
if(n<20&&(n===0||v.refreshing)){setTimeout(function(){lookUsers(n+1)},3000);return}
|
|
47893
|
-
r={ok:false}}
|
|
47894
|
-
if(!r.ok){alertTo.innerHTML='<option value="">'+esc(r.error||"The users could not be read.")+' Your own login is used if your email matches one.</option>';usersLoaded=false;return}
|
|
47895
|
-
alertTo.setAttribute("data-loaded","1");
|
|
47896
|
-
var me=base(document.getElementById("owner-email").value),cur=alertTo.value;
|
|
47897
|
-
alertTo.innerHTML='<option value="">Choose a person</option>'+r.users.map(function(u){return '<option value="'+esc(u.id)+'"'+(u.email&&base(u.email)===me?" selected":"")+'>'+esc(u.name)+(u.email?" ("+esc(u.email)+")":"")+'</option>'}).join("");
|
|
47898
|
-
if(cur&&[].some.call(alertTo.options,function(o){return o.value===cur}))alertTo.value=cur; // a pick the member made stays
|
|
47899
|
-
/* How old the people list is (Codex MED 5, re-review), and a look again while a Refresh re-reads it. */
|
|
47900
|
-
var ua=document.getElementById("users-age");if(!ua){ua=document.createElement("p");ua.id="users-age";ua.className="hint cds-age";ua.setAttribute("data-age","shopper-users");ua.setAttribute("role","status");alertTo.parentNode.insertBefore(ua,alertTo.nextSibling)}
|
|
47901
|
-
ua.textContent=ageLine(r.cache||null);
|
|
47902
|
-
if(r.cache&&r.cache.refreshing&&n<20)setTimeout(function(){lookUsers(n+1)},3000);
|
|
47903
|
-
}).catch(function(){usersLoaded=false;alertTo.innerHTML='<option value="">The users could not be read. Your own login is used if your email matches one.</option>'});
|
|
47904
|
-
}
|
|
47905
|
-
if(ff)ff.addEventListener("change",refreshAlertTo);
|
|
47906
|
-
window.addEventListener("beforeprint",function(){document.querySelectorAll("main.shopper details").forEach(function(d){d.setAttribute("data-was-open",d.open?"1":"0");d.open=true})});
|
|
47907
|
-
window.addEventListener("afterprint",function(){document.querySelectorAll("main.shopper details[data-was-open]").forEach(function(d){d.open=d.getAttribute("data-was-open")==="1"})});
|
|
47908
|
-
var look=document.getElementById("look-now");
|
|
47909
|
-
if(look&&run){look.addEventListener("click",function(){look.disabled=true;post("/api/shopper/finish",{client:client,runId:run.runId}).then(function(){location.reload()}).catch(function(){look.disabled=false})})}
|
|
47910
|
-
if(run&&run.status==="watching"){setTimeout(function(){location.reload()},30000)}
|
|
47911
|
-
})();
|
|
47912
|
-
</script>`;
|
|
47913
|
-
}
|
|
47914
|
-
var esc2, chip, pageHead, panel, callout, statTile, list2, tl, OUTCOME, PROBLEM_CHIP, dot, CHIP, CHANNEL, mins, when, reportHref, CSS;
|
|
47915
|
-
var init_page = __esm({
|
|
47916
|
-
"src/command-os/shopper/page.ts"() {
|
|
47917
|
-
"use strict";
|
|
47918
|
-
init_cockpit_shell();
|
|
47919
|
-
init_cockpit_cache();
|
|
47920
|
-
init_script_json();
|
|
47921
|
-
init_steps();
|
|
47922
|
-
init_score();
|
|
47923
|
-
esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
47924
|
-
chip = (tone, text3) => `<span class="cds-chip${tone ? ` ${tone}` : ""}">${esc2(text3)}</span>`;
|
|
47925
|
-
pageHead = (title2, sub, action = "") => `<div class="cds-page-head"><div class="txt"><div class="cds-kicker">Workflow Audit Pro</div><h1 class="cds-page-title">${title2}</h1><p class="cds-page-sub">${sub}</p></div>${action}</div>`;
|
|
47926
|
-
panel = (label2, title2, meta, inner, attrs = "", cls = "") => `<section class="cds-panel${cls ? ` ${cls}` : ""}"${attrs}><div class="cds-panel-head"><div class="txt"><div class="cds-panel-label">${label2}</div><h2 class="cds-panel-title">${title2}</h2></div>${meta ? `<div class="cds-panel-meta">${meta}</div>` : ""}</div>${inner}</section>`;
|
|
47927
|
-
callout = (tone, title2, inner, attrs = "") => `<div class="cds-callout${tone ? ` ${tone}` : ""}"${attrs}>${title2 ? `<span class="ttl">${title2}</span>` : ""}${inner}</div>`;
|
|
47928
|
-
statTile = (value, label2, tone = "") => `<div class="cds-stat"><div class="big${tone ? ` ${tone}` : ""}">${esc2(String(value))}</div><div class="lbl">${esc2(label2)}</div></div>`;
|
|
47929
|
-
list2 = (inner) => `<ul class="cds-list">${inner}</ul>`;
|
|
47930
|
-
tl = (inner) => `<ol class="cds-tl">${inner}</ol>`;
|
|
47931
|
-
OUTCOME = {
|
|
47932
|
-
ran: ["ok", "Ran"],
|
|
47933
|
-
branch: ["info", "Path taken"],
|
|
47934
|
-
did_nothing: ["warn", "Did nothing"],
|
|
47935
|
-
failed: ["bad", "Failed, holding"],
|
|
47936
|
-
waiting: ["warn", "Waiting"],
|
|
47937
|
-
entered: ["", "Entered"],
|
|
47938
|
-
finished: ["", "Ended"],
|
|
47939
|
-
unclear: ["warn", "Unclear"]
|
|
47940
|
-
};
|
|
47941
|
-
PROBLEM_CHIP = { failed_holding: "Failed, holding every lead", failed_gave_up: "Failed, delayed every lead", did_nothing: "Did nothing", skipped: "Skipped" };
|
|
47942
|
-
dot = (tone) => `<span class="dot${tone === "ok" || tone === "warn" || tone === "bad" ? ` ${tone}` : ""}"></span>`;
|
|
47943
|
-
CHIP = {
|
|
47944
|
-
blank_field: "Blank name",
|
|
47945
|
-
code_showing: "Code showing",
|
|
47946
|
-
wrong_business: "Wrong business",
|
|
47947
|
-
broken_link: "Broken link",
|
|
47948
|
-
duplicate: "Sent twice"
|
|
47949
|
-
};
|
|
47950
|
-
CHANNEL = { sms: "Text message", email: "Email", other: "Message" };
|
|
47951
|
-
mins = (m) => {
|
|
47952
|
-
const whole = Math.floor(m);
|
|
47953
|
-
const sec = Math.round((m - whole) * 60);
|
|
47954
|
-
return `+${whole}:${String(sec).padStart(2, "0")}`;
|
|
47955
|
-
};
|
|
47956
|
-
when = (ms) => `<time data-ms="${ms}">${new Date(ms).toISOString()}</time>`;
|
|
47957
|
-
reportHref = (run) => `/shopper/report?client=${encodeURIComponent(run.locationId)}&run=${encodeURIComponent(run.runId)}`;
|
|
47958
|
-
CSS = `
|
|
47959
|
-
main.shopper{display:grid;gap:var(--space-5);align-content:start}
|
|
47960
|
-
.shopper > *{min-width:0}
|
|
47961
|
-
.shopper .picker{max-width:420px}
|
|
47962
|
-
.shopper .stats{display:grid;gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
47963
|
-
.shopper .sum{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-5);align-items:center}
|
|
47964
|
-
.shopper .cds-list .ttl{min-width:0;overflow-wrap:anywhere}
|
|
47965
|
-
.shopper .cds-list .end{flex-wrap:wrap;justify-content:flex-end}
|
|
47966
|
-
.shopper .cds-tl .what{min-width:0;overflow-wrap:anywhere}
|
|
47967
|
-
.shopper .cds-callout[role=status]{display:flex;flex-wrap:wrap;gap:var(--space-3);align-items:center;justify-content:space-between}
|
|
47968
|
-
.shopper .startform{display:grid;gap:var(--space-4);max-width:720px}
|
|
47969
|
-
.shopper fieldset{border:0;margin:0;padding:0;min-width:0}
|
|
47970
|
-
.shopper legend{padding:0;margin:0 0 var(--space-2)}
|
|
47971
|
-
.shopper .wf{display:grid;gap:var(--space-2);max-height:320px;overflow:auto}
|
|
47972
|
-
.shopper details{display:grid;gap:var(--space-3)}
|
|
47973
|
-
.shopper details > summary{padding:var(--space-2) 0}
|
|
47974
|
-
.shopper .result:empty{display:none}
|
|
47975
|
-
.shopper mark{background:var(--surface-sunk);color:var(--bad);font-weight:700}
|
|
47976
|
-
[hidden]{display:none!important}
|
|
47977
|
-
@media (max-width:900px){.shopper .sum{grid-template-columns:minmax(0,1fr);justify-items:start}}
|
|
47978
|
-
@media print{.shopper .picker,#look-now,#watching{display:none!important}}
|
|
47979
|
-
`;
|
|
47980
|
-
}
|
|
47981
|
-
});
|
|
47982
|
-
|
|
47983
|
-
// src/command-os/shopper/analyze.ts
|
|
47984
|
-
function excerpt(text3, start, end, radius = 60) {
|
|
47985
|
-
const flat = (s) => s.replace(/\s+/g, " ");
|
|
47986
|
-
let a = Math.max(0, start - radius);
|
|
47987
|
-
let b = Math.min(text3.length, end + radius);
|
|
47988
|
-
const back = text3.slice(a, start);
|
|
47989
|
-
const cut = Math.max(back.lastIndexOf("\n"), back.lastIndexOf(". "));
|
|
47990
|
-
if (cut >= 0) a += cut + 1;
|
|
47991
|
-
const fwd = text3.slice(end, b);
|
|
47992
|
-
const stop3 = fwd.search(/\n|[.!?](\s|$)/);
|
|
47993
|
-
if (stop3 >= 0) b = end + stop3 + 1;
|
|
47994
|
-
return (a > 0 ? "\u2026" : "") + flat(text3.slice(a, b)).trim() + (b < text3.length ? "\u2026" : "");
|
|
47995
|
-
}
|
|
47996
|
-
function blankGapIsIntended(text3, index, matched) {
|
|
47997
|
-
const after = text3.slice(index + matched.length, index + matched.length + 2);
|
|
47998
|
-
if (/^[.:;]/.test(after)) return true;
|
|
47999
|
-
if (/[:;][)(DPp]/.test(matched + after.slice(0, 1))) return true;
|
|
48000
|
-
return false;
|
|
48001
|
-
}
|
|
48002
|
-
function usableForeignNames(own, others) {
|
|
48003
|
-
const ownL = own.trim().toLowerCase();
|
|
48004
|
-
const out = /* @__PURE__ */ new Set();
|
|
48005
|
-
for (const raw of others) {
|
|
48006
|
-
const name2 = raw.trim();
|
|
48007
|
-
const l = name2.toLowerCase();
|
|
48008
|
-
if (name2.length < 6 || !l || l === ownL) continue;
|
|
48009
|
-
if (ownL.includes(l) || l.includes(ownL)) continue;
|
|
48010
|
-
const words2 = l.split(/[^a-z0-9]+/).filter(Boolean);
|
|
48011
|
-
if (words2.every((w) => GENERIC.has(w))) continue;
|
|
48012
|
-
out.add(name2);
|
|
48013
|
-
}
|
|
48014
|
-
return [...out];
|
|
48015
|
-
}
|
|
48016
|
-
function messageText3(m) {
|
|
48017
|
-
return m.subject ? `${m.subject}
|
|
48018
|
-
${m.body}` : m.body;
|
|
48019
|
-
}
|
|
48020
|
-
function checkMessage(m, ctx, foreign) {
|
|
48021
|
-
const problems = [];
|
|
48022
|
-
const text3 = messageText3(m);
|
|
48023
|
-
const minute2 = minuteOf2(m, ctx.startedAt);
|
|
48024
|
-
const base = { messageId: m.id, channel: m.channel, minute: minute2 };
|
|
48025
|
-
const gaps = [];
|
|
48026
|
-
for (const g of text3.matchAll(BLANK_GAP)) {
|
|
48027
|
-
const index = g.index ?? 0;
|
|
48028
|
-
if (blankGapIsIntended(text3, index, g[0])) continue;
|
|
48029
|
-
if (/[!?;:]/.test(g[3]) && !GREETING.test(g[1])) continue;
|
|
48030
|
-
gaps.push({ index, match: g[0], word: g[1] });
|
|
48031
|
-
}
|
|
48032
|
-
if (gaps.length) {
|
|
48033
|
-
const first2 = gaps[0];
|
|
48034
|
-
const sms = m.channel === "sms";
|
|
48035
|
-
problems.push({
|
|
48036
|
-
...base,
|
|
48037
|
-
kind: "blank_field",
|
|
48038
|
-
title: gaps.length > 1 ? `${gaps.length} places where a name or detail came out blank` : "A name or detail came out blank",
|
|
48039
|
-
quote: excerpt(text3, first2.index, first2.index + first2.match.length),
|
|
48040
|
-
highlight: first2.match,
|
|
48041
|
-
fix: sms ? `Texts can't fill in a missing name, so reword it so it reads right without one, for example "Hi! Thanks for\u2026".` : 'Set a fallback on that merge field in the email editor (for example "there", so it reads "Hi there,"), or reword it.'
|
|
48042
|
-
});
|
|
48043
|
-
}
|
|
48044
|
-
const code = [...text3.matchAll(CODE_SHOWING)];
|
|
48045
|
-
if (code.length) {
|
|
48046
|
-
const c = code[0];
|
|
48047
|
-
const index = c.index ?? 0;
|
|
48048
|
-
problems.push({
|
|
48049
|
-
...base,
|
|
48050
|
-
kind: "code_showing",
|
|
48051
|
-
title: code.length > 1 ? `${code.length} pieces of template code went out as text` : "A piece of template code went out as text",
|
|
48052
|
-
quote: excerpt(text3, index, index + c[0].length),
|
|
48053
|
-
highlight: c[0],
|
|
48054
|
-
fix: "GoHighLevel printed this as-is because the field doesn't exist or is misspelled for this lead. Pick the field again from the editor's merge-field list, or remove it."
|
|
48055
|
-
});
|
|
48056
|
-
}
|
|
48057
|
-
for (const name2 of foreign) {
|
|
48058
|
-
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe4(name2)}(?![\\p{L}\\p{N}])`, "iu");
|
|
48059
|
-
const hit = re.exec(text3);
|
|
48060
|
-
if (!hit) continue;
|
|
48061
|
-
problems.push({
|
|
48062
|
-
...base,
|
|
48063
|
-
kind: "wrong_business",
|
|
48064
|
-
title: "The message names a different business",
|
|
48065
|
-
quote: excerpt(text3, hit.index, hit.index + hit[0].length),
|
|
48066
|
-
highlight: hit[0],
|
|
48067
|
-
fix: `Left over from the account it was copied from. Replace it with ${ctx.businessName ? `"${ctx.businessName}"` : "this client's name"}, or better, the business-name merge field so it can't drift again.`
|
|
48068
|
-
});
|
|
48069
|
-
break;
|
|
48070
|
-
}
|
|
48071
|
-
for (const link2 of ctx.links ?? []) {
|
|
48072
|
-
if (link2.state !== "broken") continue;
|
|
48073
|
-
const at = text3.indexOf(link2.url);
|
|
48074
|
-
if (at < 0) continue;
|
|
48075
|
-
problems.push({
|
|
48076
|
-
...base,
|
|
48077
|
-
kind: "broken_link",
|
|
48078
|
-
title: "A link doesn't open",
|
|
48079
|
-
quote: excerpt(text3, at, at + link2.url.length, 40),
|
|
48080
|
-
highlight: link2.url,
|
|
48081
|
-
fix: `${link2.detail} Fix or replace the link.`
|
|
48082
|
-
});
|
|
48083
|
-
}
|
|
48084
|
-
return problems;
|
|
48085
|
-
}
|
|
48086
|
-
function analyzeMessages(messages, ctx) {
|
|
48087
|
-
const sorted = [...messages].sort((a, b) => a.at - b.at);
|
|
48088
|
-
const foreign = usableForeignNames(ctx.businessName, ctx.otherBusinessNames);
|
|
48089
|
-
const problems = [];
|
|
48090
|
-
const seen = /* @__PURE__ */ new Map();
|
|
48091
|
-
for (const m of sorted) {
|
|
48092
|
-
problems.push(...checkMessage(m, ctx, foreign));
|
|
48093
|
-
const key2 = `${m.channel}|${normal(m.subject ?? "")}|${normal(m.body)}`;
|
|
48094
|
-
if (normal(m.body) && seen.has(key2)) {
|
|
48095
|
-
problems.push({
|
|
48096
|
-
kind: "duplicate",
|
|
48097
|
-
messageId: m.id,
|
|
48098
|
-
channel: m.channel,
|
|
48099
|
-
minute: minuteOf2(m, ctx.startedAt),
|
|
48100
|
-
title: "The same message went out twice",
|
|
48101
|
-
quote: excerpt(m.body, 0, Math.min(m.body.length, 80), 0),
|
|
48102
|
-
highlight: "",
|
|
48103
|
-
fix: "Two workflows (or one that re-enrolled the lead) send this. Keep one, or add a condition so it only goes once."
|
|
48104
|
-
});
|
|
48105
|
-
} else seen.set(key2, m.id);
|
|
48106
|
-
}
|
|
48107
|
-
const missing = [];
|
|
48108
|
-
const notYetDue = [];
|
|
48109
|
-
for (const e of ctx.expect ?? []) {
|
|
48110
|
-
const want = normal(e.match);
|
|
48111
|
-
const got = sorted.some((m) => (!e.channel || e.channel === m.channel) && want && normal(messageText3(m)).includes(want));
|
|
48112
|
-
if (got) continue;
|
|
48113
|
-
const row = { label: e.label, channel: e.channel, byMinute: e.byMinute, workflowId: e.workflowId, stepId: e.stepId, afterWaits: e.afterWaits };
|
|
48114
|
-
(e.byMinute <= ctx.windowMinutes ? missing : notYetDue).push(row);
|
|
48115
|
-
}
|
|
48116
|
-
const sentBy2 = /* @__PURE__ */ new Map();
|
|
48117
|
-
const sentByStep = /* @__PURE__ */ new Map();
|
|
48118
|
-
const ambiguous = /* @__PURE__ */ new Set();
|
|
48119
|
-
const incomplete = /* @__PURE__ */ new Set();
|
|
48120
|
-
const list4 = ctx.messageSteps;
|
|
48121
|
-
const whole = !!list4 && list4.complete === true && Array.isArray(list4.steps);
|
|
48122
|
-
for (const m of sorted) {
|
|
48123
|
-
const text3 = normal(messageText3(m));
|
|
48124
|
-
const hit = (ctx.expect ?? []).find((e) => (!e.channel || e.channel === m.channel) && normal(e.match) && text3.includes(normal(e.match)));
|
|
48125
|
-
if (hit) sentBy2.set(m.id, hit.label);
|
|
48126
|
-
if (!list4) continue;
|
|
48127
|
-
if (!whole) {
|
|
48128
|
-
incomplete.add(m.id);
|
|
48129
|
-
continue;
|
|
48130
|
-
}
|
|
48131
|
-
const cands = list4.steps.filter((s) => s.channel === m.channel && normal(s.match) && text3.includes(normal(s.match)));
|
|
48132
|
-
const ids = new Set(cands.map((s) => `${s.workflowId ?? ""}|${s.stepId ?? ""}`));
|
|
48133
|
-
if (ids.size === 1 && cands[0].workflowId && cands[0].stepId) sentByStep.set(m.id, { label: hit?.label ?? "", match: cands[0].match, byMinute: 0, workflowId: cands[0].workflowId, stepId: cands[0].stepId });
|
|
48134
|
-
else if (ids.size > 1) ambiguous.add(m.id);
|
|
48135
|
-
}
|
|
48136
|
-
for (const p of problems) {
|
|
48137
|
-
if (sentBy2.has(p.messageId)) p.sentBy = sentBy2.get(p.messageId);
|
|
48138
|
-
const e = sentByStep.get(p.messageId);
|
|
48139
|
-
if (e?.workflowId && e.stepId) {
|
|
48140
|
-
p.sentByWorkflowId = e.workflowId;
|
|
48141
|
-
p.sentByStepId = e.stepId;
|
|
48142
|
-
}
|
|
48143
|
-
if (ambiguous.has(p.messageId)) p.sentByAmbiguous = true;
|
|
48144
|
-
if (incomplete.has(p.messageId)) p.sentByIncomplete = true;
|
|
48145
|
-
}
|
|
48146
|
-
problems.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.minute - b.minute);
|
|
48147
|
-
const links = ctx.links ?? [];
|
|
48148
|
-
const timeline = sorted.map((m) => ({
|
|
48149
|
-
messageId: m.id,
|
|
48150
|
-
channel: m.channel,
|
|
48151
|
-
minute: minuteOf2(m, ctx.startedAt),
|
|
48152
|
-
subject: m.subject,
|
|
48153
|
-
preview: excerpt(m.body, 0, 0, 90).replace(/^…/, ""),
|
|
48154
|
-
source: m.source,
|
|
48155
|
-
sentBy: sentBy2.get(m.id),
|
|
48156
|
-
problems: problems.filter((p) => p.messageId === m.id).map((p) => p.kind)
|
|
48157
|
-
}));
|
|
48158
|
-
return {
|
|
48159
|
-
sent: sorted.length,
|
|
48160
|
-
problems,
|
|
48161
|
-
missing,
|
|
48162
|
-
notYetDue,
|
|
48163
|
-
links: {
|
|
48164
|
-
ok: links.filter((l) => l.state === "ok").length,
|
|
48165
|
-
broken: links.filter((l) => l.state === "broken").length,
|
|
48166
|
-
skipped: links.filter((l) => l.state === "skipped").length,
|
|
48167
|
-
total: links.length
|
|
48168
|
-
},
|
|
48169
|
-
timeline
|
|
48170
|
-
};
|
|
48171
|
-
}
|
|
48172
|
-
var KIND_ORDER, minuteOf2, BLANK_GAP, GREETING, CODE_SHOWING, escapeRe4, GENERIC, normal;
|
|
48173
|
-
var init_analyze = __esm({
|
|
48174
|
-
"src/command-os/shopper/analyze.ts"() {
|
|
48175
|
-
"use strict";
|
|
48176
|
-
KIND_ORDER = { blank_field: 0, code_showing: 1, wrong_business: 2, broken_link: 3, duplicate: 4 };
|
|
48177
|
-
minuteOf2 = (m, startedAt) => Math.max(0, Math.round((m.at - startedAt) / 6e3) / 10);
|
|
48178
|
-
BLANK_GAP = /([\p{L}\p{N}'’]+)([ \t ]+)([,.!?;:])(?=[\s"'”)\]]|$)/gu;
|
|
48179
|
-
GREETING = /^(?:hi|hey|hello|dear|thanks|welcome|congrats|congratulations|morning|afternoon|evening)$/i;
|
|
48180
|
-
CODE_SHOWING = /\{\{[^{}\n]{0,80}\}\}|\{\{|\}\}|\{\s*(?:contact|user|location|appointment|custom_values)\.[a-z_]+\s*\}|%[A-Z][A-Z_]{2,}%|\[\[[^\]\n]{1,40}\]\]/g;
|
|
48181
|
-
escapeRe4 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48182
|
-
GENERIC = /* @__PURE__ */ new Set(["clinic", "demo", "test", "testing", "template", "sandbox", "account", "agency", "company", "llc", "inc"]);
|
|
48183
|
-
normal = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
48184
|
-
}
|
|
48185
|
-
});
|
|
48186
|
-
|
|
48187
|
-
// src/command-os/shopper/links.ts
|
|
48188
|
-
function extractUrls(text3, html2) {
|
|
48189
|
-
const out = /* @__PURE__ */ new Set();
|
|
48190
|
-
const add2 = (u) => {
|
|
48191
|
-
const clean5 = u.replace(/&/g, "&").replace(/[.,;:!?]+$/, "");
|
|
48192
|
-
if (/^https?:\/\//i.test(clean5)) out.add(clean5);
|
|
48193
|
-
};
|
|
48194
|
-
if (html2) for (const m of html2.matchAll(HREF_RE)) add2(m[1]);
|
|
48195
|
-
for (const m of text3.matchAll(URL_RE)) add2(m[0]);
|
|
48196
|
-
return [...out];
|
|
48197
|
-
}
|
|
48198
|
-
function siteHost(website) {
|
|
48199
|
-
if (!website?.trim()) return void 0;
|
|
48200
|
-
try {
|
|
48201
|
-
const u = new URL(/^https?:\/\//i.test(website) ? website : `https://${website.trim()}`);
|
|
48202
|
-
return u.hostname.toLowerCase().replace(/^www\./, "") || void 0;
|
|
48203
|
-
} catch {
|
|
48204
|
-
return void 0;
|
|
48205
|
-
}
|
|
48206
|
-
}
|
|
48207
|
-
function skipReason(url, ownHosts) {
|
|
48208
|
-
if (UNSUB.test(url)) return "Not opened: an unsubscribe or preference link would opt the test lead out.";
|
|
48209
|
-
let host;
|
|
48210
|
-
try {
|
|
48211
|
-
host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
48212
|
-
} catch {
|
|
48213
|
-
return "Not opened: not a readable address.";
|
|
48214
|
-
}
|
|
48215
|
-
const own = ownHosts.some((h) => host === h || host === `www.${h}`);
|
|
48216
|
-
if (!own) return "Not opened: it is not on the client's own website, so it may be a tracked link that counts as the lead clicking.";
|
|
48217
|
-
if (TRACKING_PATH.test(url)) return "Not opened: it looks like a tracked or trigger link, and opening it could fire the client's automations.";
|
|
48218
|
-
return null;
|
|
48219
|
-
}
|
|
48220
|
-
async function checkLinks(urls, ownHosts, fetcher, timeoutMs = 1e4) {
|
|
48221
|
-
const results = [];
|
|
48222
|
-
for (const url of urls) {
|
|
48223
|
-
const skip = skipReason(url, ownHosts);
|
|
48224
|
-
if (skip) {
|
|
48225
|
-
results.push({ url, state: "skipped", detail: skip });
|
|
48226
|
-
continue;
|
|
48227
|
-
}
|
|
48228
|
-
const ctrl = new AbortController();
|
|
48229
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
48230
|
-
try {
|
|
48231
|
-
let cur = url;
|
|
48232
|
-
let r = await fetcher(cur, { method: "GET", redirect: "manual", signal: ctrl.signal, headers: { "User-Agent": "Mozilla/5.0 (Command OS secret shopper)" } });
|
|
48233
|
-
let stopped = null;
|
|
48234
|
-
for (let hop = 0; r.status >= 300 && r.status < 400 && r.location; hop++) {
|
|
48235
|
-
let next;
|
|
48236
|
-
try {
|
|
48237
|
-
next = new URL(r.location, cur).toString();
|
|
48238
|
-
} catch {
|
|
48239
|
-
stopped = "It redirects to an address that is not readable, so it was not followed.";
|
|
48240
|
-
break;
|
|
48241
|
-
}
|
|
48242
|
-
const why = hop >= MAX_LINK_HOPS ? "it redirects more than 5 times" : skipReason(next, ownHosts)?.replace(/^Not opened: /, "") ?? null;
|
|
48243
|
-
if (why) {
|
|
48244
|
-
stopped = `It redirects to ${next}, which was not opened: ${why}`;
|
|
48245
|
-
break;
|
|
48246
|
-
}
|
|
48247
|
-
cur = next;
|
|
48248
|
-
r = await fetcher(cur, { method: "GET", redirect: "manual", signal: ctrl.signal, headers: { "User-Agent": "Mozilla/5.0 (Command OS secret shopper)" } });
|
|
48249
|
-
}
|
|
48250
|
-
results.push(stopped ? { url, state: "skipped", detail: stopped } : r.status < 400 ? { url, state: "ok", detail: `Opened (HTTP ${r.status}).` } : { url, state: "broken", detail: `The page answered HTTP ${r.status}.` });
|
|
48251
|
-
} catch (e) {
|
|
48252
|
-
const why = ctrl.signal.aborted ? `It did not answer within ${Math.round(timeoutMs / 1e3)} seconds.` : `It could not be reached (${e instanceof Error ? e.message : String(e)}).`;
|
|
48253
|
-
results.push({ url, state: "broken", detail: why });
|
|
48254
|
-
} finally {
|
|
48255
|
-
clearTimeout(timer);
|
|
48256
|
-
}
|
|
48257
|
-
}
|
|
48258
|
-
return results;
|
|
48259
|
-
}
|
|
48260
|
-
function htmlToText(body) {
|
|
48261
|
-
if (!/<(?:p|div|br|table|td|span|a|html|body|h\d|li|style|strong|em|b|i)(?:\s+[a-z][a-z0-9:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>=]+))?)*\s*\/?>/i.test(body)) return body;
|
|
48262
|
-
return body.replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(?:p|div|tr|li|h\d|table)>/gi, "\n").replace(/<\/(?:td|th)>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /gi, "\xA0").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d))).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
48263
|
-
}
|
|
48264
|
-
var URL_RE, HREF_RE, TRACKING_PATH, UNSUB, MAX_LINK_HOPS;
|
|
48265
|
-
var init_links2 = __esm({
|
|
48266
|
-
"src/command-os/shopper/links.ts"() {
|
|
48267
|
-
"use strict";
|
|
48268
|
-
URL_RE = /https?:\/\/[^\s<>"'\])]+/g;
|
|
48269
|
-
HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
48270
|
-
TRACKING_PATH = /\/(?:c|o|l|ls|tl|t|r|e|wf|track|click|open)\/|\/ls\/click|[?&](?:upn|trigger_link|utm_id|mc_cid|_hs(?:enc|mi))=/i;
|
|
48271
|
-
UNSUB = /unsubscribe|opt-?out|\/preferences|email-preferences|unsubscribe-view|\/emails\/builder\//i;
|
|
48272
|
-
MAX_LINK_HOPS = 5;
|
|
48273
|
-
}
|
|
48274
|
-
});
|
|
48275
|
-
|
|
48276
|
-
// src/command-os/shopper/fastforward.ts
|
|
48277
|
-
var fastforward_exports = {};
|
|
48278
|
-
__export(fastforward_exports, {
|
|
48279
|
-
FF_PREFIX: () => FF_PREFIX,
|
|
48280
|
-
SWEEP_MIN_AGE_MS: () => SWEEP_MIN_AGE_MS,
|
|
48281
|
-
builderAdmin: () => builderAdmin,
|
|
48282
|
-
journal: () => journal,
|
|
48283
|
-
makeCopies: () => makeCopies,
|
|
48284
|
-
newToken: () => newToken,
|
|
48285
|
-
normalizeHost: () => normalizeHost,
|
|
48286
|
-
openCopies: () => openCopies,
|
|
48287
|
-
planCopy: () => planCopy,
|
|
48288
|
-
remapSelf: () => remapSelf,
|
|
48289
|
-
remapWorkflows: () => remapWorkflows,
|
|
48290
|
-
removeCopies: () => removeCopies,
|
|
48291
|
-
staffRecipients: () => staffRecipients,
|
|
48292
|
-
sweep: () => sweep,
|
|
48293
|
-
toSource: () => toSource,
|
|
48294
|
-
webhookDestinations: () => webhookDestinations
|
|
48295
|
-
});
|
|
48296
|
-
function windowText(raw) {
|
|
48297
|
-
const w = asObj8(raw);
|
|
48298
|
-
if (!w.start || !w.end) return void 0;
|
|
48299
|
-
const days = asArr4(w.days).map((d) => typeof d === "number" ? DAY_NAMES[d] ?? String(d) : String(d));
|
|
48300
|
-
const cond = str10(w.condition);
|
|
48301
|
-
return `runs only ${str10(w.start)}\u2013${str10(w.end)}${days.length ? ` on ${days.join(", ")}` : ""}${cond ? ` (${cond})` : ""}`;
|
|
48302
|
-
}
|
|
48303
|
-
function rerouteAlert(attrs, owner) {
|
|
48304
|
-
const channel = str10(attrs.type) ?? "notification";
|
|
48305
|
-
if (channel === "notification") {
|
|
48306
|
-
const n = asObj8(attrs.notification);
|
|
48307
|
-
attrs.notification = { body: n.body ?? "", title: `TEST: ${str10(n.title) ?? ""}`.trim(), userType: "user", redirectPage: n.redirectPage ?? "contact", type: n.type ?? "send_notification", selectedUser: owner };
|
|
48308
|
-
delete attrs.email;
|
|
48309
|
-
delete attrs.sms;
|
|
48310
|
-
return void 0;
|
|
48311
|
-
}
|
|
48312
|
-
if (channel === "email") {
|
|
48313
|
-
const e = asObj8(attrs.email);
|
|
48314
|
-
attrs.email = { isCloned: false, html: e.html ?? "", subject: `TEST: ${str10(e.subject) ?? ""}`.trim(), selectedUser: [owner], userType: "user", attachments: [] };
|
|
48315
|
-
delete attrs.notification;
|
|
48316
|
-
delete attrs.sms;
|
|
48317
|
-
return void 0;
|
|
48318
|
-
}
|
|
48319
|
-
return channel;
|
|
48320
|
-
}
|
|
48321
|
-
function staffRecipients(step) {
|
|
48322
|
-
const type = str10(step.type) ?? "";
|
|
48323
|
-
const a = asObj8(step.attributes);
|
|
48324
|
-
if (type === "internal_notification") {
|
|
48325
|
-
const out = [];
|
|
48326
|
-
for (const k of ["notification", "email", "sms"]) {
|
|
48327
|
-
const ch = asObj8(a[k]);
|
|
48328
|
-
if (!Object.keys(ch).length) continue;
|
|
48329
|
-
if (str10(ch.userType) !== "user") out.push(`everyone (${k})`);
|
|
48330
|
-
for (const u of Array.isArray(ch.selectedUser) ? ch.selectedUser : [ch.selectedUser]) if (typeof u === "string") out.push(u);
|
|
48331
|
-
}
|
|
48332
|
-
return out;
|
|
48333
|
-
}
|
|
48334
|
-
if (type === "task-notification") return [str10(a.assignedTo) ?? "nobody"];
|
|
48335
|
-
if (type === "assign_user") return [...asArr4(a.user_list), ...asArr4(a.customUserList)].map((u) => typeof u === "string" ? u : JSON.stringify(u));
|
|
48336
|
-
if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
|
|
48337
|
-
return [...a.assignedTo !== void 0 ? [String(a.assignedTo)] : [], ...asArr4(a.__customInputFields__).map(asObj8).filter((f) => /assign/i.test(str10(f.filterField) ?? "")).map((f) => String(f.value))];
|
|
48338
|
-
}
|
|
48339
|
-
return [];
|
|
48340
|
-
}
|
|
48341
|
-
function waitLength(a) {
|
|
48342
|
-
const s = asObj8(a.startAfter);
|
|
48343
|
-
const v = Number(s.value);
|
|
48344
|
-
const unit = (str10(s.type) ?? "").toLowerCase();
|
|
48345
|
-
const word = unit.startsWith("min") ? "minute" : unit.startsWith("hour") ? "hour" : unit.startsWith("day") ? "day" : unit;
|
|
48346
|
-
return `${v} ${word}${v === 1 ? "" : "s"}`;
|
|
48347
|
-
}
|
|
48348
|
-
function webhookDestinations(sources) {
|
|
48349
|
-
const out = [];
|
|
48350
|
-
for (const s of sources) {
|
|
48351
|
-
for (const n of s.templates.map(asObj8)) {
|
|
48352
|
-
if (str10(n.type) !== "webhook") continue;
|
|
48353
|
-
const url = str10(asObj8(n.attributes).url) ?? "";
|
|
48354
|
-
const h = hostOf(url);
|
|
48355
|
-
const merged = /\{\{|%7b/i.test(url.split(/[/?#]/).slice(0, 3).join("/")) || h !== void 0 && /[{}]|%7b|%7d/i.test(h);
|
|
48356
|
-
const problem = merged ? "merge_field" : !h ? "no_address" : void 0;
|
|
48357
|
-
out.push({ ...problem ? { problem } : { host: h }, stepName: str10(n.name) ?? "Webhook", workflowName: s.name });
|
|
48358
|
-
}
|
|
48359
|
-
}
|
|
48360
|
-
return out;
|
|
48361
|
-
}
|
|
48362
|
-
function planCopy(src, opts) {
|
|
48363
|
-
const allowed = opts.allowedHosts.map(normalizeHost).filter(Boolean);
|
|
48364
|
-
const nodes = src.templates.map(asObj8);
|
|
48365
|
-
if (nodes.length === 0) return { ok: false, refusal: `"${src.name}" has no steps to test.` };
|
|
48366
|
-
if (nodes.length > 40) return { ok: false, refusal: `"${src.name}" has ${nodes.length} steps; fast-forward copies at most 40.` };
|
|
48367
|
-
if (str10(src.status)?.toLowerCase() === "draft") return { ok: false, refusal: `"${src.name}" is still a draft, so real leads never go through it. Publish it first, or test it as it is.` };
|
|
48368
|
-
const idMap = /* @__PURE__ */ new Map();
|
|
48369
|
-
for (const n of nodes) {
|
|
48370
|
-
const id2 = str10(n.id);
|
|
48371
|
-
if (id2) idMap.set(id2, import_node_crypto20.default.randomUUID());
|
|
48372
|
-
}
|
|
48373
|
-
const remap = (id2) => typeof id2 === "string" ? idMap.get(id2) ?? id2 : id2;
|
|
48374
|
-
const waits = [];
|
|
48375
|
-
const hosts = /* @__PURE__ */ new Set();
|
|
48376
|
-
const actions = [];
|
|
48377
|
-
for (const n of nodes) {
|
|
48378
|
-
const type = str10(n.type) ?? "";
|
|
48379
|
-
const name2 = str10(n.name) ?? type;
|
|
48380
|
-
if (type === "if_else" || str10(n.nodeType)?.startsWith("branch") || str10(n.cat) === "conditions") {
|
|
48381
|
-
return { ok: false, refusal: `"${src.name}" has an if/else branch ("${name2}"). Fast-forward handles straight-line workflows for now; branches come next, with test leads that book, reply or stay silent.` };
|
|
48382
|
-
}
|
|
48383
|
-
if (type === "add_to_workflow" || type === "goto") {
|
|
48384
|
-
return { ok: false, refusal: `"${src.name}" hands the lead to another place ("${name2}"). Following a lead into other workflows is the next piece of fast-forward.` };
|
|
48385
|
-
}
|
|
48386
|
-
if (!ALLOWED.has(type)) {
|
|
48387
|
-
return { ok: false, refusal: `"${src.name}" has a "${type}" step ("${name2}") that fast-forward does not know how to copy safely yet, so nothing was copied.` };
|
|
48388
|
-
}
|
|
48389
|
-
const attrs = { ...asObj8(n.attributes) };
|
|
48390
|
-
if (type === "wait") {
|
|
48391
|
-
if (str10(attrs.type) !== "time" || !asObj8(attrs.startAfter).type) {
|
|
48392
|
-
return { ok: false, refusal: `"${src.name}" has a wait that is not a plain time wait ("${name2}"). Fast-forward handles time waits only for now.` };
|
|
48393
|
-
}
|
|
48394
|
-
waits.push(`${name2}: ${waitLength(attrs)} in real life`);
|
|
48395
|
-
attrs.startAfter = { type: "minutes", value: 1, when: "after" };
|
|
48396
|
-
}
|
|
48397
|
-
if (type === "internal_notification") {
|
|
48398
|
-
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, staff alerts go to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48399
|
-
const unknown = rerouteAlert(attrs, opts.ownerUserId);
|
|
48400
|
-
if (unknown) return { ok: false, refusal: `"${src.name}" has a staff alert sent by ${unknown === "sms" ? "text" : `"${unknown}"`} ("${name2}"). Fast-forward cannot yet send that kind to you instead of your staff, so nothing was copied.` };
|
|
48401
|
-
}
|
|
48402
|
-
if (type === "assign_user") {
|
|
48403
|
-
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, leads are assigned to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48404
|
-
if (asArr4(attrs.customUserList).length) return { ok: false, refusal: `"${src.name}" assigns the lead from a custom value ("${name2}"). Fast-forward cannot yet send that assignment to you instead of your staff, so nothing was copied.` };
|
|
48405
|
-
const o = opts.ownerUserId;
|
|
48406
|
-
Object.assign(attrs, { user_list: [o], traffic_split: "equally", traffic_weightage: { [o]: 1 }, traffic_index: [{ id: o, indexes: [1] }], total_index: 1 });
|
|
48407
|
-
}
|
|
48408
|
-
if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
|
|
48409
|
-
const assigns = attrs.assignedTo !== void 0 || asArr4(attrs.__customInputFields__).map(asObj8).some((f) => /assign/i.test(str10(f.filterField) ?? ""));
|
|
48410
|
-
if (assigns) {
|
|
48411
|
-
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, cards are assigned to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48412
|
-
if (attrs.assignedTo !== void 0) attrs.assignedTo = opts.ownerUserId;
|
|
48413
|
-
attrs.__customInputFields__ = asArr4(attrs.__customInputFields__).map(asObj8).map((f) => /assign/i.test(str10(f.filterField) ?? "") ? { ...f, value: opts.ownerUserId } : f);
|
|
48414
|
-
}
|
|
48415
|
-
}
|
|
48416
|
-
if (type === "task-notification") {
|
|
48417
|
-
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, tasks go to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48418
|
-
attrs.assignedTo = opts.ownerUserId;
|
|
48419
|
-
attrs.title = `TEST: ${str10(attrs.title) ?? ""}`.trim();
|
|
48420
|
-
}
|
|
48421
|
-
if (type === "webhook") {
|
|
48422
|
-
const url = str10(attrs.url) ?? "";
|
|
48423
|
-
if (/^[^/]*\/\/[^/?#]*\{\{/.test(url) || /^\{\{/.test(url)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
|
|
48424
|
-
const h = hostOf(url);
|
|
48425
|
-
if (h && /[{}]|%7b|%7d/i.test(h)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
|
|
48426
|
-
if (!h) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") without a readable address.` };
|
|
48427
|
-
hosts.add(h);
|
|
48428
|
-
if (!allowed.includes(h)) {
|
|
48429
|
-
return { ok: false, refusal: `"${src.name}" sends the lead to ${h} ("${name2}"). A fast-forward check sends the test lead there for real, so tick ${h} to allow it.` };
|
|
48430
|
-
}
|
|
48431
|
-
}
|
|
48432
|
-
const copy = { ...n, id: remap(n.id), attributes: attrs };
|
|
48433
|
-
if (n.next !== void 0) copy.next = Array.isArray(n.next) ? n.next.map(remap) : remap(n.next);
|
|
48434
|
-
if (n.parentKey !== void 0) copy.parentKey = remap(n.parentKey);
|
|
48435
|
-
if (n.parent !== void 0) copy.parent = remap(n.parent);
|
|
48436
|
-
if (Array.isArray(n.sibling)) copy.sibling = n.sibling.map(remap);
|
|
48437
|
-
actions.push(copy);
|
|
48438
|
-
}
|
|
48439
|
-
const w = asObj8(src.window);
|
|
48440
|
-
return {
|
|
48441
|
-
ok: true,
|
|
48442
|
-
plan: {
|
|
48443
|
-
sourceId: src.id,
|
|
48444
|
-
sourceName: src.name,
|
|
48445
|
-
actions,
|
|
48446
|
-
waits,
|
|
48447
|
-
window: windowText(w),
|
|
48448
|
-
webhookHosts: [...hosts],
|
|
48449
|
-
stepIds: Object.fromEntries([...idMap].map(([src2, copy]) => [copy, src2])),
|
|
48450
|
-
settings: { stopOnResponse: src.stopOnResponse, allowMultiple: src.allowMultiple, removeContactFromLastStep: src.removeContactFromLastStep }
|
|
48451
|
-
}
|
|
48452
|
-
};
|
|
48453
|
-
}
|
|
48454
|
-
function remapWorkflows(value, sourceToCopy, key2) {
|
|
48455
|
-
const REF = /* @__PURE__ */ new Set(["workflow_id", "workflowId", "targetWorkflowId"]);
|
|
48456
|
-
if (typeof value === "string") return key2 !== void 0 && REF.has(key2) ? sourceToCopy.get(value) ?? value : value;
|
|
48457
|
-
if (Array.isArray(value)) return value.map((v) => remapWorkflows(v, sourceToCopy, key2));
|
|
48458
|
-
if (value && typeof value === "object") {
|
|
48459
|
-
const out = {};
|
|
48460
|
-
for (const [k, v] of Object.entries(value)) out[k] = remapWorkflows(v, sourceToCopy, k);
|
|
48461
|
-
return out;
|
|
48462
|
-
}
|
|
48463
|
-
return value;
|
|
48464
|
-
}
|
|
48465
|
-
function journal(e, base) {
|
|
48466
|
-
const f = journalFile(base);
|
|
48467
|
-
import_node_fs7.default.mkdirSync(import_node_path6.default.dirname(f), { recursive: true, mode: 448 });
|
|
48468
|
-
import_node_fs7.default.appendFileSync(f, JSON.stringify(e) + "\n", { encoding: "utf8", mode: 384 });
|
|
48469
|
-
}
|
|
48470
|
-
function openCopies(locationId2, base) {
|
|
48471
|
-
let lines = [];
|
|
48472
|
-
try {
|
|
48473
|
-
lines = import_node_fs7.default.readFileSync(journalFile(base), "utf8").split("\n").filter(Boolean);
|
|
48474
|
-
} catch {
|
|
48475
|
-
return [];
|
|
48476
|
-
}
|
|
48477
|
-
const byName = /* @__PURE__ */ new Map();
|
|
48478
|
-
for (const l of lines) {
|
|
48479
|
-
let e;
|
|
48480
|
-
try {
|
|
48481
|
-
e = JSON.parse(l);
|
|
48482
|
-
} catch {
|
|
48483
|
-
continue;
|
|
48484
|
-
}
|
|
48485
|
-
if (e.locationId !== locationId2) continue;
|
|
48486
|
-
const cur = byName.get(e.name) ?? { name: e.name, at: e.at, deleted: false };
|
|
48487
|
-
if (e.copyId) cur.copyId = e.copyId;
|
|
48488
|
-
if (e.event === "deleted") cur.deleted = true;
|
|
48489
|
-
byName.set(e.name, cur);
|
|
48490
|
-
}
|
|
48491
|
-
return [...byName.values()].filter((x) => !x.deleted).map(({ deleted: _d, ...x }) => x);
|
|
48492
|
-
}
|
|
48493
|
-
function toSource(id2, raw) {
|
|
48494
|
-
const wf = asObj8(raw);
|
|
48495
|
-
return {
|
|
48496
|
-
id: id2,
|
|
48497
|
-
name: str10(wf.name) ?? id2,
|
|
48498
|
-
status: str10(wf.status),
|
|
48499
|
-
templates: asArr4(asObj8(wf.workflowData).templates),
|
|
48500
|
-
triggers: asArr4(wf.triggers),
|
|
48501
|
-
stopOnResponse: typeof wf.stopOnResponse === "boolean" ? wf.stopOnResponse : void 0,
|
|
48502
|
-
allowMultiple: typeof wf.allowMultiple === "boolean" ? wf.allowMultiple : void 0,
|
|
48503
|
-
removeContactFromLastStep: typeof wf.removeContactFromLastStep === "boolean" ? wf.removeContactFromLastStep : void 0,
|
|
48504
|
-
window: wf.window
|
|
48505
|
-
};
|
|
48506
|
-
}
|
|
48507
|
-
async function makeCopies(admin, locationId2, sourceIds, opts) {
|
|
48508
|
-
const now = opts.now ?? Date.now;
|
|
48509
|
-
if (sourceIds.length > 5) return { ok: false, refusal: "A fast-forward check copies at most 5 workflows.", copies: [] };
|
|
48510
|
-
const plans = [];
|
|
48511
|
-
for (const id2 of sourceIds) {
|
|
48512
|
-
const r = planCopy(toSource(id2, await admin.get(id2)), opts);
|
|
48513
|
-
if (!r.ok) return { ok: false, refusal: r.refusal, copies: [] };
|
|
48514
|
-
plans.push(r.plan);
|
|
48515
|
-
}
|
|
48516
|
-
if (!/^[0-9a-z]{6}$/.test(opts.token)) return { ok: false, refusal: "Internal: a fast-forward token must be 6 letters or digits.", copies: [] };
|
|
48517
|
-
const copies = [];
|
|
48518
|
-
const fail = (e) => ({ ok: false, refusal: `The fast-forward copy could not be set up: ${e instanceof Error ? e.message.split("\n")[0] : String(e)} The copies made so far are being removed.`, copies });
|
|
48519
|
-
for (const p of plans) {
|
|
48520
|
-
const name2 = Array.from(`${FF_PREFIX} ${opts.token} ${p.sourceName}`).slice(0, 120).join("");
|
|
48521
|
-
journal({ at: now(), locationId: locationId2, event: "creating", name: name2 }, opts.base);
|
|
48522
|
-
try {
|
|
48523
|
-
const copyId = (await admin.create(name2)).id;
|
|
48524
|
-
if (!copyId || sourceIds.includes(copyId)) throw new Error("GoHighLevel did not return a new workflow id.");
|
|
48525
|
-
journal({ at: now(), locationId: locationId2, event: "created", name: name2, copyId }, opts.base);
|
|
48526
|
-
copies.push({ copyId, copyName: name2, sourceId: p.sourceId, sourceName: p.sourceName, waits: p.waits, window: p.window, stepIds: p.stepIds });
|
|
48527
|
-
} catch (e) {
|
|
48528
|
-
return fail(e);
|
|
48529
|
-
}
|
|
48530
|
-
}
|
|
48531
|
-
const sourceToCopy = new Map(copies.map((c) => [c.sourceId, c.copyId]));
|
|
48532
|
-
for (const [i, p] of plans.entries()) {
|
|
48533
|
-
const { copyId, copyName } = copies[i];
|
|
48534
|
-
try {
|
|
48535
|
-
await admin.save(copyId, { actions: remapWorkflows(p.actions, sourceToCopy), triggers: [], status: "draft", window: null, ...p.settings });
|
|
48536
|
-
const back = asObj8(await admin.get(copyId));
|
|
48537
|
-
const steps = asArr4(asObj8(back.workflowData).templates).map(asObj8);
|
|
48538
|
-
const longWait = steps.find((s) => str10(s.type) === "wait" && Number(asObj8(asObj8(s.attributes).startAfter).value) !== 1);
|
|
48539
|
-
if (asArr4(back.triggers).length !== 0) throw new Error(`the copy of "${p.sourceName}" came back with a trigger, so it was not switched on.`);
|
|
48540
|
-
if (steps.length !== p.actions.length) throw new Error(`the copy of "${p.sourceName}" came back with ${steps.length} steps instead of ${p.actions.length}.`);
|
|
48541
|
-
if (longWait) throw new Error(`a wait in the copy of "${p.sourceName}" did not shorten.`);
|
|
48542
|
-
if (!(str10(back.name) ?? "").startsWith(tokenPrefix(copyName))) throw new Error("the copy's name did not save.");
|
|
48543
|
-
const stray = steps.flatMap(staffRecipients).find((u) => u !== opts.ownerUserId);
|
|
48544
|
-
if (stray) throw new Error(`a staff step in the copy of "${p.sourceName}" still reaches ${stray} instead of the person chosen, so it was not switched on.`);
|
|
48545
|
-
if (steps.some((s) => sourceIds.some((id2) => JSON.stringify(s).includes(`"${id2}"`)))) throw new Error(`a step in the copy of "${p.sourceName}" still points at an original workflow, so it was not switched on.`);
|
|
48546
|
-
await admin.publish(copyId);
|
|
48547
|
-
} catch (e) {
|
|
48548
|
-
return fail(e);
|
|
48549
|
-
}
|
|
48550
|
-
}
|
|
48551
|
-
return { ok: true, copies };
|
|
48552
|
-
}
|
|
48553
|
-
async function removeCopies(admin, locationId2, copies, base, now = Date.now) {
|
|
48554
|
-
const problems = [];
|
|
48555
|
-
for (const c of copies) {
|
|
48556
|
-
if (!c.copyId) continue;
|
|
48557
|
-
try {
|
|
48558
|
-
const wf = asObj8(await admin.get(c.copyId));
|
|
48559
|
-
const name2 = str10(wf.name) ?? "";
|
|
48560
|
-
const ours = TOKEN_NAME.test(c.name) && (c.matchedByName ? name2 === c.name : name2.startsWith(tokenPrefix(c.name)));
|
|
48561
|
-
if (!ours) {
|
|
48562
|
-
problems.push(`Left alone: workflow ${c.copyId} is not a fast-forward copy this check made (named "${name2}").`);
|
|
48563
|
-
continue;
|
|
48564
|
-
}
|
|
48565
|
-
const parked = await admin.parked(c.copyId);
|
|
48566
|
-
if (parked > (c.matchedByName ? 0 : 1)) {
|
|
48567
|
-
problems.push(`Not removed: ${parked} contact${parked === 1 ? " is" : "s are"} in "${name2}", more than our test lead. Check it in GoHighLevel.`);
|
|
48568
|
-
continue;
|
|
48569
|
-
}
|
|
48570
|
-
await admin.remove(c.copyId, parked || void 0);
|
|
48571
|
-
journal({ at: now(), locationId: locationId2, event: "deleted", name: c.name, copyId: c.copyId }, base);
|
|
48572
|
-
} catch (e) {
|
|
48573
|
-
const msg3 = e instanceof Error ? e.message : String(e);
|
|
48574
|
-
if (/\b404\b|not found/i.test(msg3)) {
|
|
48575
|
-
journal({ at: now(), locationId: locationId2, event: "deleted", name: c.name, copyId: c.copyId }, base);
|
|
48576
|
-
continue;
|
|
48577
|
-
}
|
|
48578
|
-
problems.push(`The copy "${c.name}" could not be removed (${msg3.split("\n")[0]}). Delete it in GoHighLevel.`);
|
|
48579
|
-
}
|
|
48580
|
-
}
|
|
48581
|
-
return problems;
|
|
48582
|
-
}
|
|
48583
|
-
async function sweep(admin, locationId2, base, now = Date.now, keep = []) {
|
|
48584
|
-
const open2 = openCopies(locationId2, base).filter((o) => (!o.copyId || !keep.includes(o.copyId)) && now() - o.at >= SWEEP_MIN_AGE_MS && TOKEN_NAME.test(o.name));
|
|
48585
|
-
if (open2.length === 0) return [];
|
|
48586
|
-
const targets = open2.filter((o) => o.copyId);
|
|
48587
|
-
const unnamed = open2.filter((o) => !o.copyId);
|
|
48588
|
-
if (unnamed.length) {
|
|
48589
|
-
try {
|
|
48590
|
-
const all = await admin.list();
|
|
48591
|
-
for (const o of unnamed) {
|
|
48592
|
-
const hits = all.filter((w) => w.name === o.name && !keep.includes(w.id));
|
|
48593
|
-
if (hits.length === 1) targets.push({ copyId: hits[0].id, name: o.name, matchedByName: true });
|
|
48594
|
-
}
|
|
48595
|
-
} catch {
|
|
48596
|
-
}
|
|
48597
|
-
}
|
|
48598
|
-
return removeCopies(admin, locationId2, targets, base, now);
|
|
48599
|
-
}
|
|
48600
|
-
function builderAdmin(b, check) {
|
|
48601
|
-
return {
|
|
48602
|
-
get: (id2) => b.getWorkflow(id2),
|
|
48603
|
-
list: async () => {
|
|
48604
|
-
const out = [];
|
|
48605
|
-
for (let skip = 0; skip < 1e3; skip += 100) {
|
|
48606
|
-
const rows2 = asArr4(asObj8(await b.listWorkflows(100, skip)).rows).map(asObj8);
|
|
48607
|
-
for (const r of rows2) {
|
|
48608
|
-
const id2 = str10(r._id) ?? str10(r.id);
|
|
48609
|
-
if (id2) out.push({ id: id2, name: str10(r.name) ?? "", createdAt: str10(r.createdAt) });
|
|
48610
|
-
}
|
|
48611
|
-
if (rows2.length < 100) break;
|
|
48612
|
-
}
|
|
48613
|
-
return out;
|
|
48614
|
-
},
|
|
48615
|
-
create: async (name2) => {
|
|
48616
|
-
const w = asObj8(await b.createWorkflow(name2));
|
|
48617
|
-
return { id: str10(w._id) ?? str10(w.id) ?? "" };
|
|
48618
|
-
},
|
|
48619
|
-
save: (id2, body) => b.updateWorkflow(id2, body),
|
|
48620
|
-
publish: async (id2) => {
|
|
48621
|
-
if (!check) throw new PublishRefusedError("Not published: the pre-publish check is not connected here, so the copy was not switched on. Nothing was changed.");
|
|
48622
|
-
return b.publishWorkflow(id2, check);
|
|
48623
|
-
},
|
|
48624
|
-
parked: async (id2) => (await b.parkedContacts(id2)).reduce((sum, p) => sum + (Number(p.total) || 0), 0),
|
|
48625
|
-
remove: (id2, ack) => b.deleteWorkflow(id2, ack ? { acknowledgeContacts: ack } : void 0)
|
|
48626
|
-
};
|
|
48627
|
-
}
|
|
48628
|
-
var import_node_fs7, import_node_path6, import_node_crypto20, asObj8, asArr4, str10, FF_PREFIX, ALLOWED, hostOf, normalizeHost, newToken, TOKEN_NAME, tokenPrefix, DAY_NAMES, remapSelf, journalFile, SWEEP_MIN_AGE_MS;
|
|
48629
|
-
var init_fastforward = __esm({
|
|
48630
|
-
"src/command-os/shopper/fastforward.ts"() {
|
|
48631
|
-
"use strict";
|
|
48632
|
-
import_node_fs7 = __toESM(require("node:fs"));
|
|
48633
|
-
import_node_path6 = __toESM(require("node:path"));
|
|
48634
|
-
import_node_crypto20 = __toESM(require("node:crypto"));
|
|
48635
|
-
init_run();
|
|
48636
|
-
init_workflow_publish_gate();
|
|
48637
|
-
asObj8 = (v) => v && typeof v === "object" ? v : {};
|
|
48638
|
-
asArr4 = (v) => Array.isArray(v) ? v : [];
|
|
48639
|
-
str10 = (v) => typeof v === "string" ? v : void 0;
|
|
48640
|
-
FF_PREFIX = "ZZ TEST FF";
|
|
48641
|
-
ALLOWED = /* @__PURE__ */ new Set([
|
|
48642
|
-
"email",
|
|
48643
|
-
"sms",
|
|
48644
|
-
"wait",
|
|
48645
|
-
"add_contact_tag",
|
|
48646
|
-
"remove_contact_tag",
|
|
48647
|
-
"update_contact_field",
|
|
48648
|
-
"internal_create_opportunity",
|
|
48649
|
-
"internal_update_opportunity",
|
|
48650
|
-
"assign_user",
|
|
48651
|
-
"internal_notification",
|
|
48652
|
-
"task-notification",
|
|
48653
|
-
"add_notes",
|
|
48654
|
-
"webhook",
|
|
48655
|
-
"remove_from_workflow",
|
|
48656
|
-
"dnd_contact"
|
|
48657
|
-
]);
|
|
48658
|
-
hostOf = (url) => {
|
|
48659
|
-
try {
|
|
48660
|
-
return new URL(url).hostname.toLowerCase();
|
|
48661
|
-
} catch {
|
|
48662
|
-
return void 0;
|
|
48663
|
-
}
|
|
48664
|
-
};
|
|
48665
|
-
normalizeHost = (h) => {
|
|
48666
|
-
const t = h.trim().toLowerCase();
|
|
48667
|
-
if (!t) return "";
|
|
48668
|
-
return hostOf(/^[a-z][a-z0-9+.-]*:\/\//.test(t) ? t : `https://${t}`) ?? t;
|
|
48669
|
-
};
|
|
48670
|
-
newToken = () => {
|
|
48671
|
-
let t = "";
|
|
48672
|
-
while (t.length < 6) t += (import_node_crypto20.default.randomBytes(1)[0] % 36).toString(36);
|
|
48673
|
-
return t;
|
|
48674
|
-
};
|
|
48675
|
-
TOKEN_NAME = new RegExp(`^${FF_PREFIX} [0-9a-z]{6} `);
|
|
48676
|
-
tokenPrefix = (name2) => name2.match(TOKEN_NAME)?.[0] ?? "\0never";
|
|
48677
|
-
DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
48678
|
-
remapSelf = (value, sourceId, copyId) => remapWorkflows(value, /* @__PURE__ */ new Map([[sourceId, copyId]]));
|
|
48679
|
-
journalFile = (base) => import_node_path6.default.join(shopperDir(base), "fast-forward-journal.jsonl");
|
|
48680
|
-
SWEEP_MIN_AGE_MS = 15 * 6e4;
|
|
48681
|
-
}
|
|
48682
|
-
});
|
|
48683
|
-
|
|
48684
47938
|
// src/command-os/shopper/run.ts
|
|
48685
47939
|
function heldWaits(rows2) {
|
|
48686
47940
|
const out = [];
|
|
@@ -48692,42 +47946,42 @@ function heldWaits(rows2) {
|
|
|
48692
47946
|
return out;
|
|
48693
47947
|
}
|
|
48694
47948
|
function shopperDir(base = appDataDir()) {
|
|
48695
|
-
return
|
|
47949
|
+
return import_node_path6.default.join(base, "secret-shopper");
|
|
48696
47950
|
}
|
|
48697
47951
|
function runPath(locationId2, runId, base) {
|
|
48698
47952
|
if (!SAFE_ID.test(locationId2) || !SAFE_ID.test(runId)) throw new Error("That client or run id is not valid.");
|
|
48699
|
-
return
|
|
47953
|
+
return import_node_path6.default.join(shopperDir(base), locationId2, `${runId}.json`);
|
|
48700
47954
|
}
|
|
48701
47955
|
function otherBusinessNames(locations, self) {
|
|
48702
47956
|
return locations.filter((l) => l.locationId !== self && typeof l.name === "string" && l.name.trim() !== "").map((l) => l.name.trim());
|
|
48703
47957
|
}
|
|
48704
47958
|
function saveRun(run, base) {
|
|
48705
47959
|
const file3 = runPath(run.locationId, run.runId, base);
|
|
48706
|
-
|
|
47960
|
+
import_node_fs7.default.mkdirSync(import_node_path6.default.dirname(file3), { recursive: true, mode: 448 });
|
|
48707
47961
|
const tmp = `${file3}.${process.pid}.${Date.now()}.tmp`;
|
|
48708
|
-
|
|
48709
|
-
|
|
47962
|
+
import_node_fs7.default.writeFileSync(tmp, JSON.stringify(run, null, 1), { mode: 384 });
|
|
47963
|
+
import_node_fs7.default.renameSync(tmp, file3);
|
|
48710
47964
|
}
|
|
48711
47965
|
function readRun(locationId2, runId, base) {
|
|
48712
47966
|
try {
|
|
48713
|
-
return JSON.parse(
|
|
47967
|
+
return JSON.parse(import_node_fs7.default.readFileSync(runPath(locationId2, runId, base), "utf8"));
|
|
48714
47968
|
} catch {
|
|
48715
47969
|
return void 0;
|
|
48716
47970
|
}
|
|
48717
47971
|
}
|
|
48718
47972
|
function listRuns(locationId2, base) {
|
|
48719
47973
|
if (!SAFE_ID.test(locationId2)) return [];
|
|
48720
|
-
const dir =
|
|
47974
|
+
const dir = import_node_path6.default.join(shopperDir(base), locationId2);
|
|
48721
47975
|
let names = [];
|
|
48722
47976
|
try {
|
|
48723
|
-
names =
|
|
47977
|
+
names = import_node_fs7.default.readdirSync(dir).filter((n) => n.endsWith(".json"));
|
|
48724
47978
|
} catch {
|
|
48725
47979
|
return [];
|
|
48726
47980
|
}
|
|
48727
47981
|
const runs = [];
|
|
48728
47982
|
for (const n of names) {
|
|
48729
47983
|
try {
|
|
48730
|
-
runs.push(JSON.parse(
|
|
47984
|
+
runs.push(JSON.parse(import_node_fs7.default.readFileSync(import_node_path6.default.join(dir, n), "utf8")));
|
|
48731
47985
|
} catch {
|
|
48732
47986
|
}
|
|
48733
47987
|
}
|
|
@@ -48757,7 +48011,7 @@ function clampMinutes(n) {
|
|
|
48757
48011
|
return Math.min(1440, Math.max(1, Math.round(n)));
|
|
48758
48012
|
}
|
|
48759
48013
|
function channelOf(msg3) {
|
|
48760
|
-
const mt = (
|
|
48014
|
+
const mt = (str10(msg3.messageType) ?? "").toUpperCase();
|
|
48761
48015
|
if (mt === "TYPE_SMS") return "sms";
|
|
48762
48016
|
if (mt === "TYPE_EMAIL") return "email";
|
|
48763
48017
|
if (!mt) {
|
|
@@ -48768,30 +48022,30 @@ function channelOf(msg3) {
|
|
|
48768
48022
|
return null;
|
|
48769
48023
|
}
|
|
48770
48024
|
async function readSentMessages(client, locationId2, contactId, since) {
|
|
48771
|
-
const convs =
|
|
48025
|
+
const convs = asArr4(asObj8(await client.get("/conversations/search", { params: { locationId: locationId2, contactId, limit: 20 } })).conversations);
|
|
48772
48026
|
const out = [];
|
|
48773
48027
|
for (const c of convs) {
|
|
48774
|
-
const convId =
|
|
48028
|
+
const convId = str10(asObj8(c).id);
|
|
48775
48029
|
if (!convId) continue;
|
|
48776
|
-
const raw =
|
|
48777
|
-
const list4 =
|
|
48030
|
+
const raw = asObj8(await client.get(`/conversations/${convId}/messages`, { params: { limit: 100 } }));
|
|
48031
|
+
const list4 = asArr4(raw.messages).length ? asArr4(raw.messages) : asArr4(asObj8(raw.messages).messages);
|
|
48778
48032
|
for (const item of list4) {
|
|
48779
|
-
const m =
|
|
48780
|
-
if (
|
|
48033
|
+
const m = asObj8(item);
|
|
48034
|
+
if (str10(m.direction)?.toLowerCase() !== "outbound") continue;
|
|
48781
48035
|
const channel = channelOf(m);
|
|
48782
48036
|
if (!channel) continue;
|
|
48783
|
-
const at = Date.parse(
|
|
48037
|
+
const at = Date.parse(str10(m.dateAdded) ?? "");
|
|
48784
48038
|
if (Number.isNaN(at) || at < since - 5e3) continue;
|
|
48785
|
-
const id2 =
|
|
48039
|
+
const id2 = str10(m.id);
|
|
48786
48040
|
if (!id2) continue;
|
|
48787
48041
|
out.push({
|
|
48788
48042
|
id: id2,
|
|
48789
48043
|
channel,
|
|
48790
48044
|
at,
|
|
48791
|
-
subject:
|
|
48792
|
-
body: htmlToText(
|
|
48793
|
-
raw:
|
|
48794
|
-
source:
|
|
48045
|
+
subject: str10(asObj8(asObj8(m.meta).email).subject),
|
|
48046
|
+
body: htmlToText(str10(m.body) ?? ""),
|
|
48047
|
+
raw: str10(m.body) ?? "",
|
|
48048
|
+
source: str10(m.source)
|
|
48795
48049
|
});
|
|
48796
48050
|
}
|
|
48797
48051
|
}
|
|
@@ -48799,16 +48053,16 @@ async function readSentMessages(client, locationId2, contactId, since) {
|
|
|
48799
48053
|
}
|
|
48800
48054
|
async function findContactIdByEmail(client, locationId2, email) {
|
|
48801
48055
|
const raw = await client.get("/contacts/", { params: { locationId: locationId2, query: email, limit: 10 } });
|
|
48802
|
-
for (const c of
|
|
48803
|
-
const co =
|
|
48804
|
-
if (
|
|
48056
|
+
for (const c of asArr4(asObj8(raw).contacts)) {
|
|
48057
|
+
const co = asObj8(c);
|
|
48058
|
+
if (str10(co.email)?.toLowerCase() === email) return str10(co.id);
|
|
48805
48059
|
}
|
|
48806
48060
|
return void 0;
|
|
48807
48061
|
}
|
|
48808
48062
|
async function startShopper(input, deps) {
|
|
48809
48063
|
const now = deps.now ?? Date.now;
|
|
48810
48064
|
const sleep8 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
48811
|
-
const token2 =
|
|
48065
|
+
const token2 = import_node_crypto20.default.randomUUID().replace(/-/g, "").slice(0, 8);
|
|
48812
48066
|
const leadEmail = plusAddress(input.ownerEmail, token2);
|
|
48813
48067
|
if (!SAFE_ID.test(input.locationId)) throw new Error("That client id is not valid.");
|
|
48814
48068
|
if (input.entry.kind === "workflows" && input.entry.workflowIds.length === 0) throw new Error("Choose at least one workflow for the test lead to enter.");
|
|
@@ -48822,9 +48076,9 @@ async function startShopper(input, deps) {
|
|
|
48822
48076
|
const refused = protectedRefusalFor(input.locationId);
|
|
48823
48077
|
if (refused) throw new Error(refused);
|
|
48824
48078
|
}
|
|
48825
|
-
const loc =
|
|
48826
|
-
const businessName =
|
|
48827
|
-
const websiteHost2 = siteHost(
|
|
48079
|
+
const loc = asObj8(asObj8(await deps.client.get(`/locations/${input.locationId}`)).location);
|
|
48080
|
+
const businessName = str10(loc.name) ?? "";
|
|
48081
|
+
const websiteHost2 = siteHost(str10(loc.website));
|
|
48828
48082
|
const testPhone = input.testPhone?.trim() || void 0;
|
|
48829
48083
|
let expect = input.expect ?? [];
|
|
48830
48084
|
const planNote = { ...input.planNote ?? { source: expect.length ? "given" : "none" } };
|
|
@@ -48892,8 +48146,8 @@ async function startShopper(input, deps) {
|
|
|
48892
48146
|
};
|
|
48893
48147
|
if (firstName) body.firstName = firstName;
|
|
48894
48148
|
if (testPhone) body.phone = testPhone;
|
|
48895
|
-
const created =
|
|
48896
|
-
run.contactId =
|
|
48149
|
+
const created = asObj8(await deps.client.post("/contacts/", { body, noRetry: true }));
|
|
48150
|
+
run.contactId = str10(asObj8(created.contact).id) ?? str10(created.id);
|
|
48897
48151
|
if (!run.contactId) throw new Error("GoHighLevel did not return the test lead's id.");
|
|
48898
48152
|
if (input.entry.kind === "workflows") {
|
|
48899
48153
|
for (const wid of input.entry.workflowIds) {
|
|
@@ -48931,7 +48185,7 @@ async function listLeadCarriers(client, run) {
|
|
|
48931
48185
|
}
|
|
48932
48186
|
if (run.contactId && !carriers.has(run.contactId)) {
|
|
48933
48187
|
try {
|
|
48934
|
-
const k = carrierOf(
|
|
48188
|
+
const k = carrierOf(asObj8(await client.get(`/contacts/${run.contactId}`)).contact);
|
|
48935
48189
|
if (k && k.email === run.leadEmail) carriers.set(k.id, k);
|
|
48936
48190
|
} catch (e) {
|
|
48937
48191
|
if (!notFound(e)) partial = true;
|
|
@@ -48939,7 +48193,7 @@ async function listLeadCarriers(client, run) {
|
|
|
48939
48193
|
}
|
|
48940
48194
|
for (const k of [...carriers.values()].slice(0, 5)) {
|
|
48941
48195
|
try {
|
|
48942
|
-
const full = carrierOf(
|
|
48196
|
+
const full = carrierOf(asObj8(await client.get(`/contacts/${k.id}`)).contact);
|
|
48943
48197
|
if (full?.name) carriers.set(k.id, { ...k, name: full.name });
|
|
48944
48198
|
} catch {
|
|
48945
48199
|
}
|
|
@@ -48958,20 +48212,20 @@ function pidAlive(pid) {
|
|
|
48958
48212
|
}
|
|
48959
48213
|
function claimRun(locationId2, runId, base) {
|
|
48960
48214
|
const lock = `${runPath(locationId2, runId, base)}.lock`;
|
|
48961
|
-
|
|
48962
|
-
const token2 =
|
|
48215
|
+
import_node_fs7.default.mkdirSync(import_node_path6.default.dirname(lock), { recursive: true, mode: 448 });
|
|
48216
|
+
const token2 = import_node_crypto20.default.randomUUID();
|
|
48963
48217
|
const mine = JSON.stringify({ pid: process.pid, token: token2 });
|
|
48964
48218
|
for (let attempt2 = 0; attempt2 < 3; attempt2++) {
|
|
48965
48219
|
try {
|
|
48966
|
-
const fd =
|
|
48220
|
+
const fd = import_node_fs7.default.openSync(lock, "wx", 384);
|
|
48967
48221
|
try {
|
|
48968
|
-
|
|
48222
|
+
import_node_fs7.default.writeSync(fd, mine);
|
|
48969
48223
|
} finally {
|
|
48970
|
-
|
|
48224
|
+
import_node_fs7.default.closeSync(fd);
|
|
48971
48225
|
}
|
|
48972
48226
|
return () => {
|
|
48973
48227
|
try {
|
|
48974
|
-
if (
|
|
48228
|
+
if (import_node_fs7.default.readFileSync(lock, "utf8") === mine) import_node_fs7.default.unlinkSync(lock);
|
|
48975
48229
|
} catch {
|
|
48976
48230
|
}
|
|
48977
48231
|
};
|
|
@@ -48980,8 +48234,8 @@ function claimRun(locationId2, runId, base) {
|
|
|
48980
48234
|
let held = "";
|
|
48981
48235
|
let age = 0;
|
|
48982
48236
|
try {
|
|
48983
|
-
held =
|
|
48984
|
-
age = Date.now() -
|
|
48237
|
+
held = import_node_fs7.default.readFileSync(lock, "utf8");
|
|
48238
|
+
age = Date.now() - import_node_fs7.default.statSync(lock).mtimeMs;
|
|
48985
48239
|
} catch {
|
|
48986
48240
|
continue;
|
|
48987
48241
|
}
|
|
@@ -48993,7 +48247,7 @@ function claimRun(locationId2, runId, base) {
|
|
|
48993
48247
|
const stale = age > 10 * 6e4 || pid > 0 && !pidAlive(pid);
|
|
48994
48248
|
if (!stale) return null;
|
|
48995
48249
|
try {
|
|
48996
|
-
if (
|
|
48250
|
+
if (import_node_fs7.default.readFileSync(lock, "utf8") === held) import_node_fs7.default.unlinkSync(lock);
|
|
48997
48251
|
} catch {
|
|
48998
48252
|
}
|
|
48999
48253
|
}
|
|
@@ -49059,61 +48313,867 @@ async function finishShopper(locationId2, runId, deps) {
|
|
|
49059
48313
|
run.fastForward.removed = problems.length === 0;
|
|
49060
48314
|
run.fastForward.problems = problems.length ? problems : void 0;
|
|
49061
48315
|
}
|
|
49062
|
-
}
|
|
49063
|
-
saveRun(run, deps.base);
|
|
49064
|
-
return run;
|
|
49065
|
-
} finally {
|
|
49066
|
-
release();
|
|
48316
|
+
}
|
|
48317
|
+
saveRun(run, deps.base);
|
|
48318
|
+
return run;
|
|
48319
|
+
} finally {
|
|
48320
|
+
release();
|
|
48321
|
+
}
|
|
48322
|
+
}
|
|
48323
|
+
function watchingRuns(locationIds, base) {
|
|
48324
|
+
return locationIds.flatMap((id2) => listRuns(id2, base).filter((r) => r.status === "watching"));
|
|
48325
|
+
}
|
|
48326
|
+
function summarizeRun(run) {
|
|
48327
|
+
if (run.status === "failed") return `The Workflow Audit Pro could not run: ${run.error ?? "unknown error"}`;
|
|
48328
|
+
const f = run.findings;
|
|
48329
|
+
const st = run.steps;
|
|
48330
|
+
const heldNote = run.heldAt?.length && f?.notYetDue.some((m) => m.heldByWait) ? ` GoHighLevel was still holding the test lead at a wait that should have ended, so ${f.notYetDue.filter((m) => m.heldByWait).length} later message(s) were not judged; run the check again.` : "";
|
|
48331
|
+
const stepNote = heldNote + (st?.read && st.problems.length ? ` ${st.problems.length} workflow step${st.problems.length === 1 ? "" : "s"} failed, skipped or did nothing.` : "") + (st && !st.read ? " The workflow steps could not be read, so only the messages were checked." : "") + (st?.read && st.rows.length === 0 ? " No workflow steps were logged for the test lead, so the steps are not checked." : "") + (st?.unreadWorkflows?.length ? ` Could not read the steps of: ${st.unreadWorkflows.join(", ")}.` : "") + (st?.partialWorkflows?.length ? ` Only partly read (very busy): ${st.partialWorkflows.join(", ")}.` : "") + (st?.counts.unclear ? ` ${st.counts.unclear} step${st.counts.unclear === 1 ? " has" : "s have"} a status this version cannot read.` : "") + (run.draftWorkflows?.length ? ` ${run.draftWorkflows.length === 1 ? "A chosen workflow is" : `${run.draftWorkflows.length} chosen workflows are`} still a draft, so GoHighLevel took the lead and ran nothing: ${run.draftWorkflows.join(", ")}.` : "");
|
|
48332
|
+
const draftNow = run.draftWorkflows?.length ? ` Warning: ${run.draftWorkflows.join(", ")} ${run.draftWorkflows.length === 1 ? "is" : "are"} still a draft, so GoHighLevel will take the lead and run nothing there.` : "";
|
|
48333
|
+
const card = run.status === "done" ? scoreRun(run) : void 0;
|
|
48334
|
+
const gradeLine = !card ? "" : card.graded ? `Grade ${card.grade} (${card.score}/100${card.capped ? `, capped at ${card.capped.at}` : ""}). ${card.coverageLabel ? `${card.coverageLabel}. ` : ""}${card.coverage}. ` : `${/^Not graded/.test(card.reason) ? card.reason : `Not graded: ${card.reason}`} `;
|
|
48335
|
+
if (!f) return `Watching: the test lead went in at ${new Date(run.startedAt).toISOString()}. The window closes at ${new Date(run.closesAt).toISOString()}.${draftNow}`;
|
|
48336
|
+
const n = f.problems.length;
|
|
48337
|
+
if (f.sent === 0) {
|
|
48338
|
+
const why = nothingArrivedWhy(run.steps);
|
|
48339
|
+
const nothing = `Nothing arrived in ${run.status === "watching" ? "the time so far" : `${run.watchMinutes} minutes`}. ${why}${stepNote}`;
|
|
48340
|
+
return run.status === "watching" ? `So far: ${nothing} Still watching until ${new Date(run.closesAt).toISOString()}.` : gradeLine + nothing;
|
|
48341
|
+
}
|
|
48342
|
+
const head4 = `${f.sent} message${f.sent === 1 ? "" : "s"} sent, ${n === 0 ? "no problems a customer would notice" : `${n} problem${n === 1 ? "" : "s"} a customer would notice`}${f.missing.length ? `, ${f.missing.length} planned message${f.missing.length === 1 ? "" : "s"} never sent` : ""}.`;
|
|
48343
|
+
if (run.status === "watching") return `So far: ${head4}${stepNote} Still watching until ${new Date(run.closesAt).toISOString()}.`;
|
|
48344
|
+
return gradeLine + head4 + stepNote;
|
|
48345
|
+
}
|
|
48346
|
+
var import_node_fs7, import_node_path6, import_node_crypto20, MAX_EXTEND_MINUTES, asObj8, asArr4, str10, SAFE_ID, notFound;
|
|
48347
|
+
var init_run = __esm({
|
|
48348
|
+
"src/command-os/shopper/run.ts"() {
|
|
48349
|
+
"use strict";
|
|
48350
|
+
import_node_fs7 = __toESM(require("node:fs"));
|
|
48351
|
+
import_node_path6 = __toESM(require("node:path"));
|
|
48352
|
+
import_node_crypto20 = __toESM(require("node:crypto"));
|
|
48353
|
+
init_credentials_store();
|
|
48354
|
+
init_analyze();
|
|
48355
|
+
init_links2();
|
|
48356
|
+
init_mask();
|
|
48357
|
+
init_account_rules();
|
|
48358
|
+
init_score();
|
|
48359
|
+
init_steps();
|
|
48360
|
+
init_net_guard();
|
|
48361
|
+
init_test_contacts();
|
|
48362
|
+
MAX_EXTEND_MINUTES = 10;
|
|
48363
|
+
asObj8 = (v) => v && typeof v === "object" ? v : {};
|
|
48364
|
+
asArr4 = (v) => Array.isArray(v) ? v : [];
|
|
48365
|
+
str10 = (v) => typeof v === "string" ? v : void 0;
|
|
48366
|
+
SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
48367
|
+
notFound = (e) => {
|
|
48368
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
48369
|
+
return /GHL API Error 404\b/.test(m) || /GHL API Error 400\b/.test(m) && /contact\b[^\n]{0,60}not found/i.test(m);
|
|
48370
|
+
};
|
|
48371
|
+
}
|
|
48372
|
+
});
|
|
48373
|
+
|
|
48374
|
+
// src/command-os/shopper/fastforward.ts
|
|
48375
|
+
var fastforward_exports = {};
|
|
48376
|
+
__export(fastforward_exports, {
|
|
48377
|
+
FF_PREFIX: () => FF_PREFIX,
|
|
48378
|
+
SWEEP_MIN_AGE_MS: () => SWEEP_MIN_AGE_MS,
|
|
48379
|
+
builderAdmin: () => builderAdmin,
|
|
48380
|
+
journal: () => journal,
|
|
48381
|
+
makeCopies: () => makeCopies,
|
|
48382
|
+
newToken: () => newToken,
|
|
48383
|
+
normalizeHost: () => normalizeHost,
|
|
48384
|
+
openCopies: () => openCopies,
|
|
48385
|
+
planCopy: () => planCopy,
|
|
48386
|
+
remapSelf: () => remapSelf,
|
|
48387
|
+
remapWorkflows: () => remapWorkflows,
|
|
48388
|
+
removeCopies: () => removeCopies,
|
|
48389
|
+
staffRecipients: () => staffRecipients,
|
|
48390
|
+
sweep: () => sweep,
|
|
48391
|
+
toSource: () => toSource,
|
|
48392
|
+
webhookDestinations: () => webhookDestinations
|
|
48393
|
+
});
|
|
48394
|
+
function windowText(raw) {
|
|
48395
|
+
const w = asObj9(raw);
|
|
48396
|
+
if (!w.start || !w.end) return void 0;
|
|
48397
|
+
const days = asArr5(w.days).map((d) => typeof d === "number" ? DAY_NAMES[d] ?? String(d) : String(d));
|
|
48398
|
+
const cond = str11(w.condition);
|
|
48399
|
+
return `runs only ${str11(w.start)}\u2013${str11(w.end)}${days.length ? ` on ${days.join(", ")}` : ""}${cond ? ` (${cond})` : ""}`;
|
|
48400
|
+
}
|
|
48401
|
+
function rerouteAlert(attrs, owner) {
|
|
48402
|
+
const channel = str11(attrs.type) ?? "notification";
|
|
48403
|
+
if (channel === "notification") {
|
|
48404
|
+
const n = asObj9(attrs.notification);
|
|
48405
|
+
attrs.notification = { body: n.body ?? "", title: `TEST: ${str11(n.title) ?? ""}`.trim(), userType: "user", redirectPage: n.redirectPage ?? "contact", type: n.type ?? "send_notification", selectedUser: owner };
|
|
48406
|
+
delete attrs.email;
|
|
48407
|
+
delete attrs.sms;
|
|
48408
|
+
return void 0;
|
|
48409
|
+
}
|
|
48410
|
+
if (channel === "email") {
|
|
48411
|
+
const e = asObj9(attrs.email);
|
|
48412
|
+
attrs.email = { isCloned: false, html: e.html ?? "", subject: `TEST: ${str11(e.subject) ?? ""}`.trim(), selectedUser: [owner], userType: "user", attachments: [] };
|
|
48413
|
+
delete attrs.notification;
|
|
48414
|
+
delete attrs.sms;
|
|
48415
|
+
return void 0;
|
|
48416
|
+
}
|
|
48417
|
+
return channel;
|
|
48418
|
+
}
|
|
48419
|
+
function staffRecipients(step) {
|
|
48420
|
+
const type = str11(step.type) ?? "";
|
|
48421
|
+
const a = asObj9(step.attributes);
|
|
48422
|
+
if (type === "internal_notification") {
|
|
48423
|
+
const out = [];
|
|
48424
|
+
for (const k of ["notification", "email", "sms"]) {
|
|
48425
|
+
const ch = asObj9(a[k]);
|
|
48426
|
+
if (!Object.keys(ch).length) continue;
|
|
48427
|
+
if (str11(ch.userType) !== "user") out.push(`everyone (${k})`);
|
|
48428
|
+
for (const u of Array.isArray(ch.selectedUser) ? ch.selectedUser : [ch.selectedUser]) if (typeof u === "string") out.push(u);
|
|
48429
|
+
}
|
|
48430
|
+
return out;
|
|
48431
|
+
}
|
|
48432
|
+
if (type === "task-notification") return [str11(a.assignedTo) ?? "nobody"];
|
|
48433
|
+
if (type === "assign_user") return [...asArr5(a.user_list), ...asArr5(a.customUserList)].map((u) => typeof u === "string" ? u : JSON.stringify(u));
|
|
48434
|
+
if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
|
|
48435
|
+
return [...a.assignedTo !== void 0 ? [String(a.assignedTo)] : [], ...asArr5(a.__customInputFields__).map(asObj9).filter((f) => /assign/i.test(str11(f.filterField) ?? "")).map((f) => String(f.value))];
|
|
48436
|
+
}
|
|
48437
|
+
return [];
|
|
48438
|
+
}
|
|
48439
|
+
function waitLength(a) {
|
|
48440
|
+
const s = asObj9(a.startAfter);
|
|
48441
|
+
const v = Number(s.value);
|
|
48442
|
+
const unit = (str11(s.type) ?? "").toLowerCase();
|
|
48443
|
+
const word = unit.startsWith("min") ? "minute" : unit.startsWith("hour") ? "hour" : unit.startsWith("day") ? "day" : unit;
|
|
48444
|
+
return `${v} ${word}${v === 1 ? "" : "s"}`;
|
|
48445
|
+
}
|
|
48446
|
+
function webhookDestinations(sources) {
|
|
48447
|
+
const out = [];
|
|
48448
|
+
for (const s of sources) {
|
|
48449
|
+
for (const n of s.templates.map(asObj9)) {
|
|
48450
|
+
if (str11(n.type) !== "webhook") continue;
|
|
48451
|
+
const url = str11(asObj9(n.attributes).url) ?? "";
|
|
48452
|
+
const h = hostOf(url);
|
|
48453
|
+
const merged = /\{\{|%7b/i.test(url.split(/[/?#]/).slice(0, 3).join("/")) || h !== void 0 && /[{}]|%7b|%7d/i.test(h);
|
|
48454
|
+
const problem = merged ? "merge_field" : !h ? "no_address" : void 0;
|
|
48455
|
+
out.push({ ...problem ? { problem } : { host: h }, stepName: str11(n.name) ?? "Webhook", workflowName: s.name });
|
|
48456
|
+
}
|
|
48457
|
+
}
|
|
48458
|
+
return out;
|
|
48459
|
+
}
|
|
48460
|
+
function planCopy(src, opts) {
|
|
48461
|
+
const allowed = opts.allowedHosts.map(normalizeHost).filter(Boolean);
|
|
48462
|
+
const nodes = src.templates.map(asObj9);
|
|
48463
|
+
if (nodes.length === 0) return { ok: false, refusal: `"${src.name}" has no steps to test.` };
|
|
48464
|
+
if (nodes.length > 40) return { ok: false, refusal: `"${src.name}" has ${nodes.length} steps; fast-forward copies at most 40.` };
|
|
48465
|
+
if (str11(src.status)?.toLowerCase() === "draft") return { ok: false, refusal: `"${src.name}" is still a draft, so real leads never go through it. Publish it first, or test it as it is.` };
|
|
48466
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
48467
|
+
for (const n of nodes) {
|
|
48468
|
+
const id2 = str11(n.id);
|
|
48469
|
+
if (id2) idMap.set(id2, import_node_crypto21.default.randomUUID());
|
|
48470
|
+
}
|
|
48471
|
+
const remap = (id2) => typeof id2 === "string" ? idMap.get(id2) ?? id2 : id2;
|
|
48472
|
+
const waits = [];
|
|
48473
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
48474
|
+
const actions = [];
|
|
48475
|
+
for (const n of nodes) {
|
|
48476
|
+
const type = str11(n.type) ?? "";
|
|
48477
|
+
const name2 = str11(n.name) ?? type;
|
|
48478
|
+
if (type === "if_else" || str11(n.nodeType)?.startsWith("branch") || str11(n.cat) === "conditions") {
|
|
48479
|
+
return { ok: false, refusal: `"${src.name}" has an if/else branch ("${name2}"). Fast-forward handles straight-line workflows for now; branches come next, with test leads that book, reply or stay silent.` };
|
|
48480
|
+
}
|
|
48481
|
+
if (type === "add_to_workflow" || type === "goto") {
|
|
48482
|
+
return { ok: false, refusal: `"${src.name}" hands the lead to another place ("${name2}"). Following a lead into other workflows is the next piece of fast-forward.` };
|
|
48483
|
+
}
|
|
48484
|
+
if (!ALLOWED.has(type)) {
|
|
48485
|
+
return { ok: false, refusal: `"${src.name}" has a "${type}" step ("${name2}") that fast-forward does not know how to copy safely yet, so nothing was copied.` };
|
|
48486
|
+
}
|
|
48487
|
+
const attrs = { ...asObj9(n.attributes) };
|
|
48488
|
+
if (type === "wait") {
|
|
48489
|
+
if (str11(attrs.type) !== "time" || !asObj9(attrs.startAfter).type) {
|
|
48490
|
+
return { ok: false, refusal: `"${src.name}" has a wait that is not a plain time wait ("${name2}"). Fast-forward handles time waits only for now.` };
|
|
48491
|
+
}
|
|
48492
|
+
waits.push(`${name2}: ${waitLength(attrs)} in real life`);
|
|
48493
|
+
attrs.startAfter = { type: "minutes", value: 1, when: "after" };
|
|
48494
|
+
}
|
|
48495
|
+
if (type === "internal_notification") {
|
|
48496
|
+
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, staff alerts go to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48497
|
+
const unknown = rerouteAlert(attrs, opts.ownerUserId);
|
|
48498
|
+
if (unknown) return { ok: false, refusal: `"${src.name}" has a staff alert sent by ${unknown === "sms" ? "text" : `"${unknown}"`} ("${name2}"). Fast-forward cannot yet send that kind to you instead of your staff, so nothing was copied.` };
|
|
48499
|
+
}
|
|
48500
|
+
if (type === "assign_user") {
|
|
48501
|
+
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, leads are assigned to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48502
|
+
if (asArr5(attrs.customUserList).length) return { ok: false, refusal: `"${src.name}" assigns the lead from a custom value ("${name2}"). Fast-forward cannot yet send that assignment to you instead of your staff, so nothing was copied.` };
|
|
48503
|
+
const o = opts.ownerUserId;
|
|
48504
|
+
Object.assign(attrs, { user_list: [o], traffic_split: "equally", traffic_weightage: { [o]: 1 }, traffic_index: [{ id: o, indexes: [1] }], total_index: 1 });
|
|
48505
|
+
}
|
|
48506
|
+
if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
|
|
48507
|
+
const assigns = attrs.assignedTo !== void 0 || asArr5(attrs.__customInputFields__).map(asObj9).some((f) => /assign/i.test(str11(f.filterField) ?? ""));
|
|
48508
|
+
if (assigns) {
|
|
48509
|
+
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, cards are assigned to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48510
|
+
if (attrs.assignedTo !== void 0) attrs.assignedTo = opts.ownerUserId;
|
|
48511
|
+
attrs.__customInputFields__ = asArr5(attrs.__customInputFields__).map(asObj9).map((f) => /assign/i.test(str11(f.filterField) ?? "") ? { ...f, value: opts.ownerUserId } : f);
|
|
48512
|
+
}
|
|
48513
|
+
}
|
|
48514
|
+
if (type === "task-notification") {
|
|
48515
|
+
if (!opts.ownerUserId) return { ok: false, refusal: 'During a fast-forward check, tasks go to one person instead of your staff, but nobody was chosen and your email is not a user in this account. Choose who should get them (on the Workflow Audit Pro screen, "Staff alerts, tasks and assignments go to"; in chat, name the person).' };
|
|
48516
|
+
attrs.assignedTo = opts.ownerUserId;
|
|
48517
|
+
attrs.title = `TEST: ${str11(attrs.title) ?? ""}`.trim();
|
|
48518
|
+
}
|
|
48519
|
+
if (type === "webhook") {
|
|
48520
|
+
const url = str11(attrs.url) ?? "";
|
|
48521
|
+
if (/^[^/]*\/\/[^/?#]*\{\{/.test(url) || /^\{\{/.test(url)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
|
|
48522
|
+
const h = hostOf(url);
|
|
48523
|
+
if (h && /[{}]|%7b|%7d/i.test(h)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
|
|
48524
|
+
if (!h) return { ok: false, refusal: `"${src.name}" has a webhook ("${name2}") without a readable address.` };
|
|
48525
|
+
hosts.add(h);
|
|
48526
|
+
if (!allowed.includes(h)) {
|
|
48527
|
+
return { ok: false, refusal: `"${src.name}" sends the lead to ${h} ("${name2}"). A fast-forward check sends the test lead there for real, so tick ${h} to allow it.` };
|
|
48528
|
+
}
|
|
48529
|
+
}
|
|
48530
|
+
const copy = { ...n, id: remap(n.id), attributes: attrs };
|
|
48531
|
+
if (n.next !== void 0) copy.next = Array.isArray(n.next) ? n.next.map(remap) : remap(n.next);
|
|
48532
|
+
if (n.parentKey !== void 0) copy.parentKey = remap(n.parentKey);
|
|
48533
|
+
if (n.parent !== void 0) copy.parent = remap(n.parent);
|
|
48534
|
+
if (Array.isArray(n.sibling)) copy.sibling = n.sibling.map(remap);
|
|
48535
|
+
actions.push(copy);
|
|
48536
|
+
}
|
|
48537
|
+
const w = asObj9(src.window);
|
|
48538
|
+
return {
|
|
48539
|
+
ok: true,
|
|
48540
|
+
plan: {
|
|
48541
|
+
sourceId: src.id,
|
|
48542
|
+
sourceName: src.name,
|
|
48543
|
+
actions,
|
|
48544
|
+
waits,
|
|
48545
|
+
window: windowText(w),
|
|
48546
|
+
webhookHosts: [...hosts],
|
|
48547
|
+
stepIds: Object.fromEntries([...idMap].map(([src2, copy]) => [copy, src2])),
|
|
48548
|
+
settings: { stopOnResponse: src.stopOnResponse, allowMultiple: src.allowMultiple, removeContactFromLastStep: src.removeContactFromLastStep }
|
|
48549
|
+
}
|
|
48550
|
+
};
|
|
48551
|
+
}
|
|
48552
|
+
function remapWorkflows(value, sourceToCopy, key2) {
|
|
48553
|
+
const REF = /* @__PURE__ */ new Set(["workflow_id", "workflowId", "targetWorkflowId"]);
|
|
48554
|
+
if (typeof value === "string") return key2 !== void 0 && REF.has(key2) ? sourceToCopy.get(value) ?? value : value;
|
|
48555
|
+
if (Array.isArray(value)) return value.map((v) => remapWorkflows(v, sourceToCopy, key2));
|
|
48556
|
+
if (value && typeof value === "object") {
|
|
48557
|
+
const out = {};
|
|
48558
|
+
for (const [k, v] of Object.entries(value)) out[k] = remapWorkflows(v, sourceToCopy, k);
|
|
48559
|
+
return out;
|
|
48560
|
+
}
|
|
48561
|
+
return value;
|
|
48562
|
+
}
|
|
48563
|
+
function journal(e, base) {
|
|
48564
|
+
const f = journalFile(base);
|
|
48565
|
+
import_node_fs8.default.mkdirSync(import_node_path7.default.dirname(f), { recursive: true, mode: 448 });
|
|
48566
|
+
import_node_fs8.default.appendFileSync(f, JSON.stringify(e) + "\n", { encoding: "utf8", mode: 384 });
|
|
48567
|
+
}
|
|
48568
|
+
function openCopies(locationId2, base) {
|
|
48569
|
+
let lines = [];
|
|
48570
|
+
try {
|
|
48571
|
+
lines = import_node_fs8.default.readFileSync(journalFile(base), "utf8").split("\n").filter(Boolean);
|
|
48572
|
+
} catch {
|
|
48573
|
+
return [];
|
|
48574
|
+
}
|
|
48575
|
+
const byName = /* @__PURE__ */ new Map();
|
|
48576
|
+
for (const l of lines) {
|
|
48577
|
+
let e;
|
|
48578
|
+
try {
|
|
48579
|
+
e = JSON.parse(l);
|
|
48580
|
+
} catch {
|
|
48581
|
+
continue;
|
|
48582
|
+
}
|
|
48583
|
+
if (e.locationId !== locationId2) continue;
|
|
48584
|
+
const cur = byName.get(e.name) ?? { name: e.name, at: e.at, deleted: false };
|
|
48585
|
+
if (e.copyId) cur.copyId = e.copyId;
|
|
48586
|
+
if (e.event === "deleted") cur.deleted = true;
|
|
48587
|
+
byName.set(e.name, cur);
|
|
48588
|
+
}
|
|
48589
|
+
return [...byName.values()].filter((x) => !x.deleted).map(({ deleted: _d, ...x }) => x);
|
|
48590
|
+
}
|
|
48591
|
+
function toSource(id2, raw) {
|
|
48592
|
+
const wf = asObj9(raw);
|
|
48593
|
+
return {
|
|
48594
|
+
id: id2,
|
|
48595
|
+
name: str11(wf.name) ?? id2,
|
|
48596
|
+
status: str11(wf.status),
|
|
48597
|
+
templates: asArr5(asObj9(wf.workflowData).templates),
|
|
48598
|
+
triggers: asArr5(wf.triggers),
|
|
48599
|
+
stopOnResponse: typeof wf.stopOnResponse === "boolean" ? wf.stopOnResponse : void 0,
|
|
48600
|
+
allowMultiple: typeof wf.allowMultiple === "boolean" ? wf.allowMultiple : void 0,
|
|
48601
|
+
removeContactFromLastStep: typeof wf.removeContactFromLastStep === "boolean" ? wf.removeContactFromLastStep : void 0,
|
|
48602
|
+
window: wf.window
|
|
48603
|
+
};
|
|
48604
|
+
}
|
|
48605
|
+
async function makeCopies(admin, locationId2, sourceIds, opts) {
|
|
48606
|
+
const now = opts.now ?? Date.now;
|
|
48607
|
+
if (sourceIds.length > 5) return { ok: false, refusal: "A fast-forward check copies at most 5 workflows.", copies: [] };
|
|
48608
|
+
const plans = [];
|
|
48609
|
+
for (const id2 of sourceIds) {
|
|
48610
|
+
const r = planCopy(toSource(id2, await admin.get(id2)), opts);
|
|
48611
|
+
if (!r.ok) return { ok: false, refusal: r.refusal, copies: [] };
|
|
48612
|
+
plans.push(r.plan);
|
|
48613
|
+
}
|
|
48614
|
+
if (!/^[0-9a-z]{6}$/.test(opts.token)) return { ok: false, refusal: "Internal: a fast-forward token must be 6 letters or digits.", copies: [] };
|
|
48615
|
+
const copies = [];
|
|
48616
|
+
const fail = (e) => ({ ok: false, refusal: `The fast-forward copy could not be set up: ${e instanceof Error ? e.message.split("\n")[0] : String(e)} The copies made so far are being removed.`, copies });
|
|
48617
|
+
for (const p of plans) {
|
|
48618
|
+
const name2 = Array.from(`${FF_PREFIX} ${opts.token} ${p.sourceName}`).slice(0, 120).join("");
|
|
48619
|
+
journal({ at: now(), locationId: locationId2, event: "creating", name: name2 }, opts.base);
|
|
48620
|
+
try {
|
|
48621
|
+
const copyId = (await admin.create(name2)).id;
|
|
48622
|
+
if (!copyId || sourceIds.includes(copyId)) throw new Error("GoHighLevel did not return a new workflow id.");
|
|
48623
|
+
journal({ at: now(), locationId: locationId2, event: "created", name: name2, copyId }, opts.base);
|
|
48624
|
+
copies.push({ copyId, copyName: name2, sourceId: p.sourceId, sourceName: p.sourceName, waits: p.waits, window: p.window, stepIds: p.stepIds });
|
|
48625
|
+
} catch (e) {
|
|
48626
|
+
return fail(e);
|
|
48627
|
+
}
|
|
48628
|
+
}
|
|
48629
|
+
const sourceToCopy = new Map(copies.map((c) => [c.sourceId, c.copyId]));
|
|
48630
|
+
for (const [i, p] of plans.entries()) {
|
|
48631
|
+
const { copyId, copyName } = copies[i];
|
|
48632
|
+
try {
|
|
48633
|
+
await admin.save(copyId, { actions: remapWorkflows(p.actions, sourceToCopy), triggers: [], status: "draft", window: null, ...p.settings });
|
|
48634
|
+
const back = asObj9(await admin.get(copyId));
|
|
48635
|
+
const steps = asArr5(asObj9(back.workflowData).templates).map(asObj9);
|
|
48636
|
+
const longWait = steps.find((s) => str11(s.type) === "wait" && Number(asObj9(asObj9(s.attributes).startAfter).value) !== 1);
|
|
48637
|
+
if (asArr5(back.triggers).length !== 0) throw new Error(`the copy of "${p.sourceName}" came back with a trigger, so it was not switched on.`);
|
|
48638
|
+
if (steps.length !== p.actions.length) throw new Error(`the copy of "${p.sourceName}" came back with ${steps.length} steps instead of ${p.actions.length}.`);
|
|
48639
|
+
if (longWait) throw new Error(`a wait in the copy of "${p.sourceName}" did not shorten.`);
|
|
48640
|
+
if (!(str11(back.name) ?? "").startsWith(tokenPrefix(copyName))) throw new Error("the copy's name did not save.");
|
|
48641
|
+
const stray = steps.flatMap(staffRecipients).find((u) => u !== opts.ownerUserId);
|
|
48642
|
+
if (stray) throw new Error(`a staff step in the copy of "${p.sourceName}" still reaches ${stray} instead of the person chosen, so it was not switched on.`);
|
|
48643
|
+
if (steps.some((s) => sourceIds.some((id2) => JSON.stringify(s).includes(`"${id2}"`)))) throw new Error(`a step in the copy of "${p.sourceName}" still points at an original workflow, so it was not switched on.`);
|
|
48644
|
+
await admin.publish(copyId);
|
|
48645
|
+
} catch (e) {
|
|
48646
|
+
return fail(e);
|
|
48647
|
+
}
|
|
48648
|
+
}
|
|
48649
|
+
return { ok: true, copies };
|
|
48650
|
+
}
|
|
48651
|
+
async function removeCopies(admin, locationId2, copies, base, now = Date.now) {
|
|
48652
|
+
const problems = [];
|
|
48653
|
+
for (const c of copies) {
|
|
48654
|
+
if (!c.copyId) continue;
|
|
48655
|
+
try {
|
|
48656
|
+
const wf = asObj9(await admin.get(c.copyId));
|
|
48657
|
+
const name2 = str11(wf.name) ?? "";
|
|
48658
|
+
const ours = TOKEN_NAME.test(c.name) && (c.matchedByName ? name2 === c.name : name2.startsWith(tokenPrefix(c.name)));
|
|
48659
|
+
if (!ours) {
|
|
48660
|
+
problems.push(`Left alone: workflow ${c.copyId} is not a fast-forward copy this check made (named "${name2}").`);
|
|
48661
|
+
continue;
|
|
48662
|
+
}
|
|
48663
|
+
const parked = await admin.parked(c.copyId);
|
|
48664
|
+
if (parked > (c.matchedByName ? 0 : 1)) {
|
|
48665
|
+
problems.push(`Not removed: ${parked} contact${parked === 1 ? " is" : "s are"} in "${name2}", more than our test lead. Check it in GoHighLevel.`);
|
|
48666
|
+
continue;
|
|
48667
|
+
}
|
|
48668
|
+
await admin.remove(c.copyId, parked || void 0);
|
|
48669
|
+
journal({ at: now(), locationId: locationId2, event: "deleted", name: c.name, copyId: c.copyId }, base);
|
|
48670
|
+
} catch (e) {
|
|
48671
|
+
const msg3 = e instanceof Error ? e.message : String(e);
|
|
48672
|
+
if (/\b404\b|not found/i.test(msg3)) {
|
|
48673
|
+
journal({ at: now(), locationId: locationId2, event: "deleted", name: c.name, copyId: c.copyId }, base);
|
|
48674
|
+
continue;
|
|
48675
|
+
}
|
|
48676
|
+
problems.push(`The copy "${c.name}" could not be removed (${msg3.split("\n")[0]}). Delete it in GoHighLevel.`);
|
|
48677
|
+
}
|
|
48678
|
+
}
|
|
48679
|
+
return problems;
|
|
48680
|
+
}
|
|
48681
|
+
async function sweep(admin, locationId2, base, now = Date.now, keep = []) {
|
|
48682
|
+
const open2 = openCopies(locationId2, base).filter((o) => (!o.copyId || !keep.includes(o.copyId)) && now() - o.at >= SWEEP_MIN_AGE_MS && TOKEN_NAME.test(o.name));
|
|
48683
|
+
if (open2.length === 0) return [];
|
|
48684
|
+
const targets = open2.filter((o) => o.copyId);
|
|
48685
|
+
const unnamed = open2.filter((o) => !o.copyId);
|
|
48686
|
+
if (unnamed.length) {
|
|
48687
|
+
try {
|
|
48688
|
+
const all = await admin.list();
|
|
48689
|
+
for (const o of unnamed) {
|
|
48690
|
+
const hits = all.filter((w) => w.name === o.name && !keep.includes(w.id));
|
|
48691
|
+
if (hits.length === 1) targets.push({ copyId: hits[0].id, name: o.name, matchedByName: true });
|
|
48692
|
+
}
|
|
48693
|
+
} catch {
|
|
48694
|
+
}
|
|
48695
|
+
}
|
|
48696
|
+
return removeCopies(admin, locationId2, targets, base, now);
|
|
48697
|
+
}
|
|
48698
|
+
function builderAdmin(b, check) {
|
|
48699
|
+
return {
|
|
48700
|
+
get: (id2) => b.getWorkflow(id2),
|
|
48701
|
+
list: async () => {
|
|
48702
|
+
const out = [];
|
|
48703
|
+
for (let skip = 0; skip < 1e3; skip += 100) {
|
|
48704
|
+
const rows2 = asArr5(asObj9(await b.listWorkflows(100, skip)).rows).map(asObj9);
|
|
48705
|
+
for (const r of rows2) {
|
|
48706
|
+
const id2 = str11(r._id) ?? str11(r.id);
|
|
48707
|
+
if (id2) out.push({ id: id2, name: str11(r.name) ?? "", createdAt: str11(r.createdAt) });
|
|
48708
|
+
}
|
|
48709
|
+
if (rows2.length < 100) break;
|
|
48710
|
+
}
|
|
48711
|
+
return out;
|
|
48712
|
+
},
|
|
48713
|
+
create: async (name2) => {
|
|
48714
|
+
const w = asObj9(await b.createWorkflow(name2));
|
|
48715
|
+
return { id: str11(w._id) ?? str11(w.id) ?? "" };
|
|
48716
|
+
},
|
|
48717
|
+
save: (id2, body) => b.updateWorkflow(id2, body),
|
|
48718
|
+
publish: async (id2) => {
|
|
48719
|
+
if (!check) throw new PublishRefusedError("Not published: the pre-publish check is not connected here, so the copy was not switched on. Nothing was changed.");
|
|
48720
|
+
return b.publishWorkflow(id2, check);
|
|
48721
|
+
},
|
|
48722
|
+
parked: async (id2) => (await b.parkedContacts(id2)).reduce((sum, p) => sum + (Number(p.total) || 0), 0),
|
|
48723
|
+
remove: (id2, ack) => b.deleteWorkflow(id2, ack ? { acknowledgeContacts: ack } : void 0)
|
|
48724
|
+
};
|
|
48725
|
+
}
|
|
48726
|
+
var import_node_fs8, import_node_path7, import_node_crypto21, asObj9, asArr5, str11, FF_PREFIX, ALLOWED, hostOf, normalizeHost, newToken, TOKEN_NAME, tokenPrefix, DAY_NAMES, remapSelf, journalFile, SWEEP_MIN_AGE_MS;
|
|
48727
|
+
var init_fastforward = __esm({
|
|
48728
|
+
"src/command-os/shopper/fastforward.ts"() {
|
|
48729
|
+
"use strict";
|
|
48730
|
+
import_node_fs8 = __toESM(require("node:fs"));
|
|
48731
|
+
import_node_path7 = __toESM(require("node:path"));
|
|
48732
|
+
import_node_crypto21 = __toESM(require("node:crypto"));
|
|
48733
|
+
init_run();
|
|
48734
|
+
init_workflow_publish_gate();
|
|
48735
|
+
asObj9 = (v) => v && typeof v === "object" ? v : {};
|
|
48736
|
+
asArr5 = (v) => Array.isArray(v) ? v : [];
|
|
48737
|
+
str11 = (v) => typeof v === "string" ? v : void 0;
|
|
48738
|
+
FF_PREFIX = "ZZ TEST FF";
|
|
48739
|
+
ALLOWED = /* @__PURE__ */ new Set([
|
|
48740
|
+
"email",
|
|
48741
|
+
"sms",
|
|
48742
|
+
"wait",
|
|
48743
|
+
"add_contact_tag",
|
|
48744
|
+
"remove_contact_tag",
|
|
48745
|
+
"update_contact_field",
|
|
48746
|
+
"internal_create_opportunity",
|
|
48747
|
+
"internal_update_opportunity",
|
|
48748
|
+
"assign_user",
|
|
48749
|
+
"internal_notification",
|
|
48750
|
+
"task-notification",
|
|
48751
|
+
"add_notes",
|
|
48752
|
+
"webhook",
|
|
48753
|
+
"remove_from_workflow",
|
|
48754
|
+
"dnd_contact"
|
|
48755
|
+
]);
|
|
48756
|
+
hostOf = (url) => {
|
|
48757
|
+
try {
|
|
48758
|
+
return new URL(url).hostname.toLowerCase();
|
|
48759
|
+
} catch {
|
|
48760
|
+
return void 0;
|
|
48761
|
+
}
|
|
48762
|
+
};
|
|
48763
|
+
normalizeHost = (h) => {
|
|
48764
|
+
const t = h.trim().toLowerCase();
|
|
48765
|
+
if (!t) return "";
|
|
48766
|
+
return hostOf(/^[a-z][a-z0-9+.-]*:\/\//.test(t) ? t : `https://${t}`) ?? t;
|
|
48767
|
+
};
|
|
48768
|
+
newToken = () => {
|
|
48769
|
+
let t = "";
|
|
48770
|
+
while (t.length < 6) t += (import_node_crypto21.default.randomBytes(1)[0] % 36).toString(36);
|
|
48771
|
+
return t;
|
|
48772
|
+
};
|
|
48773
|
+
TOKEN_NAME = new RegExp(`^${FF_PREFIX} [0-9a-z]{6} `);
|
|
48774
|
+
tokenPrefix = (name2) => name2.match(TOKEN_NAME)?.[0] ?? "\0never";
|
|
48775
|
+
DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
48776
|
+
remapSelf = (value, sourceId, copyId) => remapWorkflows(value, /* @__PURE__ */ new Map([[sourceId, copyId]]));
|
|
48777
|
+
journalFile = (base) => import_node_path7.default.join(shopperDir(base), "fast-forward-journal.jsonl");
|
|
48778
|
+
SWEEP_MIN_AGE_MS = 15 * 6e4;
|
|
49067
48779
|
}
|
|
48780
|
+
});
|
|
48781
|
+
|
|
48782
|
+
// src/command-os/shopper/page.ts
|
|
48783
|
+
function capped(rows2, wrap) {
|
|
48784
|
+
if (rows2.length <= 8) return wrap(rows2.join(""));
|
|
48785
|
+
return `${wrap(rows2.slice(0, 8).join(""))}<details><summary class="cds-link">Show all ${rows2.length}</summary>${wrap(rows2.slice(8).join(""))}</details>`;
|
|
49068
48786
|
}
|
|
49069
|
-
function
|
|
49070
|
-
|
|
48787
|
+
function ringHtml(score2, caption, complete = true) {
|
|
48788
|
+
const s = Math.max(0, Math.min(100, Math.round(score2)));
|
|
48789
|
+
const r = 52, c = 2 * Math.PI * r, g = gradeOf(s), k = complete ? `cds-g-${g.toLowerCase()}` : "cds-g-incomplete";
|
|
48790
|
+
return `<div class="cds-ring" role="img" aria-label="Grade ${g}, ${s} out of 100${complete ? "" : ""}. ${esc2(caption)}"><svg width="160" height="160" viewBox="0 0 120 120" aria-hidden="true"><circle class="trk" cx="60" cy="60" r="${r}" stroke-width="10"></circle><circle class="arc ${k}" cx="60" cy="60" r="${r}" stroke-width="10" stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${(c * (1 - s / 100)).toFixed(2)}" transform="rotate(-90 60 60)"></circle><text class="letter ${k}" x="60" y="66" text-anchor="middle" font-size="44">${g}</text><text class="out" x="60" y="86" text-anchor="middle">${s}/100</text></svg><div class="cap">${esc2(caption)}</div></div>`;
|
|
49071
48791
|
}
|
|
49072
|
-
function
|
|
49073
|
-
|
|
48792
|
+
function stepRow(r) {
|
|
48793
|
+
const [tone, word] = r.gaveUp ? ["bad", "Failed, gave up"] : OUTCOME[r.outcome] ?? ["", ""];
|
|
48794
|
+
return `<li><span class="t"><time data-ms="${r.at}">${esc2(new Date(r.at).toISOString().slice(11, 16))}</time></span>${dot(tone)}<span class="what">${esc2(r.stepName)}<small>${esc2(r.detail)}</small></span>${word ? chip(tone, word) : ""}</li>`;
|
|
48795
|
+
}
|
|
48796
|
+
function stepsHtml(a) {
|
|
48797
|
+
if (!a) return "";
|
|
48798
|
+
if (!a.read) return panel("Every step", "What each workflow step did", "", callout("warn", "", esc2(a.unreadable ?? "The step log could not be read.")));
|
|
48799
|
+
const rows2 = a.problems.map((p) => `<li><span class="ttl">${esc2(p.stepName)}<span class="sub">${esc2(p.detail)}</span><span class="sub"><b>The fix:</b> ${esc2(p.fix)}</span></span><span class="end">${/^The Checkup found: /.test(p.detail ?? "") && p.kind !== "failed_holding" && p.kind !== "failed_gave_up" ? chip("bad", "Failed") : chip(p.kind === "skipped" ? "warn" : "bad", PROBLEM_CHIP[p.kind])}${chip("", p.workflowName)}</span></li>`);
|
|
48800
|
+
const diagnosed = a.problems.filter((p) => p.kind !== "failed_holding" && p.kind !== "failed_gave_up" && /^The Checkup found: /.test(p.detail ?? "")).length;
|
|
48801
|
+
const byWf = /* @__PURE__ */ new Map();
|
|
48802
|
+
for (const r of a.rows) byWf.set(r.workflowName, [...byWf.get(r.workflowName) ?? [], r]);
|
|
48803
|
+
const notes = [
|
|
48804
|
+
...a.unreadWorkflows?.length ? [`Could not read the steps of: ${a.unreadWorkflows.join(", ")}. Their steps are not judged here.`] : [],
|
|
48805
|
+
...a.partialWorkflows?.length ? [`Very busy workflow, only partly read: ${a.partialWorkflows.join(", ")}. Some of the test lead's steps may be missing from this list.`] : [],
|
|
48806
|
+
...a.rows.length === 0 ? ["No steps were logged for the test lead. That is not a pass: the lead may not have entered, or GoHighLevel had not logged it yet."] : [],
|
|
48807
|
+
...a.counts.unclear ? [`${a.counts.unclear} step${a.counts.unclear === 1 ? " has" : "s have"} a status this version cannot read; check ${a.counts.unclear === 1 ? "it" : "them"} in GoHighLevel's Execution logs.`] : []
|
|
48808
|
+
];
|
|
48809
|
+
const noteHtml = notes.length ? callout("warn", "", notes.map(esc2).join("<br>")) : "";
|
|
48810
|
+
const lists = [...byWf.entries()].map(([name2, steps]) => `<details${a.problems.length ? " open" : ""}><summary class="cds-link">${esc2(name2)} (${steps.length} step${steps.length === 1 ? "" : "s"})</summary>${capped(steps.map(stepRow), tl)}</details>`).join("");
|
|
48811
|
+
return `${a.problems.length ? panel("Fix these too", "Steps that did not do their job", "From GoHighLevel's own execution log", capped(rows2, list2)) : ""}
|
|
48812
|
+
${panel("Every step", "What each workflow step did", `${a.counts.ran} ran \xB7 ${Math.max(0, a.counts.didNothing - diagnosed)} skipped or did nothing \xB7 ${a.counts.failed + diagnosed} failed`, noteHtml + lists)}`;
|
|
48813
|
+
}
|
|
48814
|
+
function marked(quote2, highlight) {
|
|
48815
|
+
if (!highlight) return esc2(quote2);
|
|
48816
|
+
const i = quote2.indexOf(highlight);
|
|
48817
|
+
if (i < 0) return esc2(quote2);
|
|
48818
|
+
return `${esc2(quote2.slice(0, i))}<mark>${esc2(highlight)}</mark>${esc2(quote2.slice(i + highlight.length))}`;
|
|
48819
|
+
}
|
|
48820
|
+
function problemRow(p) {
|
|
48821
|
+
return `<li><span class="ttl">${esc2(p.title)}<span class="sub">What the lead got: "${marked(p.quote, p.highlight)}"</span><span class="sub">Sent by: ${p.sentBy ? esc2(p.sentBy) : "Not matched to a planned step"}</span><span class="sub"><b>The fix:</b> ${esc2(p.fix)}</span></span><span class="end">${chip("bad", CHIP[p.kind])}${chip("info", CHANNEL[p.channel] ?? "Message")}${chip("", mins(p.minute))}</span></li>`;
|
|
48822
|
+
}
|
|
48823
|
+
function timelineRow(t) {
|
|
48824
|
+
const [tone, word] = t.problems.length === 0 ? ["ok", "OK"] : t.problems.length === 1 ? ["bad", CHIP[t.problems[0]]] : ["bad", `${t.problems.length} problems`];
|
|
48825
|
+
const what = t.channel === "email" ? `Email: "${esc2(t.subject ?? t.preview)}"` : `${CHANNEL[t.channel] ?? "Message"}: "${esc2(t.preview)}"`;
|
|
48826
|
+
return `<li><span class="t">${mins(t.minute)}</span>${dot(tone)}<span class="what">${what}<small>${esc2(t.sentBy ?? (t.source === "workflow" ? "Sent by a workflow" : t.source ? `Sent by ${t.source}` : ""))}</small></span>${chip(tone, word)}</li>`;
|
|
48827
|
+
}
|
|
48828
|
+
function aboutHtml(run) {
|
|
48829
|
+
const rows2 = [
|
|
48830
|
+
["Started", when(run.startedAt)],
|
|
48831
|
+
["Test lead", `${esc2(run.leadEmail)} (your address, not a customer's)`],
|
|
48832
|
+
...run.leadHasFirstName ? [] : [["First name", "No first name, on purpose"]],
|
|
48833
|
+
["Watching for", `${run.watchMinutes} minute${run.watchMinutes === 1 ? "" : "s"}${run.watchReason ? `. ${esc2(run.watchReason)}` : ""}`]
|
|
48834
|
+
];
|
|
48835
|
+
return panel("The test lead", "How this check ran", "", `<div class="cds-table-wrap"><table class="cds-table"><tbody>${rows2.map(([k, v]) => `<tr><th scope="row">${k}</th><td>${v}</td></tr>`).join("")}</tbody></table></div>`);
|
|
48836
|
+
}
|
|
48837
|
+
function resultHtml(run, now, clientName, action) {
|
|
48838
|
+
if (run.status === "failed") {
|
|
48839
|
+
return {
|
|
48840
|
+
head: pageHead("The Workflow Audit Pro could not run", esc2(run.error ?? "Unknown error"), action),
|
|
48841
|
+
rest: callout("warn", "", esc2(run.cleanup?.detail ?? "No test lead was sent."))
|
|
48842
|
+
};
|
|
48843
|
+
}
|
|
49074
48844
|
const f = run.findings;
|
|
49075
|
-
const
|
|
49076
|
-
const
|
|
49077
|
-
|
|
49078
|
-
|
|
49079
|
-
|
|
49080
|
-
|
|
49081
|
-
|
|
49082
|
-
|
|
49083
|
-
|
|
49084
|
-
|
|
49085
|
-
|
|
49086
|
-
|
|
48845
|
+
const watching = run.status === "watching";
|
|
48846
|
+
const card = scoreRun(run);
|
|
48847
|
+
if (run.status === "done" && card.graded && f) {
|
|
48848
|
+
const href = reportHref(run);
|
|
48849
|
+
const charged2 = card.deductions.filter((d) => d.points > 0).length;
|
|
48850
|
+
const leftovers = `${run.fastForward && !run.fastForward.removed ? callout("warn", "Not cleaned up:", ` temporary copies are still in the account: ${esc2(run.fastForward.copies.map((c) => `"${c.copyName}"`).join(", "))}. Delete them in GoHighLevel.`) : ""}${run.cleanup && !run.cleanup.removed ? callout("warn", "Test lead:", ` ${esc2(run.cleanup.detail)}`) : ""}`;
|
|
48851
|
+
const summary = `<div class="sum"><div class="cds-stack"><p>Finished ${when(run.finishedAt ?? run.startedAt)}. ${esc2(card.coverage.split(". Not checked")[0])}.</p>${leftovers}<div class="cds-row cds-no-print"><a class="cds-btn ghost" href="${href}&print=1" target="_blank" rel="noopener">Save as PDF</a></div></div>
|
|
48852
|
+
${ringHtml(card.score, card.coverageLabel ?? card.coverage, true)}</div>`;
|
|
48853
|
+
return {
|
|
48854
|
+
head: pageHead(
|
|
48855
|
+
charged2 ? `${charged2} ${charged2 === 1 ? "thing" : "things"} would let a new lead down` : "No problems in what was checked",
|
|
48856
|
+
"The report card is ready: the grade, what to fix, how the score was reached, and every step. Open it to read, save as a PDF or hand to your client.",
|
|
48857
|
+
action
|
|
48858
|
+
),
|
|
48859
|
+
rest: panel("Report card", esc2(clientName) || "This check", "", summary)
|
|
48860
|
+
};
|
|
49087
48861
|
}
|
|
49088
|
-
const
|
|
49089
|
-
|
|
49090
|
-
|
|
48862
|
+
const minutesLeft = Math.max(0, Math.ceil((run.closesAt - now) / 6e4));
|
|
48863
|
+
const planned = run.planNote.source !== "none" && run.expect.length > 0;
|
|
48864
|
+
const held = f?.notYetDue.filter((m) => m.heldByWait) ?? [];
|
|
48865
|
+
const title2 = !f ? "Watching what the test lead receives" : f.sent === 0 ? !watching && nothingArrivedHeading(run.steps) || `Nothing arrived in ${watching ? "the time so far" : `${run.watchMinutes} minutes`}` : f.problems.length || f.missing.length ? "What a new lead actually received" : held.length ? "Not finished: GoHighLevel was still holding the test lead" : planned ? "A new lead got exactly what you planned" : "No problems in what the lead received";
|
|
48866
|
+
const head4 = pageHead(title2, `${productName()} made a test lead, then read every text and email this account sent it, word for word. ${esc2(run.entryNote)}`, action);
|
|
48867
|
+
const worst = f?.problems[0];
|
|
48868
|
+
const quote2 = (tone, text3, cite) => `<div class="cds-callout quote${tone ? ` ${tone}` : ""}">${text3}<cite>${cite}</cite></div>`;
|
|
48869
|
+
const quoteHtml = f && f.sent === 0 ? quote2("warn", "The test lead received no text and no email.", esc2(nothingArrivedWhy(run.steps, run.draftWorkflows))) : worst ? quote2("bad", `${worst.kind === "blank_field" ? "A new lead with no first name on file got" : "A new lead got"} "${marked(worst.quote, "")}"`, `${esc2(worst.title)}${f && f.problems.length > 1 ? `. The first of ${f.problems.length}.` : "."}`) : f && held.length && !f.missing.length ? quote2("warn", `${held.length} planned message${held.length === 1 ? " was" : "s were"} still waiting behind a wait GoHighLevel had not let go of.`, "Not judged. Run the check again.") : f && f.missing.length ? quote2("warn", `${f.missing.length} planned message${f.missing.length === 1 ? " never" : "s never"} arrived.`, "Everything that did arrive read correctly.") : f ? quote2("", `Every message read correctly${planned ? " and arrived when planned" : ""}.`, `Checked for the first ${run.watchMinutes} minutes. Later messages are listed below as not yet checked.`) : "";
|
|
48870
|
+
const stats = f ? `<div class="stats">
|
|
48871
|
+
${statTile(f.sent, "Messages sent")}
|
|
48872
|
+
${statTile(f.problems.length, "A customer would notice", f.problems.length ? "bad" : "")}
|
|
48873
|
+
${run.steps?.read ? statTile(run.steps.rows.length ? run.steps.problems.length : "\u2013", "Steps failed, skipped or did nothing", run.steps.problems.length ? "bad" : run.steps.rows.length && !run.steps.unreadWorkflows?.length && !run.steps.partialWorkflows?.length && !run.steps.counts.unclear ? "" : "warn") : ""}
|
|
48874
|
+
${statTile(run.planNote.source === "none" ? "\u2013" : f.missing.length, "Planned, never sent", f.missing.length || held.length ? "warn" : "")}
|
|
48875
|
+
${(() => {
|
|
48876
|
+
const opened = f.links.total - f.links.skipped;
|
|
48877
|
+
return opened ? statTile(`${f.links.ok} of ${opened}`, `Links open${f.links.skipped ? ` \xB7 ${f.links.skipped} not opened` : ""}`, f.links.broken ? "bad" : "") : statTile(0, `Links opened${f.links.skipped ? ` \xB7 ${f.links.skipped} not` : ""}`);
|
|
48878
|
+
})()}
|
|
48879
|
+
</div>` : "";
|
|
48880
|
+
const notGraded = !card.graded && run.status === "done" ? callout("warn", "Not graded.", ` ${esc2(card.reason)}`) : "";
|
|
48881
|
+
const watchNote = watching ? callout("info", "", `<span><b>Still watching.</b> ${minutesLeft ? `About ${minutesLeft} minute${minutesLeft === 1 ? "" : "s"} left; this page updates itself and finishes on its own.` : "Finishing now."}${f ? " What you see below is a partial look." : ""}</span><button class="cds-btn ghost" type="button" id="look-now">Look now</button>`, ` role="status" id="watching"`) : "";
|
|
48882
|
+
const draftNote = run.draftWorkflows?.length ? callout("bad", "Still a draft, so it did nothing:", ` ${esc2(run.draftWorkflows.join(", "))}. GoHighLevel accepts a lead added to a draft workflow and then runs none of its steps, with no error anywhere. Publish it, then run the Workflow Audit Pro again.`) : "";
|
|
48883
|
+
const top = notGraded + watchNote + stats + quoteHtml + draftNote;
|
|
48884
|
+
if (!f) return { head: head4, rest: top + aboutHtml(run) };
|
|
48885
|
+
const fixes = f.problems.length ? panel("Fix these", "What a customer would have seen wrong", `${f.problems.length} problem${f.problems.length === 1 ? "" : "s"} \xB7 most serious first`, capped(f.problems.map(problemRow), list2)) : "";
|
|
48886
|
+
const rows2 = [
|
|
48887
|
+
...f.timeline.map(timelineRow),
|
|
48888
|
+
...f.missing.map((m) => `<li><span class="t">by ${mins(m.byMinute)}</span>${dot("warn")}<span class="what">Planned but never sent: ${esc2(m.label)}<small>Its workflow may be a draft, its step may have failed, or a condition skipped it.</small></span>${chip("warn", "Missing")}</li>`)
|
|
48889
|
+
];
|
|
48890
|
+
const timeline = panel(
|
|
48891
|
+
"Timeline",
|
|
48892
|
+
"Everything the lead received, in order",
|
|
48893
|
+
"Minutes after the lead went in",
|
|
48894
|
+
rows2.length ? capped(rows2, tl) : `<div class="cds-empty"><h3>Nothing has arrived yet.</h3><p>Every text and email the account sends the test lead is listed here, in order.</p></div>`
|
|
48895
|
+
);
|
|
48896
|
+
const { coverage, cleanup } = coverageParts(run, f);
|
|
48897
|
+
return { head: head4, rest: top + fixes + stepsHtml(run.steps) + timeline + aboutHtml(run) + coverage + cleanup };
|
|
49091
48898
|
}
|
|
49092
|
-
|
|
49093
|
-
|
|
49094
|
-
|
|
48899
|
+
function coverageParts(run, f) {
|
|
48900
|
+
const plan = run.planNote.source === "workflows" ? `Planned messages were read from the workflows: ${run.expect.length}.${run.planNote.conditional ? ` ${run.planNote.conditional} left out because they depend on what the lead does (an if/else or a wait for a reply).` : ""}${run.planNote.noWording ? ` ${run.planNote.noWording} left out because their wording is all merge fields.` : ""}${run.planNote.unread?.length ? ` ${run.planNote.unread.length} workflow${run.planNote.unread.length === 1 ? "" : "s"} could not be read.` : ""}${run.planNote.noPhone ? ` ${run.planNote.noPhone} text-message step${run.planNote.noPhone === 1 ? " was" : "s were"} not checked: no test phone was given.` : ""}` : run.planNote.source === "given" ? `Planned messages were given for this run: ${run.expect.length}.` : "No plan was known for this run, so nothing is reported as missing.";
|
|
48901
|
+
const notYet = [
|
|
48902
|
+
...f.notYetDue.map((m) => m.heldByWait ? `${esc2(m.label)}: GoHighLevel was still holding the test lead at ${esc2(run.heldAt?.map((h) => `"${h.stepName}"`).join(", ") ?? "a wait")} after it should have ended${run.extendedMinutes ? `, even after the check waited ${run.extendedMinutes} extra minutes` : ""}. Not judged; run the check again.` : `${esc2(m.label)}, due after the window closed.`),
|
|
48903
|
+
`Messages after the ${run.watchMinutes}-minute window. Choose a longer watch on the next run.`,
|
|
48904
|
+
"Booking an actual time, or replying as the lead.",
|
|
48905
|
+
"Phone calls and voice agents.",
|
|
48906
|
+
"Whether the wording persuades. This checks that it's correct, not that it sells.",
|
|
48907
|
+
...f.links.skipped ? [`${f.links.skipped} link${f.links.skipped === 1 ? "" : "s"} deliberately not opened: only links on the client's own website are opened, because a tracked link counts as the lead clicking and an unsubscribe link opts them out.`] : [],
|
|
48908
|
+
...run.testPhone ? [] : ["Text messages: no test phone was given, so the lead could not receive any."],
|
|
48909
|
+
...run.fastForward ? [
|
|
48910
|
+
`Real timing: every wait was cut to 1 minute. In real life: ${run.fastForward.copies.flatMap((c) => c.waits).join("; ") || "no waits"}.`,
|
|
48911
|
+
...run.fastForward.copies.filter((c) => c.window).map((c) => `${c.sourceName} ${c.window} in real life; the copy ran at any hour.`),
|
|
48912
|
+
"That staff receive alerts, tasks and assigned leads: during the check they came to you instead.",
|
|
48913
|
+
...run.fastForward.copies.length ? ["Where a webhook's receiving service forwards the lead next: only the first address is checked."] : [],
|
|
48914
|
+
'Wording that depends on time ("see you tomorrow") reads oddly after a 1-minute wait; it is not flagged.'
|
|
48915
|
+
] : [],
|
|
48916
|
+
...run.websiteHost ? [] : ["Links: this account has no website on file, so no link was opened. Add the website in the account's business profile to have them checked."]
|
|
48917
|
+
];
|
|
48918
|
+
const checks = [
|
|
48919
|
+
...run.steps?.read ? ["Every workflow step: ran, did nothing, failed or waiting, from GoHighLevel's own log"] : [],
|
|
48920
|
+
"Blank names and empty fields in every message",
|
|
48921
|
+
"Template code left showing, like {{ \u2026 }}",
|
|
48922
|
+
"Another of your accounts' business names",
|
|
48923
|
+
...f.links.total - f.links.skipped ? ["Links on the client's own website, opened to see they load"] : [],
|
|
48924
|
+
"Messages the plan promised that never came",
|
|
48925
|
+
"The same message sent twice"
|
|
48926
|
+
];
|
|
48927
|
+
const coverage = panel(
|
|
48928
|
+
"Coverage",
|
|
48929
|
+
"What this check looked at",
|
|
48930
|
+
"So a clean result means something",
|
|
48931
|
+
`${list2(checks.map((c) => `<li><span>${esc2(c)}</span></li>`).join(""))}
|
|
48932
|
+
<p>${esc2(plan)}</p>
|
|
48933
|
+
<details><summary class="cds-link">Not checked (${notYet.length})</summary>${list2(notYet.map((n) => `<li><span>${n}</span></li>`).join(""))}</details>`
|
|
48934
|
+
);
|
|
48935
|
+
const ffLine = run.fastForward ? run.fastForward.removed ? " The temporary fast-forward copies were removed." : run.fastForward.problems?.length ? ` ${run.fastForward.problems.join(" ")}` : ` Temporary copies still in the account: ${run.fastForward.copies.map((c) => `"${c.copyName}"`).join(", ")}. The cockpit retries every 30 minutes while it runs; if they are still there tomorrow, delete them in GoHighLevel.` : "";
|
|
48936
|
+
const leftover = !!run.cleanup && (!run.cleanup.removed || !!run.fastForward && !run.fastForward.removed);
|
|
48937
|
+
const cleanup = run.cleanup ? callout(leftover ? "warn" : "", run.cleanup.removed ? "Cleaned up:" : "Test lead:", ` ${esc2(run.cleanup.detail + ffLine)} Anything that looked like a password or key in a message was hidden before it reached this page.`) : "";
|
|
48938
|
+
return { coverage, cleanup };
|
|
48939
|
+
}
|
|
48940
|
+
function startForm(d, inlineSubmit) {
|
|
48941
|
+
if (!d.client) return "";
|
|
48942
|
+
const form = `<form id="start-form" class="startform">
|
|
48943
|
+
<fieldset class="cds-field"><legend class="lbl">Workflows the test lead enters</legend><div class="wf" id="wf-list"><p class="hint">Loading this account's workflows\u2026</p></div></fieldset>
|
|
48944
|
+
<div class="cds-field"><label for="owner-email">Your email</label><div class="hint" id="owner-email-hint">The test lead uses it, so every message comes to you.</div><input class="cds-input" id="owner-email" name="ownerEmail" type="email" required value="${esc2(d.ownerEmail ?? "")}" autocomplete="email" aria-describedby="owner-email-hint"></div>
|
|
48945
|
+
<div class="cds-field"><label for="test-phone">Test phone, optional</label><div class="hint" id="test-phone-hint">A phone you own; texts will really send to it.</div><input class="cds-input" id="test-phone" name="testPhone" type="tel" autocomplete="tel" placeholder="+1 555 010 0100" aria-describedby="test-phone-hint"></div>
|
|
48946
|
+
<div class="cds-field"><label for="watch">Watch for</label><select class="cds-select" id="watch" name="watchMinutes"><option value="auto" selected>Automatic: until the last planned message, plus 2 minutes</option><option value="10">10 minutes</option><option value="30">30 minutes</option><option value="60">1 hour</option><option value="240">4 hours</option><option value="1440">24 hours</option></select></div>
|
|
48947
|
+
|
|
48948
|
+
<label class="cds-check"><input type="checkbox" id="ff" name="fastForward"><span>Fast-forward: test long workflows in minutes.<small>A temporary copy with every step kept and every wait cut to 1 minute; staff alerts, tasks and lead assignments go to one person you choose, marked TEST; the copy is deleted afterwards.</small></span></label>
|
|
48949
|
+
<div class="cds-field" id="alert-to-wrap" hidden><label for="alert-to">Staff alerts, tasks and assignments go to</label><div class="hint" id="alert-to-hint">Instead of your staff, marked TEST.</div><select class="cds-select" id="alert-to" name="alertUserId" aria-describedby="alert-to-hint"></select></div>
|
|
48950
|
+
<fieldset class="cds-field" id="hooks" hidden><legend class="lbl">Outside services these workflows send the lead to</legend><div class="wf" id="hooks-list"></div></fieldset>
|
|
48951
|
+
${callout("warn", "", "The test lead has no first name, on purpose. The workflows you tick will really send to your address and may alert your staff once.")}
|
|
48952
|
+
${inlineSubmit ? `<div class="cds-row"><button class="cds-btn ghost" type="submit" id="start-btn" disabled>Start the check</button></div>` : ""}
|
|
48953
|
+
<div id="start-ask"></div>
|
|
48954
|
+
<p class="cds-callout result" id="start-result" role="status" aria-live="polite"></p>
|
|
48955
|
+
</form>`;
|
|
48956
|
+
return panel("New check", `${d.run?.status === "done" ? "Run another check" : "Run the Workflow Audit Pro"} on ${esc2(d.client.name)}`, "Sends one test lead, then lists the contacts that carry its address", form, ` id="start"`, "cds-no-print");
|
|
48957
|
+
}
|
|
48958
|
+
function shopperPageHtml(d, seatToken, seatHeader) {
|
|
48959
|
+
const picker = `<form class="picker cds-no-print" method="get" action="/shopper"><div class="cds-field"><label for="client">Client account</label><select class="cds-select" id="client" name="client" data-picker="" onchange="this.form.submit()"><option value="">Choose a client\u2026</option>${d.accounts.map((a) => `<option value="${esc2(a.locationId)}"${d.client?.locationId === a.locationId ? ' selected=""' : ""}>${esc2(a.name)}</option>`).join("")}</select></div><noscript><button class="cds-btn ghost" type="submit">Open</button></noscript></form>`;
|
|
48960
|
+
const run = d.run;
|
|
48961
|
+
const graded2 = !!(run && run.status === "done" && run.findings && scoreRun(run).graded);
|
|
48962
|
+
const canStart = !!d.client && run?.status !== "watching";
|
|
48963
|
+
const action = graded2 && run ? `<a class="cds-btn" href="${reportHref(run)}" target="_blank" rel="noopener">Open the report</a>` : canStart ? `<button class="cds-btn" type="submit" form="start-form" id="start-btn" disabled>Start the check</button>` : "";
|
|
48964
|
+
let body;
|
|
48965
|
+
if (!d.client) {
|
|
48966
|
+
body = pageHead("See what a new lead actually receives", `Pick a client account. ${productName()} makes a test lead with your own email address, puts it through the workflows you choose, then reads every text and email the account sent it, word for word, and tells you what a real customer would have seen wrong.`) + picker;
|
|
48967
|
+
} else if (!run) {
|
|
48968
|
+
body = pageHead(`No check has run on ${esc2(d.client.name)} yet`, "Choose the workflows a new lead goes through, usually speed-to-lead and the welcome sequence, then start.", action) + picker + startForm(d, false);
|
|
48969
|
+
} else {
|
|
48970
|
+
const r = resultHtml(run, d.now, d.client.name, action);
|
|
48971
|
+
body = r.head + picker + r.rest + (canStart ? startForm(d, graded2) : "");
|
|
48972
|
+
}
|
|
48973
|
+
return `${cockpitHeadHtml(`Workflow Audit Pro \xB7 ${productName()}`, { extraCss: CSS })}${cockpitHeaderHtml("shopper")}
|
|
48974
|
+
<main class="shopper">${body}</main>${cockpitShellClose()}
|
|
48975
|
+
<script>
|
|
48976
|
+
${COCKPIT_TYPE_JS}
|
|
48977
|
+
(function(){
|
|
48978
|
+
var seat=${scriptJson(seatToken)}, seatHeader=${scriptJson(seatHeader)}, client=${scriptJson(d.client?.locationId ?? "")}, clientName=${scriptJson(d.client?.name ?? "this account")}, run=${scriptJson(d.run ? { runId: d.run.runId, status: d.run.status, due: d.now >= d.run.closesAt } : null)};
|
|
48979
|
+
document.querySelectorAll("time[data-ms]").forEach(function(t){var dt=new Date(Number(t.getAttribute("data-ms")));t.textContent=dt.toLocaleString([], {weekday:"short",hour:"numeric",minute:"2-digit"});});
|
|
48980
|
+
function esc(t){return String(t).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c]})}
|
|
48981
|
+
function hdrs(extra){var h=extra||{};h[seatHeader]=seat;return h}
|
|
48982
|
+
var list=document.getElementById("wf-list");
|
|
48983
|
+
// The list exactly as drawn: Start sends it back, and the server refuses when the account's list has changed since
|
|
48984
|
+
// (safety release 2026-10-14). drawWorkflows keeps the ticks of workflows still on the list.
|
|
48985
|
+
var shownWorkflows=null;
|
|
48986
|
+
function drawWorkflows(ws,keep){shownWorkflows=ws.map(function(w){return {id:w.id,name:w.name,status:w.status}});
|
|
48987
|
+
if(!ws.length){list.innerHTML='<p class="err">This account has no workflows to test.</p>';return}
|
|
48988
|
+
list.innerHTML=ws.map(function(w){return '<label class="cds-check"><input type="checkbox" name="wf" value="'+esc(w.id)+'"'+(keep&&keep[w.id]?' checked':'')+'><span>'+esc(w.name)+(w.status&&w.status!=="published"?' <span class="cds-chip warn">'+esc(w.status)+'</span>':'')+'</span></label>'}).join("");
|
|
48989
|
+
var b=document.getElementById("start-btn");if(b)b.disabled=false}
|
|
48990
|
+
// The cockpit keeps what it has loaded (cockpit-cache.ts): the list says how old it is, with one Refresh; a stale
|
|
48991
|
+
// list is re-read behind the page, and a look that found nothing new never redraws it.
|
|
48992
|
+
${AGE_LINE_JS}
|
|
48993
|
+
var wfAge=null,wfSig="",wfFollow=0,wfAsked=0;
|
|
48994
|
+
function paintWfAge(){if(!list)return;var p=document.getElementById("wf-age");if(!p){p=document.createElement("p");p.id="wf-age";p.className="hint cds-age";p.setAttribute("data-age","shopper");p.setAttribute("role","status");
|
|
48995
|
+
var t=document.createElement("span");t.className="cds-age-text";p.appendChild(t);var b=document.createElement("button");b.type="button";b.className="cds-btn-link cds-age-refresh";b.textContent="Refresh";
|
|
48996
|
+
b.onclick=function(){askWf(false)};p.appendChild(document.createTextNode(" "));p.appendChild(b);list.parentNode.insertBefore(p,list)}
|
|
48997
|
+
p.querySelector(".cds-age-text").textContent=ageLine(wfAge)}
|
|
48998
|
+
function askWf(staleOnly){if(staleOnly&&(!wfAge||!(wfAge.stale||wfAge.cold)||wfAge.refreshing||Date.now()-wfAsked<60000))return;wfAsked=Date.now();
|
|
48999
|
+
post("/api/cache/refresh-client",{page:"shopper",locationId:client,staleOnly:staleOnly}).then(function(j){if(j&&j.queued){wfAge=Object.assign({},wfAge,{refreshing:true});paintWfAge();wfFollow=0;setTimeout(lookWf,3000);
|
|
49000
|
+
/* The one Refresh covers the people list too (Codex MED 5, re-review): look again while it is re-read. */
|
|
49001
|
+
if(alertTo&&alertTo.getAttribute("data-loaded")==="1")setTimeout(function(){lookUsers(1)},3000)}}).catch(function(){});
|
|
49002
|
+
/* ...and the outside services of the ticked workflows, read again now (their own POST). */
|
|
49003
|
+
if(!staleOnly&&ff&&ff.checked)refreshHooks(true)}
|
|
49004
|
+
function lookWf(){fetch("/api/shopper/workflows?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){if(!r.ok)return;wfAge=r.cache||null;
|
|
49005
|
+
if(r.cold){paintWfAge();if(wfAge&&wfAge.refreshing&&wfFollow++<20)setTimeout(lookWf,3000);else if(!wfSig)list.innerHTML='<p class="err">'+esc("This account's workflows could not be read. Check its connection on the board.")+'</p>';return}
|
|
49006
|
+
var s=JSON.stringify(r.workflows);if(s!==wfSig){wfSig=s;var keep={};[].slice.call(list.querySelectorAll('input[name="wf"]:checked')).forEach(function(i){keep[i.value]=true});drawWorkflows(r.workflows,keep)}
|
|
49007
|
+
paintWfAge();if(wfAge&&wfAge.refreshing&&wfFollow++<20)setTimeout(lookWf,3000)}).catch(function(){})}
|
|
49008
|
+
if(list&&client){fetch("/api/shopper/workflows?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){
|
|
49009
|
+
if(!r.ok){list.innerHTML='<p class="err">'+esc(r.error||"This account's workflows could not be read.")+'</p>';return}
|
|
49010
|
+
// Nothing loaded yet (a GET never reads, Codex MED 3): say so, ask for the read by POST, and look until it lands.
|
|
49011
|
+
if(r.cold){list.innerHTML='<p class="hint">'+esc(ageLine(r.cache||{cold:true,refreshing:true}))+'</p>';wfAge=r.cache||{cold:true};paintWfAge();askWf(true);return}
|
|
49012
|
+
wfSig=JSON.stringify(r.workflows);drawWorkflows(r.workflows);
|
|
49013
|
+
if(r.cache){wfAge=r.cache;paintWfAge();askWf(true)}
|
|
49014
|
+
}).catch(function(){list.innerHTML='<p class="err">The workflows could not be loaded. Check that ${productName()} is still running.</p>'})}
|
|
49015
|
+
if(run&&run.status==="watching"&&run.due){fetch("/api/shopper/finish",{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify({client:client,runId:run.runId})}).then(function(r){return r.json()}).then(function(r){if(r.ok)location.reload()})}
|
|
49016
|
+
function post(path, body){return fetch(path,{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify(body)}).then(function(r){return r.json()})}
|
|
49017
|
+
var form=document.getElementById("start-form"), startConfirmed=false;
|
|
49018
|
+
function showStartAsk(ids){
|
|
49019
|
+
var names=ids.map(function(id){var w=(shownWorkflows||[]).filter(function(x){return x.id===id})[0];return w?w.name:id});
|
|
49020
|
+
var ff=document.getElementById("ff").checked, email=document.getElementById("owner-email").value, phone=document.getElementById("test-phone").value;
|
|
49021
|
+
var li=['A test contact named "Secret Shopper" with your email ('+esc(email)+')'+(phone?' and the phone '+esc(phone):'')+', and the tag command-os-secret-shopper. It stays in the account afterwards; delete it when you no longer need it.'];
|
|
49022
|
+
if(ff)li.push('Temporary copies of '+names.length+' workflow'+(names.length===1?'':'s')+', switched on while the check runs: '+names.map(function(n){return esc("${FF_PREFIX} "+n)}).join(", ")+' (each name also carries a short code). The test lead goes into the copies, not the originals, and the copies are deleted when the check ends.');
|
|
49023
|
+
else li.push('The test lead goes into '+names.map(esc).join(", ")+'. Those workflows really text and email you, and may alert your staff once.');
|
|
49024
|
+
var ask=document.getElementById("start-ask");
|
|
49025
|
+
ask.innerHTML='<div class="cds-callout"><span class="ttl">Before the check starts: this writes to '+esc(clientName)+'</span><ul>'+li.map(function(x){return '<li>'+x+'</li>'}).join("")+'</ul><div class="cds-actions"><button class="cds-btn" type="button" id="start-yes">Start the check in '+esc(clientName)+'</button><button class="cds-btn ghost" type="button" id="start-no">Cancel</button></div></div>';
|
|
49026
|
+
document.getElementById("start-yes").onclick=function(){startConfirmed=true;form.dispatchEvent(new Event("submit",{cancelable:true}))};
|
|
49027
|
+
document.getElementById("start-no").onclick=function(){ask.innerHTML=""};
|
|
49028
|
+
if(ask.scrollIntoView)ask.scrollIntoView({block:"nearest"})}
|
|
49029
|
+
// 8a LOW-2: a confirm describes the form as it was; any change to the form clears it, so Start asks again.
|
|
49030
|
+
if(form){["input","change"].forEach(function(t){form.addEventListener(t,function(){var a=document.getElementById("start-ask");if(a&&a.innerHTML){a.innerHTML="";startConfirmed=false}})})}
|
|
49031
|
+
if(form){form.addEventListener("submit",function(e){e.preventDefault();
|
|
49032
|
+
var ids=[].slice.call(form.querySelectorAll('input[name="wf"]:checked')).map(function(i){return i.value});
|
|
49033
|
+
var out=document.getElementById("start-result");
|
|
49034
|
+
// The start button can sit in the page header, far from this line: bring the answer into view.
|
|
49035
|
+
function say(t){out.textContent=t;if(out.scrollIntoView)out.scrollIntoView({block:"nearest"})}
|
|
49036
|
+
if(!ids.length){say("Tick at least one workflow for the test lead to enter.");return}
|
|
49037
|
+
if(document.getElementById("ff").checked&&!document.getElementById("alert-to").value&&document.getElementById("alert-to").getAttribute("data-loaded")==="1"){say("Choose who gets the staff alerts, tasks and assignments during the test.");return}
|
|
49038
|
+
if(document.getElementById("ff").checked&&form.querySelector('[data-hook-blocked]')){say("One of these workflows sends the lead to an address built from a merge field, so fast-forward cannot test it yet. Untick that workflow, or run without fast-forward.");return}
|
|
49039
|
+
// Walk 2026-10-28 W8: before anything is written, say what the check writes, in names, in this account.
|
|
49040
|
+
if(!startConfirmed){showStartAsk(ids);return}
|
|
49041
|
+
startConfirmed=false;document.getElementById("start-ask").innerHTML="";
|
|
49042
|
+
var btn=document.getElementById("start-btn");btn.disabled=true;say("Starting\u2026");
|
|
49043
|
+
var hosts=[].slice.call(form.querySelectorAll('input[name="hook"]:checked')).map(function(i){return i.value});
|
|
49044
|
+
post("/api/shopper/start",{client:client,workflowIds:ids,ownerEmail:document.getElementById("owner-email").value,testPhone:document.getElementById("test-phone").value||undefined,watchMinutes:(document.getElementById("watch").value==="auto"?undefined:Number(document.getElementById("watch").value)),fastForward:document.getElementById("ff").checked,allowedHosts:hosts,alertUserId:(document.getElementById("alert-to").value||undefined),workflowsShown:shownWorkflows})
|
|
49045
|
+
.then(function(r){if(r.ok){location.reload();return}
|
|
49046
|
+
// Out of date: the list is drawn again as the account has it now, the ticks still there are kept, and the
|
|
49047
|
+
// refusal says what changed. Nothing started; pressing Start again sends the list now shown.
|
|
49048
|
+
if(r.stale&&r.workflows){var keep={};ids.forEach(function(id){keep[id]=true});drawWorkflows(r.workflows,keep)}
|
|
49049
|
+
btn.disabled=false;say(r.error||"That did not start.")})
|
|
49050
|
+
.catch(function(){btn.disabled=false;say("That did not start. Check that ${productName()} is still running.")});
|
|
49051
|
+
})}
|
|
49052
|
+
// Fast-forward sends the test lead to the workflows' outside services for real:
|
|
49053
|
+
// read them from the chosen workflows and let the owner tick each one.
|
|
49054
|
+
var hookSeq=0, hooks=document.getElementById("hooks"), hooksList=document.getElementById("hooks-list"), ff=document.getElementById("ff");
|
|
49055
|
+
function refreshHooks(force){
|
|
49056
|
+
if(!hooks||!form)return;
|
|
49057
|
+
var ids=[].slice.call(form.querySelectorAll('input[name="wf"]:checked')).map(function(i){return i.value});
|
|
49058
|
+
if(!ff.checked||!ids.length){hooks.hidden=true;hooksList.innerHTML="";return}
|
|
49059
|
+
var kept={};[].slice.call(hooksList.querySelectorAll('input[name="hook"]:checked')).forEach(function(i){kept[i.value]=true});
|
|
49060
|
+
var mine=++hookSeq;
|
|
49061
|
+
hooks.hidden=false;hooksList.innerHTML='<p class="hint">Reading the chosen workflows\u2026</p>';
|
|
49062
|
+
fetch("/api/shopper/webhooks",{method:"POST",headers:hdrs({"Content-Type":"application/json"}),body:JSON.stringify({client:client,ids:ids,refresh:force===true})}).then(function(r){return r.json()}).then(function(r){
|
|
49063
|
+
if(mine!==hookSeq)return; // an older answer arriving late: the newer one wins
|
|
49064
|
+
if(!r.ok){hooksList.innerHTML='<p class="err">'+esc(r.error||"The workflows could not be read.")+'</p>';return}
|
|
49065
|
+
// How old this list is (Codex MED 5, re-review): the services as last read, with its age.
|
|
49066
|
+
var hooksAge='<p class="hint cds-age" data-age="shopper-hooks" role="status">'+esc(ageLine(r.cache||null))+'</p>';
|
|
49067
|
+
if(!r.destinations.length){hooksList.innerHTML='<p class="hint">None. These workflows do not send the lead to any outside service, so nothing leaves GoHighLevel during the test.</p>'+hooksAge;return}
|
|
49068
|
+
var byHost={};r.destinations.forEach(function(d){var k=d.host||("!"+(d.problem||"merge_field"));(byHost[k]=byHost[k]||[]).push(d)});
|
|
49069
|
+
hooksList.innerHTML='<p class="hint">During a fast-forward check these steps run for real, so the test lead (no first name, your email) is sent to each service below and may appear there as a new record. Tick each one you are happy to receive it. Leave one unticked and the check stops before it starts and names the step.</p>'+Object.keys(byHost).map(function(h){var steps=byHost[h].map(function(d){return '"'+esc(d.stepName)+'" in '+esc(d.workflowName)}).join(", ");
|
|
49070
|
+
return h.charAt(0)!=="!"?'<label class="cds-check"><input type="checkbox" name="hook" value="'+esc(h)+'"'+(kept[h]?" checked":"")+'><span><b>'+esc(h)+'</b>, used by '+steps+'</span></label>':'<p class="err" data-hook-blocked>'+steps+(h==="!no_address"?' has no address filled in, so it cannot run. Fix the step, or untick this workflow.':' sends the lead to an address filled in from a merge field, so where it goes cannot be checked in advance. Fast-forward cannot test this workflow yet.')+'</p>'}).join("")+hooksAge;
|
|
49071
|
+
}).catch(function(){hooksList.innerHTML='<p class="err">The workflows could not be read. Check that ${productName()} is still running.</p>'});
|
|
49072
|
+
}
|
|
49073
|
+
if(ff&&list){ff.addEventListener("change",refreshHooks);list.addEventListener("change",refreshHooks)}
|
|
49074
|
+
// Who stands in for the staff: this account's users, the owner's own login pre-selected when the email matches.
|
|
49075
|
+
var alertWrap=document.getElementById("alert-to-wrap"), alertTo=document.getElementById("alert-to"), usersLoaded=false;
|
|
49076
|
+
function base(e){e=String(e||"").trim().toLowerCase();var p=e.split("@");return p[0].split("+")[0]+"@"+(p[1]||"")}
|
|
49077
|
+
function refreshAlertTo(){
|
|
49078
|
+
if(!alertWrap)return;
|
|
49079
|
+
alertWrap.hidden=!ff.checked;
|
|
49080
|
+
if(!ff.checked||usersLoaded)return;usersLoaded=true;
|
|
49081
|
+
alertTo.innerHTML='<option value="">Loading the people in this account\u2026</option>';
|
|
49082
|
+
lookUsers(0)}
|
|
49083
|
+
// Cache only (Codex MED 3): a list not loaded yet is asked for by POST, then looked at every 3 seconds, a minute at most.
|
|
49084
|
+
function lookUsers(n){
|
|
49085
|
+
fetch("/api/shopper/users?client="+encodeURIComponent(client),{headers:hdrs()}).then(function(r){return r.json()}).then(function(r){
|
|
49086
|
+
if(r.ok&&r.cold){var v=r.cache||{};
|
|
49087
|
+
if(n===0&&!v.refreshing)post("/api/cache/refresh-client",{page:"shopper",locationId:client,staleOnly:true}).catch(function(){});
|
|
49088
|
+
if(n<20&&(n===0||v.refreshing)){setTimeout(function(){lookUsers(n+1)},3000);return}
|
|
49089
|
+
r={ok:false}}
|
|
49090
|
+
if(!r.ok){alertTo.innerHTML='<option value="">'+esc(r.error||"The users could not be read.")+' Your own login is used if your email matches one.</option>';usersLoaded=false;return}
|
|
49091
|
+
alertTo.setAttribute("data-loaded","1");
|
|
49092
|
+
var me=base(document.getElementById("owner-email").value),cur=alertTo.value;
|
|
49093
|
+
alertTo.innerHTML='<option value="">Choose a person</option>'+r.users.map(function(u){return '<option value="'+esc(u.id)+'"'+(u.email&&base(u.email)===me?" selected":"")+'>'+esc(u.name)+(u.email?" ("+esc(u.email)+")":"")+'</option>'}).join("");
|
|
49094
|
+
if(cur&&[].some.call(alertTo.options,function(o){return o.value===cur}))alertTo.value=cur; // a pick the member made stays
|
|
49095
|
+
/* How old the people list is (Codex MED 5, re-review), and a look again while a Refresh re-reads it. */
|
|
49096
|
+
var ua=document.getElementById("users-age");if(!ua){ua=document.createElement("p");ua.id="users-age";ua.className="hint cds-age";ua.setAttribute("data-age","shopper-users");ua.setAttribute("role","status");alertTo.parentNode.insertBefore(ua,alertTo.nextSibling)}
|
|
49097
|
+
ua.textContent=ageLine(r.cache||null);
|
|
49098
|
+
if(r.cache&&r.cache.refreshing&&n<20)setTimeout(function(){lookUsers(n+1)},3000);
|
|
49099
|
+
}).catch(function(){usersLoaded=false;alertTo.innerHTML='<option value="">The users could not be read. Your own login is used if your email matches one.</option>'});
|
|
49100
|
+
}
|
|
49101
|
+
if(ff)ff.addEventListener("change",refreshAlertTo);
|
|
49102
|
+
window.addEventListener("beforeprint",function(){document.querySelectorAll("main.shopper details").forEach(function(d){d.setAttribute("data-was-open",d.open?"1":"0");d.open=true})});
|
|
49103
|
+
window.addEventListener("afterprint",function(){document.querySelectorAll("main.shopper details[data-was-open]").forEach(function(d){d.open=d.getAttribute("data-was-open")==="1"})});
|
|
49104
|
+
var look=document.getElementById("look-now");
|
|
49105
|
+
if(look&&run){look.addEventListener("click",function(){look.disabled=true;post("/api/shopper/finish",{client:client,runId:run.runId}).then(function(){location.reload()}).catch(function(){look.disabled=false})})}
|
|
49106
|
+
if(run&&run.status==="watching"){setTimeout(function(){location.reload()},30000)}
|
|
49107
|
+
})();
|
|
49108
|
+
</script>`;
|
|
49109
|
+
}
|
|
49110
|
+
var esc2, chip, pageHead, panel, callout, statTile, list2, tl, OUTCOME, PROBLEM_CHIP, dot, CHIP, CHANNEL, mins, when, reportHref, CSS;
|
|
49111
|
+
var init_page = __esm({
|
|
49112
|
+
"src/command-os/shopper/page.ts"() {
|
|
49095
49113
|
"use strict";
|
|
49096
|
-
|
|
49097
|
-
|
|
49098
|
-
|
|
49099
|
-
|
|
49100
|
-
init_analyze();
|
|
49101
|
-
init_links2();
|
|
49102
|
-
init_mask();
|
|
49103
|
-
init_account_rules();
|
|
49104
|
-
init_score();
|
|
49114
|
+
init_cockpit_shell();
|
|
49115
|
+
init_cockpit_cache();
|
|
49116
|
+
init_script_json();
|
|
49117
|
+
init_fastforward();
|
|
49105
49118
|
init_steps();
|
|
49106
|
-
|
|
49107
|
-
|
|
49108
|
-
|
|
49109
|
-
|
|
49110
|
-
|
|
49111
|
-
|
|
49112
|
-
|
|
49113
|
-
|
|
49114
|
-
|
|
49115
|
-
|
|
49119
|
+
init_score();
|
|
49120
|
+
esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
49121
|
+
chip = (tone, text3) => `<span class="cds-chip${tone ? ` ${tone}` : ""}">${esc2(text3)}</span>`;
|
|
49122
|
+
pageHead = (title2, sub, action = "") => `<div class="cds-page-head"><div class="txt"><div class="cds-kicker">Workflow Audit Pro</div><h1 class="cds-page-title">${title2}</h1><p class="cds-page-sub">${sub}</p></div>${action}</div>`;
|
|
49123
|
+
panel = (label2, title2, meta, inner, attrs = "", cls = "") => `<section class="cds-panel${cls ? ` ${cls}` : ""}"${attrs}><div class="cds-panel-head"><div class="txt"><div class="cds-panel-label">${label2}</div><h2 class="cds-panel-title">${title2}</h2></div>${meta ? `<div class="cds-panel-meta">${meta}</div>` : ""}</div>${inner}</section>`;
|
|
49124
|
+
callout = (tone, title2, inner, attrs = "") => `<div class="cds-callout${tone ? ` ${tone}` : ""}"${attrs}>${title2 ? `<span class="ttl">${title2}</span>` : ""}${inner}</div>`;
|
|
49125
|
+
statTile = (value, label2, tone = "") => `<div class="cds-stat"><div class="big${tone ? ` ${tone}` : ""}">${esc2(String(value))}</div><div class="lbl">${esc2(label2)}</div></div>`;
|
|
49126
|
+
list2 = (inner) => `<ul class="cds-list">${inner}</ul>`;
|
|
49127
|
+
tl = (inner) => `<ol class="cds-tl">${inner}</ol>`;
|
|
49128
|
+
OUTCOME = {
|
|
49129
|
+
ran: ["ok", "Ran"],
|
|
49130
|
+
branch: ["info", "Path taken"],
|
|
49131
|
+
did_nothing: ["warn", "Did nothing"],
|
|
49132
|
+
failed: ["bad", "Failed, holding"],
|
|
49133
|
+
waiting: ["warn", "Waiting"],
|
|
49134
|
+
entered: ["", "Entered"],
|
|
49135
|
+
finished: ["", "Ended"],
|
|
49136
|
+
unclear: ["warn", "Unclear"]
|
|
49137
|
+
};
|
|
49138
|
+
PROBLEM_CHIP = { failed_holding: "Failed, holding every lead", failed_gave_up: "Failed, delayed every lead", did_nothing: "Did nothing", skipped: "Skipped" };
|
|
49139
|
+
dot = (tone) => `<span class="dot${tone === "ok" || tone === "warn" || tone === "bad" ? ` ${tone}` : ""}"></span>`;
|
|
49140
|
+
CHIP = {
|
|
49141
|
+
blank_field: "Blank name",
|
|
49142
|
+
code_showing: "Code showing",
|
|
49143
|
+
wrong_business: "Wrong business",
|
|
49144
|
+
broken_link: "Broken link",
|
|
49145
|
+
duplicate: "Sent twice"
|
|
49146
|
+
};
|
|
49147
|
+
CHANNEL = { sms: "Text message", email: "Email", other: "Message" };
|
|
49148
|
+
mins = (m) => {
|
|
49149
|
+
const whole = Math.floor(m);
|
|
49150
|
+
const sec = Math.round((m - whole) * 60);
|
|
49151
|
+
return `+${whole}:${String(sec).padStart(2, "0")}`;
|
|
49116
49152
|
};
|
|
49153
|
+
when = (ms) => `<time data-ms="${ms}">${new Date(ms).toISOString()}</time>`;
|
|
49154
|
+
reportHref = (run) => `/shopper/report?client=${encodeURIComponent(run.locationId)}&run=${encodeURIComponent(run.runId)}`;
|
|
49155
|
+
CSS = `
|
|
49156
|
+
main.shopper{display:grid;gap:var(--space-5);align-content:start}
|
|
49157
|
+
.shopper > *{min-width:0}
|
|
49158
|
+
.shopper .picker{max-width:420px}
|
|
49159
|
+
.shopper .stats{display:grid;gap:var(--space-3);grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
49160
|
+
.shopper .sum{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--space-5);align-items:center}
|
|
49161
|
+
.shopper .cds-list .ttl{min-width:0;overflow-wrap:anywhere}
|
|
49162
|
+
.shopper .cds-list .end{flex-wrap:wrap;justify-content:flex-end}
|
|
49163
|
+
.shopper .cds-tl .what{min-width:0;overflow-wrap:anywhere}
|
|
49164
|
+
.shopper .cds-callout[role=status]{display:flex;flex-wrap:wrap;gap:var(--space-3);align-items:center;justify-content:space-between}
|
|
49165
|
+
.shopper .startform{display:grid;gap:var(--space-4);max-width:720px}
|
|
49166
|
+
.shopper fieldset{border:0;margin:0;padding:0;min-width:0}
|
|
49167
|
+
.shopper legend{padding:0;margin:0 0 var(--space-2)}
|
|
49168
|
+
.shopper .wf{display:grid;gap:var(--space-2);max-height:320px;overflow:auto}
|
|
49169
|
+
.shopper details{display:grid;gap:var(--space-3)}
|
|
49170
|
+
.shopper details > summary{padding:var(--space-2) 0}
|
|
49171
|
+
.shopper .result:empty{display:none}
|
|
49172
|
+
.shopper mark{background:var(--surface-sunk);color:var(--bad);font-weight:700}
|
|
49173
|
+
[hidden]{display:none!important}
|
|
49174
|
+
@media (max-width:900px){.shopper .sum{grid-template-columns:minmax(0,1fr);justify-items:start}}
|
|
49175
|
+
@media print{.shopper .picker,#look-now,#watching{display:none!important}}
|
|
49176
|
+
`;
|
|
49117
49177
|
}
|
|
49118
49178
|
});
|
|
49119
49179
|
|
|
@@ -51010,15 +51070,16 @@ function draw(){
|
|
|
51010
51070
|
strip.appendChild(sys);
|
|
51011
51071
|
/* Today and renewals */
|
|
51012
51072
|
const second=grid(root,"cds-grid two");
|
|
51013
|
-
|
|
51014
|
-
|
|
51073
|
+
/* Walk 2026-10-28 W2: a Pro brief reads one client, so it never says "across your accounts". */
|
|
51074
|
+
const today=section(second,"today",PRO?"This client":"Across your accounts","Today",PRO?"Appointments in this client's account.":"Appointments in the accounts read.");
|
|
51075
|
+
list(today,b.today.map(r=>({text:r.client+": "+(r.who||r.title||"Appointment"),when:r.at})),PRO?"No appointments today in this client's account.":"No appointments in the accounts read. Any account that could not be read is named under What was read.",false);
|
|
51015
51076
|
const renewals=section(second,"renewals","Local records","Renewals",(PRO?"Renewal dates from your local client records.":"Renewal dates from your local client records; Stripe's live amounts are in its own panel."));
|
|
51016
51077
|
rowsTable(renewals,b.renewals.map(r=>({subject:r.client,detail:r.inWords,when:r.on})),["Client","Renews","On"],"No renewals due in the records checked.");
|
|
51017
51078
|
/* Running: full width. What waits on you is listed once, in Needs you today. */
|
|
51018
51079
|
const running=section(root,"running","Client builds","Running","Work in progress and accounts not started yet. Builds waiting for your decision are in Needs you today.");
|
|
51019
51080
|
const rc=grid(running,"running-body");
|
|
51020
51081
|
list(card(rc,"In progress"),b.runningNow.map(r=>({text:r.client+": "+tidy(r.client,r.title)})),"No current work recorded.");
|
|
51021
|
-
list(card(rc,"Never started"),b.neverStarted.map(r=>({text:r.client})),(PRO?"
|
|
51082
|
+
list(card(rc,"Never started"),b.neverStarted.map(r=>({text:r.client})),(PRO?"None: this client's build has started.":"No unstarted accounts on the board."),n=>"Show all "+plural(n,"account","accounts")+" never started");
|
|
51022
51083
|
/* Client attention and leads */
|
|
51023
51084
|
const third=grid(root,"cds-grid two");
|
|
51024
51085
|
const attention=section(third,"client-attention","Money and momentum","Client attention","Clients under your floor or paying without recent bookings.");
|
|
@@ -52117,11 +52178,14 @@ function renderClientStatusHtml(input, opts = {}) {
|
|
|
52117
52178
|
@media print {
|
|
52118
52179
|
/* !important: the on-screen stepper writes an inline font-size that must never reach paper. */
|
|
52119
52180
|
html { font-size:12.5pt !important; }
|
|
52120
|
-
:
|
|
52121
|
-
|
|
52122
|
-
|
|
52123
|
-
|
|
52124
|
-
|
|
52181
|
+
/* Walk 2026-10-28 W22: :root:not([data-theme=light]) (the dark scheme) outranked a plain :root here, so a Mac in
|
|
52182
|
+
dark mode printed pale ink on white. !important wins at any specificity. */
|
|
52183
|
+
:root, :root:not([data-theme="light"]), :root[data-theme="dark"] {
|
|
52184
|
+
color-scheme:light !important;
|
|
52185
|
+
--accent:${accentLight} !important; --ink:#1A1A1A !important; --muted:#4A4A4A !important; --paper:#FFFFFF !important; --ground:#FFFFFF !important; --line:#CFCFCF !important; --head:#101010 !important;
|
|
52186
|
+
--ok:#20654A !important; --soon:#8E3B37 !important; --wait:#6B4100 !important; --okbg:transparent !important; --soonbg:transparent !important; --waitbg:transparent !important; --raise:none !important;
|
|
52187
|
+
}
|
|
52188
|
+
body { background:#fff !important; color:#1A1A1A !important; font-family:-apple-system, "Helvetica Neue", Arial, sans-serif; }
|
|
52125
52189
|
.chrome, .tiles, .stamp, .foot { display:none !important; }
|
|
52126
52190
|
.page { max-width:none; padding:0; }
|
|
52127
52191
|
.sheet { border:0; border-radius:0; padding:0; box-shadow:none; }
|
|
@@ -52952,7 +53016,7 @@ var init_engine = __esm({
|
|
|
52952
53016
|
["capacity", "money", "If I found you thirty percent more work tomorrow, could you do it?", "What breaks first, crews or trucks or you?", "text", "If they can't deliver more, the whole report points at margin and time instead of volume."],
|
|
52953
53017
|
["calls_month", "phone", "Roughly how many calls come in on a normal month?", "Think about a busy Tuesday and work out from there.", "count", "The base for everything else on the phone."],
|
|
52954
53018
|
["calls_missed", "phone", "How many of those go to voicemail or just ring out?", "What happens when you're up a ladder and it rings?", "count", "The most common leak, and the one owners underestimate most."],
|
|
52955
|
-
["missed_never_return", "phone", "Of the
|
|
53019
|
+
["missed_never_return", "phone", "Of the calls you miss, what share never call back?", "A percentage. Half is 50%.", "rate", "This is what stops us overclaiming. We only count the ones you genuinely lose."],
|
|
52956
53020
|
["who_answers", "phone", "Who answers the phone when it rings?", "And what were they doing before it rang?", "text", "If it's you, this is a time finding as well as a money one."],
|
|
52957
53021
|
["after_hours_calls", "phone", "What share of your calls come in outside working hours?", "Evenings and weekends. Storm season?", "rate", "Separates a staffing problem from an automation one."],
|
|
52958
53022
|
["voicemail_fate", "phone", "When someone does leave a voicemail, what happens to it?", "", "text", "'I get to them at night' is the after-hours finding in their own words."],
|
|
@@ -52969,7 +53033,7 @@ var init_engine = __esm({
|
|
|
52969
53033
|
["quote_time", "quotes", "How many hours a week go on putting quotes together?", "", "hours", "Quoting is often the biggest single block of hours we find."],
|
|
52970
53034
|
["quote_delay", "quotes", "How long between the site visit and them getting the price?", "", "text", "A quote that arrives four days later competes with three that arrived first."],
|
|
52971
53035
|
["booked_month", "booking", "How many appointments or site visits get booked in a month?", "", "count", "The base for the no-show finding."],
|
|
52972
|
-
["show_rate", "booking", "Of those,
|
|
53036
|
+
["show_rate", "booking", "Of those, what share actually happen?", "A percentage. Eight in ten is 80%. Count out nobody home, canceled last minute, forgot.", "rate", "A no-show is a job you paid to get twice."],
|
|
52973
53037
|
["reminders", "booking", "Does anything remind them before the appointment?", "", "text", "If the answer is 'I text them myself', that's both a time and a money finding."],
|
|
52974
53038
|
["noshow_fate", "booking", "When someone no-shows, what happens next?", "", "text", "'Nothing' means the rebooking finding is real."],
|
|
52975
53039
|
["dormant_count", "dormant", "How many people are sitting in your system who enquired once and never went anywhere?", "Old quotes, old customers, people who called in 2023.", "count", "These people already raised their hand once. The cheapest money you have."],
|
|
@@ -53043,7 +53107,7 @@ function tagFor(stage) {
|
|
|
53043
53107
|
}
|
|
53044
53108
|
function renderHuman(state2, computed, q3, today2) {
|
|
53045
53109
|
const lines = [];
|
|
53046
|
-
lines.push(`${NOTE_TITLE}
|
|
53110
|
+
lines.push(`${NOTE_TITLE}: ${state2.biz || "unnamed"}${state2.trade ? ", " + state2.trade : ""}, ${today2}`);
|
|
53047
53111
|
const who2 = clientLine(state2.client);
|
|
53048
53112
|
if (who2) lines.push(who2);
|
|
53049
53113
|
if (computed) {
|
|
@@ -53084,7 +53148,7 @@ function renderHuman(state2, computed, q3, today2) {
|
|
|
53084
53148
|
lines.push("ASSUMPTIONS THE OPERATOR SET");
|
|
53085
53149
|
for (const [k, v] of asm) {
|
|
53086
53150
|
const label2 = q3.assumption?.(k) ?? k.replace(/_/g, " ");
|
|
53087
|
-
lines.push(` ${label2}: ${Math.round((Number(v) || 0) * 100)}%${Number(v) > 0 ? "" : " (set to zero
|
|
53151
|
+
lines.push(` ${label2}: ${Math.round((Number(v) || 0) * 100)}%${Number(v) > 0 ? "" : " (set to zero: its finding was dropped, not estimated)"}`);
|
|
53088
53152
|
}
|
|
53089
53153
|
}
|
|
53090
53154
|
return lines.join("\n");
|
|
@@ -53179,7 +53243,7 @@ function buildContact(state2, stage, contact) {
|
|
|
53179
53243
|
function buildOpportunity(state2, computed, stage, proposalValue) {
|
|
53180
53244
|
const value = typeof proposalValue === "number" && proposalValue > 0 ? proposalValue : Math.max(0, Math.round(computed?.annualStop ?? 0));
|
|
53181
53245
|
return {
|
|
53182
|
-
name: `${state2.biz || "Assessment"}
|
|
53246
|
+
name: `${state2.biz || "Assessment"}: assessment`,
|
|
53183
53247
|
stageName: stage,
|
|
53184
53248
|
monetaryValue: value,
|
|
53185
53249
|
status: stage === "Won" ? "won" : stage === "Lost" ? "lost" : "open"
|
|
@@ -53406,22 +53470,19 @@ function fillFromSnapshot(state2, locationId2, questions) {
|
|
|
53406
53470
|
if (!snapshot) throw new Error("Attach an assessment first.");
|
|
53407
53471
|
const result = mapAssessmentToIntake(snapshot.assessment, questions, snapshot.contact);
|
|
53408
53472
|
const answers = { ...state2.intakeInputs?.[locationId2]?.answers, ...state2.intakeDrafts?.[locationId2]?.answers };
|
|
53473
|
+
const kept = [];
|
|
53409
53474
|
for (const [label2, value] of Object.entries(result.answers)) {
|
|
53410
53475
|
const q3 = questions.find((q4) => q4.label === label2);
|
|
53411
53476
|
const previous = answers[label2] ?? answers[q3.key];
|
|
53412
|
-
|
|
53413
|
-
if ((q3.key === "anything_else" || q3.key === "price_points") && previous && !String(previous).includes(String(value))) merged = `${previous}
|
|
53414
|
-
${value}`;
|
|
53415
|
-
else if ((q3.key === "anything_else" || q3.key === "price_points") && previous) merged = previous;
|
|
53416
|
-
if (!matchesQuestionChoices(q3, merged)) {
|
|
53477
|
+
if (Array.isArray(previous) ? previous.some((v) => String(v).trim()) : String(previous ?? "").trim()) {
|
|
53417
53478
|
result.filled = result.filled.filter((question) => question !== label2);
|
|
53418
|
-
|
|
53479
|
+
kept.push(label2);
|
|
53419
53480
|
continue;
|
|
53420
53481
|
}
|
|
53421
|
-
answers[label2] =
|
|
53482
|
+
answers[label2] = value;
|
|
53422
53483
|
if (q3.key !== label2) delete answers[q3.key];
|
|
53423
53484
|
}
|
|
53424
|
-
return { state: { ...state2, intakeDrafts: { ...state2.intakeDrafts, [locationId2]: { answers, savedAt: (/* @__PURE__ */ new Date()).toISOString() } } }, filled: result.filled, leftForMember: result.leftForMember };
|
|
53485
|
+
return { state: { ...state2, intakeDrafts: { ...state2.intakeDrafts, [locationId2]: { answers, savedAt: (/* @__PURE__ */ new Date()).toISOString() } } }, filled: result.filled, kept, leftForMember: result.leftForMember };
|
|
53425
53486
|
}
|
|
53426
53487
|
function assessmentFeatureLines(snapshot, purchased = [], availableOnly = true) {
|
|
53427
53488
|
return ASSESSMENT_FEATURES.filter((f) => !availableOnly || !purchased.includes(f.id)).map((feature) => {
|
|
@@ -55561,10 +55622,8 @@ function ownerPublicationSentences(o) {
|
|
|
55561
55622
|
return [o.summary, ...o.requests];
|
|
55562
55623
|
}
|
|
55563
55624
|
function ownerContactLine(o) {
|
|
55564
|
-
|
|
55565
|
-
|
|
55566
|
-
if (who2) return `Questions? Contact ${who2}.`;
|
|
55567
|
-
return "Questions? Contact us.";
|
|
55625
|
+
if (!o.agency.contact) return "";
|
|
55626
|
+
return `Questions? Contact ${o.agency.name || "us"}: ${o.agency.contact}.`;
|
|
55568
55627
|
}
|
|
55569
55628
|
function ownerPageTitle(o) {
|
|
55570
55629
|
return `${o.accountName}: your new client system${o.agency.name ? ` \xB7 ${o.agency.name}` : ""}`;
|
|
@@ -55594,7 +55653,7 @@ function renderOwnerHandoffHtml(o, colours, stamp) {
|
|
|
55594
55653
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
55595
55654
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;600;700;800&family=Playfair+Display:wght@700&display=swap">
|
|
55596
55655
|
<style>
|
|
55597
|
-
:root { --accent:${colours.light}; --ink:#0f1b2d; --muted:#44526a; --line:#d7dde7; --ground:#f4f6f9; --paper:#
|
|
55656
|
+
:root { --accent:${colours.light}; --ink:#0f1b2d; --muted:#44526a; --line:#d7dde7; --ground:#f4f6f9; --paper:#ffffff; --surface:#ffffff; }
|
|
55598
55657
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme:dark; --accent:${colours.dark}; --ink:#eef2f8; --muted:#b5c0d3; --line:#2a3650; --ground:#0b1220; --paper:#161f31; --surface:#131c2e; } }
|
|
55599
55658
|
:root[data-theme="dark"] { color-scheme:dark; --accent:${colours.dark}; --ink:#eef2f8; --muted:#b5c0d3; --line:#2a3650; --ground:#0b1220; --paper:#161f31; --surface:#131c2e; }
|
|
55600
55659
|
html { font-size:22px; }
|
|
@@ -55640,8 +55699,11 @@ function renderOwnerHandoffHtml(o, colours, stamp) {
|
|
|
55640
55699
|
@media print {
|
|
55641
55700
|
/* !important: the on-screen stepper writes an inline font-size that must never reach paper. */
|
|
55642
55701
|
html { font-size:10.5pt !important; }
|
|
55643
|
-
:
|
|
55644
|
-
|
|
55702
|
+
/* Walk 2026-10-28 W22/W23a: on a Mac in dark mode the dark-scheme rule (:root:not([data-theme=light])) outranked a
|
|
55703
|
+
plain :root here, so paper printed with near-white ink on white: a blank page. !important wins at any specificity. */
|
|
55704
|
+
:root, :root:not([data-theme="light"]), :root[data-theme="dark"] { color-scheme:light !important; --accent:${colours.light} !important; --ink:#111 !important; --muted:#444 !important; --line:#ccc !important; --ground:#fff !important; --paper:#fff !important; --surface:#fff !important; }
|
|
55705
|
+
body { background:#fff !important; color:#111 !important; }
|
|
55706
|
+
.step, .stages span { background:#fff !important; }
|
|
55645
55707
|
.chrome { display:none !important; }
|
|
55646
55708
|
.page { max-width:none; padding:0; }
|
|
55647
55709
|
.paper { border:0; border-radius:0; padding:0; box-shadow:none; }
|
|
@@ -55664,7 +55726,7 @@ function renderOwnerHandoffHtml(o, colours, stamp) {
|
|
|
55664
55726
|
<h2>Your new client system</h2>
|
|
55665
55727
|
<p class="lead">${esc10(o.summary)}</p>
|
|
55666
55728
|
${sections.join("\n")}
|
|
55667
|
-
<div class="foot"><p>${esc10(ownerContactLine(o)
|
|
55729
|
+
<div class="foot"><p>${esc10([ownerContactLine(o), OWNER_UPDATES_LINE].filter(Boolean).join(" "))}</p>${stamp ? `<p class="stamp">${esc10(stamp)}</p>` : ""}</div>
|
|
55668
55730
|
</main></div>
|
|
55669
55731
|
<script>
|
|
55670
55732
|
(function(){
|
|
@@ -55935,7 +55997,7 @@ function composeStatusPageSections(input, opts = {}) {
|
|
|
55935
55997
|
...owner.steps.length || owner.afterSteps ? [{ q: "What happens to a new lead", a: [...owner.steps.map((st) => `${st.title}: ${st.text}`), owner.afterSteps ?? ""].filter(Boolean).join(" ") }] : [],
|
|
55936
55998
|
...owner.stages.length ? [{ q: "Your sales stages", a: owner.stages.join(" \xB7 ") }] : [],
|
|
55937
55999
|
{ q: "Before we switch it on, from you", a: owner.requests.length ? owner.requests.join(" ") : NOTHING_NEEDED },
|
|
55938
|
-
{ q: "Questions", a: `${ownerContactLine(owner)} ${OWNER_UPDATES_LINE}` }
|
|
56000
|
+
ownerContactLine(owner) ? { q: "Questions", a: `${ownerContactLine(owner)} ${OWNER_UPDATES_LINE}` } : { q: "Updates", a: OWNER_UPDATES_LINE }
|
|
55939
56001
|
];
|
|
55940
56002
|
const faq = faqItems.length >= 2 ? buildFaq({ title: "What we built for you", items: faqItems }) : null;
|
|
55941
56003
|
if (!faq) warnings.push("Fewer than two sections to show, so the detail list is left off this page rather than shown half-built.");
|
|
@@ -56589,6 +56651,8 @@ var init_cockpit_routes = __esm({
|
|
|
56589
56651
|
post("/api/account", "client", BOTH),
|
|
56590
56652
|
post("/api/assessment-choice", "client", BOTH),
|
|
56591
56653
|
post("/api/assessment-save", "client", BOTH),
|
|
56654
|
+
post("/api/assessment-save-preview", "client", BOTH),
|
|
56655
|
+
// reads only: what Save will write (walk W8)
|
|
56592
56656
|
post("/api/build-approval", "client", BOTH),
|
|
56593
56657
|
post("/api/build-confirm", "client", BOTH),
|
|
56594
56658
|
post("/api/capability-draft", "client", BOTH),
|
|
@@ -59150,7 +59214,7 @@ function actions(item,client){const box=el("div","","item-actions");let ch=null;
|
|
|
59150
59214
|
if(r.sendApproval&&item.kind==="draft"){say(r.result+learned+" Sending\u2026","");
|
|
59151
59215
|
const s=await call("/api/brain/send",{client,decisionId:item.id,approvalId:r.sendApproval.id});
|
|
59152
59216
|
// the outcome must outlive the refresh (load() clears the status line): reload first, then say it, with what was saved
|
|
59153
|
-
await load();if(s.ok){say("","");done("Reply to "+(item.lead||"this lead"),r.result+learned+" Sent. "+(s.said||""))}else say(r.result+learned+" Not sent: "+(s.why||s.error||"the send was refused."),"bad");return}
|
|
59217
|
+
await load();if(s.ok){say("","");done("Reply to "+(item.lead||"this lead"),r.result+learned+" Sent from "+accountName()+". "+(s.said||""))}else say(r.result+learned+" Not sent: "+(s.why||s.error||"the send was refused."),"bad");return}
|
|
59154
59218
|
say("","");done(item.kind==="draft"?"Reply to "+(item.lead||"this lead"):"Proposed "+item.target,r.confirmation||r.result);load()};
|
|
59155
59219
|
const go=async(decision)=>{ok.disabled=no.disabled=true;shown.textContent="";
|
|
59156
59220
|
const body={client,kind:item.kind,id:item.id,bindingHash:ch?item.bindings[ch.value]:item.bindingHash,decision,reason:ta.value,reviewer:reviewer.value};if(ch)body.channel=ch.value;if(pu)body.purpose=pu.value;
|
|
@@ -59183,7 +59247,10 @@ function draftPanel(d,client,sendingOn){const lead=d.lead||"this lead";const s=p
|
|
|
59183
59247
|
const found=d.found||[];if(found.length){const ul=el("ul","","cds-list");found.forEach(f=>{const li=el("li");li.appendChild(el("span",(f.label||f.check)+": "+f.found,"ttl"));ul.appendChild(li)});
|
|
59184
59248
|
const det=el("details","","cds-more");det.open=true;det.appendChild(el("summary","What the brain found ("+found.length+")"));det.appendChild(ul);b.appendChild(det)}
|
|
59185
59249
|
if(d.approveBlocked)b.appendChild(el("p",d.approveBlocked,"cds-callout bad"));if(d.labelOnly&&sendingOn)b.appendChild(el("p",d.labelOnly,"cds-callout warn"));
|
|
59250
|
+
// Walk 2026-10-28 W8: in send mode, Approve writes to GoHighLevel; it says so first, naming the account and the note.
|
|
59251
|
+
if(sendingOn&&!d.approveBlocked)b.appendChild(el("p","Approve sends this from "+accountName()+" to "+lead+" by the channel you choose, and adds a note on "+lead+"'s contact there saying it was sent.","cds-callout info approve-writes"));
|
|
59186
59252
|
b.appendChild(el("p","Content hash "+d.contentHash.slice(0,12)+". Your yes applies to this exact text, to "+lead+", on the channel you choose, only.","small muted"));b.appendChild(actions(d,client));s.appendChild(b);return s}
|
|
59253
|
+
function accountName(){const o=Array.prototype.find.call(clientSel.options,x=>x.value===clientSel.value);return (o&&o.textContent||"").trim()||"this account"}
|
|
59187
59254
|
function proposalPanel(p,client){const s=panel("Proposed "+p.target,"From "+p.source,"by "+p.proposedBy);s.id="proposal-"+p.id;const b=el("div","","item-body");
|
|
59188
59255
|
const gl=p.target==="glossary"&&p.proposed&&typeof p.proposed==="object"&&typeof p.proposed.term==="string"?p.proposed:null;
|
|
59189
59256
|
const shown=gl?({calendar:"Calendar",service:"Product or service",staff:"Staff name"}[gl.kind]||"Glossary")+": "+gl.term+". "+(gl.meaning||"")+(gl.kind==="calendar"?" Approving it does not make a booking on it count as booked; a person decides that in the brain's glossary.":""):p.proposed&&typeof p.proposed==="object"&&typeof p.proposed.fact==="string"?p.proposed.fact:p.target==="source_purpose"&&p.proposed&&typeof p.proposed.example==="string"?"Automatic message: "+p.proposed.example:JSON.stringify(p.proposed);
|
|
@@ -61284,7 +61351,11 @@ function userMatches(planned, users) {
|
|
|
61284
61351
|
const name2 = (planned.name || [planned.firstName, planned.lastName].filter(Boolean).join(" ")).trim().toLowerCase();
|
|
61285
61352
|
return Boolean(name2) && users.filter((u) => u.name.trim().toLowerCase() === name2).length === 1;
|
|
61286
61353
|
}
|
|
61287
|
-
function handoffInAccount(h, facts, readback, plannedUsers = [], openRecipientJobs = 0) {
|
|
61354
|
+
function handoffInAccount(h, facts, readback, plannedUsers = [], openRecipientJobs = 0, plannedCalendars = /* @__PURE__ */ new Map()) {
|
|
61355
|
+
if (h.ref === ASSIGN_CALENDAR_STAFF) {
|
|
61356
|
+
const s = calendarStaff(h, facts, plannedCalendars);
|
|
61357
|
+
return s && !s.buildOnly ? { readable: true, done: s.done } : { readable: false, done: false };
|
|
61358
|
+
}
|
|
61288
61359
|
if (!READ_HANDOFFS.has(h.ref)) return { readable: false, done: false };
|
|
61289
61360
|
if (h.ref === "handoff.phone_number") {
|
|
61290
61361
|
const phones = (facts.phoneValues ?? []).map(digits).filter((d) => d.length === 10);
|
|
@@ -61309,7 +61380,13 @@ function handoffInAccount(h, facts, readback, plannedUsers = [], openRecipientJo
|
|
|
61309
61380
|
if (h.ref === "handoff.link_domain") return { readable: true, done: bookingLinkInAccount(vals, facts) === true };
|
|
61310
61381
|
return { readable: true, done: true };
|
|
61311
61382
|
}
|
|
61312
|
-
function handoffReadReason(h, facts) {
|
|
61383
|
+
function handoffReadReason(h, facts, plannedCalendars = /* @__PURE__ */ new Map()) {
|
|
61384
|
+
if (h.ref === ASSIGN_CALENDAR_STAFF) {
|
|
61385
|
+
const s = calendarStaff(h, facts, plannedCalendars);
|
|
61386
|
+
if (!s || s.done) return void 0;
|
|
61387
|
+
if (s.buildOnly) return `Build put ${namesOf(s.buildOnly.memberIds, facts)} on ${s.buildOnly.name}. Keep or change who takes consults.`;
|
|
61388
|
+
return s.missing ? `GoHighLevel shows no calendar named ${s.missing} in this account.` : `GoHighLevel shows nobody on the ${s.empty} calendar's team yet.`;
|
|
61389
|
+
}
|
|
61313
61390
|
if (h.ref === "handoff.phone_number") {
|
|
61314
61391
|
if (facts.phoneValues === void 0) return void 0;
|
|
61315
61392
|
if (!facts.phoneValues.length) return "GoHighLevel shows no phone number in this account yet.";
|
|
@@ -61342,6 +61419,45 @@ function bookingLinkInAccount(vals, facts) {
|
|
|
61342
61419
|
const keys = facts.calendarKeys;
|
|
61343
61420
|
return targets.every((t) => t !== "" && (keys.includes(`id:${t}`) || keys.includes(`slug:${t.toLowerCase()}`)));
|
|
61344
61421
|
}
|
|
61422
|
+
function calendarStaff(h, facts, planned) {
|
|
61423
|
+
const names = (h.blocks ?? []).filter((r) => r.startsWith("calendar.")).map((r) => planned.get(r)?.trim()).filter((n) => Boolean(n));
|
|
61424
|
+
if (!names.length || !facts.calendarTeams) return void 0;
|
|
61425
|
+
const teams = [];
|
|
61426
|
+
for (const name2 of names) {
|
|
61427
|
+
const found = facts.calendarTeams.filter((c) => c.name.trim().toLowerCase() === name2.toLowerCase());
|
|
61428
|
+
if (!found.length) return { done: false, missing: name2, teams };
|
|
61429
|
+
if (found.length > 1 || !found[0].memberIds) return void 0;
|
|
61430
|
+
if (!found[0].memberIds.length) return { done: false, empty: found[0].name, teams };
|
|
61431
|
+
const ours = facts.calendarTeamsBuilt?.[name2.toLowerCase()] ?? [];
|
|
61432
|
+
if (found[0].memberIds.every((id2) => ours.includes(id2))) return { done: false, buildOnly: { name: found[0].name, memberIds: found[0].memberIds }, teams };
|
|
61433
|
+
teams.push({ name: found[0].name, memberIds: found[0].memberIds });
|
|
61434
|
+
}
|
|
61435
|
+
return { done: true, teams };
|
|
61436
|
+
}
|
|
61437
|
+
function withBuiltCalendarTeams(facts, saved) {
|
|
61438
|
+
const built = saved?.calendarTeamsBuilt;
|
|
61439
|
+
return built && Object.keys(built).length ? { ...facts, calendarTeamsBuilt: built } : facts;
|
|
61440
|
+
}
|
|
61441
|
+
function mergeCalendarTeamsBuilt(kept, built) {
|
|
61442
|
+
return { ...kept ?? {}, ...Object.fromEntries(built.filter((b) => b.type === "calendar" && b.teamIds?.length).map((b) => [b.name.trim().toLowerCase(), [...b.teamIds]])) };
|
|
61443
|
+
}
|
|
61444
|
+
function namesOf(ids, facts) {
|
|
61445
|
+
const named = ids.map((id2) => facts.userList?.find((u) => u.id === id2)?.name.trim()).filter((n) => Boolean(n));
|
|
61446
|
+
return named.length === ids.length ? named.length === 1 ? named[0] : `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]}` : `${ids.length} ${ids.length === 1 ? "person" : "people"}`;
|
|
61447
|
+
}
|
|
61448
|
+
function confirmedHandoffLines(handoffs, facts, plannedCalendars) {
|
|
61449
|
+
if (!facts) return [];
|
|
61450
|
+
const out = [];
|
|
61451
|
+
for (const h of handoffs) {
|
|
61452
|
+
if (h.ref !== ASSIGN_CALENDAR_STAFF) continue;
|
|
61453
|
+
const s = calendarStaff(h, facts, plannedCalendars);
|
|
61454
|
+
if (!s?.done) continue;
|
|
61455
|
+
for (const t of s.teams) {
|
|
61456
|
+
out.push(`Confirmed: ${namesOf(t.memberIds, facts)} ${t.memberIds.length === 1 ? "takes" : "take"} bookings on the ${t.name} calendar.`);
|
|
61457
|
+
}
|
|
61458
|
+
}
|
|
61459
|
+
return out;
|
|
61460
|
+
}
|
|
61345
61461
|
function trialOfferAddsUp(facts) {
|
|
61346
61462
|
const amount = (k) => {
|
|
61347
61463
|
const listed = (facts.customValues ?? []).filter((v) => key(v.name) === k);
|
|
@@ -61355,6 +61471,27 @@ function trialOfferAddsUp(facts) {
|
|
|
61355
61471
|
function handoffJobText(h) {
|
|
61356
61472
|
return obligationTaskText({ type: "handoff", name: h.title || h.name || h.ref, reason: h.instruction || "Finish this manual action." });
|
|
61357
61473
|
}
|
|
61474
|
+
function readablePhone(v) {
|
|
61475
|
+
const d = v.replace(/\D/g, "");
|
|
61476
|
+
const ten = d.length === 11 && d.startsWith("1") ? d.slice(1) : d.length === 10 ? d : "";
|
|
61477
|
+
return ten ? `+1 ${ten.slice(0, 3)} ${ten.slice(3, 6)} ${ten.slice(6)}` : v.trim();
|
|
61478
|
+
}
|
|
61479
|
+
function handoffShownText(h, facts) {
|
|
61480
|
+
const title2 = h.title || h.name || h.ref;
|
|
61481
|
+
const instruction = h.instruction || "Finish this manual action.";
|
|
61482
|
+
if (h.ref === "handoff.business_details") return obligationTaskText({ type: "handoff", name: title2.replace(/^Confirm your\b/, "Set your"), reason: instruction });
|
|
61483
|
+
if (h.ref !== "handoff.phone_number") return handoffJobText(h);
|
|
61484
|
+
const numbers = facts?.phoneValues?.filter((p) => digits(p).length === 10);
|
|
61485
|
+
if (numbers && !numbers.length) return handoffJobText(h);
|
|
61486
|
+
const rest = instruction.replace(/^[^]*?\bbuy\b[^]*?\.(?:\s+|$)/i, "");
|
|
61487
|
+
const tail = rest === instruction ? "" : rest.replace(/\bA2P registration \(next\) needs the number too\b/, "A2P registration needs the number too");
|
|
61488
|
+
if (numbers?.length) {
|
|
61489
|
+
const shown = numbers.slice(0, 3).map(readablePhone);
|
|
61490
|
+
const lead = shown.length === 1 ? `Use your number ${shown[0]}, already in this account, or buy another.` : `Use one of your numbers already in this account (${shown.join(", ")}${numbers.length > 3 ? ", and more" : ""}), or buy another.`;
|
|
61491
|
+
return obligationTaskText({ type: "handoff", name: title2.replace(/^Get a phone number\b/, "Choose the phone number"), reason: `${lead} Put the one you use in the business_phone custom value.${tail ? ` ${tail}` : ""}` });
|
|
61492
|
+
}
|
|
61493
|
+
return obligationTaskText({ type: "handoff", name: title2, reason: `Add a number under Settings > Phone Numbers, or use one already there. Put the one you use in the business_phone custom value.${tail ? ` ${tail}` : ""}` });
|
|
61494
|
+
}
|
|
61358
61495
|
function clearedByRead(o, ev) {
|
|
61359
61496
|
const row = ev.readback.find((r) => r.ref === o.ref);
|
|
61360
61497
|
switch (o.type) {
|
|
@@ -61445,22 +61582,26 @@ function catalogJobs(input) {
|
|
|
61445
61582
|
const openRecipientJobs = input.obligations.filter((o) => o.type === "staff_pending" && o.ref.startsWith("workflow.") && !input.ticked?.has(`${o.type}:${o.ref}`)).length;
|
|
61446
61583
|
for (const h of input.handoffs) {
|
|
61447
61584
|
if (!h || typeof h.ref !== "string") continue;
|
|
61448
|
-
const
|
|
61585
|
+
const plannedCalendars = input.plannedCalendars ?? /* @__PURE__ */ new Map();
|
|
61586
|
+
const state2 = handoffInAccount(h, input.facts ?? {}, input.readback ?? [], input.plannedUsers ?? [], openRecipientJobs, plannedCalendars);
|
|
61449
61587
|
const text3 = handoffJobText(h);
|
|
61450
61588
|
if (!(state2.readable && state2.done)) {
|
|
61451
|
-
const why = state2.readable && input.facts ? handoffReadReason(h, input.facts) : void 0;
|
|
61452
|
-
push({ id: h.ref, text:
|
|
61589
|
+
const why = (state2.readable || h.ref === ASSIGN_CALENDAR_STAFF) && input.facts ? handoffReadReason(h, input.facts, plannedCalendars) : void 0;
|
|
61590
|
+
push({ id: h.ref, text: handoffShownText(h, input.facts), timing: jobTiming(text3), selfReport: !state2.readable, key: requirementsKey([text3]), ...why ? { why } : {} });
|
|
61453
61591
|
}
|
|
61454
61592
|
const part = HANDOFF_PARTS[h.ref];
|
|
61455
61593
|
if (part) {
|
|
61456
61594
|
const extra = /review-ask/i.test(`${h.title ?? ""} ${h.instruction ?? ""}`) ? " Also confirm a test contact tagged review-ask got the review text." : "";
|
|
61457
61595
|
const t = `Manual action: ${h.title || h.ref} (${part.title}): ${part.text}${extra}`;
|
|
61596
|
+
const shown = part.own ? `Manual action: ${part.own}: ${part.text}${extra}` : t;
|
|
61458
61597
|
const info = h.ref === "handoff.business_details" && input.facts ? phoneInfo(input.facts) : void 0;
|
|
61459
|
-
push({ id: `${h.ref}:${part.part}`, text:
|
|
61598
|
+
push({ id: `${h.ref}:${part.part}`, text: shown, timing: jobTiming(text3), selfReport: true, key: requirementsKey([t]), ...info ? { why: info } : {} });
|
|
61460
61599
|
}
|
|
61461
61600
|
}
|
|
61601
|
+
const smsCovered = input.handoffs.some((h) => /^handoff\.phone_numbers?$/.test(h?.ref)) && input.handoffs.some((h) => /^handoff\.a2p(?:_brand_campaign)?$/.test(h?.ref));
|
|
61462
61602
|
for (const o of input.obligations) {
|
|
61463
61603
|
if (NOT_A_JOB.has(o.type)) continue;
|
|
61604
|
+
if (smsCovered && o.type === "bought-not-built" && o.ref === "bought:sms") continue;
|
|
61464
61605
|
const reqs = requirementsOf(o);
|
|
61465
61606
|
const texts = reqs.map((r) => obligationTaskText({ type: o.type, name: o.name, reason: r }));
|
|
61466
61607
|
const text3 = texts.length === 1 ? texts[0] : `${o.name}: ${reqs.join(" ")}`;
|
|
@@ -61492,6 +61633,12 @@ function jobFactsFrom(raw) {
|
|
|
61492
61633
|
const cals = calsRaw && calsRaw.every((c) => field(c, "id") !== "") ? calsRaw : void 0;
|
|
61493
61634
|
return {
|
|
61494
61635
|
...cals ? { calendarKeys: [...new Set(cals.flatMap((c) => [`id:${field(c, "id")}`, ...["slug", "widgetSlug"].map((k) => field(c, k).toLowerCase()).filter(Boolean).map((v) => `slug:${v}`)]))] } : {},
|
|
61636
|
+
// Who is on each calendar's team (walk W15), in the list shape listCalendars reads: teamMembers[].userId. A calendar
|
|
61637
|
+
// with no teamMembers key keeps memberIds undefined (unknown, never "nobody").
|
|
61638
|
+
...cals ? { calendarTeams: cals.map((c) => {
|
|
61639
|
+
const tm = Reflect.get(Object(c), "teamMembers");
|
|
61640
|
+
return { name: field(c, "name"), ...Array.isArray(tm) ? { memberIds: tm.map((m) => field(m, "userId")).filter(Boolean) } : {} };
|
|
61641
|
+
}) } : {},
|
|
61495
61642
|
...phones ? { phoneNumbers: phones.length, phoneValues: phones.map((p) => field(p, "value") || field(p, "phoneNumber")).filter(Boolean) } : {},
|
|
61496
61643
|
...values2 ? { customValues: values2.flatMap((v) => field(v, "name") ? [{ name: field(v, "name"), value: v && typeof v === "object" ? Reflect.get(v, "value") : void 0 }] : []) } : {},
|
|
61497
61644
|
// A deleted user GoHighLevel still lists is not on the team.
|
|
@@ -61531,7 +61678,8 @@ function planShape(plan) {
|
|
|
61531
61678
|
const am = Reflect.get(w, "allowMultiple");
|
|
61532
61679
|
return [w.ref, { steps: Array.isArray(actions) ? actions.length : 0, allowMultiple: am === true }];
|
|
61533
61680
|
}));
|
|
61534
|
-
|
|
61681
|
+
const calendars = new Map(arr2("calendars").filter(isRef).map((c) => [c.ref, field(c, "name")]).filter(([, n]) => n !== ""));
|
|
61682
|
+
return { handoffs, users, workflows, calendars };
|
|
61535
61683
|
}
|
|
61536
61684
|
function mergeHandoffDefs(known, current) {
|
|
61537
61685
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -61558,7 +61706,7 @@ function handoffPartId(ref) {
|
|
|
61558
61706
|
const part = HANDOFF_PARTS[ref];
|
|
61559
61707
|
return part ? `${ref}:${part.part}` : void 0;
|
|
61560
61708
|
}
|
|
61561
|
-
var key, digits, httpsUrl2, GHL_WIDGET_HOST, money6, placeholder, field, READ_HANDOFFS, HANDOFF_PARTS, READ_KINDS, NOT_A_JOB, publishedRead;
|
|
61709
|
+
var key, digits, httpsUrl2, GHL_WIDGET_HOST, money6, placeholder, field, READ_HANDOFFS, HANDOFF_PARTS, ASSIGN_CALENDAR_STAFF, READ_KINDS, NOT_A_JOB, publishedRead;
|
|
61562
61710
|
var init_jobs = __esm({
|
|
61563
61711
|
"src/jobs.ts"() {
|
|
61564
61712
|
"use strict";
|
|
@@ -61589,11 +61737,12 @@ var init_jobs = __esm({
|
|
|
61589
61737
|
};
|
|
61590
61738
|
READ_HANDOFFS = /* @__PURE__ */ new Set(["handoff.phone_number", "handoff.business_details", "handoff.trial_offer", "handoff.link_domain", "handoff.review_link", "handoff.balance_product", "handoff.add_staff"]);
|
|
61591
61739
|
HANDOFF_PARTS = {
|
|
61592
|
-
"handoff.business_details": { part: "correct", title: "confirm they are real", text: "Confirm these are the clinic's real phone, street address and owner's first name." },
|
|
61740
|
+
"handoff.business_details": { part: "correct", title: "confirm they are real", own: "Check the phone, address and sign-off name are the clinic's real ones", text: "Confirm these are the clinic's real phone, street address and owner's first name." },
|
|
61593
61741
|
"handoff.link_domain": { part: "destination", title: "confirm it opens", text: "Confirm the booking link opens the booking page and its domain is at least 30 days old." },
|
|
61594
61742
|
"handoff.review_link": { part: "destination", title: "confirm it opens", text: "Confirm the review link opens the clinic's Google review page." },
|
|
61595
61743
|
"handoff.balance_product": { part: "destination", title: "confirm it opens", text: "Confirm the balance link opens a payment page for the trial balance." }
|
|
61596
61744
|
};
|
|
61745
|
+
ASSIGN_CALENDAR_STAFF = "handoff.assign_calendar_staff";
|
|
61597
61746
|
READ_KINDS = /* @__PURE__ */ new Set(["workflow-publish", "workflow-setting", "plan-dependency"]);
|
|
61598
61747
|
NOT_A_JOB = /* @__PURE__ */ new Set(["handoff", "intake-repair"]);
|
|
61599
61748
|
publishedRead = (row) => Boolean(row?.workflow && row.workflow.published && row.workflow.errorFree && row.workflow.triggersActive);
|
|
@@ -64960,7 +65109,7 @@ var init_build_steps = __esm({
|
|
|
64960
65109
|
"Command OS has built and checked the account. You sign off here. The list below says what you can switch on now and what waits until its approval comes through."
|
|
64961
65110
|
];
|
|
64962
65111
|
BUILD_STEPS_SCRIPT = String.raw`
|
|
64963
|
-
let bsStaffSaid={},bsChange={},bsErr={},bsBusy={},bsStaff={},bsSeen={},bsDraft={},bsNote={},bsInSteps=false,bsMoreOpen={},bsInfo={},bsAgeAsked={},bsPwHeld={};
|
|
65112
|
+
let bsFillNote={},bsStaffSaid={},bsChange={},bsErr={},bsBusy={},bsStaff={},bsSeen={},bsDraft={},bsNote={},bsInSteps=false,bsMoreOpen={},bsInfo={},bsAgeAsked={},bsPwHeld={};
|
|
64964
65113
|
/* Check again keeps the one-time password lines (they are shown once) and drops the rest (3.90.2). */
|
|
64965
65114
|
function bsKeepPasswords(id){const keep=(bsStaffSaid[id]||[]).filter(l=>l.includes(" Their one-time password is "));if(keep.length)bsStaffSaid[id]=keep;else delete bsStaffSaid[id]}
|
|
64966
65115
|
/* This account's one-time password lines from its last Builds (S1). A second client in the same sub-account (the same
|
|
@@ -65046,7 +65195,9 @@ function bsLive(run,stage){const box=mk("div","cds-stack bs-livebox");
|
|
|
65046
65195
|
/* Run 8 finding 4: about 1.5 minutes with nothing to watch, then a list appeared unannounced. Say what happens. */
|
|
65047
65196
|
/* Once the list is confirmed the note says so, instead of promising a second stop (run 9 finding 10). */
|
|
65048
65197
|
if(stage===3&&!(run.progress&&run.progress.groups&&run.progress.groups.length)){const bp=(((lastState||{}).buildPreviews||{})[run.locationId]);
|
|
65049
|
-
|
|
65198
|
+
/* Walk 2026-10-28 W12, Jerry: "it should tell me it is putting together a list for me to confirm and approve so I don't leave the page." */
|
|
65199
|
+
const acct=((typeof ROSTER!=="undefined"&&ROSTER.find(r=>r.locationId===run.locationId))||{}).name||"this account";
|
|
65200
|
+
const pn=mk("p",bp&&bp.confirmed?"":"cds-callout",bp&&bp.confirmed?"You confirmed the list. It is building now, exactly what the list showed.":"Putting together the list of everything this build will create in "+acct+", for you to check and approve. Stay on this page. Nothing is written until you click Confirm and build.");pn.setAttribute("role","status");box.appendChild(pn)}
|
|
65050
65201
|
const clock=mk("p","bs-count","");box.appendChild(clock);if(BS_TYPICAL[stage])box.appendChild(mk("p","muted",BS_TYPICAL[stage]));
|
|
65051
65202
|
const began=run.startedAt?new Date(run.startedAt).getTime():0;
|
|
65052
65203
|
const tick=()=>{if(!began){clock.textContent="";return}if(statusIsStale()){clock.textContent="Checking it is still running…";return}
|
|
@@ -65109,6 +65260,13 @@ function bsBody(n,c,state,run,box){const id=c.locationId;const key="s"+n+":"+id;
|
|
|
65109
65260
|
bsErrLine(key,box);return}
|
|
65110
65261
|
if(n===3){let v={};try{v=boughtView(state,id)}catch(e){}box.appendChild(assessmentQuestion(id,v));return}
|
|
65111
65262
|
if(n===4){box.appendChild(mk("h4","cds-panel-label","The business details"));
|
|
65263
|
+
/* Walk 2026-10-28 W10: an attached Assessment fills this step's blank answers. The button runs the same fill again
|
|
65264
|
+
(blank answers only, never what is already typed) and says what it filled. Saved on this computer only. */
|
|
65265
|
+
let bv={};try{bv=boughtView(state,id)}catch(e){}
|
|
65266
|
+
if(bv.attached){const fk="fill:"+id;box.appendChild(bsBtn("Fill from the attached Assessment",async()=>{delete bsErr[fk];
|
|
65267
|
+
const j=await bsPost("/api/client-assessment",{clientLocationId:id,action:"fill"});
|
|
65268
|
+
if(!j.ok)bsErr[fk]=j.error;else{bsFillNote[id]=j.message||"";delete iData[id]}load(true)},true,"Filling…"));
|
|
65269
|
+
if(bsFillNote[id])box.appendChild(mk("p","muted",bsFillNote[id]));bsErrLine(fk,box)}
|
|
65112
65270
|
if(!iData[id])iLoad(id);bsInSteps=true;try{box.appendChild(unnest(intakeWorkspace(id,c.name)))}finally{bsInSteps=false}
|
|
65113
65271
|
const team=bsTeam(c,state,box);
|
|
65114
65272
|
box.appendChild(bsBtn(bsBusy[key]?"Saving…":"Save and go to step 6",async()=>{delete bsErr[key];
|
|
@@ -65147,8 +65305,8 @@ function bsBody(n,c,state,run,box){const id=c.locationId;const key="s"+n+":"+id;
|
|
|
65147
65305
|
/* A job's short title: its words before the first colon, without "Manual action:". */
|
|
65148
65306
|
/* The pause before Build's first write (command-center 2026-10-03). Three groups in plain words; a change to
|
|
65149
65307
|
something already in the account happens only when its box is ticked. The server checks the confirm again. */
|
|
65150
|
-
const BS_KIND={users:"staff member",pipelines:"pipeline",customFields:"custom field",tags:"tag",customValues:"custom value",calendars:"calendar",forms:"form",workflows:"workflow",emails:"email",sms:"text message",emailTemplates:"email template",smsTemplates:"text message template"};
|
|
65151
|
-
let bsAllow={};
|
|
65308
|
+
const BS_KIND={users:"staff member",pipelines:"pipeline",customFields:"custom field",tags:"tag",customValues:"custom value",calendars:"calendar",forms:"form",workflows:"workflow",emails:"email inside the workflows",sms:"text message inside the workflows",emailTemplates:"email template",smsTemplates:"text message template"};
|
|
65309
|
+
let bsAllow={},bsA2pNot={};
|
|
65152
65310
|
function bsPause(c,state,pv,box){const id=c.locationId;const key="confirm:"+id;
|
|
65153
65311
|
/* A list from before 3.90.1 names no logins: out of date, made again before anything can be confirmed. */
|
|
65154
65312
|
if(!Array.isArray(pv.logins)){box.appendChild(mk("p","cds-callout warn","This list is out of date. Start the build again to see the current one."));bsRun(c,state,lastRun||{},3,"Start the build",box);return}
|
|
@@ -65156,9 +65314,15 @@ function bsPause(c,state,pv,box){const id=c.locationId;const key="confirm:"+id;
|
|
|
65156
65314
|
if(!bsAllow[id]||bsAllow[id].fp!==pv.fingerprint)bsAllow[id]={fp:pv.fingerprint,keys:{}};const allow=bsAllow[id].keys;
|
|
65157
65315
|
box.appendChild(mk("h3","","Before Build writes anything"));
|
|
65158
65316
|
box.appendChild(mk("p","","Nothing has been written to "+(c.name||"this account")+" yet. Here is what Build will do. Check it, then confirm."));
|
|
65159
|
-
|
|
65160
|
-
|
|
65161
|
-
|
|
65317
|
+
/* Walk 2026-10-28 W13/W8: "17 emails, 8 email templates" said nothing about the difference, and the list named nothing.
|
|
65318
|
+
Each kind says what it is, and opens to the name of every object of that kind. */
|
|
65319
|
+
const kindWord=(k,n)=>{const w=BS_KIND[k]||k;const sp=w.indexOf(" inside ");return n===1?w:(sp>0?w.slice(0,sp)+"s"+w.slice(sp):w+"s")};
|
|
65320
|
+
box.appendChild(mk("h4","","Will create in "+(c.name||"this account")+": "+pv.createCount+" new "+plural(pv.createCount,"item")));
|
|
65321
|
+
for(const [k,n] of Object.entries(pv.createByType||{}).filter(x=>x[1]>0)){const names=((pv.createNames||{})[k])||[];
|
|
65322
|
+
const d=mk("details","cds-details");d.appendChild(mk("summary","",n+" "+kindWord(k,n)));
|
|
65323
|
+
if(names.length){const ul=mk("ul","cds-list");for(const nm of names)ul.appendChild(mk("li","",nm));d.appendChild(ul)}else d.appendChild(mk("p","muted","Their names are in the approved plan."));box.appendChild(d)}
|
|
65324
|
+
/* Item 14: the progress note Command OS keeps in the account is on the list every time, not only when one exists. */
|
|
65325
|
+
if(!pv.change.some(e=>/^board:/.test(e.key)||e.name==="Command OS Board"))box.appendChild(mk("p","muted","Also, from this build on: one progress note in "+(c.name||"this account")+", the custom value \u201cCommand OS Board\u201d, so teammates can see where the build is."));
|
|
65162
65326
|
/* The logins it would make, each named with the job the team list gives and "Admin login" where it is one
|
|
65163
65327
|
(3.90.1, Codex on ad610635), before Confirm and build can be pressed. */
|
|
65164
65328
|
if((pv.logins||[]).length){const sc=(state.staffChecks||{})[id]||{};const jobOf=e=>{const p=[...(sc.listed||[]),...(sc.matched||[]),...(sc.missing||[])].find(x=>String(x.email).trim().toLowerCase()===String(e).trim().toLowerCase());return p&&p.role?p.role:""};
|
|
@@ -65189,9 +65353,10 @@ function bsSnippets(c,state,box){const id=c.locationId;const key="snip:"+id;cons
|
|
|
65189
65353
|
l.appendChild(cb);l.appendChild(mk("span","","Add ready-made text snippets to your Templates? Note: GoHighLevel doesn\u2019t let these be removed later."));box.appendChild(l);bsErrLine(key,box)}
|
|
65190
65354
|
function bsJobTitle(t){const s=String(t||"").replace(/^Manual action:\s*/i,"");const i=s.indexOf(": ");return (i>0?s.slice(0,i):s).replace(/[.\s]+$/,"")}
|
|
65191
65355
|
/* The question to give Claude for one job (Jerry 2026-10-03): it walks the person through it, one click at a time. */
|
|
65192
|
-
/*
|
|
65193
|
-
|
|
65194
|
-
function
|
|
65356
|
+
/* Walk W20 (2026-10-28): "Walk me through get a phone number" put a job title where a noun goes. ONE rule for every
|
|
65357
|
+
title, whatever its grammar: the title is quoted as the job's name, after a colon, exactly as the list shows it. */
|
|
65358
|
+
function bsAskLine(name,title){return "Ask Claude: \u201cWalk me through this job in GoHighLevel for "+(name||"this client")+", one click at a time: "+(t=>t+(/[?!]$/.test(t)?"":"."))(String(title||"").replace(/[.:;,\s]+$/,""))+"\u201d"}
|
|
65359
|
+
function bsAskClaude(c,title){return mk("small","muted bs-ask",bsAskLine(c.name,title))}
|
|
65195
65360
|
/* Step 12: what Command OS did, and the numbered list of what the person finishes by hand in GoHighLevel. */
|
|
65196
65361
|
function bsSignOffList(c,state){const box=mk("div","cds-stack bs-signoff");const mt=((state.manualTasks||{})[c.locationId])||[];
|
|
65197
65362
|
box.appendChild(mk("h4","cds-panel-label","What Command OS did"));
|
|
@@ -65291,10 +65456,16 @@ function bsStaffStep(c,state,box){const id=c.locationId;const sc=(state.staffChe
|
|
|
65291
65456
|
|
|
65292
65457
|
if((sc.missing||[]).length){
|
|
65293
65458
|
if(sc.canAddForYou&&!sc.createRefused){if((sc.creations||[]).length){box.appendChild(mk("p","","We can add them to this client's GoHighLevel account for you. GoHighLevel should email each new person an invitation. Let your client know, and check with them that it arrived."));
|
|
65294
|
-
|
|
65459
|
+
/* Walk 2026-10-28 W8: Add them for me asks first, naming the account and each login it creates, as the board does. */
|
|
65460
|
+
const toAdd=(sc.missing||[]).filter(m=>!(sc.waiting||[]).some(x=>x.email===m.email));
|
|
65461
|
+
const addBtn=mk("button","cds-btn sbig",bsBusy[key+":add"]?"Adding…":"Add them for me");addBtn.type="button";addBtn.disabled=Boolean(bsBusy[key+":add"]);
|
|
65462
|
+
addBtn.onclick=()=>{const ask=mk("div","cds-callout bs-addask");ask.appendChild(mk("p","","This adds "+toAdd.length+" "+plural(toAdd.length,"login")+" to "+c.name+": "+toAdd.map(m=>{const cr=(sc.creations||[]).find(x=>x.email===String(m.email).trim().toLowerCase());return m.name+" ("+m.email+")"+(cr&&cr.role==="admin"?", "+BS_ADMIN_MARK:"")}).join("; ")+". GoHighLevel should email each of them an invitation."));
|
|
65463
|
+
const row=mk("div","cds-actions");row.appendChild(bsBtn("Create the logins in "+c.name,()=>bsAddStaff(id,sc,key)));row.appendChild(bsBtn("Cancel",()=>ask.replaceWith(addBtn),true));ask.appendChild(row);addBtn.replaceWith(ask)};
|
|
65464
|
+
box.appendChild(addBtn)}}
|
|
65295
65465
|
else{const how=mk("ol","bs-how");for(const t of BS_HOW_TO_ADD)how.appendChild(mk("li","",t));box.appendChild(mk("p","",bsHowToAddLead(sc)));box.appendChild(how)}}
|
|
65296
65466
|
const again=bsBtn(bsBusy[key]?"Checking…":"Check again",()=>{bsBusy[key]=true;bsKeepPasswords(id);bsCheckStaff(id);render(lastState,lastRun)},sc.status==="ready");box.appendChild(again);
|
|
65297
65467
|
bsErrLine(key,box)}
|
|
65468
|
+
async function bsAddStaff(id,sc,key){bsBusy[key+":add"]=true;delete bsErr[key];render(lastState,lastRun);const j=await bsPost("/api/staff/add",{locationId:id,shown:sc.creations||[]});bsBusy[key+":add"]=false;bsSupersede(id,j.results);bsStaffSaid[id]=[...(bsStaffSaid[id]||[]).filter(l=>l.includes(" Their one-time password is ")),...(j.ok?(j.said||[]):[]),...(j.results||[]).filter(x=>x.generatedPassword&&!x.passwordUnconfirmed).map(x=>x.said+" Their one-time password is "+x.generatedPassword+". Give it to them privately.")];bsHoldPasswords(id,j.results);if(!j.ok){bsErr[key]=j.error;if(j.check)bsStaff[id]=j.check}else bsStaff[id]=j.check;load(true)}
|
|
65298
65469
|
async function bsCheckStaff(id){const key="staff:"+id;let j=null;try{j=await(await fetch("/api/staff/check",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id})})).json()}catch(e){}
|
|
65299
65470
|
bsBusy[key]=false;if(j&&j.ok){bsStaff[id]=j.check;delete bsErr[key]}else bsErr[key]=(j&&j.error)||"We could not check this client's staff. Try Check again.";load(true)}
|
|
65300
65471
|
/* The jobs that hold up go-live, by the gate's own rule (jobTiming "before", not ticked off). */
|
|
@@ -65305,6 +65476,8 @@ function bsJobKey(t){if(typeof t.selfReport==="boolean")return (t.timing||"befor
|
|
|
65305
65476
|
function bsMustDo(c,state){const keys=new Set();for(const t of (((state.manualTasks||{})[c.locationId])||[]))if(t.blocks!==undefined?t.blocks:((t.timing||"before")==="before"&&!t.claimed))keys.add(bsJobKey(t));return [...keys]}
|
|
65306
65477
|
/* Step 11's list: the jobs Verify wrote, ticked as they are done. */
|
|
65307
65478
|
function bsJobs(c,state){const mt=((state.manualTasks||{})[c.locationId])||[];const box=mk("div","cds-stack");
|
|
65479
|
+
/* Walk W15: a job the check read as done is off the list; it says what it confirmed, by name. */
|
|
65480
|
+
const conf=((state.confirmedJobs||{})[c.locationId])||[];for(const l of conf)box.appendChild(mk("p","bs-confirmed","\u2713 "+l));
|
|
65308
65481
|
if(!mt.length){box.appendChild(mk("p","muted","Nothing to do by hand."));return box}
|
|
65309
65482
|
/* Blind run 2: 43 boxes in one list, repeats included, nothing saying which mattered. Now: the jobs that hold up
|
|
65310
65483
|
go-live first and numbered, the automations to switch on as one line, the rest folded under For later. */
|
|
@@ -65317,37 +65490,50 @@ function bsJobs(c,state){const mt=((state.manualTasks||{})[c.locationId])||[];co
|
|
|
65317
65490
|
/* Only the box and the job's own title tick it (run 10: the whole row was one label, so clicking the Ask Claude
|
|
65318
65491
|
question to copy it ticked a job nobody had done). */
|
|
65319
65492
|
const tick=g=>{const row=document.createElement("div");row.className="cds-check";const cb=document.createElement("input");cb.type="checkbox";cb.checked=g.items.every(t=>t.claimed);/* A stable id per client and job, so the page puts keyboard focus back on the same box after it redraws (Codex). */cb.id="bs-job-"+String(c.locationId+"-"+bsJobKey(g.items[0])).replace(/[^A-Za-z0-9_-]/g,"_");
|
|
65320
|
-
|
|
65493
|
+
const claim=async v=>{delete bsErr["jobs:"+c.locationId];for(const t of g.items){if(Boolean(t.claimed)===v)continue;let by="";try{by=localStorage.getItem("cmdos-operator")||""}catch(e){}const j=await bsPost("/api/manual-task",{locationId:c.locationId,...(c.clientKey?{clientKey:c.clientKey}:{}),id:t.id,claimed:v,by,...(typeof jobShown==="function"?jobShown(t):{})});if(!j.ok){bsErr["jobs:"+c.locationId]=j.error;break}}load(true)};
|
|
65494
|
+
cb.onchange=()=>claim(cb.checked);
|
|
65321
65495
|
const topic=g.items[0].topic&&g.timing==="before"?BS_TOPICS[g.items[0].topic]:null;
|
|
65496
|
+
/* Walk W21 (2026-10-28): a job the check reads in GoHighLevel is cleared by the check alone, so it has no box: a tick
|
|
65497
|
+
there never counted, and a ticked box beside "2 of 9 left" looked done when it was not. */
|
|
65498
|
+
const readJob=g.items.every(t=>t.selfReport===false);
|
|
65499
|
+
/* Walk W14: GoHighLevel gives the check no read of text registration (A2P), so the member answers it as a question. */
|
|
65500
|
+
const t0=g.items[0];const a2p=g.items.length===1&&t0.selfReport===true&&/^handoff\.a2p(?:_brand_campaign)?$/.test(String(t0.id||""));
|
|
65322
65501
|
/* Everything about a job sits in ONE body beside its box (run 8: a note rendered under the next job). */
|
|
65323
65502
|
const body=mk("span","bs-jobbody");
|
|
65324
|
-
const lab=document.createElement("label");lab.htmlFor=cb.id;lab.className="bs-jobtitle";
|
|
65325
|
-
if(
|
|
65326
|
-
else{lab.
|
|
65327
|
-
|
|
65503
|
+
const lab=document.createElement(readJob||a2p?"span":"label");if(!readJob&&!a2p)lab.htmlFor=cb.id;lab.className="bs-jobtitle";
|
|
65504
|
+
if(a2p)lab.appendChild(mk("b","","Is business texting registration (A2P) already approved for this account?"));
|
|
65505
|
+
else if(topic){lab.appendChild(mk("b","",topic));body.appendChild(lab);for(const t of g.items){body.appendChild(document.createElement("br"));body.appendChild(mk("small","muted",String(t.text).replace(/^Manual action:\s*/i,"")))}}
|
|
65506
|
+
else lab.textContent=String(t0.text).replace(/^Manual action:\s*/i,"");
|
|
65507
|
+
if(a2p||!topic)body.appendChild(lab);
|
|
65508
|
+
if(readJob||a2p){const mark=mk("span","bs-jobmark"+(a2p&&cb.checked?" done":""),a2p&&cb.checked?"✓":"•");mark.setAttribute("aria-hidden","true");row.appendChild(mark)}else row.appendChild(cb);
|
|
65509
|
+
row.appendChild(body);
|
|
65328
65510
|
const note=txt=>{body.appendChild(document.createElement("br"));body.appendChild(mk("small","muted",txt))};
|
|
65329
65511
|
/* The workflow keeps its name "(72 hours)" so rebuilt accounts get no duplicate; wherever a job names it, the window
|
|
65330
65512
|
is said in days, so it never reads as the 72-hour price a client declined (command-center, 2026-10-04). */
|
|
65331
|
-
{const hw=/\((\d+) hours\)/.exec(String(
|
|
65332
|
-
|
|
65333
|
-
|
|
65334
|
-
const t0=
|
|
65335
|
-
|
|
65336
|
-
|
|
65337
|
-
|
|
65338
|
-
|
|
65513
|
+
{const hw=/\((\d+) hours\)/.exec(String(t0.text));if(hw&&Number(hw[1])%24===0)note("The texts run over "+(Number(hw[1])/24)+" days after the visit. It is not a "+hw[1]+"-hour price offer.")}
|
|
65514
|
+
const ask=()=>{const ttl=topic||bsJobTitle(t0.text);if(ttl){body.appendChild(document.createElement("br"));body.appendChild(bsAskClaude(c,ttl))}};
|
|
65515
|
+
const why=g.items.map(t=>t.why).find(Boolean);
|
|
65516
|
+
if(a2p){const who=t0.claimedBy?(" by "+t0.claimedBy):"";const when=t0.claimedAt?(" on "+niceDate(t0.claimedAt,true)):"";
|
|
65517
|
+
if(cb.checked){note("Yes, already approved (answered"+who+when+"). The check does not read text registration in GoHighLevel, so your answer is the record.");const ch=mk("button","cds-link","Change answer");ch.type="button";ch.onclick=()=>{delete bsA2pNot[t0.id+":"+c.locationId];claim(false)};body.appendChild(document.createElement("br"));body.appendChild(ch)}
|
|
65518
|
+
else{const btns=mk("span","cds-row bs-a2p");const yes=mk("button","cds-btn ghost","Yes, already approved");yes.type="button";yes.onclick=()=>claim(true);const no=mk("button","cds-btn ghost","Not yet");no.type="button";no.setAttribute("aria-pressed",bsA2pNot[t0.id+":"+c.locationId]?"true":"false");no.onclick=()=>{bsA2pNot[t0.id+":"+c.locationId]=true;render(lastState,lastRun)};btns.appendChild(yes);btns.appendChild(no);body.appendChild(document.createElement("br"));body.appendChild(btns);
|
|
65519
|
+
if(bsA2pNot[t0.id+":"+c.locationId]){note("How to do it: "+String(t0.text).replace(/^Manual action:\s*/i,""));ask()}}
|
|
65520
|
+
return row}
|
|
65521
|
+
if(g.timing!=="at-go-live"&&(readJob||!cb.checked))ask();
|
|
65522
|
+
/* A job only a tick clears says who ticked it and when. Never "GoHighLevel cannot show it" (walk W15: it said so of a
|
|
65523
|
+
calendar's team, which GoHighLevel shows): what is true is that the check does not read it. */
|
|
65524
|
+
if(cb.checked&&g.items.every(t=>t.selfReport)){const who=t0.claimedBy?("by "+t0.claimedBy+" "):"";const when=t0.claimedAt?("on "+niceDate(t0.claimedAt,true)):"";note("Ticked "+who+when+". The check does not read this in GoHighLevel, so your tick is the record.")}
|
|
65339
65525
|
/* A job only a tick clears says so before it is ticked too (run 10: the booking link's "confirm it opens" part read
|
|
65340
65526
|
like something the check would clear, and a stranger waited 20 minutes for it). */
|
|
65341
|
-
else if(!cb.checked&&g.timing!=="at-go-live"&&g.items.every(t=>t.selfReport))note("
|
|
65342
|
-
else if(g.
|
|
65343
|
-
|
|
65344
|
-
|
|
65345
|
-
|
|
65527
|
+
else if(!cb.checked&&g.timing!=="at-go-live"&&g.items.every(t=>t.selfReport))note("The check does not read this in GoHighLevel, so your tick is the record."+(why?" "+why:""));
|
|
65528
|
+
else if(readJob&&g.timing!=="at-go-live"){
|
|
65529
|
+
/* Walk W21: what the check last found, and what to do about it. */
|
|
65530
|
+
const found=why?" "+String(why).replace(/^GoHighLevel shows /,"GoHighLevel still shows "):" The check reads this in GoHighLevel and has not seen it done yet.";
|
|
65531
|
+
note("Waiting for the check:"+found+(/ empty\.$/.test(String(why||""))?" Set it":" Do it in GoHighLevel")+", then press Run the check again in step 10, Verify.")}
|
|
65346
65532
|
else if(cb.checked&&g.items.some(t=>t.blocks))note("Ticked. The check confirms it next time it runs.");return row};
|
|
65347
65533
|
const must=groups.filter(g=>g.timing==="before"),on=groups.filter(g=>g.timing==="at-go-live"),later=groups.filter(g=>g.timing==="later");
|
|
65348
65534
|
const left=must.filter(g=>g.items.some(t=>t.blocks!==undefined?t.blocks:!t.claimed)).length;
|
|
65349
65535
|
box.appendChild(mk("h4","cds-panel-label",must.length?"Must do before sign-off: "+left+" of "+must.length+" left":"Nothing must be done before sign-off"));
|
|
65350
|
-
if(must.length){const ol=mk("ol","bs-jobs");for(const g of must){const li=mk("li","");li.appendChild(tick(g));ol.appendChild(li)}box.appendChild(ol)}
|
|
65536
|
+
if(must.length){const ol=mk("ol","bs-jobs bs-numbered");for(const g of must){const li=mk("li","");li.appendChild(tick(g));ol.appendChild(li)}box.appendChild(ol)}
|
|
65351
65537
|
if(on.length){const d=mk("details","cds-details");d.setAttribute("data-key","jobs-on:"+c.locationId);
|
|
65352
65538
|
/* Run 7: Build said 19 automations and this said 18. A number worked out here goes wrong after a rebuild (review of
|
|
65353
65539
|
c8fbe32), so the line says where the others are instead of counting them. */
|
|
@@ -65625,7 +65811,7 @@ ${[...pkgs.map((pk, i) => pkgRow(pk, i)), pkgRow({}, pkgs.length)].join("\n")}
|
|
|
65625
65811
|
<div class="cds-grid two">
|
|
65626
65812
|
${SERVICE_IDS.map((id2) => ` <label class="cds-check"><input type="checkbox" name="svc" value="${id2}"${sold.offered.includes(id2) ? " checked" : ""}><span>${esc14(SERVICES[id2].label)}</span></label>`).join("\n")}
|
|
65627
65813
|
</div>
|
|
65628
|
-
${area("svcCustom", "Your own services: one per line", sold.custom.map((c) => c.name).join("\n"), { rows: Math.max(3, sold.custom.length + 1), placeholder: "
|
|
65814
|
+
${area("svcCustom", "Your own services: one per line", sold.custom.map((c) => c.name).join("\n"), { rows: Math.max(3, sold.custom.length + 1), placeholder: "Map listing tune-up", hint: "Saved as yours. To have one pitched on a report, tell Claude which service above it covers, for example: my map listing tune-up covers local search." })}
|
|
65629
65815
|
</section>
|
|
65630
65816
|
|
|
65631
65817
|
<section class="cds-panel">
|
|
@@ -66039,13 +66225,15 @@ async function assessmentAttachmentAction(args, deps) {
|
|
|
66039
66225
|
if (!args.contactId) throw new Error("Choose an assessment first.");
|
|
66040
66226
|
const snapshot2 = await takeAssessmentSnapshot(source(), args.sourceLocationId, args.contactId, args.opportunityId);
|
|
66041
66227
|
deps.write(attachSnapshot(deps.read(), id2, snapshot2));
|
|
66042
|
-
|
|
66228
|
+
const filled2 = fillFromSnapshot(deps.read(), id2, deps.questions(id2));
|
|
66229
|
+
deps.write(filled2.state);
|
|
66230
|
+
detail = { filled: filled2.filled, kept: filled2.kept, leftForMember: filled2.leftForMember, message: `The ${snapshot2.assessment.biz || "Assessment"} assessment is attached. ${fillSentence(filled2.filled, filled2.kept)}` };
|
|
66043
66231
|
break;
|
|
66044
66232
|
}
|
|
66045
66233
|
case "fill": {
|
|
66046
66234
|
const filled2 = fillFromSnapshot(deps.read(), id2, deps.questions(id2));
|
|
66047
66235
|
deps.write(filled2.state);
|
|
66048
|
-
detail = { filled: filled2.filled, leftForMember: filled2.leftForMember, message:
|
|
66236
|
+
detail = { filled: filled2.filled, kept: filled2.kept, leftForMember: filled2.leftForMember, message: fillSentence(filled2.filled, filled2.kept) };
|
|
66049
66237
|
break;
|
|
66050
66238
|
}
|
|
66051
66239
|
case "detach":
|
|
@@ -66075,6 +66263,11 @@ async function assessmentAttachmentAction(args, deps) {
|
|
|
66075
66263
|
availableNext: snapshot && purchased ? assessmentFeatureLines(snapshot, purchased) : []
|
|
66076
66264
|
};
|
|
66077
66265
|
}
|
|
66266
|
+
function fillSentence(filled2, kept) {
|
|
66267
|
+
const n = filled2.length;
|
|
66268
|
+
const head4 = n ? `Filled ${n} intake ${n === 1 ? "answer" : "answers"} from it: ${filled2.join(", ")}.` : "No blank intake answer could be filled from it.";
|
|
66269
|
+
return kept.length ? `${head4} Kept what you had already answered: ${kept.join(", ")}.` : head4;
|
|
66270
|
+
}
|
|
66078
66271
|
var init_attachment_actions = __esm({
|
|
66079
66272
|
"src/command-os/assessment/attachment-actions.ts"() {
|
|
66080
66273
|
"use strict";
|
|
@@ -66098,13 +66291,17 @@ async function assessmentRequest(id,action,extra={}){
|
|
|
66098
66291
|
const response=await fetch('/api/client-assessment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientLocationId:id,action,...extra})});
|
|
66099
66292
|
const result=await response.json();if(!response.ok||!result.ok)throw new Error(result.error||'The assessment action did not finish.');
|
|
66100
66293
|
if(action==='list'){p.list=result.assessments;p.listed=true;p.selected='';if(!p.list.length)p.notice=ASSESSMENT_LIST_EMPTY}
|
|
66101
|
-
else{p.data=result;p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
|
|
66294
|
+
else{p.data=result;p.notice=result.message||'';if(action==='fill'||action==='attach'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
|
|
66102
66295
|
if(action==='purchases'||action==='attach'||action==='detach')await load();
|
|
66103
66296
|
}catch(e){p.notice=e.message||'The assessment could not be read. Try again.'}
|
|
66104
66297
|
p.busy=false;renderFor(tok);
|
|
66105
66298
|
}
|
|
66106
66299
|
function assessmentPanel(id){
|
|
66107
66300
|
const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:''});
|
|
66301
|
+
/* Walk 2026-10-28 W6b: with one account the list showed it as chosen and listed nothing until it was picked again.
|
|
66302
|
+
One account is that account, and its assessments are listed straight away (once, when the panel first opens). */
|
|
66303
|
+
const accounts=ROSTER.filter(r=>!r.pending);
|
|
66304
|
+
if(fresh&&!p.source&&accounts.length===1){p.source=accounts[0].locationId;setTimeout(()=>assessmentRequest(id,'list',{sourceLocationId:p.source}),0)}
|
|
66108
66305
|
const box=document.createElement('section');box.className='cds-stack';
|
|
66109
66306
|
const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
|
|
66110
66307
|
const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
|
|
@@ -66113,7 +66310,7 @@ function assessmentPanel(id){
|
|
|
66113
66310
|
text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
|
|
66114
66311
|
const select=text('select','');select.className='cds-select';select.setAttribute('aria-label','Your account where the assessment was saved');select.disabled=p.busy;
|
|
66115
66312
|
const blank=document.createElement('option');blank.value='';blank.textContent='Choose your own account';select.appendChild(blank);
|
|
66116
|
-
for(const account of
|
|
66313
|
+
for(const account of accounts){const option=document.createElement('option');option.value=account.locationId;option.textContent=account.name;option.selected=p.source===account.locationId;select.appendChild(option)}
|
|
66117
66314
|
select.onchange=()=>{p.source=select.value;p.list=[];p.listed=false;p.selected='';if(p.source)assessmentRequest(id,'list',{sourceLocationId:p.source});else render(lastState,lastRun)};
|
|
66118
66315
|
button('Refresh the assessments',()=>assessmentRequest(id,'list',{sourceLocationId:p.source}),!p.source);
|
|
66119
66316
|
if(p.list.length){const choice=text('select','');choice.className='cds-select';choice.setAttribute('aria-label','Saved assessment');choice.disabled=p.busy;const prompt=document.createElement('option');prompt.value='';prompt.textContent='Pick an assessment';choice.appendChild(prompt);
|
|
@@ -66132,7 +66329,7 @@ function assessmentPanel(id){
|
|
|
66132
66329
|
if(p.data.availableNext.length){text('h4','Available next');for(const line of p.data.availableNext)text('p',line)}
|
|
66133
66330
|
}else if(p.data)text('p','No assessment is attached.');
|
|
66134
66331
|
text('p','Nothing is written to the client’s account. The snapshot lives on this machine, on the client’s card.');
|
|
66135
|
-
if(p.fill
|
|
66332
|
+
if(p.fill&&Array.isArray(p.fill.filled)){if(p.fill.leftForMember.length){text('h4','Left for you');for(const item of p.fill.leftForMember)text('p',item.question+': '+item.reason)}}
|
|
66136
66333
|
const notice=text('p',p.busy?'Reading or saving the assessment.':p.notice||'');notice.setAttribute('role','status');
|
|
66137
66334
|
if(!p.busy&&p.listed&&!p.list.length){const a=document.createElement('a');a.className='cds-link';a.href='/assessment';a.target='_blank';a.rel='noopener';a.textContent='Open the Assessment';box.appendChild(a)}
|
|
66138
66335
|
if(fresh)queueMicrotask(()=>assessmentRequest(id,'get'));
|
|
@@ -66263,7 +66460,7 @@ var Q = [
|
|
|
66263
66460
|
|
|
66264
66461
|
["calls_month","phone","Roughly how many calls come in on a normal month?","Think about a busy Tuesday and work out from there.","count","The base for everything else on the phone."],
|
|
66265
66462
|
["calls_missed","phone","How many of those go to voicemail or just ring out?","What happens when you're up a ladder and it rings?","count","The most common leak, and the one owners underestimate most."],
|
|
66266
|
-
["missed_never_return","phone","Of the
|
|
66463
|
+
["missed_never_return","phone","Of the calls you miss, what share never call back?","A percentage. Half is 50%.","rate","This is what stops us overclaiming. We only count the ones you genuinely lose."],
|
|
66267
66464
|
["who_answers","phone","Who answers the phone when it rings?","And what were they doing before it rang?","text","If it's you, this is a time finding as well as a money one."],
|
|
66268
66465
|
["after_hours_calls","phone","What share of your calls come in outside working hours?","Evenings and weekends. Storm season?","rate","Separates a staffing problem from an automation one."],
|
|
66269
66466
|
["voicemail_fate","phone","When someone does leave a voicemail, what happens to it?","","text","'I get to them at night' is the after-hours finding in their own words."],
|
|
@@ -66284,7 +66481,7 @@ var Q = [
|
|
|
66284
66481
|
["quote_delay","quotes","How long between the site visit and them getting the price?","","text","A quote that arrives four days later competes with three that arrived first."],
|
|
66285
66482
|
|
|
66286
66483
|
["booked_month","booking","How many appointments or site visits get booked in a month?","","count","The base for the no-show finding."],
|
|
66287
|
-
["show_rate","booking","Of those,
|
|
66484
|
+
["show_rate","booking","Of those, what share actually happen?","A percentage. Eight in ten is 80%. Count out nobody home, canceled last minute, forgot.","rate","A no-show is a job you paid to get twice."],
|
|
66288
66485
|
["reminders","booking","Does anything remind them before the appointment?","","text","If the answer is 'I text them myself', that's both a time and a money finding."],
|
|
66289
66486
|
["noshow_fate","booking","When someone no-shows, what happens next?","","text","'Nothing' means the rebooking finding is real."],
|
|
66290
66487
|
|
|
@@ -66943,8 +67140,24 @@ function bizWarnHtml(){
|
|
|
66943
67140
|
function goBiz(){S.tab="ask";S.area="client";save();draw();
|
|
66944
67141
|
var el=document.getElementById("f_biz");
|
|
66945
67142
|
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});el.focus()}}
|
|
67143
|
+
/**
|
|
67144
|
+
* Walk 2026-10-28 W5: with the business name, average value and close rate filled, the core warning cleared but the
|
|
67145
|
+
* report still said "We could not put a number on this yet" and listed seven counts; Jerry read it as the Assessment
|
|
67146
|
+
* refusing. The standing warning now says that one area's counts are needed, names the quickest pair, and goes there.
|
|
67147
|
+
*/
|
|
67148
|
+
function areaWarnHtml(){
|
|
67149
|
+
if(coreMissing(S.ans,S.skip).length)return "";
|
|
67150
|
+
var r;try{r=compute(S.ans,S.skip,S.asm)}catch(e){return ""}
|
|
67151
|
+
if(!r||r.verdict!=="insufficient")return "";
|
|
67152
|
+
return '<div class="cds-callout warn"><span class="ttl">'+TODO+'One area still needs its counts before anything can be priced</span>'
|
|
67153
|
+
+'The two main numbers are in. Each finding also needs the counts for its own area. The quickest is the phone: how many calls go to voicemail or ring out in a month, and what share of those never call back.'
|
|
67154
|
+
+'<div class="cds-actions"><button class="cds-btn ghost" onclick="goAreaCounts()">Take me to it</button></div></div>';
|
|
67155
|
+
}
|
|
67156
|
+
function goAreaCounts(){S.tab="ask";S.area="phone";save();draw();
|
|
67157
|
+
var el=document.getElementById("q_calls_missed");
|
|
67158
|
+
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});var i=el.querySelector("input");if(i)i.focus()}}
|
|
66946
67159
|
/** Every standing warning, in one place, so all three tabs show the same set. */
|
|
66947
|
-
function warnsHtml(){return coreWarnHtml()+bizWarnHtml()}
|
|
67160
|
+
function warnsHtml(){return coreWarnHtml()+areaWarnHtml()+bizWarnHtml()}
|
|
66948
67161
|
|
|
66949
67162
|
function goCore(){S.tab="ask";S.area="money";save();draw();
|
|
66950
67163
|
var g=coreMissing(S.ans,S.skip);
|
|
@@ -66997,10 +67210,19 @@ function cf(k,label,type,ph){var c=S.client||{};
|
|
|
66997
67210
|
+'<input class="cds-input" id="c_'+k+'" type="'+(type||"text")+'" maxlength="'+CLIENT_FIELDS[k].max+'"'+(ph?' placeholder="'+escH(ph)+'"':'')+' value="'+escH(c[k]||"")+'" oninput="setClient(\\''+k+'\\',this.value)"></div>'}
|
|
66998
67211
|
function setClient(k,v){if(!S.client)S.client={};S.client[k]=v;save();drawHead()}
|
|
66999
67212
|
function setSaveTo(v){S.saveTo=v;save();try{localStorage.setItem(K+":saveTo",v)}catch(e){}}
|
|
67213
|
+
/* Walk 2026-10-28 W6: the drop-down showed the only account as picked while Save said "Which account?". The account in
|
|
67214
|
+
use is one the list has: a remembered one that is no longer listed counts as none, and a list of one is that one. */
|
|
67215
|
+
function currentSaveTo(){
|
|
67216
|
+
var list=window.COMMAND_OS_CLIENTS||[];
|
|
67217
|
+
var cur=S.saveTo;try{if(!cur)cur=localStorage.getItem(K+":saveTo")||""}catch(e){}
|
|
67218
|
+
if(list.length&&!list.some(function(c){return c.locationId===cur}))cur="";
|
|
67219
|
+
if(!cur&&list.length===1)cur=list[0].locationId;
|
|
67220
|
+
return cur;
|
|
67221
|
+
}
|
|
67000
67222
|
function accountSelectHtml(){
|
|
67001
67223
|
var list=window.COMMAND_OS_CLIENTS;
|
|
67002
67224
|
if(!list||!list.length)return '<p>Open this page from Command OS to choose the account it is saved into.</p>';
|
|
67003
|
-
var cur=
|
|
67225
|
+
var cur=currentSaveTo();
|
|
67004
67226
|
return '<div class="cds-field"><label for="f_saveTo">Account</label>'
|
|
67005
67227
|
+'<div class="hint">Usually your own agency account: the prospect becomes a contact there, the answers a note on that contact, and the deal a card on your Assessments pipeline. A client account only once they are one.</div>'
|
|
67006
67228
|
+'<select class="cds-select" id="f_saveTo" onchange="setSaveTo(this.value)"><option value="">Which account does this belong to?</option>'
|
|
@@ -67110,7 +67332,7 @@ function repHtml(){
|
|
|
67110
67332
|
+'<div class="cds-row"><button class="cds-btn'+(ghl?' ghost':'')+'" onclick="openReport()">Open it full size</button>'
|
|
67111
67333
|
+'<button class="cds-btn ghost" onclick="showSource()">Save the report</button>'
|
|
67112
67334
|
+'<button class="cds-btn ghost" onclick="dl()">Save answers</button>'
|
|
67113
|
-
+(ghl?'<button class="cds-btn" onclick="
|
|
67335
|
+
+(ghl?'<button class="cds-btn" onclick="askSaveToGhl()">Save to GoHighLevel</button>':'')+'</div>'
|
|
67114
67336
|
+'<div id="savemsg">'+pipeMissingHtml()+'</div></section>'
|
|
67115
67337
|
+'<div class="cds-callout info"><span class="ttl">Publishing it</span>Open it full size and print to PDF, or hand the file to your own site. Putting it on your domain automatically is the next piece, and it is not built yet.</div>';
|
|
67116
67338
|
}
|
|
@@ -67213,16 +67435,16 @@ function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.clie
|
|
|
67213
67435
|
had to save again to see it. The last save's answer is kept with the page's state for that account and business. */
|
|
67214
67436
|
/* Reviewer on c392c620: the offer is bound to the account it was made for. Another account picked since shows no
|
|
67215
67437
|
offer, and the button never creates a pipeline in an account it was not offered for. */
|
|
67216
|
-
function pipeMissingHtml(){var m=S.pipeMissing;var loc=
|
|
67438
|
+
function pipeMissingHtml(){var m=S.pipeMissing;var loc=currentSaveTo();
|
|
67217
67439
|
if(!m||!loc||m.loc!==loc||m.biz!==S.biz)return "";
|
|
67218
|
-
return '<div class="cds-callout warn"><span class="ttl">'+TODO+'Saved, but no deal yet</span>'+String(m.note||"").replace(/</g,"<")+'<div class="cds-actions"><button class="cds-btn" id="mkPipe" onclick="this.disabled=true;saveToGhl(true)">Create the Assessments pipeline</button></div><div class="cds-help">
|
|
67440
|
+
return '<div class="cds-callout warn"><span class="ttl">'+TODO+'Saved, but no deal yet</span>'+String(m.note||"").replace(/</g,"<")+'<div class="cds-actions"><button class="cds-btn" id="mkPipe" onclick="this.disabled=true;saveToGhl(true)">Create the Assessments pipeline</button></div><div class="cds-help">'+pipeHelp(loc)+'</div></div>'}
|
|
67219
67441
|
function saveToGhl(createPipeline){
|
|
67220
67442
|
var box=document.getElementById("savemsg");
|
|
67221
|
-
var loc=
|
|
67443
|
+
var loc=currentSaveTo();
|
|
67222
67444
|
var pm=S.pipeMissing;if(createPipeline===true&&!(pm&&loc&&pm.loc===loc&&pm.biz===S.biz))createPipeline=false;
|
|
67223
67445
|
if(!loc){if(box)box.innerHTML='<div class="cds-callout warn"><span class="ttl">'+TODO+'Which account?</span>Pick it on The client step first.<div class="cds-actions"><button class="cds-btn ghost" onclick="goBiz()">Take me there</button></div></div>';return}
|
|
67224
67446
|
if(!S.biz||!S.biz.trim()){if(box)box.innerHTML='<div class="cds-callout warn"><span class="ttl">'+TODO+'No business name yet</span><div class="cds-actions"><button class="cds-btn ghost" onclick="goBiz()">Take me to it</button></div></div>';return}
|
|
67225
|
-
if(box)box.innerHTML='<div class="cds-callout"><span class="ttl">Saving\u2026</span>The prospect as a contact, the answers as a note, the deal on
|
|
67447
|
+
if(box)box.innerHTML='<div class="cds-callout"><span class="ttl">Saving to '+escA(accountNameOf(loc))+'\u2026</span>The prospect as a contact, the answers as a note, the deal on the Assessments pipeline.</div>';
|
|
67226
67448
|
var c=S.client||{};
|
|
67227
67449
|
// The deal this page last showed for this business in this account (its last save's answer). The server refuses
|
|
67228
67450
|
// the save when that deal changed since, and never moves a deal this page did not show (safety release 2026-10-14).
|
|
@@ -67238,11 +67460,41 @@ function saveToGhl(createPipeline){
|
|
|
67238
67460
|
// A deal this page never showed was not moved: the server's sentence, exactly, and nothing else.
|
|
67239
67461
|
if(j.dealLeftAlone){box.innerHTML='<div class="cds-callout info">'+String(j.opportunityNote||"").replace(/</g,"<")+'</div>';return}
|
|
67240
67462
|
// No Assessments pipeline (audit 2026-10-28 #1): one button creates it, with its five stages, and adds the deal.
|
|
67241
|
-
if(j.pipelineMissing){S.pipeMissing={loc:loc,biz:S.biz,note:j.opportunityNote};save();box.innerHTML='<div class="cds-callout warn"><span class="ttl">'+TODO+'Saved, but no deal yet</span>'+String(j.opportunityNote||"").replace(/</g,"<")+'<div class="cds-actions"><button class="cds-btn" id="mkPipe" onclick="this.disabled=true;saveToGhl(true)">Create the Assessments pipeline</button></div><div class="cds-help">
|
|
67463
|
+
if(j.pipelineMissing){S.pipeMissing={loc:loc,biz:S.biz,note:j.opportunityNote};save();box.innerHTML='<div class="cds-callout warn"><span class="ttl">'+TODO+'Saved, but no deal yet</span>'+String(j.opportunityNote||"").replace(/</g,"<")+'<div class="cds-actions"><button class="cds-btn" id="mkPipe" onclick="this.disabled=true;saveToGhl(true)">Create the Assessments pipeline</button></div><div class="cds-help">'+pipeHelp(loc)+'</div></div>';return}
|
|
67242
67464
|
if(S.pipeMissing){delete S.pipeMissing;save()}
|
|
67243
|
-
box.innerHTML='<div class="cds-callout info"><span class="ttl">Saved</span>'+String(j.
|
|
67465
|
+
box.innerHTML='<div class="cds-callout info"><span class="ttl">Saved</span>'+String(j.savedSentence||"Saved.").replace(/</g,"<")+(/^https:\\/\\/app\\.gohighlevel\\.com\\//.test(String(j.contactUrl||""))?' <a class="cds-link" target="_blank" rel="noopener" href="'+String(j.contactUrl).replace(/"/g,""")+'">Open the contact in GoHighLevel (opens in a new tab)</a>.':'')+' '+String(j.opportunityNote||"").replace(/</g,"<")+(j.warning?' '+String(j.warning).replace(/</g,"<"):'')+'</div>'})
|
|
67244
67466
|
.catch(function(e){if(box)box.innerHTML='<div class="cds-callout bad"><span class="ttl">Not saved</span>Command OS is not answering on this computer.</div>'})}
|
|
67245
67467
|
|
|
67468
|
+
function escA(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}
|
|
67469
|
+
function accountNameOf(loc){var c=(window.COMMAND_OS_CLIENTS||[]).filter(function(x){return x.locationId===loc})[0];return c&&c.name?c.name:"this account"}
|
|
67470
|
+
function pipeHelp(loc){return "This creates a pipeline called Assessments in "+escA(accountNameOf(loc))+" with the stages Gathering, Findings, Proposal sent, Won and Lost, then adds the deal "+escA((S.biz||"Assessment")+": assessment")+"."}
|
|
67471
|
+
/* Walk 2026-10-28 W8: Save says first what it writes and where, read from the account (the contact GoHighLevel will
|
|
67472
|
+
match, whether the Assessments pipeline is there), and nothing is written until Save to <account> is pressed. */
|
|
67473
|
+
function askSaveToGhl(){
|
|
67474
|
+
var box=document.getElementById("savemsg");if(!box)return;
|
|
67475
|
+
var loc=currentSaveTo();
|
|
67476
|
+
if(!loc||!S.biz||!S.biz.trim()){saveToGhl();return}
|
|
67477
|
+
var c=S.client||{};
|
|
67478
|
+
box.innerHTML='<div class="cds-callout"><span class="ttl">Checking '+escA(accountNameOf(loc))+'\u2026</span>Reading what Save would change. Nothing is written yet.</div>';
|
|
67479
|
+
fetch("/api/assessment-save-preview",{method:"POST",headers:{"Content-Type":"application/json","x-command-os-seat":window.COMMAND_OS_SEAT||""},
|
|
67480
|
+
body:JSON.stringify({locationId:loc,assessment:{biz:S.biz,trade:S.trade,ans:S.ans,skip:S.skip,asm:S.asm,client:c},contactName:c.name||"",email:c.email||"",phone:c.phone||""})})
|
|
67481
|
+
.then(function(r){return r.json()}).then(function(p){
|
|
67482
|
+
if(!p||!p.ok){box.innerHTML='<div class="cds-callout bad"><span class="ttl">Not saved</span>'+escA(p&&p.error||"The account could not be read, so nothing was written.")+'</div>';return}
|
|
67483
|
+
box.innerHTML=saveConfirmHtml(p,loc)})
|
|
67484
|
+
.catch(function(){box.innerHTML='<div class="cds-callout bad"><span class="ttl">Not saved</span>Command OS is not answering on this computer.</div>'})}
|
|
67485
|
+
function saveConfirmHtml(p,loc){
|
|
67486
|
+
var c=S.client||{},who=(c.name||"").trim()||S.biz,acct=p.accountName||accountNameOf(loc),tags=(p.tags||[]).join(" and ");
|
|
67487
|
+
var shown=S.deal&&S.deal.loc===loc&&S.deal.biz===S.biz;
|
|
67488
|
+
var li=[];
|
|
67489
|
+
if(p.existing)li.push("Updates the contact already there, "+escA(p.existing.name)+(p.existing.email?" ("+escA(p.existing.email)+")":"")+" (GoHighLevel matches by email, then by phone), and adds the tags "+escA(tags)+".");
|
|
67490
|
+
else if(p.existing===null)li.push("Creates a contact for "+escA(who)+" with the tags "+escA(tags)+".");
|
|
67491
|
+
else li.push("Creates a contact for "+escA(who)+", or updates the one GoHighLevel already has (it could not be checked; GoHighLevel matches by email, then by phone), with the tags "+escA(tags)+".");
|
|
67492
|
+
li.push("Adds a note on that contact with the answers, unless the same answers are already there.");
|
|
67493
|
+
if(p.pipeline===false)li.push("No deal: "+escA(acct)+" has no Assessments pipeline yet. You can create it after saving.");
|
|
67494
|
+
else if(p.existingDeal&&!(shown&&S.deal.id===p.existingDeal.id))li.push("Leaves the existing deal "+escA(p.existingDeal.name)+" alone: this page has not shown it. Look at it in GoHighLevel, then save again here to move it.");
|
|
67495
|
+
else li.push((shown?"Moves the deal ":"Adds the deal ")+escA(p.dealName)+(shown?" to ":" at ")+escA(p.stage)+" on the Assessments pipeline"+(p.pipeline===undefined?", if the account has one":"")+".");
|
|
67496
|
+
return '<div class="cds-callout"><span class="ttl">Before you save: this writes to '+escA(acct)+'</span><ul>'+li.map(function(x){return "<li>"+x+"</li>"}).join("")+'</ul>'
|
|
67497
|
+
+'<div class="cds-actions"><button class="cds-btn" onclick="saveToGhl()">Save to '+escA(acct)+'</button><button class="cds-btn ghost" onclick="document.getElementById(\\'savemsg\\').innerHTML=\\'\\'">Cancel</button></div></div>'}
|
|
67246
67498
|
Array.prototype.forEach.call(document.querySelectorAll("#tabs .cds-tab"),function(b){
|
|
67247
67499
|
b.onclick=function(ev){if(ev&&ev.preventDefault)ev.preventDefault();tab(b.dataset.t)}});
|
|
67248
67500
|
draw();
|
|
@@ -69438,6 +69690,12 @@ var init_blueprint_review = __esm({
|
|
|
69438
69690
|
function capabilitySentence(cap2, clientName) {
|
|
69439
69691
|
return cap2.says.replace(/\{client\}/g, clientName);
|
|
69440
69692
|
}
|
|
69693
|
+
function capabilityWrites(key2) {
|
|
69694
|
+
return (CAPABILITY_TOOLS[key2] ?? []).some((t) => WRITE_TOOL_PATTERN.test(t));
|
|
69695
|
+
}
|
|
69696
|
+
function capabilityBefore(key2) {
|
|
69697
|
+
return capabilityWrites(key2) ? CAPABILITY_BEFORE[key2] ?? "" : CAPABILITY_READS_ONLY;
|
|
69698
|
+
}
|
|
69441
69699
|
function capabilitySpec(cap2, clientName, approvedPlan) {
|
|
69442
69700
|
const tools = CAPABILITY_TOOLS[cap2.key] ?? [];
|
|
69443
69701
|
if (!tools.length) throw new Error(`${cap2.label} is not built from a button. Run it from your own Claude, watching.`);
|
|
@@ -69473,7 +69731,7 @@ RESULT: {"ok":true,"summary":"<what exists now versus what was already there, wi
|
|
|
69473
69731
|
If it cannot be done, finish with: RESULT: {"ok":false,"error":"<short reason>"}`
|
|
69474
69732
|
};
|
|
69475
69733
|
}
|
|
69476
|
-
var T, ALWAYS, CAPABILITY_TOOLS;
|
|
69734
|
+
var T, ALWAYS, CAPABILITY_TOOLS, WRITE_TOOL_PATTERN, CAPABILITY_BEFORE, CAPABILITY_READS_ONLY;
|
|
69477
69735
|
var init_capability_runs = __esm({
|
|
69478
69736
|
"src/command-os/capability-runs.ts"() {
|
|
69479
69737
|
"use strict";
|
|
@@ -69513,6 +69771,25 @@ var init_capability_runs = __esm({
|
|
|
69513
69771
|
integrations: ["list_webhooks", "get_webhook", "get_trigger_links", "list_custom_objects", "get_custom_object"],
|
|
69514
69772
|
handoff: ["build_offboarding_kit", "build_audit_report", "list_snapshots", "create_snapshot_share_link", "export_account", "run_checkup"]
|
|
69515
69773
|
};
|
|
69774
|
+
WRITE_TOOL_PATTERN = /^(create|update|install|crawl|train|verify_funnel)/;
|
|
69775
|
+
CAPABILITY_BEFORE = {
|
|
69776
|
+
pipelines: "This may add pipelines and their stages to {client}, or change ones already there. They are live as soon as they are made.",
|
|
69777
|
+
calendars: "This may add booking calendars to {client}, or change ones already there. A calendar can take bookings as soon as it is made.",
|
|
69778
|
+
forms: "This may add forms to {client}, or change ones already there. A form is live as soon as it is made.",
|
|
69779
|
+
pages: "This may install a page template and add or change funnels and pages in {client}. It then sends one test lead, named blueprint-qa, through the form. That test contact stays in Contacts, and it may start this client's own automations.",
|
|
69780
|
+
fields: "This may add custom fields, custom values and tags to {client}, or change ones already there. They are live as soon as they are made.",
|
|
69781
|
+
workflows: "This may add workflows to {client}, or change ones already there. A new workflow is a draft and sends nothing until you publish it. A change to a workflow that is already on takes effect at once.",
|
|
69782
|
+
reactivation: "This may add a tag, a smart list and a workflow to {client}, or change a workflow already there. A new workflow is a draft and sends nothing until you publish it.",
|
|
69783
|
+
reviews: "This may add a review-request workflow to {client}, or change one already there. A new workflow is a draft and sends nothing until you publish it. A change to a workflow that is already on takes effect at once.",
|
|
69784
|
+
templates: "This may add email and text templates to {client}, or change ones already there. A template sends nothing by itself.",
|
|
69785
|
+
broadcasts: "This may add or change an email template in {client} for a broadcast. Nothing is sent or scheduled.",
|
|
69786
|
+
"conversation-ai": "This may add a knowledge base to {client} (reading the client's website into it), its questions and answers, and a chat bot with its actions.",
|
|
69787
|
+
"voice-ai": "This may add a voice agent and its actions to {client}, or change ones already there.",
|
|
69788
|
+
payments: "This may add products to {client}, without prices.",
|
|
69789
|
+
memberships: "This may add a course, its categories and lessons, and an offer to {client}.",
|
|
69790
|
+
handoff: "This may make a share link for a snapshot of {client}, so the account can be copied. Nothing else in it changes."
|
|
69791
|
+
};
|
|
69792
|
+
CAPABILITY_READS_ONLY = "This reads {client} only. Nothing in it changes.";
|
|
69516
69793
|
}
|
|
69517
69794
|
});
|
|
69518
69795
|
|
|
@@ -70359,6 +70636,43 @@ async function findStage(client, locationId2, stage, mayCreate = false) {
|
|
|
70359
70636
|
}
|
|
70360
70637
|
return { pipelineId: pipe.id, stageId: st.id, stageName: stage };
|
|
70361
70638
|
}
|
|
70639
|
+
async function previewAssessmentSave(client, locationId2, args) {
|
|
70640
|
+
const state2 = normalise(args.assessment);
|
|
70641
|
+
const stage = stageFor({ verdict: computedFor(state2).verdict });
|
|
70642
|
+
const { payload } = buildContact(state2, stage, { name: args.contactName, email: args.email, phone: args.phone });
|
|
70643
|
+
let existing = null;
|
|
70644
|
+
let contactId;
|
|
70645
|
+
try {
|
|
70646
|
+
let c = null;
|
|
70647
|
+
if (payload.email) c = (await client.get("/contacts/search/duplicate", { params: { locationId: locationId2, email: payload.email } }))?.contact;
|
|
70648
|
+
if (!c && payload.phone) c = (await client.get("/contacts/search/duplicate", { params: { locationId: locationId2, number: payload.phone } }))?.contact;
|
|
70649
|
+
const name2 = c ? (c.contactName || c.name || [c.firstName, c.lastName].filter(Boolean).join(" ")).trim() : "";
|
|
70650
|
+
existing = c ? { name: name2 || "a contact with no name", ...c.email ? { email: c.email } : {} } : null;
|
|
70651
|
+
contactId = c?.id;
|
|
70652
|
+
} catch {
|
|
70653
|
+
existing = void 0;
|
|
70654
|
+
}
|
|
70655
|
+
let pipeline;
|
|
70656
|
+
let pipelineId;
|
|
70657
|
+
try {
|
|
70658
|
+
const raw = await client.get("/opportunities/pipelines", { params: { locationId: locationId2 } });
|
|
70659
|
+
const hit = (raw?.pipelines ?? []).find((p) => (p.name ?? "").trim().toLowerCase() === PIPELINE_NAME.toLowerCase());
|
|
70660
|
+
pipeline = Boolean(hit);
|
|
70661
|
+
pipelineId = hit?.id;
|
|
70662
|
+
} catch {
|
|
70663
|
+
pipeline = void 0;
|
|
70664
|
+
}
|
|
70665
|
+
let existingDeal;
|
|
70666
|
+
if (contactId && pipelineId) {
|
|
70667
|
+
try {
|
|
70668
|
+
const raw = await client.get("/opportunities/search", { params: { location_id: locationId2, pipeline_id: pipelineId, contact_id: contactId } });
|
|
70669
|
+
const o = (raw?.opportunities ?? []).find((x) => x.id);
|
|
70670
|
+
if (o?.id) existingDeal = { id: o.id, name: o.name || "this business's deal" };
|
|
70671
|
+
} catch {
|
|
70672
|
+
}
|
|
70673
|
+
}
|
|
70674
|
+
return { stage, tags: payload.tags, dealName: buildOpportunity(state2, null, stage).name, existing, pipeline, ...existingDeal && { existingDeal } };
|
|
70675
|
+
}
|
|
70362
70676
|
async function saveAssessment(client, locationId2, args, onOpportunityNoteFailure) {
|
|
70363
70677
|
const state2 = normalise(args.assessment);
|
|
70364
70678
|
const computed = computedFor(state2);
|
|
@@ -70894,6 +71208,7 @@ function composePreview(input) {
|
|
|
70894
71208
|
const reuse = [];
|
|
70895
71209
|
const change = [];
|
|
70896
71210
|
const createByType = {};
|
|
71211
|
+
const createNames = {};
|
|
70897
71212
|
let createCount = 0;
|
|
70898
71213
|
const seen = /* @__PURE__ */ new Set();
|
|
70899
71214
|
for (const item of input.items) {
|
|
@@ -70901,6 +71216,8 @@ function composePreview(input) {
|
|
|
70901
71216
|
if (item.status === "would_create") {
|
|
70902
71217
|
createCount++;
|
|
70903
71218
|
createByType[item.type] = (createByType[item.type] ?? 0) + 1;
|
|
71219
|
+
const filled2 = input.bookingCalendar && item.type === "customValues" && customValueKeyOf(item.name) === "booking_link";
|
|
71220
|
+
if (item.name) (createNames[item.type] ??= []).push(filled2 ? `${item.name}, filled with the ${input.bookingCalendar} booking page` : item.name);
|
|
70904
71221
|
}
|
|
70905
71222
|
continue;
|
|
70906
71223
|
}
|
|
@@ -70915,7 +71232,7 @@ function composePreview(input) {
|
|
|
70915
71232
|
}
|
|
70916
71233
|
if (input.board) change.push({ key: `${BOARD_KEY_PREFIX}${input.board.id}`, type: "customValues", name: input.board.name, id: input.board.id, changes: [BOARD_CHANGE] });
|
|
70917
71234
|
const core = { locationId: input.locationId, planHash: input.planHash, reuse, change, logins: [...input.logins ?? []] };
|
|
70918
|
-
return { v: 1, recordKey: input.recordKey, at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(), createCount, createByType, ...core, fingerprint: previewFingerprint(core) };
|
|
71235
|
+
return { v: 1, recordKey: input.recordKey, at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(), createCount, createByType, createNames, ...core, fingerprint: previewFingerprint(core) };
|
|
70919
71236
|
}
|
|
70920
71237
|
function allowedWorkflowIds(allow) {
|
|
70921
71238
|
return new Set(allow.filter((k) => k.startsWith("workflows:")).map((k) => k.slice("workflows:".length)));
|
|
@@ -71017,6 +71334,7 @@ var init_build_confirm = __esm({
|
|
|
71017
71334
|
import_node_path15 = require("node:path");
|
|
71018
71335
|
init_credentials_store();
|
|
71019
71336
|
init_build_contract();
|
|
71337
|
+
init_build_facts();
|
|
71020
71338
|
BUILD_PAUSED = "Build paused before writing anything. Open the Build step to see what it will create, what already in the account it will use, and what it would change, then confirm.";
|
|
71021
71339
|
BUILD_PREVIEW_CHANGED = "What Build would use or change in the account is not what you confirmed (something in the account or the plan changed). Nothing was written. Look at the Build step again and confirm the new list.";
|
|
71022
71340
|
keyOf4 = (type, id2) => `${type}:${id2}`;
|
|
@@ -71403,6 +71721,7 @@ __export(dashboard_exports, {
|
|
|
71403
71721
|
fillBusinessType: () => fillBusinessType,
|
|
71404
71722
|
fingerprintOf: () => fingerprintOf,
|
|
71405
71723
|
forgetAnthropicKey: () => forgetAnthropicKey,
|
|
71724
|
+
forgetBuildConfirm: () => forgetBuildConfirm,
|
|
71406
71725
|
forgetRevertedBuild: () => forgetRevertedBuild,
|
|
71407
71726
|
forgetVerifiedHandoff: () => forgetVerifiedHandoff,
|
|
71408
71727
|
guarded: () => guarded,
|
|
@@ -71720,9 +72039,9 @@ function standingTicks(tasks2, currentKeys) {
|
|
|
71720
72039
|
}
|
|
71721
72040
|
function catalogFor(rk, previous, read2) {
|
|
71722
72041
|
const handoffs = knownHandoffs(rk);
|
|
71723
|
-
const { users } = planShape(readSavedPlan(rk)?.plan);
|
|
72042
|
+
const { users, calendars } = planShape(readSavedPlan(rk)?.plan);
|
|
71724
72043
|
const evidence = read2 ?? readCockpitState().jobEvidence?.[rk];
|
|
71725
|
-
return [...catalogJobs({ handoffs, obligations: readBuildObligations(rk), plannedUsers: users, ticked: standingTicks(previous), ...evidence ? { readback: evidence.readback, facts: evidence.facts } : {} }), ...carriedJobs(previous)];
|
|
72044
|
+
return [...catalogJobs({ handoffs, obligations: readBuildObligations(rk), plannedUsers: users, plannedCalendars: calendars, ticked: standingTicks(previous), ...evidence ? { readback: evidence.readback, facts: evidence.facts } : {} }), ...carriedJobs(previous)];
|
|
71726
72045
|
}
|
|
71727
72046
|
function approvalBoughtHash(state2, recordKey) {
|
|
71728
72047
|
return boughtBindingHash(scopeHashOf(state2.scope?.[recordKey]), state2, recordKey);
|
|
@@ -71817,7 +72136,13 @@ function setBuildApproval(state2, locationId2, approvedAt) {
|
|
|
71817
72136
|
const approved = { ...state2.buildApproved ?? {} };
|
|
71818
72137
|
if (approvedAt) approved[locationId2] = approvedAt;
|
|
71819
72138
|
else delete approved[locationId2];
|
|
71820
|
-
return { ...state2, buildApproved: approved };
|
|
72139
|
+
return forgetBuildConfirm({ ...state2, buildApproved: approved }, locationId2);
|
|
72140
|
+
}
|
|
72141
|
+
function forgetBuildConfirm(state2, recordKey) {
|
|
72142
|
+
if (!state2.buildConfirms?.[recordKey]) return state2;
|
|
72143
|
+
const confirms = { ...state2.buildConfirms };
|
|
72144
|
+
delete confirms[recordKey];
|
|
72145
|
+
return { ...state2, buildConfirms: confirms };
|
|
71821
72146
|
}
|
|
71822
72147
|
function accountTypeOf(state2, locationId2) {
|
|
71823
72148
|
if (SANDBOX_ALLOWLIST[locationId2]) return "sandbox";
|
|
@@ -71970,6 +72295,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
71970
72295
|
} catch {
|
|
71971
72296
|
runFacts = {};
|
|
71972
72297
|
}
|
|
72298
|
+
runFacts = withBuiltCalendarTeams(runFacts, readSavedPlan(rk));
|
|
71973
72299
|
} catch (e) {
|
|
71974
72300
|
context2.readback = plannedReadbacks(verificationPlan).map(({ ref, type, name: name2 }) => ({ ref, type, name: name2, status: "unreadable", detail: e instanceof Error ? e.message : String(e) }));
|
|
71975
72301
|
}
|
|
@@ -71996,15 +72322,15 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
71996
72322
|
const heldWithJob = new Set(readBuildObligations(rk).filter((o) => o.type === "workflow-publish").map((o) => o.ref));
|
|
71997
72323
|
saveBuildObligations(rk, readback.filter((r) => r.status !== "exists" && !(r.held && heldWithJob.has(r.ref))).map((r) => ({ ref: r.ref, type: "plan-dependency", name: r.name, reason: r.detail })));
|
|
71998
72324
|
{
|
|
71999
|
-
const { users: plannedUsers, workflows: plannedWorkflows } = planShape(plan?.plan);
|
|
72325
|
+
const { users: plannedUsers, workflows: plannedWorkflows, calendars: plannedCalendars } = planShape(plan?.plan);
|
|
72000
72326
|
const planHandoffs = knownHandoffs(rk);
|
|
72001
72327
|
const tasksNow = readCockpitState().manualTasks?.[rk] ?? [];
|
|
72002
72328
|
const currentKeys = /* @__PURE__ */ new Map();
|
|
72003
|
-
for (const j of catalogJobs({ handoffs: planHandoffs, obligations: readBuildObligations(rk), plannedUsers })) currentKeys.set(j.id, j.key);
|
|
72329
|
+
for (const j of catalogJobs({ handoffs: planHandoffs, obligations: readBuildObligations(rk), plannedUsers, plannedCalendars })) currentKeys.set(j.id, j.key);
|
|
72004
72330
|
const ticked = standingTicks(tasksNow, currentKeys);
|
|
72005
72331
|
const openRecipients = readBuildObligations(rk).filter((o) => o.type === "staff_pending" && o.ref.startsWith("workflow.") && !ticked.has(`${o.type}:${o.ref}`)).length;
|
|
72006
72332
|
const openHandoffs = planHandoffs.filter((h) => {
|
|
72007
|
-
const st = handoffInAccount(h, runFacts, readback, plannedUsers, openRecipients);
|
|
72333
|
+
const st = handoffInAccount(h, runFacts, readback, plannedUsers, openRecipients, plannedCalendars);
|
|
72008
72334
|
const mainOpen = !(st.readable && st.done) && !ticked.has(h.ref);
|
|
72009
72335
|
const part = handoffPartId(h.ref);
|
|
72010
72336
|
return mainOpen || part !== void 0 && !ticked.has(part);
|
|
@@ -72608,9 +72934,9 @@ function proPickerHtml(roster) {
|
|
|
72608
72934
|
const rows2 = roster.map((c) => `<li><a class="cds-link" href="/for/${e(first2)}?client=${encodeURIComponent(c.locationId)}">${e(c.name)}</a></li>`).join("");
|
|
72609
72935
|
return `${cockpitHeadHtml("Your clients \xB7 Command Pro", { extraCss: "#main{display:grid;gap:var(--space-6);align-content:start;min-width:0}" })}<body data-screen="pick">
|
|
72610
72936
|
${cockpitHeaderHtml("board", { trail: ["Your clients"] })}
|
|
72611
|
-
<main id="main" class="pick"><div class="cds-page-head"><div class="txt"><h1 class="cds-page-title">Your clients</h1><p class="cds-page-sub">Pick the client to work on.</p></div></div>
|
|
72937
|
+
<main id="main" class="pick"><div class="cds-page-head"><div class="txt"><h1 class="cds-page-title">Your clients</h1><p class="cds-page-sub">Pick the client to work on. Its first page has Build this client's account.</p></div></div>
|
|
72612
72938
|
${roster.length ? `<ul class="cds-list" id="pick-list" data-picker="">${rows2}</ul>` : `<div class="cds-empty"><p>No client is connected yet.</p></div>`}
|
|
72613
|
-
<p><a class="cds-link" href="/new">
|
|
72939
|
+
<p><a class="cds-link" href="/new">Add a new client account</a></p></main>
|
|
72614
72940
|
${cockpitShellClose()}
|
|
72615
72941
|
<script>${COCKPIT_TYPE_JS}</script></body></html>`;
|
|
72616
72942
|
}
|
|
@@ -72808,7 +73134,7 @@ ${cockpitShellClose()}
|
|
|
72808
73134
|
<script>${COCKPIT_TYPE_JS}</script></body></html>`;
|
|
72809
73135
|
}
|
|
72810
73136
|
function html(roster, seatToken, screen = "board", cap2) {
|
|
72811
|
-
const capForPage = cap2 ? { ...cap2, groupLabel: CAPABILITY_GROUPS.find((g) => g.key === cap2.group)?.label ?? cap2.group, noBuild: NO_BUILD_BUTTON.has(cap2.key) } : null;
|
|
73137
|
+
const capForPage = cap2 ? { ...cap2, groupLabel: CAPABILITY_GROUPS.find((g) => g.key === cap2.group)?.label ?? cap2.group, noBuild: NO_BUILD_BUTTON.has(cap2.key), before: capabilityBefore(cap2.key) } : null;
|
|
72812
73138
|
return `${cockpitHeadHtml(cap2 ? `${cap2.label} \xB7 Command OS` : BOARD_TITLES[screen] ?? BOARD_TITLES.board, { extraCss: `
|
|
72813
73139
|
/* Layout only (Command Design System, guidelines/40-app-screens.md): every color,
|
|
72814
73140
|
font, size, radius and border comes from the system classes the markup wears. */
|
|
@@ -72880,6 +73206,13 @@ function html(roster, seatToken, screen = "board", cap2) {
|
|
|
72880
73206
|
.bs-how{margin:0;padding-left:1.4em;display:grid;gap:var(--space-1);color:var(--text)}
|
|
72881
73207
|
.bs-warn{font-size:13px}
|
|
72882
73208
|
.bs-jobs{margin:0;padding-left:1.4em;display:grid;gap:var(--space-2)}
|
|
73209
|
+
/* Walk W19 (2026-10-28): the browser's own "1." sat under the job's box. The number is its own grid column now, so
|
|
73210
|
+
the number, the box and the words never overlap at any width. */
|
|
73211
|
+
.bs-numbered{list-style:none;padding-left:0;counter-reset:bsjob}
|
|
73212
|
+
.bs-numbered>li{counter-increment:bsjob;display:grid;grid-template-columns:2em minmax(0,1fr);align-items:start}
|
|
73213
|
+
.bs-numbered>li::before{content:counter(bsjob) ".";font-size:15px;line-height:1.5;font-weight:600;color:var(--text)}
|
|
73214
|
+
.bs-jobmark{width:20px;text-align:center;font-size:15px;line-height:1.5;color:var(--muted)}
|
|
73215
|
+
.bs-jobmark.done{color:var(--primary);font-weight:700}
|
|
72883
73216
|
.bs-livebox{gap:var(--space-2)}
|
|
72884
73217
|
@media (max-width:720px){.bs-choices{grid-template-columns:minmax(0,1fr)}.bs-step{padding:var(--space-3)}}
|
|
72885
73218
|
.sbtns .swhy{max-width:calc(var(--space-8) * 6);text-align:right}
|
|
@@ -72948,7 +73281,7 @@ const STEP_ABOUT=[["Creates the intake form, its fields and one notification wor
|
|
|
72948
73281
|
["Reads the intake into a brief.","Reads this client's intake, from the submitted form or the one completed here, and turns it into the brief the plan is made from."],
|
|
72949
73282
|
["You choose what they bought, then approve the build.","Choose what the client bought, review the intake and the brief, then approve the build for this client. Only what they bought gets built."],
|
|
72950
73283
|
["Builds the approved plan into this account.","Builds the approved plan into this client's account. Anything it cannot build is listed as an issue."],
|
|
72951
|
-
["Checks the build for broken links.","Checks the account for broken links between workflows, pipelines and fields. It
|
|
73284
|
+
["Checks the build for broken links.","Checks the account for broken links between workflows, pipelines and fields. It changes nothing in the account, with one exception: if you approved publishing the handoff page, it puts the handoff on that page. Anything it cannot check is reported as unchecked."],
|
|
72952
73285
|
["Jobs only a person can do. You confirm them.","The jobs only a person can do, such as text-message registration, Google sign-in, staff and phone. Confirm them when they are finished."],
|
|
72953
73286
|
["You sign off the build.","Sign off once Verify is clean and the manual tasks are finished. Then you switch it on and finish the hand jobs in GoHighLevel."]];
|
|
72954
73287
|
// Verify's wording for an older plan that built pages, the only case where it still sends a test lead.
|
|
@@ -73224,6 +73557,9 @@ function stepName(r){return r&&!r.capability&&LABELS[r.stage]?LABELS[r.stage]:"T
|
|
|
73224
73557
|
redraws only when the run itself changed (started, moved stage, finished), not on every tick. */
|
|
73225
73558
|
function shouldRedraw(prev,next){const fin=runJustFinished(prev,next);
|
|
73226
73559
|
if(userIsBusy()){if(fin)showFinishNote(prev);redrawPending=true;return false}
|
|
73560
|
+
/* Walk 2026-10-28 W23: a redraw between a press and its click replaced the link under the pointer, and Safari dropped
|
|
73561
|
+
the click. A redraw waits while the pointer is down and for 400 ms after it comes up. */
|
|
73562
|
+
if(clickInFlight()){redrawPending=true;scheduleFlush();return false}
|
|
73227
73563
|
if(fin)return true;
|
|
73228
73564
|
if(intakeIsOpen())return runKey(prev)!==runKey(next);
|
|
73229
73565
|
return true}
|
|
@@ -73235,10 +73571,11 @@ function showFinishNote(prev){if(SCREEN==="new"&&!buildClientOpen())return;let b
|
|
|
73235
73571
|
b.textContent=forLabel(prev&&prev.locationId)+stepName(prev)+" finished. Click to see the result."}
|
|
73236
73572
|
function hideFinishNote(){const b=document.getElementById("run-finished-note");if(b)b.remove()}
|
|
73237
73573
|
/* One retry timer at a time: focusout, pointer releases and finished intake work all ask, only one waits. */
|
|
73574
|
+
let pointerUpAt=0;function clickInFlight(){return pointerIsDown||Date.now()-pointerUpAt<400}
|
|
73238
73575
|
function scheduleFlush(){if(flushTimer)return;flushTimer=setTimeout(()=>{flushTimer=0;flushRedraw()},300)}
|
|
73239
|
-
function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"&&!buildClientOpen()){redrawPending=false;return}if(userIsBusy()||
|
|
73576
|
+
function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"&&!buildClientOpen()){redrawPending=false;return}if(userIsBusy()||clickInFlight()){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
|
|
73240
73577
|
document.addEventListener("pointerdown",()=>{pointerIsDown=true},true);
|
|
73241
|
-
document.addEventListener("pointerup",()=>{pointerIsDown=false},true);
|
|
73578
|
+
document.addEventListener("pointerup",()=>{pointerIsDown=false;pointerUpAt=Date.now()},true);
|
|
73242
73579
|
/* A touch that is canceled, or a release outside the window, never sends pointerup. */
|
|
73243
73580
|
document.addEventListener("pointercancel",()=>{pointerIsDown=false},true);
|
|
73244
73581
|
window.addEventListener("blur",()=>{pointerIsDown=false});
|
|
@@ -73492,7 +73829,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
73492
73829
|
const sendKey="isend:"+id;
|
|
73493
73830
|
b.onclick=()=>{const email=em.value.trim();if(!email)return showClientError("Enter the client\u2019s email.",box);let r=null;
|
|
73494
73831
|
icShow(b,sendKey,{title:"Save a manual task to send the questionnaire?",
|
|
73495
|
-
text:["
|
|
73832
|
+
text:["Nothing is created or sent in "+name+". Command OS cannot check which address the email would come from, so it saves a task on your job list: confirm the sender in this client\u2019s GoHighLevel conversations, then send the questionnaire to "+email+" from there."],
|
|
73496
73833
|
ok:"Save the task",busyLabel:"Saving the task\u2026",
|
|
73497
73834
|
run:async()=>{r=await iWork(id,async()=>(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:email,firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json());
|
|
73498
73835
|
return r&&(r.ok||r.manual)?"":clientPageError(r&&r.error)},
|
|
@@ -73837,6 +74174,7 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
73837
74174
|
else if(scopeChosen(state,id)&&!["template","hidden"].includes(typeOf(state,id))&&!PROTECTED[id])row.appendChild(approveButton(id,name,state,"review","cds-btn"));
|
|
73838
74175
|
row.appendChild(gen);d.appendChild(row);
|
|
73839
74176
|
return d}
|
|
74177
|
+
const NC_AGENCY=${scriptJson(readAgencyProfile().agencyName?.trim() || "in GoHighLevel")};
|
|
73840
74178
|
const NC_PLAN_NOTE="GHL Command creates sub-accounts on GoHighLevel's Agency Pro plan; on other plans you create it in GoHighLevel and paste its key into Command OS.";
|
|
73841
74179
|
function clientPageError(raw,next){
|
|
73842
74180
|
const sentence=value=>{const t=String(value||"").trim().replace(/\\s*\\u2014\\s*/g,". ").replace(/[.!?;:]+$/,"");return t?t.charAt(0).toUpperCase()+t.slice(1)+".":""};
|
|
@@ -73932,6 +74270,13 @@ function newClientForm(){const f=document.createElement("div");f.className="cds-
|
|
|
73932
74270
|
f.appendChild(g);
|
|
73933
74271
|
if(ageViews.snapshots)f.appendChild(ageEl("snapshots"));
|
|
73934
74272
|
if(snapshotError)showClientError(snapshotError,f);
|
|
74273
|
+
/* Walk 2026-10-28 W8: what Create sub-account makes, said above the button and kept current as the form is filled:
|
|
74274
|
+
the agency it goes in, the account's name, the owner and the snapshot it is loaded from. */
|
|
74275
|
+
const before=document.createElement("p");before.className="cds-callout info nc-before";
|
|
74276
|
+
const sayBefore=()=>{const sn=snap.value?((snapshots||[]).find(x=>x.id===snap.value)||{}).name:"";const owner=[fn.value.trim(),ln.value.trim()].filter(Boolean).join(" ");
|
|
74277
|
+
before.textContent="Create sub-account adds a new sub-account to your agency "+NC_AGENCY+": "+(name.value.trim()||"the business name above")+(owner||em.value.trim()?", owner "+[owner,em.value.trim()&&"("+em.value.trim()+")"].filter(Boolean).join(" "):"")+(tz.value.trim()?", timezone "+tz.value.trim():"")+". "+(sn?"It is loaded from the snapshot "+sn+", which copies that snapshot's pipelines, workflows and the rest into it.":"No snapshot: it starts blank.")};
|
|
74278
|
+
for(const el of [name,snap,fn,ln,em,tz]){el.addEventListener("input",sayBefore);el.addEventListener("change",sayBefore)}
|
|
74279
|
+
sayBefore();f.appendChild(before);
|
|
73935
74280
|
const b=document.createElement("button");b.className="cds-btn";b.textContent="Create sub-account";
|
|
73936
74281
|
b.onclick=async()=>{if(ncCreating)return;if(!ind.value){showClientError("Choose the client industry.",f);return}if(!name.value.trim()){showClientError("Enter the business name, then create the sub-account.",f);return}
|
|
73937
74282
|
/* One create at a time, across redraws (review of f589999: a redraw re-enabled Create mid-create, and a second
|
|
@@ -74271,7 +74616,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
74271
74616
|
const startIt=async()=>{const j=await postRun();return (j&&j.ok)?"":runFailText(j)};
|
|
74272
74617
|
/* Intake, and Verify on an older page build, ask first: in a panel under the button, not a confirm() (rehearsal 2). */
|
|
74273
74618
|
const runAsk=i===0?{title:"Create or repair the intake in "+c.name,text:["Intake writes custom fields, one form, and one published notification workflow."].concat(INTAKE_NOTIFICATION_REVIEW),ok:"Create or repair these intake items",busyLabel:"Starting\u2026",run:startIt}
|
|
74274
|
-
:(i===4&&PAGE_PLANS.includes(c.locationId))?{title:"Run the check on "+c.name,text:["Verify submits one test lead to the pages "+c.name+"\u2019s older build made
|
|
74619
|
+
:(i===4&&PAGE_PLANS.includes(c.locationId))?{title:"Run the check on "+c.name,text:["Verify submits one test lead, named blueprint-qa, to the pages "+c.name+"\u2019s older build made and watches it arrive. The test contact stays in Contacts for you to check or delete. It may fire this client\u2019s own automations once."],ok:"Run the check",busyLabel:"Starting\u2026",run:startIt}:null;
|
|
74275
74620
|
const runKeyOf="run:"+c.locationId+":"+i;
|
|
74276
74621
|
/* A Run with no question first that fails says why under its button, never in an alert() (rehearsal 3).
|
|
74277
74622
|
Brief refused only because GoHighLevel has not yet attached the answers to the contact waits and
|
|
@@ -74831,7 +75176,6 @@ function capList(rows,noun){const frag=document.createDocumentFragment();const u
|
|
|
74831
75176
|
return frag}
|
|
74832
75177
|
function capEmpty(title,text){const e=mk("div","cds-empty");e.appendChild(mk("h3","",title));e.appendChild(mk("p","",text));return e}
|
|
74833
75178
|
/* Capability jobs that read and change nothing (audit 2026-10-28 #22). */
|
|
74834
|
-
const CAP_READ_ONLY=["checkup","contacts"];
|
|
74835
75179
|
/* Payments makes catalog products from the approved plan's package names, and nothing without them (audit #18). */
|
|
74836
75180
|
function capBuildNote(){return CAP.key==="payments"?"Uses the package names in the approved plan. With none approved, it creates nothing and says so.":""}
|
|
74837
75181
|
function capBuildLabel(c){return CAP.key==="checkup"?"Run the checkup":CAP.key==="payments"?"Prepare the three packages in the catalog":CAP.key==="contacts"?"Find and review everyone who has not booked in 90 days":"Build this for "+c.name}
|
|
@@ -74859,7 +75203,7 @@ function renderCapability(m,state,run){
|
|
|
74859
75203
|
let bb=null;
|
|
74860
75204
|
if(hasBuild){bb=mk("button","cds-btn cap-build",capBuildLabel(c));if(busy)bb.disabled=true;
|
|
74861
75205
|
/* Audit 2026-10-28 #22: a read-only job never claims to create drafts. */
|
|
74862
|
-
bb.onclick=async()=>{if(!confirm("For "+c.name+": "+words+String.fromCharCode(10)+(
|
|
75206
|
+
bb.onclick=async()=>{if(!confirm("For "+c.name+": "+words+String.fromCharCode(10)+String(CAP.before||"").split("{client}").join(c.name)+String.fromCharCode(10)+"Go ahead?"))return;bb.disabled=true;bb.textContent="Starting\u2026";
|
|
74863
75207
|
const tok=clientToken();
|
|
74864
75208
|
const shownReads=capReadsFor===c.locationId&&capReads&&capReads.ok&&Array.isArray(capReads.fingerprints)?capReads.fingerprints:null;
|
|
74865
75209
|
const r=await(await fetch("/api/run-capability",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,key:CAP.key,confirmed:true,readsShown:shownReads})})).json();
|
|
@@ -74868,6 +75212,8 @@ function renderCapability(m,state,run){
|
|
|
74868
75212
|
if(!r.ok){alertFor(c.locationId,r.error||"Could not start");bb.disabled=false;bb.textContent=capBuildLabel(c);return}
|
|
74869
75213
|
if(!stillFor(tok))return;capRun={locationId:c.locationId};pollCap()};
|
|
74870
75214
|
end.appendChild(bb);if(capBuildNote())end.appendChild(mk("p","muted cap-note",capBuildNote()))}
|
|
75215
|
+
/* Walk 2026-10-28 W9: the client's first page (where Your clients leads) has its build as a button. */
|
|
75216
|
+
if(CAP.key==="intake"){const go=mk("a","cds-btn cap-build-account","Build this client's account");end.appendChild(asClientLink(go,"/new",c.locationId))}
|
|
74871
75217
|
m.appendChild(screenHead(CAP.label,CAP.what,CAP.groupLabel,end));
|
|
74872
75218
|
const sc=document.getElementById("side-client");if(sc)sc.textContent=c.name;
|
|
74873
75219
|
if(CAP.key==="intake"){renderIntakeCapability(m,c);return}
|
|
@@ -74977,8 +75323,11 @@ function capDoPanel(c,state,run,words,hasBuild,busy,last){
|
|
|
74977
75323
|
return doc}
|
|
74978
75324
|
/* The handoff document's count in step 10's own words (command-center, ASK B 2026-10-06: Health said "100 items, 81
|
|
74979
75325
|
checked" while step 10 said "GoHighLevel confirms 81 items. You confirmed 6 yourself."). One sentence, every surface. */
|
|
75326
|
+
/* Walk 2026-10-28 W18: "confirms 66 \u2026 covers 75" left nine items unexplained. The rest are named by state. */
|
|
74980
75327
|
function handoffCountLine(lines,counts){const done=lines.filter(l=>l.state==="CHECKED").length,mine=(counts&&counts.ticked)||0;
|
|
74981
|
-
|
|
75328
|
+
const waiting=lines.filter(l=>l.state==="WAITING").length,unread=lines.length-done-waiting;
|
|
75329
|
+
const rest=[unread?unread+" "+(unread===1?"was":"were")+" built but GoHighLevel could not confirm "+(unread===1?"it":"them")+" yet":"",waiting?waiting+" "+(waiting===1?"waits":"wait")+" on a step outside the account, such as an approval":""].filter(Boolean);
|
|
75330
|
+
return "GoHighLevel confirms "+done+" "+plural(done,"item")+". You confirmed "+mine+" yourself. The handoff document covers "+lines.length+" "+plural(lines.length,"item")+"."+(rest.length?" Of the other "+(lines.length-done)+", "+rest.join(", and ")+".":"")}
|
|
74982
75331
|
/* The Health pages' own list: what is verified and what is still owed. */
|
|
74983
75332
|
/* Counts the same jobs, the same way, as step 11 (/api/state jobCounts), and the handoff document separately in
|
|
74984
75333
|
step 11's own words (run 7: "63 still owed, 114 verified" beside "133 items, 114 checked" and "9 left"). */
|
|
@@ -76702,6 +77051,27 @@ async function runDashboard(argv) {
|
|
|
76702
77051
|
});
|
|
76703
77052
|
return;
|
|
76704
77053
|
}
|
|
77054
|
+
if (req.method === "POST" && url === "/api/assessment-save-preview") {
|
|
77055
|
+
readBody(req, res, 2e6, (body) => {
|
|
77056
|
+
void (async () => {
|
|
77057
|
+
try {
|
|
77058
|
+
const args = JSON.parse(body || "{}");
|
|
77059
|
+
const locationId2 = String(args.locationId ?? "");
|
|
77060
|
+
const row = currentRoster().find((r) => r.locationId === locationId2);
|
|
77061
|
+
if (!row) throw new Error("Pick which account this assessment belongs to first.");
|
|
77062
|
+
const c = clientFor(locationId2);
|
|
77063
|
+
if (!c) throw new Error("No key on file for that account. Register it in GHL Command first.");
|
|
77064
|
+
const preview = await previewAssessmentSave(c, locationId2, { assessment: StateSchema.parse(args.assessment), contactName: args.contactName || void 0, email: args.email || void 0, phone: args.phone || void 0 });
|
|
77065
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
77066
|
+
res.end(JSON.stringify({ ok: true, accountName: row.name || "this account", ...preview }));
|
|
77067
|
+
} catch (e) {
|
|
77068
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
77069
|
+
res.end(JSON.stringify({ ok: false, error: assessmentValidationMessage(e) ?? (e instanceof Error ? e.message : String(e)) }));
|
|
77070
|
+
}
|
|
77071
|
+
})();
|
|
77072
|
+
});
|
|
77073
|
+
return;
|
|
77074
|
+
}
|
|
76705
77075
|
if (req.method === "POST" && url === "/api/assessment-save") {
|
|
76706
77076
|
readBody(req, res, 2e6, (body) => {
|
|
76707
77077
|
void (async () => {
|
|
@@ -76765,8 +77135,13 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
76765
77135
|
next: "Check the Assessments pipeline in GoHighLevel before saving again."
|
|
76766
77136
|
});
|
|
76767
77137
|
}
|
|
77138
|
+
const savedTo = currentRoster().find((r) => r.locationId === locationId2)?.name ?? "your account";
|
|
77139
|
+
const biz = String(assessment?.biz ?? "").trim();
|
|
77140
|
+
const who2 = args.contactName ? `${args.contactName}${biz ? ` (${biz})` : ""}` : biz || "The client";
|
|
77141
|
+
const savedSentence = `Saved to ${savedTo}: ${who2} is a contact there with the answers as a note${deal ? `, and their deal is in Assessments at ${out.stage}` : ""}. Next: attach it when you build this client, in step 4.`;
|
|
77142
|
+
const contactUrl = out.contactId ? `https://app.gohighlevel.com/v2/location/${encodeURIComponent(locationId2)}/contacts/detail/${encodeURIComponent(out.contactId)}` : void 0;
|
|
76768
77143
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
76769
|
-
res.end(JSON.stringify({ ok: true, ...out, deal }));
|
|
77144
|
+
res.end(JSON.stringify({ ok: true, ...out, deal, savedSentence, contactUrl }));
|
|
76770
77145
|
} catch (e) {
|
|
76771
77146
|
logQuietly(`assessment save: ${(0, import_node_util5.inspect)(e)}`);
|
|
76772
77147
|
if (!res.headersSent) res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -77095,6 +77470,11 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
77095
77470
|
return { ...t, rev: manualTaskRev(t), timing, blocks: blockingSet.has(t), open: openSet.has(t), ...sendsTexts ? { sendsTexts: true } : {}, topic: (t.timing ? null : goLiveTopic(t.text)) ?? void 0, text: shown || (timing === "before" ? plain : "") };
|
|
77096
77471
|
}).filter((t) => t.text)];
|
|
77097
77472
|
})),
|
|
77473
|
+
// Walk W15: a job the check read as done leaves the list; step 11 says what it confirmed, by name.
|
|
77474
|
+
confirmedJobs: Object.fromEntries(Object.keys(st.manualTasks ?? {}).map((id2) => {
|
|
77475
|
+
const shape = planShape(readSavedPlan(id2)?.plan);
|
|
77476
|
+
return [id2, confirmedHandoffLines(mergeHandoffDefs(st.jobHandoffs?.[id2] ?? [], shape.handoffs), st.jobEvidence?.[id2]?.facts, shape.calendars)];
|
|
77477
|
+
}).filter(([, lines]) => lines.length)),
|
|
77098
77478
|
jobCounts: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id2, tasks2]) => [id2, taskCounts(tasks2)])),
|
|
77099
77479
|
protectedAccounts: onlyFor(readAccountRulesSafe().protected, proClient),
|
|
77100
77480
|
pagePlans: rosterNow.filter((r) => savedPlanBuiltPages(r.locationId)).map((r) => r.locationId),
|
|
@@ -77372,8 +77752,10 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
77372
77752
|
writeCockpitState({ ...s3, buildUndone: u });
|
|
77373
77753
|
}
|
|
77374
77754
|
}
|
|
77755
|
+
const confirmUsed = stage === 3 ? readCockpitState().buildConfirms?.[rk]?.fingerprint : void 0;
|
|
77375
77756
|
startRun(locationId2, stage, (ok) => {
|
|
77376
|
-
|
|
77757
|
+
const ended = readCockpitState();
|
|
77758
|
+
writeCockpitState(setStage(confirmUsed && ended.buildConfirms?.[rk]?.fingerprint === confirmUsed ? forgetBuildConfirm(ended, rk) : ended, locationId2, stage, ok ? "done" : "pending", who2.clientKey));
|
|
77377
77759
|
if (!who2.clientKey) pushShared(locationId2);
|
|
77378
77760
|
}, { ...who2.clientKey ? { clientKey: who2.clientKey, clientName: who2.name } : {}, name: currentRoster().find((r) => r.locationId === locationId2)?.name, approvedAt: stage <= 1 ? (/* @__PURE__ */ new Date()).toISOString() : st0.buildApproved?.[rk], writeReview: writeReviewFor, inputHash, normalizedAnswers, statusWriter: () => approvedStatusWriter(registry2, locationId2), verifyAccount: async (saved) => {
|
|
77379
77761
|
const client = clientFor(locationId2);
|
|
@@ -77674,7 +78056,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
77674
78056
|
const st0 = readCockpitState();
|
|
77675
78057
|
const snap = await readIntakeSnapshot(c, args.locationId, Boolean(st0.intakeSent?.[args.locationId]));
|
|
77676
78058
|
if (!snap.formId || !snap.formUrl) throw new Error(INTAKE_FORM_REQUIRED);
|
|
77677
|
-
if (args.confirmed !== true) throw new Error("Confirm the
|
|
78059
|
+
if (args.confirmed !== true) throw new Error("Confirm saving the task before it is saved.");
|
|
77678
78060
|
saveBuildObligations(args.locationId, [{ ref: "intake-questionnaire", type: "sender-review", name: "Send the questionnaire", reason: `Confirm the sender in this client\u2019s GoHighLevel conversations, then send one questionnaire email to ${String(args.email)} using ${snap.formUrl}.` }]);
|
|
77679
78061
|
const manualState = readCockpitState();
|
|
77680
78062
|
{
|
|
@@ -79026,7 +79408,7 @@ var init_dashboard = __esm({
|
|
|
79026
79408
|
"That account is not on the board.",
|
|
79027
79409
|
INTAKE_CONNECT_REQUIRED,
|
|
79028
79410
|
INTAKE_FORM_REQUIRED,
|
|
79029
|
-
"Confirm the
|
|
79411
|
+
"Confirm saving the task before it is saved.",
|
|
79030
79412
|
"Choose a registered client before saving the intake.",
|
|
79031
79413
|
"Enter intake answers as text or a list of choices.",
|
|
79032
79414
|
DOC_NOT_SAVED,
|
|
@@ -91758,8 +92140,10 @@ async function executeBackbone(plan, rawDeps, opts = {}) {
|
|
|
91758
92140
|
created
|
|
91759
92141
|
);
|
|
91760
92142
|
if (tagHalt) return halt(tagHalt.ref, "tag", tagHalt.reason);
|
|
92143
|
+
const bookingRef = bookingLinkCalendarRef(plan);
|
|
92144
|
+
const deferredCv = bookingRef ? (plan.customValues ?? []).filter((cv) => customValueKeyOf(cv.name) === "booking_link") : [];
|
|
91761
92145
|
const cvHalt = await buildSimple(
|
|
91762
|
-
plan.customValues ?? [],
|
|
92146
|
+
(plan.customValues ?? []).filter((cv) => !deferredCv.includes(cv)),
|
|
91763
92147
|
"cv",
|
|
91764
92148
|
() => deps.listCustomValues(),
|
|
91765
92149
|
(cv) => deps.createCustomValue(cv.name, cv.value ?? ""),
|
|
@@ -91906,9 +92290,43 @@ async function executeBackbone(plan, rawDeps, opts = {}) {
|
|
|
91906
92290
|
}
|
|
91907
92291
|
let teamIssue = teamlessDrop ?? calendarTeamReadbackIssue(cal.name, expectedTeam, { teamMemberIds: seenTeam });
|
|
91908
92292
|
if (teamReadError) teamIssue = `${teamIssue ?? "The calendar team could not be confirmed."} ${teamReadError}`;
|
|
91909
|
-
|
|
92293
|
+
const putOn = teamMemberIds ?? (staffUserId ? [staffUserId] : []);
|
|
92294
|
+
built.push({ ref: cal.ref, type: "calendar", name: cal.name, status: "created", realId: verified.id, ...putOn.length ? { teamIds: [...putOn] } : {}, ...teamIssue ? { warning: teamIssue } : {} });
|
|
91910
92295
|
if (teamIssue) manual.push({ ref: cal.ref, type: "calendar_team", name: cal.name, reason: teamIssue });
|
|
91911
92296
|
}
|
|
92297
|
+
if (deferredCv.length && bookingRef) {
|
|
92298
|
+
const calId = idMap[bookingRef];
|
|
92299
|
+
const url = calId ? bookingWidgetUrl(calId) : "";
|
|
92300
|
+
const before = built.length;
|
|
92301
|
+
const linkHalt = await buildSimple(
|
|
92302
|
+
deferredCv.map((cv) => ({ ...cv, value: url })),
|
|
92303
|
+
"cv",
|
|
92304
|
+
() => deps.listCustomValues(),
|
|
92305
|
+
(cv) => deps.createCustomValue(cv.name, cv.value ?? ""),
|
|
92306
|
+
(cv) => cv.name,
|
|
92307
|
+
pollForNew,
|
|
92308
|
+
idMap,
|
|
92309
|
+
built,
|
|
92310
|
+
keepBinding,
|
|
92311
|
+
opts.retryWait ?? sleep6,
|
|
92312
|
+
visitedRefs,
|
|
92313
|
+
reconcileTries,
|
|
92314
|
+
created
|
|
92315
|
+
);
|
|
92316
|
+
if (linkHalt) return halt(linkHalt.ref, "cv", linkHalt.reason);
|
|
92317
|
+
const item = built.slice(before).find((b) => b.type === "cv");
|
|
92318
|
+
if (item?.status === "created" && !url) {
|
|
92319
|
+
manual.push({ ref: item.ref, type: "custom_value", name: item.name, reason: `The ${item.name} custom value is blank because its calendar was not created this run. Paste the calendar's booking link into it in Settings > Custom Values.` });
|
|
92320
|
+
} else if (item?.status === "existing") {
|
|
92321
|
+
let live;
|
|
92322
|
+
try {
|
|
92323
|
+
live = (await deps.listCustomValues()).find((v) => v.id === item.realId)?.value;
|
|
92324
|
+
} catch {
|
|
92325
|
+
live = void 0;
|
|
92326
|
+
}
|
|
92327
|
+
if (url && typeof live === "string" && !live.trim()) manual.push({ ref: item.ref, type: "custom_value", name: item.name, reason: `The ${item.name} custom value is already in this account and is blank, so it was left as it is. Paste ${url} into it in Settings > Custom Values.` });
|
|
92328
|
+
}
|
|
92329
|
+
}
|
|
91912
92330
|
const calendarPendingRefs = new Set(manual.filter((m) => m.type === "calendar").map((m) => m.ref));
|
|
91913
92331
|
let cfRecords;
|
|
91914
92332
|
for (const form of plan.forms ?? []) {
|
|
@@ -92796,6 +93214,7 @@ var init_execute = __esm({
|
|
|
92796
93214
|
init_ghl_click_paths();
|
|
92797
93215
|
init_account_read_retry();
|
|
92798
93216
|
init_plan();
|
|
93217
|
+
init_build_facts();
|
|
92799
93218
|
init_executor();
|
|
92800
93219
|
init_user_provisioning();
|
|
92801
93220
|
init_build_progress();
|
|
@@ -92885,6 +93304,11 @@ function pickCustomValues(raw) {
|
|
|
92885
93304
|
}
|
|
92886
93305
|
return out;
|
|
92887
93306
|
}
|
|
93307
|
+
function bookingCalendarName(plan, result) {
|
|
93308
|
+
const ref = bookingLinkCalendarRef(plan);
|
|
93309
|
+
if (!ref || result.calendarsManual.some((c) => c.ref === ref)) return void 0;
|
|
93310
|
+
return plan.calendars?.find((c) => c.ref === ref)?.name;
|
|
93311
|
+
}
|
|
92888
93312
|
function pickObjects(raw, keys) {
|
|
92889
93313
|
const root = raw && typeof raw === "object" ? raw : {};
|
|
92890
93314
|
let list4 = Array.isArray(raw) ? raw : void 0;
|
|
@@ -94086,7 +94510,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
94086
94510
|
}
|
|
94087
94511
|
const { changes: workflowChanges, states: workflowStates } = await previewWorkflowChanges(deps, result, ours, saved?.calendarPending);
|
|
94088
94512
|
const logins = (typedPlan.users ?? []).filter((u) => result.items.some((i) => i.ref === u.ref && i.type === "users" && i.status === "would_create")).map((u) => ({ name: [u.firstName, u.lastName === "-" ? "" : u.lastName].filter(Boolean).join(" "), email: u.email, role: u.role }));
|
|
94089
|
-
const preview = composePreview({ locationId: activeLocation, recordKey, planHash: contentHash2(typedPlan), items: result.items, ours, workflowChanges, workflowStates, board, logins });
|
|
94513
|
+
const preview = composePreview({ locationId: activeLocation, recordKey, planHash: contentHash2(typedPlan), items: result.items, ours, workflowChanges, workflowStates, board, logins, ...bookingCalendarName(typedPlan, result) && { bookingCalendar: bookingCalendarName(typedPlan, result) } });
|
|
94090
94514
|
try {
|
|
94091
94515
|
saveBuildPreview(preview);
|
|
94092
94516
|
} catch (e) {
|
|
@@ -94191,7 +94615,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
94191
94615
|
let savedPlanPath;
|
|
94192
94616
|
const publishRecord = savedPublishRecord(saved, exec, typedPlan, publishWorkflows ?? false, gatedWorkflowRefs);
|
|
94193
94617
|
try {
|
|
94194
|
-
savedPlanPath = savePlanRecord({ ...saved, locationId: activeLocation, ...clientFields, planId: typedPlan.planId, savedAt: (/* @__PURE__ */ new Date()).toISOString(), plan: typedPlan, inputHash: process.env.GHL_COMMAND_INTAKE_HASH || saved?.inputHash, contentHash: contentHash2(typedPlan), dependencies: planDependencies(typedPlan), preview: result, idMap: exec.idMap, built: exec.built, manual: exec.manual, calendarPending: exec.calendarPending, scope: scopeForValidation, heldWorkflows, customValueStatus, ...publishRecord });
|
|
94618
|
+
savedPlanPath = savePlanRecord({ ...saved, locationId: activeLocation, ...clientFields, planId: typedPlan.planId, savedAt: (/* @__PURE__ */ new Date()).toISOString(), plan: typedPlan, inputHash: process.env.GHL_COMMAND_INTAKE_HASH || saved?.inputHash, contentHash: contentHash2(typedPlan), dependencies: planDependencies(typedPlan), preview: result, idMap: exec.idMap, built: exec.built, calendarTeamsBuilt: mergeCalendarTeamsBuilt(saved?.calendarTeamsBuilt, exec.built), manual: exec.manual, calendarPending: exec.calendarPending, scope: scopeForValidation, heldWorkflows, customValueStatus, ...publishRecord });
|
|
94195
94619
|
} catch {
|
|
94196
94620
|
}
|
|
94197
94621
|
let journalPathOut;
|
|
@@ -94314,7 +94738,9 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
94314
94738
|
contentHash: contentHash2(typedPlan),
|
|
94315
94739
|
inputHash: process.env.GHL_COMMAND_INTAKE_HASH || (clientKey ? contentHash2(typedPlan) : void 0),
|
|
94316
94740
|
dependencies: planDependencies(typedPlan),
|
|
94317
|
-
preview: result
|
|
94741
|
+
preview: result,
|
|
94742
|
+
// 8a LOW-7: a preview replaces the plan, never the record of who Build put on each calendar (W15 ruling).
|
|
94743
|
+
...saved?.calendarTeamsBuilt && saved.locationId === activeLocation && (saved.clientKey ?? "") === (clientKey ?? "") ? { calendarTeamsBuilt: saved.calendarTeamsBuilt } : {}
|
|
94318
94744
|
});
|
|
94319
94745
|
}
|
|
94320
94746
|
return buildReplyResponse({
|
|
@@ -94907,6 +95333,7 @@ var init_intake_to_build = __esm({
|
|
|
94907
95333
|
init_normalizer();
|
|
94908
95334
|
init_brief2();
|
|
94909
95335
|
init_plan_store();
|
|
95336
|
+
init_jobs();
|
|
94910
95337
|
init_client_record();
|
|
94911
95338
|
init_clinic_prefix();
|
|
94912
95339
|
init_clinic_prefix_registry();
|