@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/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 \u2014 ${businessName} \u2014 ${run.runId}`, locationId: loc, description: `AI employee demo for ${host}. Created by prepare_ai_employee_demo; end_ai_employee_demo deletes it.` } }));
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 \u2014 ${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 gates = computeWorkflowGates(plan, metHandoffs, existing.customValues, existing.customValuesUnreadable === true);
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.92.2",
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"><a href="/">${productName()}</a><span aria-hidden="true">/</span>${crumbLabel(active, opts.capability, opts.trail)}</nav>
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&minus;</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(/&amp;/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(/&nbsp;/gi, "\xA0").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/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}&amp;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 {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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)}&amp;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(/&amp;/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(/&nbsp;/gi, "\xA0").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/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 import_node_path7.default.join(base, "secret-shopper");
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 import_node_path7.default.join(shopperDir(base), locationId2, `${runId}.json`);
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
- import_node_fs8.default.mkdirSync(import_node_path7.default.dirname(file3), { recursive: true, mode: 448 });
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
- import_node_fs8.default.writeFileSync(tmp, JSON.stringify(run, null, 1), { mode: 384 });
48709
- import_node_fs8.default.renameSync(tmp, file3);
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(import_node_fs8.default.readFileSync(runPath(locationId2, runId, base), "utf8"));
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 = import_node_path7.default.join(shopperDir(base), locationId2);
47974
+ const dir = import_node_path6.default.join(shopperDir(base), locationId2);
48721
47975
  let names = [];
48722
47976
  try {
48723
- names = import_node_fs8.default.readdirSync(dir).filter((n) => n.endsWith(".json"));
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(import_node_fs8.default.readFileSync(import_node_path7.default.join(dir, n), "utf8")));
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 = (str11(msg3.messageType) ?? "").toUpperCase();
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 = asArr5(asObj9(await client.get("/conversations/search", { params: { locationId: locationId2, contactId, limit: 20 } })).conversations);
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 = str11(asObj9(c).id);
48028
+ const convId = str10(asObj8(c).id);
48775
48029
  if (!convId) continue;
48776
- const raw = asObj9(await client.get(`/conversations/${convId}/messages`, { params: { limit: 100 } }));
48777
- const list4 = asArr5(raw.messages).length ? asArr5(raw.messages) : asArr5(asObj9(raw.messages).messages);
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 = asObj9(item);
48780
- if (str11(m.direction)?.toLowerCase() !== "outbound") continue;
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(str11(m.dateAdded) ?? "");
48037
+ const at = Date.parse(str10(m.dateAdded) ?? "");
48784
48038
  if (Number.isNaN(at) || at < since - 5e3) continue;
48785
- const id2 = str11(m.id);
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: str11(asObj9(asObj9(m.meta).email).subject),
48792
- body: htmlToText(str11(m.body) ?? ""),
48793
- raw: str11(m.body) ?? "",
48794
- source: str11(m.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 asArr5(asObj9(raw).contacts)) {
48803
- const co = asObj9(c);
48804
- if (str11(co.email)?.toLowerCase() === email) return str11(co.id);
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 = import_node_crypto21.default.randomUUID().replace(/-/g, "").slice(0, 8);
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 = asObj9(asObj9(await deps.client.get(`/locations/${input.locationId}`)).location);
48826
- const businessName = str11(loc.name) ?? "";
48827
- const websiteHost2 = siteHost(str11(loc.website));
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 = asObj9(await deps.client.post("/contacts/", { body, noRetry: true }));
48896
- run.contactId = str11(asObj9(created.contact).id) ?? str11(created.id);
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(asObj9(await client.get(`/contacts/${run.contactId}`)).contact);
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(asObj9(await client.get(`/contacts/${k.id}`)).contact);
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
- import_node_fs8.default.mkdirSync(import_node_path7.default.dirname(lock), { recursive: true, mode: 448 });
48962
- const token2 = import_node_crypto21.default.randomUUID();
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 = import_node_fs8.default.openSync(lock, "wx", 384);
48220
+ const fd = import_node_fs7.default.openSync(lock, "wx", 384);
48967
48221
  try {
48968
- import_node_fs8.default.writeSync(fd, mine);
48222
+ import_node_fs7.default.writeSync(fd, mine);
48969
48223
  } finally {
48970
- import_node_fs8.default.closeSync(fd);
48224
+ import_node_fs7.default.closeSync(fd);
48971
48225
  }
48972
48226
  return () => {
48973
48227
  try {
48974
- if (import_node_fs8.default.readFileSync(lock, "utf8") === mine) import_node_fs8.default.unlinkSync(lock);
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 = import_node_fs8.default.readFileSync(lock, "utf8");
48984
- age = Date.now() - import_node_fs8.default.statSync(lock).mtimeMs;
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 (import_node_fs8.default.readFileSync(lock, "utf8") === held) import_node_fs8.default.unlinkSync(lock);
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 watchingRuns(locationIds, base) {
49070
- return locationIds.flatMap((id2) => listRuns(id2, base).filter((r) => r.status === "watching"));
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 summarizeRun(run) {
49073
- if (run.status === "failed") return `The Workflow Audit Pro could not run: ${run.error ?? "unknown error"}`;
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 st = run.steps;
49076
- 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.` : "";
49077
- 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(", ")}.` : "");
49078
- 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.` : "";
49079
- const card = run.status === "done" ? scoreRun(run) : void 0;
49080
- 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}`} `;
49081
- 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}`;
49082
- const n = f.problems.length;
49083
- if (f.sent === 0) {
49084
- const why = nothingArrivedWhy(run.steps);
49085
- const nothing = `Nothing arrived in ${run.status === "watching" ? "the time so far" : `${run.watchMinutes} minutes`}. ${why}${stepNote}`;
49086
- return run.status === "watching" ? `So far: ${nothing} Still watching until ${new Date(run.closesAt).toISOString()}.` : gradeLine + nothing;
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}&amp;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 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` : ""}.`;
49089
- if (run.status === "watching") return `So far: ${head4}${stepNote} Still watching until ${new Date(run.closesAt).toISOString()}.`;
49090
- return gradeLine + head4 + stepNote;
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
- var import_node_fs8, import_node_path7, import_node_crypto21, MAX_EXTEND_MINUTES, asObj9, asArr5, str11, SAFE_ID, notFound;
49093
- var init_run = __esm({
49094
- "src/command-os/shopper/run.ts"() {
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 {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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
- import_node_fs8 = __toESM(require("node:fs"));
49097
- import_node_path7 = __toESM(require("node:path"));
49098
- import_node_crypto21 = __toESM(require("node:crypto"));
49099
- init_credentials_store();
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
- init_net_guard();
49107
- init_test_contacts();
49108
- MAX_EXTEND_MINUTES = 10;
49109
- asObj9 = (v) => v && typeof v === "object" ? v : {};
49110
- asArr5 = (v) => Array.isArray(v) ? v : [];
49111
- str11 = (v) => typeof v === "string" ? v : void 0;
49112
- SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/;
49113
- notFound = (e) => {
49114
- const m = e instanceof Error ? e.message : String(e);
49115
- 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);
49119
+ init_score();
49120
+ esc2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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)}&amp;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
- const today=section(second,"today","Across your accounts","Today","Appointments in the accounts read.");
51014
- list(today,b.today.map(r=>({text:r.client+": "+(r.who||r.title||"Appointment"),when:r.at})),"No appointments in the accounts read. Any account that could not be read is named under What was read.",false);
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?"This client's build has started.":"No unstarted accounts on the board."),n=>"Show all "+plural(n,"account","accounts")+" never started");
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
- :root, :root[data-theme="dark"] {
52121
- --accent:${accentLight}; --ink:#1A1A1A; --muted:#4A4A4A; --paper:#FFFFFF; --ground:#FFFFFF; --line:#CFCFCF; --head:#101010;
52122
- --ok:#20654A; --soon:#8E3B37; --wait:#6B4100; --okbg:transparent; --soonbg:transparent; --waitbg:transparent; --raise:none;
52123
- }
52124
- body { background:#fff; font-family:-apple-system, "Helvetica Neue", Arial, sans-serif; }
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 ones you miss, how many never call back?", "Half? More?", "rate", "This is what stops us overclaiming. We only count the ones you genuinely lose."],
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, how many actually happen?", "Nobody home, canceled last minute, forgot.", "rate", "A no-show is a job you paid to get twice."],
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} \u2014 ${state2.biz || "unnamed"}${state2.trade ? " \u2014 " + state2.trade : ""} \u2014 ${today2}`);
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 \u2014 its finding was dropped, not estimated)"}`);
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"} \u2014 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
- let merged = value;
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
- result.leftForMember.push({ key: q3.key, question: label2, reason: "The combined answer does not match this intake\u2019s choices. Your saved answer was left unchanged." });
53479
+ kept.push(label2);
53419
53480
  continue;
53420
53481
  }
53421
- answers[label2] = merged;
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
- const who2 = o.agency.name;
55565
- if (who2 && o.agency.contact) return `Questions? Contact ${who2}: ${o.agency.contact}.`;
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:#fffdf8; --surface:#ffffff; }
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
- :root, :root[data-theme="dark"] { --accent:${colours.light}; --ink:#111; --muted:#444; --line:#ccc; --ground:#fff; --paper:#fff; --surface:#fff; }
55644
- body { background:#fff; }
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))} ${esc10(OWNER_UPDATES_LINE)}</p>${stamp ? `<p class="stamp">${esc10(stamp)}</p>` : ""}</div>
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 state2 = handoffInAccount(h, input.facts ?? {}, input.readback ?? [], input.plannedUsers ?? [], openRecipientJobs);
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: text3, timing: jobTiming(text3), selfReport: !state2.readable, key: requirementsKey([text3]), ...why ? { why } : {} });
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: t, timing: jobTiming(text3), selfReport: true, key: requirementsKey([t]), ...info ? { why: info } : {} });
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
- return { handoffs, users, workflows };
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
- box.appendChild(mk("p","",bp&&bp.confirmed?"You confirmed the list. It is building now, exactly what the list showed.":"First it reads the intake and works out exactly what to build. Then it stops and shows you the list of what it will create, use and change. Nothing is written to GoHighLevel until you confirm that list."))}
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
- const kinds=Object.entries(pv.createByType||{}).filter(x=>x[1]>0).map(x=>x[1]+" "+(BS_KIND[x[0]]||x[0])+(x[1]===1?"":"s"));
65160
- box.appendChild(mk("h4","","Will create: "+pv.createCount+" new "+plural(pv.createCount,"item")));
65161
- if(kinds.length)box.appendChild(mk("p","muted",kinds.join(", ")+"."));
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
- /* The title is lower-cased only where that is how the word is written mid-sentence: "SMS capability" stays as it is
65193
- (run 8: "Walk me through sMS capability"). */
65194
- function bsAskClaude(c,title){/* Lower-cased only when it reads as a sentence ("Connect your calendar"), never a name ("Trial Balance Paid", run 9). */const t=/^[A-Z][a-z]+(?:\s+[a-z]|$)/.test(title)?title.charAt(0).toLowerCase()+title.slice(1):title;const p=mk("small","muted bs-ask","Ask Claude: \u201cWalk me through "+t+" in GoHighLevel for "+(c.name||"this client")+", one click at a time.\u201d");return p}
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
- box.appendChild(bsBtn(bsBusy[key+":add"]?"Adding…":"Add them for me",async()=>{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)}))}}
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
- cb.onchange=async()=>{delete bsErr["jobs:"+c.locationId];for(const t of g.items){if(Boolean(t.claimed)===cb.checked)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:cb.checked,by,...(typeof jobShown==="function"?jobShown(t):{})});if(!j.ok){bsErr["jobs:"+c.locationId]=j.error;break}}load(true)};
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(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,"")))}}
65326
- else{lab.textContent=String(g.items[0].text).replace(/^Manual action:\s*/i,"");body.appendChild(lab)}
65327
- row.appendChild(cb);row.appendChild(body);
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(g.items[0].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.")}
65332
- /* Jerry 2026-10-03: a job GoHighLevel cannot show clears on the member's tick, and says who ticked it and when;
65333
- a job the check can read is decided by the account, so a tick there is only a note until the next check. */
65334
- const t0=g.items[0];
65335
- if(g.timing!=="at-go-live"&&!cb.checked){const ttl=topic||bsJobTitle(t0.text);if(ttl){body.appendChild(document.createElement("br"));body.appendChild(bsAskClaude(c,ttl))}}
65336
- /* A read job always says what the check found; a tick it took back says so and why (run 8 finding 1). */
65337
- const why=g.items.map(t=>t.why).find(Boolean),back=g.items.some(t=>t.reappeared&&!t.claimed);
65338
- 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+". GoHighLevel cannot show it, so your tick is the record.")}
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("GoHighLevel cannot show it, so your tick is the record."+(why?" "+why:""));
65342
- else if(g.items.some(t=>t.selfReport===false&&t.timing==="before")){
65343
- if(cb.checked)note("Ticked. The check reads this in GoHighLevel and confirms it next time it runs.");
65344
- else if(back)note("Your tick was taken back: the check read GoHighLevel and this is not done yet."+(why?" "+why:""));
65345
- else note("The check reads this in GoHighLevel."+(why?" Last check: "+why:""))}
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: "Local Lift package", hint: "Saved as yours. To have one pitched on a report, tell Claude which service above it covers, for example: my Local Lift package covers local search." })}
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
- detail.message = "The assessment snapshot is attached to this client card.";
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: "The overlapping answers are saved in the local intake." };
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 ROSTER.filter(r=>!r.pending)){const option=document.createElement('option');option.value=account.locationId;option.textContent=account.name;option.selected=p.source===account.locationId;select.appendChild(option)}
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){text('p','Filled: '+(p.fill.filled.join(', ')||'No overlapping answers')+'.');if(p.fill.leftForMember.length){text('h4','Left for you');for(const item of p.fill.leftForMember)text('p',item.question+': '+item.reason)}}
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 ones you miss, how many never call back?","Half? More?","rate","This is what stops us overclaiming. We only count the ones you genuinely lose."],
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, how many actually happen?","Nobody home, canceled last minute, forgot.","rate","A no-show is a job you paid to get twice."],
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=S.saveTo;try{if(!cur)cur=localStorage.getItem(K+":saveTo")||""}catch(e){}
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="saveToGhl()">Save to GoHighLevel</button>':'')+'</div>'
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=S.saveTo;try{if(!loc)loc=localStorage.getItem(K+":saveTo")||""}catch(e){}
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,"&lt;")+'<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">It is created in this account with the stages Gathering, Findings, Proposal sent, Won and Lost.</div></div>'}
67440
+ return '<div class="cds-callout warn"><span class="ttl">'+TODO+'Saved, but no deal yet</span>'+String(m.note||"").replace(/</g,"&lt;")+'<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=S.saveTo;try{if(!loc)loc=localStorage.getItem(K+":saveTo")||""}catch(e){}
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 your Assessments pipeline.</div>';
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,"&lt;")+'</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,"&lt;")+'<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">It is created in this account with the stages Gathering, Findings, Proposal sent, Won and Lost.</div></div>';return}
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,"&lt;")+'<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.whereItLives||"").replace(/</g,"&lt;")+' Stage: '+String(j.stage||"")+'. '+String(j.opportunityNote||"").replace(/</g,"&lt;")+(j.warning?' '+String(j.warning).replace(/</g,"&lt;"):'')+'</div>'})
67465
+ box.innerHTML='<div class="cds-callout info"><span class="ttl">Saved</span>'+String(j.savedSentence||"Saved.").replace(/</g,"&lt;")+(/^https:\\/\\/app\\.gohighlevel\\.com\\//.test(String(j.contactUrl||""))?' <a class="cds-link" target="_blank" rel="noopener" href="'+String(j.contactUrl).replace(/"/g,"&quot;")+'">Open the contact in GoHighLevel (opens in a new tab)</a>.':'')+' '+String(j.opportunityNote||"").replace(/</g,"&lt;")+(j.warning?' '+String(j.warning).replace(/</g,"&lt;"):'')+'</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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}
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">Build a client account</a></p></main>
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 only reads. Anything it cannot check is reported as unchecked."],
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()||pointerIsDown){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
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:["Creates a contact for "+email+" in "+name+" and sends one email from "+(d.sender||"the sender configured in this client\u2019s GoHighLevel account")+".","Sender not verified. Command OS saves a manual task to confirm the sender and send in GoHighLevel."],
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, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once."],ok:"Run the check",busyLabel:"Starting\u2026",run:startIt}:null;
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)+(CAP_READ_ONLY.includes(CAP.key)?"Reads the account only. Nothing in it changes. Run it?":"Creates drafts for your review. Nothing sends until you turn it on. Approve this action?")))return;bb.disabled=true;bb.textContent="Starting\u2026";
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
- return "GoHighLevel confirms "+done+" "+plural(done,"item")+". You confirmed "+mine+" yourself. The handoff document covers "+lines.length+" "+plural(lines.length,"item")+"."}
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
- writeCockpitState(setStage(readCockpitState(), locationId2, stage, ok ? "done" : "pending", who2.clientKey));
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 contact creation and email before sending the questionnaire.");
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 contact creation and email before sending the questionnaire.",
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
- built.push({ ref: cal.ref, type: "calendar", name: cal.name, status: "created", realId: verified.id, ...teamIssue ? { warning: teamIssue } : {} });
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();