@elitedcs/ghl-mcp 3.88.3 → 3.88.4

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
@@ -3470,8 +3470,8 @@ function textResponse(text2) {
3470
3470
  content: [{ type: "text", text: text2 }]
3471
3471
  };
3472
3472
  }
3473
- function escapeRegex(str8) {
3474
- return str8.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3473
+ function escapeRegex(str9) {
3474
+ return str9.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3475
3475
  }
3476
3476
  function errorMessage(error) {
3477
3477
  return error instanceof Error ? error.message : String(error);
@@ -9273,27 +9273,27 @@ function actionNameProblem(name) {
9273
9273
  function voiceActionBodyProblem(type, p) {
9274
9274
  const gap = undefinedPath2(p);
9275
9275
  if (gap) return `${gap} is undefined. JSON would drop that key silently and GoHighLevel would see a different body than you wrote \u2014 leave the key out or give it a value.`;
9276
- const str8 = (k) => typeof p[k] === "string" && p[k].trim().length > 0;
9276
+ const str9 = (k) => typeof p[k] === "string" && p[k].trim().length > 0;
9277
9277
  const num3 = (k) => typeof p[k] === "number" && Number.isFinite(p[k]);
9278
- const needTrigger = () => !str8("triggerPrompt") ? `${type} needs triggerPrompt: when, in plain words, the agent should do this ("When the caller asks for a person").` : !str8("triggerMessage") ? `${type} needs triggerMessage: what the agent says as it does it ("Let me get someone for you").` : null;
9278
+ const needTrigger = () => !str9("triggerPrompt") ? `${type} needs triggerPrompt: when, in plain words, the agent should do this ("When the caller asks for a person").` : !str9("triggerMessage") ? `${type} needs triggerMessage: what the agent says as it does it ("Let me get someone for you").` : null;
9279
9279
  switch (type) {
9280
9280
  case "CALL_TRANSFER": {
9281
9281
  const t = needTrigger();
9282
9282
  if (t) return t;
9283
9283
  if (p.transferToType !== "number") return 'CALL_TRANSFER needs transferToType: "number" (the only type GoHighLevel supports today).';
9284
- if (!str8("transferToValue") || !TRANSFER_NUMBER.test(p.transferToValue)) return "CALL_TRANSFER needs transferToValue as a full number with country code and no punctuation, e.g. +16025550100 (GoHighLevel refuses anything else with a 422).";
9284
+ if (!str9("transferToValue") || !TRANSFER_NUMBER.test(p.transferToValue)) return "CALL_TRANSFER needs transferToValue as a full number with country code and no punctuation, e.g. +16025550100 (GoHighLevel refuses anything else with a 422).";
9285
9285
  return null;
9286
9286
  }
9287
9287
  case "SMS": {
9288
9288
  const t = needTrigger();
9289
9289
  if (t) return t;
9290
- if (!str8("messageBody")) return "SMS needs messageBody: the text the caller receives.";
9290
+ if (!str9("messageBody")) return "SMS needs messageBody: the text the caller receives.";
9291
9291
  return null;
9292
9292
  }
9293
9293
  case "DATA_EXTRACTION":
9294
9294
  case "IN_CALL_DATA_EXTRACTION": {
9295
- if (!str8("contactFieldId")) return `${type} needs contactFieldId: the id of the contact field to fill (get_custom_fields). GoHighLevel refuses an unknown id with a 400 naming it.`;
9296
- if (!str8("description")) return `${type} needs description: what to listen for, in plain words.`;
9295
+ if (!str9("contactFieldId")) return `${type} needs contactFieldId: the id of the contact field to fill (get_custom_fields). GoHighLevel refuses an unknown id with a 400 naming it.`;
9296
+ if (!str9("description")) return `${type} needs description: what to listen for, in plain words.`;
9297
9297
  const ex = p.examples;
9298
9298
  if (!Array.isArray(ex) || ex.length < 1 || ex.length > 5 || ex.some((x) => typeof x !== "string" || !x.trim())) return `${type} needs examples: 1 to 5 example values (GoHighLevel refuses an empty list with a 422).`;
9299
9299
  return null;
@@ -9301,19 +9301,19 @@ function voiceActionBodyProblem(type, p) {
9301
9301
  case "WORKFLOW_TRIGGER": {
9302
9302
  const t = needTrigger();
9303
9303
  if (t) return t;
9304
- if (!str8("workflowId")) return "WORKFLOW_TRIGGER needs workflowId. It must be PUBLISHED \u2014 GoHighLevel refuses a draft or unknown id with a 400.";
9304
+ if (!str9("workflowId")) return "WORKFLOW_TRIGGER needs workflowId. It must be PUBLISHED \u2014 GoHighLevel refuses a draft or unknown id with a 400.";
9305
9305
  return null;
9306
9306
  }
9307
9307
  case "APPOINTMENT_BOOKING": {
9308
- if (!str8("calendarId")) return "APPOINTMENT_BOOKING needs calendarId (get_calendars). GoHighLevel refuses an unknown id with a 400 naming it.";
9308
+ if (!str9("calendarId")) return "APPOINTMENT_BOOKING needs calendarId (get_calendars). GoHighLevel refuses an unknown id with a 400 naming it.";
9309
9309
  for (const k of ["daysOfOfferingDates", "slotsPerDay", "hoursBetweenSlots"]) {
9310
9310
  if (!num3(k) || p[k] < 1) return `APPOINTMENT_BOOKING needs ${k} as a positive number (how many days ahead to offer, how many slots a day, hours between slots).`;
9311
9311
  }
9312
9312
  return null;
9313
9313
  }
9314
9314
  case "KNOWLEDGE_BASE": {
9315
- if (!str8("triggerPrompt")) return 'KNOWLEDGE_BASE needs triggerPrompt: when the agent should look something up ("When the caller asks about services, hours or pricing").';
9316
- if (!str8("knowledgeBaseId")) return "KNOWLEDGE_BASE needs knowledgeBaseId from THIS sub-account (list_knowledge_bases). GoHighLevel refuses an unknown id with a 400.";
9315
+ if (!str9("triggerPrompt")) return 'KNOWLEDGE_BASE needs triggerPrompt: when the agent should look something up ("When the caller asks about services, hours or pricing").';
9316
+ if (!str9("knowledgeBaseId")) return "KNOWLEDGE_BASE needs knowledgeBaseId from THIS sub-account (list_knowledge_bases). GoHighLevel refuses an unknown id with a 400.";
9317
9317
  return null;
9318
9318
  }
9319
9319
  case "CUSTOM_ACTION": {
@@ -18294,6 +18294,51 @@ var init_form_builder = __esm({
18294
18294
  }
18295
18295
  });
18296
18296
 
18297
+ // src/test-contacts.ts
18298
+ function carrierOf(raw) {
18299
+ const c = asObj(raw);
18300
+ const id = str(c.id) ?? str(c._id);
18301
+ if (!id) return void 0;
18302
+ const name = str(c.contactName) ?? [str(c.firstName), str(c.lastName)].filter(Boolean).join(" ");
18303
+ return { id, name: name ?? "", phone: str(c.phone) ?? "", email: (str(c.email) ?? "").toLowerCase(), created: str(c.dateAdded) ?? "" };
18304
+ }
18305
+ function carrierText(contacts, partial, former = []) {
18306
+ const head4 = partial ? `${CARRIER_PARTIAL} ` : "";
18307
+ const was = former.map((f) => ` ${f.id}: carried the test address during the run; now shows ${f.now || "no email"}.`).join("");
18308
+ if (!contacts.length) return `${head4}No contact carrying this run's test address was found.${was}`;
18309
+ const one = (c) => `${c.id} (name: ${c.name || "none"}; phone: ${c.phone || "none"}; email: ${c.email || "none"}; created: ${c.created || "unknown"})`;
18310
+ return `${head4}${CARRIER_HEADING}: ${contacts.map(one).join(", ")}. ${CARRIER_WARNING}${was}`;
18311
+ }
18312
+ async function readContactPages(get, params, cap = CONTACT_PAGE_CAP) {
18313
+ const limit = typeof params.limit === "number" ? params.limit : 20;
18314
+ const rows2 = [];
18315
+ let paging = {};
18316
+ for (let page = 0; page < cap; page++) {
18317
+ const raw = asObj(await get("/contacts/", { params: { ...params, ...paging } }));
18318
+ if (!Array.isArray(raw.contacts)) return void 0;
18319
+ rows2.push(...raw.contacts);
18320
+ const meta = asObj(raw.meta);
18321
+ const total = typeof meta.total === "number" ? meta.total : void 0;
18322
+ if (total !== void 0 && rows2.length >= total) return { rows: rows2, complete: true };
18323
+ const next = meta.startAfterId !== void 0 && meta.startAfterId !== null && meta.startAfter !== void 0 && meta.startAfter !== null ? { startAfterId: meta.startAfterId, startAfter: meta.startAfter } : typeof meta.nextPage === "number" ? { page: meta.nextPage } : void 0;
18324
+ if (!next || raw.contacts.length === 0) return { rows: rows2, complete: total === void 0 ? raw.contacts.length < limit : rows2.length >= total };
18325
+ paging = next;
18326
+ }
18327
+ return { rows: rows2, complete: false };
18328
+ }
18329
+ var CARRIER_HEADING, CARRIER_WARNING, CARRIER_PARTIAL, asObj, str, CONTACT_PAGE_CAP;
18330
+ var init_test_contacts = __esm({
18331
+ "src/test-contacts.ts"() {
18332
+ "use strict";
18333
+ CARRIER_HEADING = "Contacts that now carry this run's test address";
18334
+ CARRIER_WARNING = "If your form matches leads by phone, some of these may be existing customers whose email was replaced. Check each before deleting; to delete, ask Claude to delete specific contacts by id.";
18335
+ CARRIER_PARTIAL = "Partial, a search could not be read.";
18336
+ asObj = (v) => v && typeof v === "object" ? v : {};
18337
+ str = (v) => typeof v === "string" && v.trim() ? v : void 0;
18338
+ CONTACT_PAGE_CAP = 5;
18339
+ }
18340
+ });
18341
+
18297
18342
  // src/tools/funnel-qa.ts
18298
18343
  function rollupVerdict(checks) {
18299
18344
  const fail = checks.filter((c) => c.status === "fail");
@@ -18304,52 +18349,66 @@ function rollupVerdict(checks) {
18304
18349
  return { ok: fail.length === 0, verdict, summary };
18305
18350
  }
18306
18351
  function parseContact(raw) {
18307
- const c = asObj(asObj(raw).contact);
18308
- const id = str(c.id) ?? str(c._id);
18352
+ const c = asObj2(asObj2(raw).contact);
18353
+ const id = str2(c.id) ?? str2(c._id);
18309
18354
  if (!id) return void 0;
18310
18355
  const custom = /* @__PURE__ */ new Map();
18311
18356
  for (const f of asArr(c.customFields)) {
18312
- const fo = asObj(f);
18313
- const fid = str(fo.id) ?? str(fo.customFieldId) ?? str(fo.field_id);
18357
+ const fo = asObj2(f);
18358
+ const fid = str2(fo.id) ?? str2(fo.customFieldId) ?? str2(fo.field_id);
18314
18359
  if (!fid) continue;
18315
18360
  const val = fo.value ?? fo.field_value ?? fo.fieldValue ?? "";
18316
18361
  custom.set(fid, Array.isArray(val) ? val.join(",") : String(val));
18317
18362
  }
18318
18363
  return {
18319
18364
  id,
18320
- email: str(c.email)?.toLowerCase(),
18321
- phone: str(c.phone),
18322
- firstName: str(c.firstName),
18323
- lastName: str(c.lastName),
18324
- name: str(c.name),
18365
+ email: str2(c.email)?.toLowerCase(),
18366
+ phone: str2(c.phone),
18367
+ firstName: str2(c.firstName),
18368
+ lastName: str2(c.lastName),
18369
+ name: str2(c.name),
18325
18370
  tags: asArr(c.tags).map((t) => String(t).toLowerCase()),
18326
- source: str(c.source),
18327
- assignedTo: str(c.assignedTo),
18371
+ source: str2(c.source),
18372
+ assignedTo: str2(c.assignedTo),
18328
18373
  custom
18329
18374
  };
18330
18375
  }
18331
- async function findContactsByEmail(client, locationId2, email) {
18332
- const raw = await client.get("/contacts/", { params: { locationId: locationId2, query: email, limit: 20 } });
18376
+ async function searchByEmail(client, locationId2, email) {
18377
+ const paged = await readContactPages((path45, o) => client.get(path45, o), { locationId: locationId2, query: email, limit: 20 });
18378
+ if (!paged) throw new SearchUnreadable("the contact search answered in a shape that could not be read");
18333
18379
  const want = email.toLowerCase();
18334
18380
  const ids = [];
18335
- for (const c of asArr(asObj(raw).contacts)) {
18336
- const co = asObj(c);
18337
- if (str(co.email)?.toLowerCase() === want) {
18338
- const id = str(co.id) ?? str(co._id);
18339
- if (id) ids.push(id);
18381
+ const matches = [];
18382
+ const rows2 = [];
18383
+ for (const c of paged.rows) {
18384
+ const co = asObj2(c);
18385
+ const id = str2(co.id) ?? str2(co._id);
18386
+ if (!id) continue;
18387
+ ids.push(id);
18388
+ const row = carrierOf(co);
18389
+ if (row) rows2.push(row);
18390
+ if (str2(co.email)?.toLowerCase() === want) {
18391
+ const t = Date.parse(str2(co.dateAdded) ?? "");
18392
+ const name = str2(co.contactName) ?? ([str2(co.firstName), str2(co.lastName)].filter(Boolean).join(" ") || void 0);
18393
+ matches.push({ id, email: want, added: Number.isFinite(t) ? t : void 0, name, carrier: carrierOf(co) });
18340
18394
  }
18341
18395
  }
18342
- return ids;
18396
+ return { ids, matches, rows: rows2, complete: paged.complete };
18397
+ }
18398
+ async function findContactsByEmail(client, locationId2, email, onRead) {
18399
+ const read2 = await searchByEmail(client, locationId2, email);
18400
+ onRead?.(read2);
18401
+ return read2.matches.map((m) => m.id);
18343
18402
  }
18344
18403
  async function getContact(client, contactId) {
18345
18404
  return parseContact(await client.get(`/contacts/${contactId}`));
18346
18405
  }
18347
- async function settledEmailIds(client, locationId2, email, opts = {}) {
18406
+ async function settledEmailIds(client, locationId2, email, opts = {}, onRead) {
18348
18407
  const tries = opts.tries ?? 6;
18349
18408
  const delayMs = opts.delayMs ?? 3500;
18350
18409
  const seen = /* @__PURE__ */ new Set();
18351
18410
  for (let i = 1; i <= tries; i++) {
18352
- for (const id of await findContactsByEmail(client, locationId2, email)) seen.add(id);
18411
+ for (const id of await findContactsByEmail(client, locationId2, email, onRead)) seen.add(id);
18353
18412
  if (seen.size >= 2) break;
18354
18413
  if (i < tries) await sleep3(delayMs);
18355
18414
  }
@@ -18359,21 +18418,21 @@ async function hasSmsCapability(client, locationId2) {
18359
18418
  let numbers = 0;
18360
18419
  let pools = 0;
18361
18420
  try {
18362
- numbers = asArr(asObj(await client.get("/phone-system/numbers/", { params: { locationId: locationId2 } })).phoneNumbers).length;
18421
+ numbers = asArr(asObj2(await client.get("/phone-system/numbers/", { params: { locationId: locationId2 } })).phoneNumbers).length;
18363
18422
  } catch {
18364
18423
  }
18365
18424
  try {
18366
- pools = asArr(asObj(await client.get("/phone-system/number-pools/", { params: { locationId: locationId2 } })).pools).length;
18425
+ pools = asArr(asObj2(await client.get("/phone-system/number-pools/", { params: { locationId: locationId2 } })).pools).length;
18367
18426
  } catch {
18368
18427
  }
18369
18428
  return { numbers, pools };
18370
18429
  }
18371
18430
  async function latestOutboundMs(client, locationId2, contactId) {
18372
18431
  const convRaw = await client.get("/conversations/search", { params: { locationId: locationId2, contactId, limit: 20 } });
18373
- const conversations = asArr(asObj(convRaw).conversations);
18432
+ const conversations = asArr(asObj2(convRaw).conversations);
18374
18433
  let best;
18375
18434
  for (const c of conversations) {
18376
- const convId = str(asObj(c).id) ?? str(asObj(c)._id);
18435
+ const convId = str2(asObj2(c).id) ?? str2(asObj2(c)._id);
18377
18436
  if (!convId) continue;
18378
18437
  let msgsRaw;
18379
18438
  try {
@@ -18381,25 +18440,25 @@ async function latestOutboundMs(client, locationId2, contactId) {
18381
18440
  } catch {
18382
18441
  continue;
18383
18442
  }
18384
- const mo = asObj(msgsRaw);
18385
- const list3 = asArr(mo.messages).length ? asArr(mo.messages) : asArr(asObj(mo.messages).messages);
18443
+ const mo = asObj2(msgsRaw);
18444
+ const list3 = asArr(mo.messages).length ? asArr(mo.messages) : asArr(asObj2(mo.messages).messages);
18386
18445
  for (const m of list3) {
18387
- const msg3 = asObj(m);
18388
- const dir = str(msg3.direction)?.toLowerCase();
18446
+ const msg3 = asObj2(m);
18447
+ const dir = str2(msg3.direction)?.toLowerCase();
18389
18448
  if (dir !== "outbound") continue;
18390
- const at = Date.parse(str(msg3.dateAdded) ?? str(msg3.dateUpdated) ?? "");
18449
+ const at = Date.parse(str2(msg3.dateAdded) ?? str2(msg3.dateUpdated) ?? "");
18391
18450
  const atMs = Number.isNaN(at) ? void 0 : at;
18392
- if (!best || atMs && (!best.atMs || atMs > best.atMs)) best = { type: str(msg3.type), atMs };
18451
+ if (!best || atMs && (!best.atMs || atMs > best.atMs)) best = { type: str2(msg3.type), atMs };
18393
18452
  }
18394
18453
  }
18395
18454
  return best ? { found: true, ...best } : { found: false };
18396
18455
  }
18397
18456
  async function readWorkflow(bc, workflowId) {
18398
18457
  try {
18399
- const full = asObj(await bc.getWorkflow(workflowId));
18400
- const status = str(full.status);
18401
- const templates = asArr(asObj(full.workflowData).templates);
18402
- const sendsSms = templates.some((t) => str(asObj(t).type) === "sms");
18458
+ const full = asObj2(await bc.getWorkflow(workflowId));
18459
+ const status = str2(full.status);
18460
+ const templates = asArr(asObj2(full.workflowData).templates);
18461
+ const sendsSms = templates.some((t) => str2(asObj2(t).type) === "sms");
18403
18462
  return { status, sendsSms };
18404
18463
  } catch {
18405
18464
  return void 0;
@@ -18413,9 +18472,9 @@ async function submit(url, body) {
18413
18472
  function registerFunnelQaTools(server2, client, builderClient) {
18414
18473
  server2.tool(
18415
18474
  "verify_funnel",
18416
- "Prove a live public funnel actually captures a lead AND the automation acts on it \u2014 the runtime companion to audit_workflows. Submits a sentinel lead to funnelUrl, then reads GHL back to verify what REALLY happened (the UI/thank-you page is never proof): (1) backend contact truth \u2014 the contact is actually created (search_contacts), with submit\u2192appear latency; (2) field-value fidelity \u2014 each expectCustom value actually PERSISTED on the contact (a wrong field id makes upsert succeed while silently dropping the value); (3) the triggerTag landed (else the workflow can't fire); (4) SMS/A2P pre-check \u2014 flags if the account has no SMS-capable number so any SMS step would silently fail; (5) workflow status \u2014 flags a DRAFT workflow (never fires on real leads); (6) outreach fired \u2014 an OUTBOUND message was actually logged for the contact (proves enrollment + send, not just a green log), with latency; (7) consent recorded when messaging fires (TCPA/CAN-SPAM); (8) duplicate-contact retest \u2014 a second identical submit dedups to ONE contact, not a duplicate/false-failure; (9) opportunity/routing (expectOpportunity/expectAssigned/expectSource); (10) multi-surface (extraUrls) + concurrent double-submit race. Booking can't be faked server-side, so a real burner appointment is surfaced as a manual step. WARNING: this creates a real (clearly-marked 'blueprint-qa') contact and MAY fire real automation + internal notifications; it deletes the sentinel afterward by default (cleanup). Run it on the REAL production URL.",
18475
+ "Prove a live public funnel actually captures a lead AND the automation acts on it \u2014 the runtime companion to audit_workflows. Submits a sentinel lead to funnelUrl, then reads GHL back to verify what REALLY happened (the UI/thank-you page is never proof): (1) backend contact truth \u2014 a contact carrying the test address actually appears (search_contacts), with submit\u2192appear latency; (2) field-value fidelity \u2014 each expectCustom value actually PERSISTED on the contact (a wrong field id makes upsert succeed while silently dropping the value); (3) the triggerTag landed (else the workflow can't fire); (4) SMS/A2P pre-check \u2014 flags if the account has no SMS-capable number so any SMS step would silently fail; (5) workflow status \u2014 flags a DRAFT workflow (never fires on real leads); (6) outreach fired \u2014 an OUTBOUND message was actually logged for the contact (proves enrollment + send, not just a green log), with latency; (7) consent recorded when messaging fires (TCPA/CAN-SPAM); (8) duplicate-contact retest \u2014 a second identical submit dedups to ONE contact, not a duplicate/false-failure; (9) opportunity/routing (expectOpportunity/expectAssigned/expectSource); (10) multi-surface (extraUrls) + concurrent double-submit race. Booking can't be faked server-side, so a real burner appointment is surfaced as a manual step. WARNING: this submits a real (clearly-marked 'blueprint-qa') test lead, which GoHighLevel may turn into a contact, and MAY fire real automation + internal notifications; it never deletes anything: the result lists every contact that now carries the run's test address (id, name, phone, email, created date), for you to check before asking Claude to delete any. Run it on the REAL production URL.",
18417
18476
  {
18418
- funnelUrl: import_zod45.z.string().describe("The live URL the test submission POSTs to (your external lead-bridge Worker URL, or any endpoint that creates the GHL contact). POSTed as JSON."),
18477
+ funnelUrl: import_zod45.z.string().describe("The live URL the test submission POSTs to (your external lead-bridge Worker URL, or any endpoint that sends the lead into GHL). POSTed as JSON."),
18419
18478
  sentinelEmail: import_zod45.z.string().optional().describe("Override the minted sentinel email. Default: a unique blueprint-qa.<id>@example.com (won't deliver; used only to find the contact)."),
18420
18479
  testPhone: import_zod45.z.string().optional().describe("Include a phone on the submission (enables SMS-path checks). Must be a test-safe number you control \u2014 real SMS may send + bill."),
18421
18480
  submitBody: import_zod45.z.record(import_zod45.z.string(), import_zod45.z.unknown()).optional().describe("Full JSON body to POST (advanced; for non-bridge endpoints). The sentinel email is injected if absent. Default body is the lead-bridge contract {first_name,last_name,email,phone,custom,_hp}."),
@@ -18431,7 +18490,7 @@ function registerFunnelQaTools(server2, client, builderClient) {
18431
18490
  expectSource: import_zod45.z.boolean().optional().describe("Assert a lead source/attribution was captured (reporting works)."),
18432
18491
  extraUrls: import_zod45.z.array(import_zod45.z.string()).optional().describe("Additional capture surfaces to test the same way (each gets its own sentinel) \u2014 popup/inline/exit-intent forms."),
18433
18492
  testConcurrentDuplicate: import_zod45.z.boolean().optional().describe("Fire two simultaneous submits with a fresh email \u2192 assert exactly ONE contact (concurrent dedup race)."),
18434
- cleanup: import_zod45.z.boolean().optional().describe("Delete the sentinel contact(s) after verifying. Default true."),
18493
+ cleanup: import_zod45.z.boolean().optional().describe("No longer used: verify_funnel never deletes anything. The contacts that now carry the run's test address are listed in the result for you to check."),
18435
18494
  locationId: import_zod45.z.string().optional().describe("Target sub-account. Must match the active location.")
18436
18495
  },
18437
18496
  async (args) => {
@@ -18441,8 +18500,6 @@ function registerFunnelQaTools(server2, client, builderClient) {
18441
18500
  return jsonResponse({ ok: false, error: `Location mismatch: active is ${locationId2}, you passed ${args.locationId}. switch_location + confirm first.` });
18442
18501
  }
18443
18502
  const checks = [];
18444
- const cleanup = args.cleanup !== false;
18445
- const sentinels = [];
18446
18503
  const token2 = crypto.randomUUID().slice(0, 8);
18447
18504
  const sentinelEmail = (args.sentinelEmail ?? `blueprint-qa.${token2}@example.com`).toLowerCase();
18448
18505
  const custom = {};
@@ -18451,11 +18508,58 @@ function registerFunnelQaTools(server2, client, builderClient) {
18451
18508
  if (typeof body.email !== "string" || !body.email) body.email = sentinelEmail;
18452
18509
  const searchEmail = String(body.email).toLowerCase();
18453
18510
  if (args.testPhone && !body.phone) body.phone = args.testPhone;
18454
- const submitMs = Date.now();
18511
+ const addressProblem = await publicAddressProblem(args.funnelUrl);
18512
+ if (addressProblem && /local or private/i.test(addressProblem)) {
18513
+ const sentence = "Not checked: the address is private, so no test lead was sent.";
18514
+ return jsonResponse({ ok: false, verdict: "NOT_CHECKED", summary: sentence, checks: [{ id: "submit", label: "Submit reached the endpoint", status: "skip", detail: sentence }] });
18515
+ }
18516
+ const raceEmail = `blueprint-qa.${crypto.randomUUID().slice(0, 8)}@example.com`;
18517
+ const surfaceEmails = (args.extraUrls ?? []).map(() => `blueprint-qa.${crypto.randomUUID().slice(0, 8)}@example.com`);
18518
+ const runEmails = [searchEmail, ...args.testConcurrentDuplicate ? [raceEmail] : [], ...surfaceEmails];
18519
+ const notRun = (sentence) => jsonResponse({ ok: false, verdict: "NOT_RUN", summary: sentence, checks: [{ id: "preflight", label: "Test address is new", status: "fail", detail: sentence }] });
18520
+ const runStart = Date.now();
18521
+ const everSeen = /* @__PURE__ */ new Set();
18522
+ const found = /* @__PURE__ */ new Map();
18523
+ const latest = /* @__PURE__ */ new Map();
18524
+ let partial = false;
18525
+ const onRead = (r) => {
18526
+ if (!r.complete) partial = true;
18527
+ for (const id of r.ids) everSeen.add(id);
18528
+ for (const row of r.rows) latest.set(row.id, row);
18529
+ for (const m of r.matches) found.set(m.id, m);
18530
+ };
18531
+ for (const email of runEmails) {
18532
+ let read2;
18533
+ try {
18534
+ read2 = await searchByEmail(client, locationId2, email);
18535
+ } catch (e) {
18536
+ return notRun(`Not run: the contact search could not be read (${e instanceof Error ? e.message : String(e)}), so no test lead was sent.`);
18537
+ }
18538
+ onRead(read2);
18539
+ if (read2.matches.length) return notRun(`Not run: ${email} already belongs to a contact in this account; use a test address.`);
18540
+ if (!read2.complete) return notRun(`Not run: the contact search for ${email} could not be read in full, so whether the address is free was not checked and no test lead was sent.`);
18541
+ }
18542
+ const memberChosen = Boolean(args.sentinelEmail) || typeof args.submitBody?.email === "string" && args.submitBody.email !== "";
18543
+ const ownEmails = /* @__PURE__ */ new Set([...memberChosen ? [] : [searchEmail], raceEmail, ...surfaceEmails]);
18544
+ const beginSubmit = (email) => ({ email, start: Date.now(), before: new Set(everSeen) });
18545
+ const landed = (f, sub) => ownEmails.has(sub.email) && f.email === sub.email && f.added !== void 0 && f.added >= sub.start - 6e4 && !sub.before.has(f.id);
18546
+ const searchFailed = (e) => {
18547
+ partial = true;
18548
+ return `Not checked: a contact search could not be read (${e instanceof Error ? e.message.split("\n")[0] : String(e)}), so whether this submit's lead arrived was not checked.`;
18549
+ };
18550
+ const notLanded = (f, sub) => !ownEmails.has(sub.email) ? `A contact with the address you chose now exists (id ${f.id}); because that address is not the run's own, whether this run created it was not checked.` : f.added === void 0 ? `Not checked: when contact ${f.id} was added could not be read, so whether this run's lead landed was not checked.` : `Not checked: the contact found for ${sub.email} (id ${f.id}) is dated before this submit by the server clock, so whether this run's lead landed was not checked.`;
18551
+ const main2 = beginSubmit(searchEmail);
18552
+ const submitMs = main2.start;
18455
18553
  let httpInfo = "";
18554
+ const REDIRECTED = "Not checked: the form's endpoint answered with a redirect, which is not followed, so whether a lead landed was not checked.";
18555
+ let redirected = false;
18456
18556
  try {
18457
18557
  const r = await submit(args.funnelUrl, body);
18458
18558
  httpInfo = `HTTP ${r.httpStatus}`;
18559
+ if (r.httpStatus >= 300 && r.httpStatus < 400) {
18560
+ redirected = true;
18561
+ checks.push({ id: "submit", label: "Submit reached the endpoint", status: "skip", detail: `The endpoint answered ${httpInfo}, a redirect, which is not followed.` });
18562
+ }
18459
18563
  if (r.blockedByCaptcha) {
18460
18564
  checks.push({ id: "spam_gate", label: "Spam gate (Turnstile)", status: "warn", detail: `The funnel's Turnstile gate blocked this automated submit (${httpInfo}). That's correct protection \u2014 but it means verify_funnel can't drive this path. Test it manually, or temporarily allow a QA bypass.` });
18461
18565
  }
@@ -18463,27 +18567,39 @@ function registerFunnelQaTools(server2, client, builderClient) {
18463
18567
  checks.push({ id: "submit", label: "Submit reached the endpoint", status: "fail", detail: `POST to funnelUrl failed: ${e instanceof Error ? e.message : String(e)}` });
18464
18568
  }
18465
18569
  let contactId;
18466
- let cleanedUp = false;
18467
18570
  try {
18468
18571
  let appearMs;
18572
+ let seenForMain = [];
18573
+ let mainReadFailed;
18469
18574
  const backendTries = 12;
18470
- for (let i = 1; i <= backendTries; i++) {
18471
- const ids = await findContactsByEmail(client, locationId2, searchEmail);
18472
- if (ids.length) {
18473
- contactId = ids[0];
18474
- appearMs = Date.now();
18475
- break;
18575
+ try {
18576
+ for (let i = 1; i <= backendTries; i++) {
18577
+ const ids = await findContactsByEmail(client, locationId2, searchEmail, onRead);
18578
+ if (ids.length) {
18579
+ seenForMain = ids.map((id) => found.get(id)).filter((f) => !!f);
18580
+ appearMs = Date.now();
18581
+ break;
18582
+ }
18583
+ if (i < backendTries) await sleep3(2e3 + i * 500);
18476
18584
  }
18477
- if (i < backendTries) await sleep3(2e3 + i * 500);
18585
+ } catch (e) {
18586
+ mainReadFailed = searchFailed(e);
18478
18587
  }
18588
+ const mainLanded = new Set(seenForMain.filter((f) => landed(f, main2)).map((f) => f.id));
18589
+ if (mainLanded.size) contactId = [...mainLanded][0];
18479
18590
  const sms = await hasSmsCapability(client, locationId2);
18480
18591
  const wf = args.workflowId && builderClient ? await readWorkflow(builderClient, args.workflowId) : void 0;
18481
- if (!contactId) {
18592
+ if (mainReadFailed) {
18593
+ checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "skip", detail: mainReadFailed });
18594
+ } else if (!contactId && seenForMain.length) {
18595
+ checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "skip", detail: notLanded(seenForMain[0], main2) });
18596
+ } else if (!contactId && redirected) {
18597
+ checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "skip", detail: REDIRECTED });
18598
+ } else if (!contactId) {
18482
18599
  checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "fail", detail: `No contact with ${searchEmail} appeared in GHL after ~${Math.round(backendTries * 2.5)}s (${httpInfo || "submit failed"}). The funnel is a paper shredder \u2014 leads evaporate even if the page says thanks.` });
18483
18600
  } else {
18484
- sentinels.push(contactId);
18485
18601
  const latencySec = appearMs ? Math.round((appearMs - submitMs) / 1e3) : void 0;
18486
- checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "pass", detail: `Contact ${contactId} created (${httpInfo}). Submit\u2192appear ~${latencySec}s.` });
18602
+ checks.push({ id: "backend_truth", label: "Lead actually landed (backend)", status: "pass", detail: `A contact carrying this run's test address (id ${contactId}) appeared after the submit (${httpInfo}). Submit\u2192appear ~${latencySec}s.` });
18487
18603
  checks.push(latencySec !== void 0 && latencySec > 60 ? { id: "latency", label: "Speed-to-lead latency", status: "warn", detail: `Lead took ~${latencySec}s to land \u2014 slow capture loses leads. Investigate the bridge/retry path.` } : { id: "latency", label: "Speed-to-lead latency", status: "pass", detail: `~${latencySec ?? "<1"}s submit\u2192contact.` });
18488
18604
  const contact = await getContact(client, contactId);
18489
18605
  if (!contact) {
@@ -18501,11 +18617,11 @@ function registerFunnelQaTools(server2, client, builderClient) {
18501
18617
  else if (got2.trim() !== f.value.trim()) probs.push(`custom ${f.label ?? f.fieldId}: saved "${got2}" \u2260 sent "${f.value}"`);
18502
18618
  }
18503
18619
  const submitted = {
18504
- email: str(body.email)?.toLowerCase(),
18505
- phone: str(body.phone),
18506
- first_name: str(body.first_name),
18507
- last_name: str(body.last_name),
18508
- name: str(body.name)
18620
+ email: str2(body.email)?.toLowerCase(),
18621
+ phone: str2(body.phone),
18622
+ first_name: str2(body.first_name),
18623
+ last_name: str2(body.last_name),
18624
+ name: str2(body.name)
18509
18625
  };
18510
18626
  const got = {
18511
18627
  email: contact.email,
@@ -18549,7 +18665,7 @@ function registerFunnelQaTools(server2, client, builderClient) {
18549
18665
  if (args.workflowId) {
18550
18666
  if (!builderClient) checks.push({ id: "workflow_status", label: "Workflow is published", status: "skip", detail: "Workflow status needs the workflow-builder (Firebase) client, which isn't configured on this install." });
18551
18667
  else if (!wf) checks.push({ id: "workflow_status", label: "Workflow is published", status: "warn", detail: `Could not read workflow ${args.workflowId} (id wrong, or not readable).` });
18552
- else if (str(wf.status)?.toLowerCase() === "draft") checks.push({ id: "workflow_status", label: "Workflow is published", status: "fail", detail: `Workflow ${args.workflowId} is DRAFT \u2014 it will NOT fire on real leads. Publish it.` });
18668
+ else if (str2(wf.status)?.toLowerCase() === "draft") checks.push({ id: "workflow_status", label: "Workflow is published", status: "fail", detail: `Workflow ${args.workflowId} is DRAFT \u2014 it will NOT fire on real leads. Publish it.` });
18553
18669
  else checks.push({ id: "workflow_status", label: "Workflow is published", status: "pass", detail: `Workflow status: ${wf.status}.` });
18554
18670
  }
18555
18671
  const wantOutreach = args.expectOutreach ?? !!(args.triggerTag || args.workflowId);
@@ -18574,13 +18690,13 @@ function registerFunnelQaTools(server2, client, builderClient) {
18574
18690
  if (args.expectOpportunity) {
18575
18691
  try {
18576
18692
  const oppRaw = await client.get("/opportunities/search", { params: { location_id: locationId2, contact_id: contactId } });
18577
- const opps = asArr(asObj(oppRaw).opportunities).map(asObj);
18693
+ const opps = asArr(asObj2(oppRaw).opportunities).map(asObj2);
18578
18694
  if (opps.length === 0) {
18579
18695
  checks.push({ id: "opportunity", label: "Opportunity created", status: "fail", detail: "No opportunity exists for the contact \u2014 the create-opportunity step didn't run (often a dead pipeline/stage id silently kills it)." });
18580
18696
  } else {
18581
18697
  const want = args.expectOpportunity;
18582
- const match = opps.find((o) => (!want.pipelineId || str(o.pipelineId) === want.pipelineId) && (!want.stageId || str(o.pipelineStageId) === want.stageId));
18583
- checks.push(match ? { id: "opportunity", label: "Opportunity created", status: "pass", detail: `Opportunity in the expected pipeline/stage.` } : { id: "opportunity", label: "Opportunity created", status: "fail", detail: `An opportunity exists but not in the expected pipeline/stage (got pipeline ${str(opps[0].pipelineId)}, stage ${str(opps[0].pipelineStageId)}).` });
18698
+ const match = opps.find((o) => (!want.pipelineId || str2(o.pipelineId) === want.pipelineId) && (!want.stageId || str2(o.pipelineStageId) === want.stageId));
18699
+ checks.push(match ? { id: "opportunity", label: "Opportunity created", status: "pass", detail: `Opportunity in the expected pipeline/stage.` } : { id: "opportunity", label: "Opportunity created", status: "fail", detail: `An opportunity exists but not in the expected pipeline/stage (got pipeline ${str2(opps[0].pipelineId)}, stage ${str2(opps[0].pipelineStageId)}).` });
18584
18700
  }
18585
18701
  } catch (e) {
18586
18702
  checks.push({ id: "opportunity", label: "Opportunity created", status: "warn", detail: `Could not read opportunities: ${e instanceof Error ? e.message : String(e)}` });
@@ -18594,79 +18710,135 @@ function registerFunnelQaTools(server2, client, builderClient) {
18594
18710
  if (!contact) checks.push({ id: "attribution", label: "Lead source captured", status: "fail", detail: "Could not read the contact to verify source." });
18595
18711
  else checks.push(contact.source ? { id: "attribution", label: "Lead source captured", status: "pass", detail: `source = "${contact.source}".` } : { id: "attribution", label: "Lead source captured", status: "warn", detail: "No source/attribution on the contact \u2014 reporting can't tell which funnel/ad produced it." });
18596
18712
  }
18713
+ const dup = beginSubmit(searchEmail);
18597
18714
  await submit(args.funnelUrl, body).catch(() => void 0);
18598
- const dupIds = await settledEmailIds(client, locationId2, searchEmail, { tries: 6, delayMs: 3e3 });
18599
- for (const id of dupIds) if (!sentinels.includes(id)) sentinels.push(id);
18600
- checks.push(dupIds.length === 1 ? { id: "duplicate", label: "Duplicate-contact dedup", status: "pass", detail: "A second identical submit deduped to ONE contact (upsert by email)." } : dupIds.length === 0 ? { id: "duplicate", label: "Duplicate-contact dedup", status: "warn", detail: `Could not observe the contact for ${searchEmail} on re-read (search lag) \u2014 dedup not conclusively verified.` } : { id: "duplicate", label: "Duplicate-contact dedup", status: "fail", detail: `A second submit produced ${dupIds.length} contacts for ${searchEmail} \u2014 dedup is broken (real returning visitors will duplicate or false-fail).` });
18715
+ let dupMatches = [];
18716
+ let dupFailed;
18717
+ try {
18718
+ dupMatches = (await settledEmailIds(client, locationId2, searchEmail, { tries: 6, delayMs: 3e3 }, onRead)).map((id) => found.get(id)).filter((f) => !!f);
18719
+ } catch (e) {
18720
+ dupFailed = searchFailed(e);
18721
+ }
18722
+ const dupKnown = dupMatches.filter((f) => mainLanded.has(f.id) || landed(f, dup));
18723
+ const dupOther = dupMatches.filter((f) => !mainLanded.has(f.id) && !landed(f, dup));
18724
+ checks.push(dupFailed ? { id: "duplicate", label: "Duplicate-contact dedup", status: "skip", detail: dupFailed } : dupOther.length ? { id: "duplicate", label: "Duplicate-contact dedup", status: "skip", detail: `Not checked: a contact listed before the second submit now carries the address (id ${dupOther[0].id}), so whether the second submit made a duplicate was not checked.` } : dupKnown.length === 1 ? { id: "duplicate", label: "Duplicate-contact dedup", status: "pass", detail: "After a second identical submit, still ONE contact carries the address (upsert by email)." } : dupKnown.length === 0 ? { id: "duplicate", label: "Duplicate-contact dedup", status: "warn", detail: `Could not observe the contact for ${searchEmail} on re-read (search lag) \u2014 dedup not conclusively verified.` } : { id: "duplicate", label: "Duplicate-contact dedup", status: "fail", detail: `After a second submit, ${dupKnown.length} contacts carry ${searchEmail} \u2014 dedup is broken (real returning visitors will duplicate or false-fail).` });
18601
18725
  if (args.testConcurrentDuplicate) {
18602
- const raceToken = crypto.randomUUID().slice(0, 8);
18603
- const raceEmail = `blueprint-qa.${raceToken}@example.com`;
18604
- const raceBody = { ...body, email: raceEmail, last_name: `QA race ${raceToken}` };
18605
- await Promise.allSettled([submit(args.funnelUrl, raceBody), submit(args.funnelUrl, raceBody)]);
18606
- const raceIds = await settledEmailIds(client, locationId2, raceEmail, { tries: 6, delayMs: 3e3 });
18607
- for (const id of raceIds) if (!sentinels.includes(id)) sentinels.push(id);
18608
- checks.push(raceIds.length === 1 ? { id: "concurrent_race", label: "Concurrent double-submit", status: "pass", detail: `Two simultaneous submits \u2192 1 contact (no race duplicate).` } : raceIds.length === 0 ? { id: "concurrent_race", label: "Concurrent double-submit", status: "warn", detail: "Could not observe the race result (search lag, or neither submit landed)." } : { id: "concurrent_race", label: "Concurrent double-submit", status: "fail", detail: `Two simultaneous submits created ${raceIds.length} contacts \u2014 a concurrency race duplicates leads under real traffic.` });
18726
+ let raceBefore;
18727
+ try {
18728
+ await findContactsByEmail(client, locationId2, raceEmail, onRead);
18729
+ } catch (e) {
18730
+ raceBefore = searchFailed(e);
18731
+ }
18732
+ if (raceBefore) checks.push({ id: "concurrent_race", label: "Concurrent double-submit", status: "skip", detail: `${raceBefore} The concurrent pair was not sent.` });
18733
+ else {
18734
+ const race = beginSubmit(raceEmail);
18735
+ const raceBody = { ...body, email: raceEmail, last_name: `QA race ${raceEmail.slice(13, 21)}` };
18736
+ await Promise.allSettled([submit(args.funnelUrl, raceBody), submit(args.funnelUrl, raceBody)]);
18737
+ let raceMine = [];
18738
+ let raceFailed;
18739
+ try {
18740
+ raceMine = (await settledEmailIds(client, locationId2, raceEmail, { tries: 6, delayMs: 3e3 }, onRead)).map((id) => found.get(id)).filter((f) => !!f && landed(f, race));
18741
+ } catch (e) {
18742
+ raceFailed = searchFailed(e);
18743
+ }
18744
+ checks.push(raceFailed ? { id: "concurrent_race", label: "Concurrent double-submit", status: "skip", detail: raceFailed } : raceMine.length === 1 ? { id: "concurrent_race", label: "Concurrent double-submit", status: "pass", detail: `Two simultaneous submits \u2192 1 contact carrying the race address appeared (no race duplicate).` } : raceMine.length === 0 ? { id: "concurrent_race", label: "Concurrent double-submit", status: "warn", detail: "Could not observe the race result (search lag, or neither submit landed)." } : { id: "concurrent_race", label: "Concurrent double-submit", status: "fail", detail: `Two simultaneous submits \u2192 ${raceMine.length} contacts carrying the race address appeared \u2014 a concurrency race duplicates leads under real traffic.` });
18745
+ }
18609
18746
  }
18610
- for (const url of args.extraUrls ?? []) {
18611
- const sToken = crypto.randomUUID().slice(0, 8);
18612
- const sEmail = `blueprint-qa.${sToken}@example.com`;
18613
- const sBody = { ...body, email: sEmail, last_name: `QA ${sToken}` };
18747
+ for (const [k, url] of (args.extraUrls ?? []).entries()) {
18748
+ const sEmail = surfaceEmails[k];
18749
+ const sBody = { ...body, email: sEmail, last_name: `QA ${sEmail.slice(13, 21)}` };
18750
+ let sBefore;
18751
+ try {
18752
+ await findContactsByEmail(client, locationId2, sEmail, onRead);
18753
+ } catch (e) {
18754
+ sBefore = searchFailed(e);
18755
+ }
18756
+ if (sBefore) {
18757
+ checks.push({ id: `surface:${url}`, label: `Capture surface ${url}`, status: "skip", detail: `${sBefore} This surface's test lead was not sent.` });
18758
+ continue;
18759
+ }
18760
+ const surf = beginSubmit(sEmail);
18614
18761
  let ids = [];
18762
+ let sRedirected = false;
18763
+ let sFailed;
18615
18764
  try {
18616
- await submit(url, sBody);
18617
- ids = await settledEmailIds(client, locationId2, sEmail, { tries: 8, delayMs: 2500 });
18765
+ const r = await submit(url, sBody);
18766
+ sRedirected = r.httpStatus >= 300 && r.httpStatus < 400;
18767
+ try {
18768
+ ids = await settledEmailIds(client, locationId2, sEmail, { tries: 8, delayMs: 2500 }, onRead);
18769
+ } catch (e) {
18770
+ sFailed = searchFailed(e);
18771
+ }
18618
18772
  } catch {
18619
18773
  }
18620
- for (const id of ids) if (!sentinels.includes(id)) sentinels.push(id);
18621
- checks.push(ids.length >= 1 ? { id: `surface:${url}`, label: `Capture surface ${url}`, status: "pass", detail: `Submission landed ${ids.length} contact(s).` } : { id: `surface:${url}`, label: `Capture surface ${url}`, status: "fail", detail: "Submission did NOT land a contact \u2014 this surface is a paper shredder." });
18774
+ const seenHere = ids.map((id) => found.get(id)).filter((f) => !!f);
18775
+ const sMine = seenHere.filter((f) => landed(f, surf));
18776
+ checks.push(sFailed ? { id: `surface:${url}`, label: `Capture surface ${url}`, status: "skip", detail: sFailed } : sMine.length >= 1 ? { id: `surface:${url}`, label: `Capture surface ${url}`, status: "pass", detail: `${sMine.length} contact(s) carrying this surface's test address appeared after the submit.` } : seenHere.length ? { id: `surface:${url}`, label: `Capture surface ${url}`, status: "skip", detail: notLanded(seenHere[0], surf) } : sRedirected ? { id: `surface:${url}`, label: `Capture surface ${url}`, status: "skip", detail: REDIRECTED } : { id: `surface:${url}`, label: `Capture surface ${url}`, status: "fail", detail: "Submission did NOT land a contact \u2014 this surface is a paper shredder." });
18622
18777
  }
18623
18778
  }
18624
18779
  checks.push({ id: "booking", label: "Booking (manual)", status: "skip", detail: "A reachable calendar URL is not proof. Book ONE real test ('burner') appointment through the live booking widget and confirm it lands in GHL (Carlos #6). This step can't be verified server-side." });
18625
18780
  } catch (e) {
18626
- checks.push({ id: "error", label: "verify_funnel run error", status: "fail", detail: e instanceof Error ? e.message : String(e) });
18627
- } finally {
18628
- const uniq = [...new Set(sentinels)];
18629
- if (!cleanup) {
18630
- checks.push({ id: "cleanup", label: "Sentinel cleanup", status: "skip", detail: `cleanup:false \u2014 ${uniq.length} "blueprint-qa" sentinel contact(s) left in the account. Delete them so QA doesn't pollute reporting.` });
18631
- } else if (uniq.length === 0) {
18632
- cleanedUp = true;
18633
- checks.push({ id: "cleanup", label: "Sentinel cleanup", status: "skip", detail: "No sentinel contacts were created." });
18634
- } else {
18635
- let deleted = 0;
18636
- for (const id of uniq) {
18637
- try {
18638
- await client.delete(`/contacts/${id}`);
18639
- deleted++;
18640
- } catch {
18641
- }
18642
- }
18643
- cleanedUp = deleted === uniq.length;
18644
- checks.push({ id: "cleanup", label: "Sentinel cleanup", status: cleanedUp ? "pass" : "warn", detail: cleanedUp ? `Deleted ${deleted} sentinel contact(s).` : `Deleted ${deleted}/${uniq.length} sentinel contact(s) \u2014 remove any leftover "blueprint-qa" contacts manually.` });
18781
+ partial = true;
18782
+ const msg3 = e instanceof Error ? e.message.split("\n")[0] : String(e);
18783
+ const bt2 = checks.find((c) => c.id === "backend_truth");
18784
+ if (bt2?.status === "pass") {
18785
+ bt2.status = "skip";
18786
+ bt2.detail = `Not checked: a read after the lead appeared could not be completed (${msg3}), so this submit was not fully checked.`;
18787
+ for (let i = checks.length - 1; i >= 0; i--) if (checks[i].id === "latency") checks.splice(i, 1);
18788
+ checks.push({ id: "error", label: "verify_funnel read error", status: "skip", detail: msg3 });
18789
+ } else checks.push({ id: "error", label: "verify_funnel run error", status: "fail", detail: e instanceof Error ? e.message : String(e) });
18790
+ }
18791
+ for (const f of found.values()) {
18792
+ if (f.added !== void 0 && f.added < runStart - 6e4) checks.push({ id: `upsert:${f.id}`, label: "Test email on an existing contact", status: "warn", detail: `Contact ${f.id} carrying the test address is dated before this submit by the server clock; it may be an existing contact the endpoint re-used. Check that contact's email.` });
18793
+ }
18794
+ const runAddresses = /* @__PURE__ */ new Set([searchEmail, ...ownEmails]);
18795
+ for (const id of [...found.keys()].slice(0, 25)) {
18796
+ try {
18797
+ const now = carrierOf(asObj2(await client.get(`/contacts/${id}`)).contact);
18798
+ if (now) latest.set(id, now);
18799
+ } catch (e) {
18800
+ if (/\b404\b|not found/i.test(e instanceof Error ? e.message : String(e))) latest.set(id, { id, name: "", phone: "", email: "", created: "" });
18801
+ else partial = true;
18645
18802
  }
18646
18803
  }
18647
- return finish2(checks, { contactId, sentinelEmail: searchEmail, cleanedUp });
18804
+ if (found.size > 25) partial = true;
18805
+ const carriers = [];
18806
+ const former = [];
18807
+ for (const f of found.values()) {
18808
+ const now = latest.get(f.id);
18809
+ if (now && !runAddresses.has(now.email)) former.push({ id: f.id, now: now.email || "no contact by that id" });
18810
+ else carriers.push(now ?? f.carrier ?? { id: f.id, name: f.name ?? "", phone: "", email: f.email, created: "" });
18811
+ }
18812
+ checks.push({ id: "test_contacts", label: CARRIER_HEADING, status: "skip", detail: carrierText(carriers, partial, former) });
18813
+ const bt = checks.find((c) => c.id === "backend_truth");
18814
+ const notChecked = bt?.status === "skip" && !checks.some((c) => c.status === "fail") ? bt.detail : void 0;
18815
+ return finish2(checks, { contactId, sentinelEmail: searchEmail, contactsCarryingTestAddress: carriers, formerCarriers: former, contactsListPartial: partial }, carriers.length > 0, notChecked);
18648
18816
  } catch (error) {
18649
18817
  return errorResponse(error);
18650
18818
  }
18651
18819
  }
18652
18820
  );
18653
18821
  }
18654
- function finish2(checks, extra) {
18655
- const { ok, verdict, summary } = rollupVerdict(checks);
18656
- const note = "WARNING: verify_funnel created a real (clearly-marked 'blueprint-qa') contact and may have fired real automation + internal notifications. Run on the real production URL; the form/thank-you page is never proof \u2014 only the backend is.";
18822
+ function finish2(checks, extra, createdAny, notChecked) {
18823
+ const rolled = rollupVerdict(checks);
18824
+ const { ok, verdict, summary } = notChecked ? { ok: false, verdict: "NOT_CHECKED", summary: notChecked } : rolled;
18825
+ const note = createdAny ? "WARNING: verify_funnel submitted a real (clearly-marked 'blueprint-qa') test lead and may have fired real automation + internal notifications. It never deletes anything: the contacts that now carry this run's test address are listed, for you to check before deleting any. Run on the real production URL; the form/thank-you page is never proof \u2014 only the backend is." : "No contact carrying this run's test address was found.";
18657
18826
  return jsonResponse({ ok, verdict, summary, checks, note, ...extra });
18658
18827
  }
18659
- var import_zod45, asObj, asArr, str, sleep3, customFieldInput;
18828
+ var import_zod45, asObj2, asArr, str2, sleep3, SearchUnreadable, customFieldInput;
18660
18829
  var init_funnel_qa = __esm({
18661
18830
  "src/tools/funnel-qa.ts"() {
18662
18831
  "use strict";
18663
18832
  import_zod45 = require("zod");
18664
18833
  init_tool_helpers();
18834
+ init_test_contacts();
18665
18835
  init_net_guard();
18666
- asObj = (v) => v && typeof v === "object" ? v : {};
18836
+ asObj2 = (v) => v && typeof v === "object" ? v : {};
18667
18837
  asArr = (v) => Array.isArray(v) ? v : [];
18668
- str = (v) => typeof v === "string" ? v : void 0;
18838
+ str2 = (v) => typeof v === "string" ? v : void 0;
18669
18839
  sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
18840
+ SearchUnreadable = class extends Error {
18841
+ };
18670
18842
  customFieldInput = import_zod45.z.object({
18671
18843
  fieldId: import_zod45.z.string().describe("The VERIFIED GHL custom-field id the form sends (from apply_build_plan's wiring bundle)."),
18672
18844
  value: import_zod45.z.string().describe("The value the form sends for this field \u2014 asserted to PERSIST on the contact."),
@@ -18886,13 +19058,13 @@ var init_sub_account_errors = __esm({
18886
19058
  });
18887
19059
 
18888
19060
  // src/field-sanitize.ts
18889
- var MAX_STR, MAX_LIST, str2, num, list, drop, isoDate;
19061
+ var MAX_STR, MAX_LIST, str3, num, list, drop, isoDate;
18890
19062
  var init_field_sanitize = __esm({
18891
19063
  "src/field-sanitize.ts"() {
18892
19064
  "use strict";
18893
19065
  MAX_STR = 200;
18894
19066
  MAX_LIST = 50;
18895
- str2 = (x, max = MAX_STR) => {
19067
+ str3 = (x, max = MAX_STR) => {
18896
19068
  if (typeof x !== "string") return void 0;
18897
19069
  const t = x.trim();
18898
19070
  return t ? t.slice(0, max) : void 0;
@@ -18905,7 +19077,7 @@ var init_field_sanitize = __esm({
18905
19077
  };
18906
19078
  drop = (o) => Object.values(o).some((v) => v !== void 0) ? o : void 0;
18907
19079
  isoDate = (x) => {
18908
- const s = str2(x, 40);
19080
+ const s = str3(x, 40);
18909
19081
  if (!s) return void 0;
18910
19082
  const m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?$/.exec(s);
18911
19083
  if (!m) return void 0;
@@ -19017,7 +19189,7 @@ function safeParse(raw) {
19017
19189
  }
19018
19190
  }
19019
19191
  function safeUrl2(x, max = MAX_URL) {
19020
- const raw = str2(x, max);
19192
+ const raw = str3(x, max);
19021
19193
  if (!raw) return void 0;
19022
19194
  let u;
19023
19195
  try {
@@ -19048,68 +19220,68 @@ function sanitizeProfile(raw) {
19048
19220
  v: 1,
19049
19221
  canCreateSubAccounts: typeof r.canCreateSubAccounts === "boolean" ? r.canCreateSubAccounts : void 0,
19050
19222
  ghlPlanEvidence: sanitizePlanEvidence(r.ghlPlanEvidence),
19051
- agencyName: str2(r.agencyName, 80),
19052
- legalName: str2(r.legalName),
19223
+ agencyName: str3(r.agencyName, 80),
19224
+ legalName: str3(r.legalName),
19053
19225
  accent: normalizeAccent(r.accent),
19054
19226
  logoUrl: safeUrl2(r.logoUrl),
19055
19227
  website: safeUrl2(r.website),
19056
19228
  sender: drop({
19057
- fromName: str2(sender?.fromName, 80),
19058
- replyTo: str2(sender?.replyTo),
19059
- phone: str2(sender?.phone, 32)
19229
+ fromName: str3(sender?.fromName, 80),
19230
+ replyTo: str3(sender?.replyTo),
19231
+ phone: str3(sender?.phone, 32)
19060
19232
  }),
19061
19233
  offers: list(r.offers, (o) => {
19062
19234
  const v = o ?? {};
19063
- const name = str2(v.name, 120);
19235
+ const name = str3(v.name, 120);
19064
19236
  if (!name) return void 0;
19065
19237
  return {
19066
19238
  name,
19067
19239
  priceFloor: num(v.priceFloor),
19068
19240
  cadence: v.cadence === "monthly" || v.cadence === "one-time" ? v.cadence : void 0,
19069
- notes: str2(v.notes, 500)
19241
+ notes: str3(v.notes, 500)
19070
19242
  };
19071
19243
  }),
19072
19244
  packages: (() => {
19073
19245
  const seen = /* @__PURE__ */ new Set();
19074
19246
  return list(r.packages, (pk) => {
19075
19247
  const v = pk ?? {};
19076
- const name = str2(v.name, 80);
19248
+ const name = str3(v.name, 80);
19077
19249
  if (!name) return void 0;
19078
- const idRaw = str2(v.id, 60) || name;
19250
+ const idRaw = str3(v.id, 60) || name;
19079
19251
  const id = idRaw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "package";
19080
19252
  if (seen.has(id)) return void 0;
19081
19253
  seen.add(id);
19082
19254
  const features = Array.isArray(v.features) ? [...new Set(v.features.filter(isFeatureId))] : [];
19083
- return { id, name, price: str2(v.price, 60), features };
19255
+ return { id, name, price: str3(v.price, 60), features };
19084
19256
  });
19085
19257
  })(),
19086
19258
  pricing: drop({
19087
19259
  minMonthly: num(pricing?.minMonthly),
19088
19260
  minSetup: num(pricing?.minSetup),
19089
- currency: str2(pricing?.currency, 8)
19261
+ currency: str3(pricing?.currency, 8)
19090
19262
  }),
19091
19263
  delivery: drop({
19092
- sops: list(delivery?.sops, (s) => str2(s, MAX_SOP)),
19264
+ sops: list(delivery?.sops, (s) => str3(s, MAX_SOP)),
19093
19265
  hoursToDeliverTarget: num(delivery?.hoursToDeliverTarget)
19094
19266
  }),
19095
19267
  staff: list(r.staff, (s) => {
19096
19268
  const v = s ?? {};
19097
- const name = str2(v.name, 80);
19269
+ const name = str3(v.name, 80);
19098
19270
  if (!name) return void 0;
19099
19271
  return {
19100
19272
  name,
19101
- role: str2(v.role),
19102
- email: str2(v.email),
19103
- mayApprove: list(v.mayApprove, (a) => str2(a, 40))
19273
+ role: str3(v.role),
19274
+ email: str3(v.email),
19275
+ mayApprove: list(v.mayApprove, (a) => str3(a, 40))
19104
19276
  };
19105
19277
  }),
19106
19278
  defaults: drop({
19107
- timezone: str2(defaults?.timezone, 64),
19108
- industry: str2(defaults?.industry, 64)
19279
+ timezone: str3(defaults?.timezone, 64),
19280
+ industry: str3(defaults?.industry, 64)
19109
19281
  }),
19110
19282
  agencyOs: sanitizeAgencyOs(r.agencyOs),
19111
19283
  clientFilesDir: safeDir(r.clientFilesDir),
19112
- updated_at: str2(r.updated_at, 40)
19284
+ updated_at: str3(r.updated_at, 40)
19113
19285
  };
19114
19286
  }
19115
19287
  function sanitizeAgencyOs(raw) {
@@ -19117,16 +19289,16 @@ function sanitizeAgencyOs(raw) {
19117
19289
  const r = raw;
19118
19290
  const ica = r.ica ?? {};
19119
19291
  const offer = r.offer ?? {};
19120
- const lines = (x) => list(x, (s) => str2(s, MAX_STR * 2));
19292
+ const lines = (x) => list(x, (s) => str3(s, MAX_STR * 2));
19121
19293
  const out = drop({
19122
- story: str2(r.story, MAX_PARA),
19123
- method: str2(r.method, MAX_PARA),
19124
- voice: str2(r.voice, 400),
19294
+ story: str3(r.story, MAX_PARA),
19295
+ method: str3(r.method, MAX_PARA),
19296
+ voice: str3(r.voice, 400),
19125
19297
  values: lines(r.values),
19126
- goals: str2(r.goals, 400),
19298
+ goals: str3(r.goals, 400),
19127
19299
  proof: lines(r.proof),
19128
19300
  ica: drop({
19129
- who: str2(ica.who, 400),
19301
+ who: str3(ica.who, 400),
19130
19302
  pains: lines(ica.pains),
19131
19303
  desires: lines(ica.desires),
19132
19304
  objections: lines(ica.objections),
@@ -19136,11 +19308,11 @@ function sanitizeAgencyOs(raw) {
19136
19308
  villains: lines(ica.villains)
19137
19309
  }),
19138
19310
  offer: drop({
19139
- name: str2(offer.name, 120),
19140
- promise: str2(offer.promise, 400),
19311
+ name: str3(offer.name, 120),
19312
+ promise: str3(offer.promise, 400),
19141
19313
  valueStack: lines(offer.valueStack),
19142
- price: str2(offer.price, 120),
19143
- guarantee: str2(offer.guarantee, 600),
19314
+ price: str3(offer.price, 120),
19315
+ guarantee: str3(offer.guarantee, 600),
19144
19316
  accelerators: lines(offer.accelerators)
19145
19317
  })
19146
19318
  });
@@ -19149,7 +19321,7 @@ function sanitizeAgencyOs(raw) {
19149
19321
  function legacyBranding(profileFile) {
19150
19322
  try {
19151
19323
  const raw = JSON.parse(fs9.readFileSync(path8.join(path8.dirname(profileFile), "cockpit-state.json"), "utf8"));
19152
- return { agencyName: str2(raw?.agencyName, 80), accent: normalizeAccent(raw?.agencyAccent) };
19324
+ return { agencyName: str3(raw?.agencyName, 80), accent: normalizeAccent(raw?.agencyAccent) };
19153
19325
  } catch {
19154
19326
  return {};
19155
19327
  }
@@ -19259,7 +19431,7 @@ function demoRailProfile(p = readAgencyProfile(), ghl, demoDays = 7) {
19259
19431
  if (p.accent) rec.accent = p.accent;
19260
19432
  if (p.logoUrl) rec.logoUrl = p.logoUrl;
19261
19433
  if (p.sender) rec.senderIdentity = p.sender;
19262
- const g = drop({ locationId: str2(ghl?.locationId), triggerTag: str2(ghl?.triggerTag, 60) });
19434
+ const g = drop({ locationId: str3(ghl?.locationId), triggerTag: str3(ghl?.triggerTag, 60) });
19263
19435
  if (g) rec.ghl = g;
19264
19436
  rec.demoDays = num(demoDays) ?? 7;
19265
19437
  return rec;
@@ -21840,25 +22012,25 @@ function sanitizeEngagement(raw) {
21840
22012
  // "client" downstream. Coercing a typo to `software` would silently take a
21841
22013
  // real client off the build board — the failure that costs work.
21842
22014
  kind: ENGAGEMENT_KINDS.includes(r.kind) ? r.kind : void 0,
21843
- clientName: str2(r.clientName, 120),
21844
- offer: str2(r.offer, 120),
22015
+ clientName: str3(r.clientName, 120),
22016
+ offer: str3(r.offer, 120),
21845
22017
  price: num(r.price),
21846
22018
  cadence: r.cadence === "monthly" || r.cadence === "one-time" ? r.cadence : void 0,
21847
- currency: str2(r.currency, 8),
22019
+ currency: str3(r.currency, 8),
21848
22020
  startedOn: isoDate(r.startedOn),
21849
22021
  renewsOn: isoDate(r.renewsOn),
21850
22022
  status: ENGAGEMENT_STATUSES.includes(r.status) ? r.status : void 0,
21851
- owner: str2(r.owner, 80),
21852
- sops: list(r.sops, (s) => str2(s, MAX_SOP2)),
22023
+ owner: str3(r.owner, 80),
22024
+ sops: list(r.sops, (s) => str3(s, MAX_SOP2)),
21853
22025
  emails: list(r.emails, (s) => {
21854
- const v = str2(s, 254)?.trim().toLowerCase();
22026
+ const v = str3(s, 254)?.trim().toLowerCase();
21855
22027
  return isEmailAddress(v) ? v : void 0;
21856
22028
  }),
21857
- notes: str2(r.notes, MAX_NOTES)
22029
+ notes: str3(r.notes, MAX_NOTES)
21858
22030
  };
21859
22031
  const { kind: _classification, ...content } = rec;
21860
22032
  if (!drop(content)) return void 0;
21861
- const stamped = str2(r.updated_at, 40);
22033
+ const stamped = str3(r.updated_at, 40);
21862
22034
  return stamped ? { ...rec, updated_at: stamped } : rec;
21863
22035
  }
21864
22036
  function sanitizeEngagements(raw) {
@@ -21867,13 +22039,13 @@ function sanitizeEngagements(raw) {
21867
22039
  const clients = /* @__PURE__ */ Object.create(null);
21868
22040
  if (src && typeof src === "object" && !Array.isArray(src)) {
21869
22041
  for (const [id, rec] of Object.entries(src).slice(0, MAX_CLIENTS)) {
21870
- const key = str2(id, 64);
22042
+ const key = str3(id, 64);
21871
22043
  if (!key || FORBIDDEN_KEY.has(key)) continue;
21872
22044
  const clean2 = sanitizeEngagement(rec);
21873
22045
  if (clean2) clients[key] = clean2;
21874
22046
  }
21875
22047
  }
21876
- return { v: 1, clients: { ...clients }, updated_at: str2(r.updated_at, 40) };
22048
+ return { v: 1, clients: { ...clients }, updated_at: str3(r.updated_at, 40) };
21877
22049
  }
21878
22050
  function readClientEngagements(file2 = clientEngagementsPath()) {
21879
22051
  try {
@@ -21900,7 +22072,7 @@ function mergeEngagement(existing, patch) {
21900
22072
  return sanitizeEngagement({ ...merged });
21901
22073
  }
21902
22074
  function writeClientEngagement(locationId2, patch, file2 = clientEngagementsPath()) {
21903
- const id = str2(locationId2, 64);
22075
+ const id = str3(locationId2, 64);
21904
22076
  if (!id || FORBIDDEN_KEY.has(id)) throw new Error("An id is required.");
21905
22077
  const stamp = () => {
21906
22078
  try {
@@ -25682,7 +25854,9 @@ function stageBlockedBy(stages, stage) {
25682
25854
  return null;
25683
25855
  }
25684
25856
  function verifyReadbackPrompt(ctx) {
25685
- return "\nAccount read-back has already run in code. These records decide existence, regardless of RESULT ok or dependencyChecks: " + JSON.stringify(ctx?.readback ?? []) + "\nDo not contradict any record. Keep dependencyChecks for evidence you establish yourself. The final report replaces the standing Checked and Not checked headings with the deterministic roll-call. Use the live URL on the funnel or page record, and the triggerTags and id on the speed-to-lead workflow record, for the existing fake-client check. Do not invent a URL or a trigger tag. Before calling verify_funnel, disclose that it submits a marked test lead and may fire this client's automations once. Preserve the existing verify_funnel submission and cleanup. If the funnel has no working page, report the reason that live proof could not run. Add liveProof:{ok,evidence} to RESULT. Set liveProof.ok true only when verify_funnel reports that the marked lead arrived and the test contact was deleted. Quote that result's evidence, including its contact id and cleanup result. Otherwise set liveProof.ok false with the check's own failure or the reason it could not run.\n";
25857
+ return "\nAccount read-back has already run in code. These records decide existence, regardless of RESULT ok or dependencyChecks: " + JSON.stringify(ctx?.readback ?? []) + `
25858
+ Do not contradict any record. Keep dependencyChecks for evidence you establish yourself. The final report replaces the standing Checked and Not checked headings with the deterministic roll-call. Use the live URL on the funnel or page record, and the triggerTags and id on the speed-to-lead workflow record, for the existing fake-client check. Do not invent a URL or a trigger tag. Before calling verify_funnel, disclose that it submits a marked test lead and may fire this client's automations once. Preserve the existing verify_funnel submission. verify_funnel never deletes anything: report the contacts it lists as carrying the test address, for the member to check before deleting any. If the funnel has no working page, report the reason that live proof could not run. Add liveProof:{ok,evidence} to RESULT. Set liveProof.ok true only when verify_funnel's "Lead actually landed" check passed (a contact carrying the run's test address appeared after the submit). Quote that result's evidence, including its contact id and the contacts it lists as carrying the test address. Otherwise set liveProof.ok false with the check's own failure or the reason it could not run.
25859
+ `;
25686
25860
  }
25687
25861
  function buildClaudeArgs(spec, locationId2, locationName, ctx) {
25688
25862
  return [
@@ -29564,7 +29738,7 @@ var require_package = __commonJS({
29564
29738
  "package.json"(exports2, module2) {
29565
29739
  module2.exports = {
29566
29740
  name: "@elitedcs/ghl-mcp",
29567
- version: "3.88.3",
29741
+ version: "3.88.4",
29568
29742
  mcpName: "io.github.drjerryrelth/ghl-command",
29569
29743
  description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
29570
29744
  main: "dist/index.js",
@@ -30092,7 +30266,7 @@ function resultHtml(run, now, clientName, action) {
30092
30266
  if (run.status === "failed") {
30093
30267
  return {
30094
30268
  head: pageHead("The Workflow Audit Pro could not run", esc2(run.error ?? "Unknown error"), action),
30095
- rest: callout("warn", "", esc2(run.cleanup?.detail ?? "Nothing was created in the account."))
30269
+ rest: callout("warn", "", esc2(run.cleanup?.detail ?? "No test lead was sent."))
30096
30270
  };
30097
30271
  }
30098
30272
  const f = run.findings;
@@ -30198,7 +30372,7 @@ function startForm(d, inlineSubmit) {
30198
30372
  <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>
30199
30373
  <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>
30200
30374
  <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>
30201
- <label class="cds-check"><input type="checkbox" id="keep" name="keepLead"><span>Keep the test lead afterwards so I can look at it in GoHighLevel</span></label>
30375
+
30202
30376
  <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>
30203
30377
  <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>
30204
30378
  <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>
@@ -30206,7 +30380,7 @@ ${callout("warn", "", "The test lead has no first name, on purpose. The workflow
30206
30380
  ${inlineSubmit ? `<div class="cds-row"><button class="cds-btn ghost" type="submit" id="start-btn" disabled>Start the check</button></div>` : ""}
30207
30381
  <p class="cds-callout result" id="start-result" role="status" aria-live="polite"></p>
30208
30382
  </form>`;
30209
- return panel("New check", `${d.run?.status === "done" ? "Run another check" : "Run the Workflow Audit Pro"} on ${esc2(d.client.name)}`, "Writes one test contact, then removes it", form, ` id="start"`, "cds-no-print");
30383
+ 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");
30210
30384
  }
30211
30385
  function shopperPageHtml(d, seatToken, seatHeader) {
30212
30386
  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" 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>`;
@@ -30252,7 +30426,7 @@ ${COCKPIT_TYPE_JS}
30252
30426
  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}
30253
30427
  var btn=document.getElementById("start-btn");btn.disabled=true;say("Starting\u2026");
30254
30428
  var hosts=[].slice.call(form.querySelectorAll('input[name="hook"]:checked')).map(function(i){return i.value});
30255
- 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)),keepLead:document.getElementById("keep").checked,fastForward:document.getElementById("ff").checked,allowedHosts:hosts,alertUserId:(document.getElementById("alert-to").value||undefined)})
30429
+ 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)})
30256
30430
  .then(function(r){if(r.ok){location.reload()}else{btn.disabled=false;say(r.error||"That did not start.")}})
30257
30431
  .catch(function(){btn.disabled=false;say("That did not start. Check that Command OS is still running.")});
30258
30432
  })}
@@ -30666,9 +30840,9 @@ var init_mask = __esm({
30666
30840
 
30667
30841
  // src/command-os/shopper/steps.ts
30668
30842
  function reasonOf(meta) {
30669
- const resp = asObj2(asObj2(meta.actionFrom).response);
30670
- const data = asObj2(meta.data);
30671
- const cands = [str3(resp.msg), str3(meta.error), str3(meta.message), str3(data.message), str3(data.msg), str3(meta.reason)];
30843
+ const resp = asObj3(asObj3(meta.actionFrom).response);
30844
+ const data = asObj3(meta.data);
30845
+ const cands = [str4(resp.msg), str4(meta.error), str4(meta.message), str4(data.message), str4(data.msg), str4(meta.reason)];
30672
30846
  return maskSecrets((cands.find((c) => c && c.trim()) ?? "").replace(/(Webhook Action error: )+/g, "Webhook error: ").trim());
30673
30847
  }
30674
30848
  function translate(type, outcome, reason3, retryAt) {
@@ -30694,22 +30868,22 @@ function translate(type, outcome, reason3, retryAt) {
30694
30868
  };
30695
30869
  }
30696
30870
  function judgeSteps(rawRows, contactId, workflowNames) {
30697
- const mine = rawRows.map(asObj2).filter((r) => str3(r.contactId) === contactId).sort((a, b) => (Date.parse(str3(a.timestamp) ?? str3(a.createdAt) ?? "") || 0) - (Date.parse(str3(b.timestamp) ?? str3(b.createdAt) ?? "") || 0) || (str3(b.status) === "added_to_workflow" ? 1 : 0) - (str3(a.status) === "added_to_workflow" ? 1 : 0));
30871
+ const mine = rawRows.map(asObj3).filter((r) => str4(r.contactId) === contactId).sort((a, b) => (Date.parse(str4(a.timestamp) ?? str4(a.createdAt) ?? "") || 0) - (Date.parse(str4(b.timestamp) ?? str4(b.createdAt) ?? "") || 0) || (str4(b.status) === "added_to_workflow" ? 1 : 0) - (str4(a.status) === "added_to_workflow" ? 1 : 0));
30698
30872
  const rows2 = [];
30699
30873
  const passOf = /* @__PURE__ */ new Map();
30700
30874
  for (const r of mine) {
30701
- const status = (str3(r.status) ?? "").toLowerCase();
30702
- const type = str3(r.type) ?? "";
30875
+ const status = (str4(r.status) ?? "").toLowerCase();
30876
+ const type = str4(r.type) ?? "";
30703
30877
  if (status === "retry") continue;
30704
- const wfId = str3(r.workflowId) ?? "";
30878
+ const wfId = str4(r.workflowId) ?? "";
30705
30879
  if (status === "added_to_workflow") passOf.set(wfId, (passOf.get(wfId) ?? 0) + 1);
30706
30880
  const known = STATUS_OUTCOME[status];
30707
30881
  const outcome = type === "condition" && status === "success" ? "branch" : known ?? "unclear";
30708
- const at = Date.parse(str3(r.timestamp) ?? str3(r.createdAt) ?? "") || 0;
30709
- const retryAt = Date.parse(str3(r.nextExecutionAt) ?? "") || void 0;
30710
- const reason3 = reasonOf(asObj2(r.meta));
30882
+ const at = Date.parse(str4(r.timestamp) ?? str4(r.createdAt) ?? "") || 0;
30883
+ const retryAt = Date.parse(str4(r.nextExecutionAt) ?? "") || void 0;
30884
+ const reason3 = reasonOf(asObj3(r.meta));
30711
30885
  const gaveUp = status === "failed_retry_limit_reached";
30712
- const base = { workflowId: wfId, workflowName: workflowNames[wfId] ?? "Workflow", stepId: str3(r.stepId), stepName: str3(r.stepName) ?? type, type, outcome, at, pass: passOf.get(wfId) ?? 1 };
30886
+ const base = { workflowId: wfId, workflowName: workflowNames[wfId] ?? "Workflow", stepId: str4(r.stepId), stepName: str4(r.stepName) ?? type, type, outcome, at, pass: passOf.get(wfId) ?? 1 };
30713
30887
  let detail = "";
30714
30888
  if (outcome === "branch") detail = `Took the "${base.stepName}" path.`;
30715
30889
  else if (outcome === "entered") detail = "The test lead entered this workflow.";
@@ -30814,13 +30988,13 @@ async function auditSteps(reader, workflowIds, workflowNames, contactId, fromMs,
30814
30988
  const judged = judgeSteps(all, contactId, workflowNames);
30815
30989
  return { ...judged, ...unread.length ? { unreadWorkflows: unread } : {}, ...partial.length ? { partialWorkflows: partial } : {} };
30816
30990
  }
30817
- var asObj2, str3, STATUS_OUTCOME, localTime, STEP_LOG_LIMIT;
30991
+ var asObj3, str4, STATUS_OUTCOME, localTime, STEP_LOG_LIMIT;
30818
30992
  var init_steps = __esm({
30819
30993
  "src/command-os/shopper/steps.ts"() {
30820
30994
  "use strict";
30821
30995
  init_mask();
30822
- asObj2 = (v) => v && typeof v === "object" ? v : {};
30823
- str3 = (v) => typeof v === "string" ? v : void 0;
30996
+ asObj3 = (v) => v && typeof v === "object" ? v : {};
30997
+ str4 = (v) => typeof v === "string" ? v : void 0;
30824
30998
  STATUS_OUTCOME = {
30825
30999
  success: "ran",
30826
31000
  executed: "ran",
@@ -30862,24 +31036,24 @@ __export(fastforward_exports, {
30862
31036
  webhookDestinations: () => webhookDestinations
30863
31037
  });
30864
31038
  function windowText(raw) {
30865
- const w = asObj3(raw);
31039
+ const w = asObj4(raw);
30866
31040
  if (!w.start || !w.end) return void 0;
30867
31041
  const days = asArr2(w.days).map((d) => typeof d === "number" ? DAY_NAMES[d] ?? String(d) : String(d));
30868
- const cond = str4(w.condition);
30869
- return `runs only ${str4(w.start)}\u2013${str4(w.end)}${days.length ? ` on ${days.join(", ")}` : ""}${cond ? ` (${cond})` : ""}`;
31042
+ const cond = str5(w.condition);
31043
+ return `runs only ${str5(w.start)}\u2013${str5(w.end)}${days.length ? ` on ${days.join(", ")}` : ""}${cond ? ` (${cond})` : ""}`;
30870
31044
  }
30871
31045
  function rerouteAlert(attrs, owner) {
30872
- const channel = str4(attrs.type) ?? "notification";
31046
+ const channel = str5(attrs.type) ?? "notification";
30873
31047
  if (channel === "notification") {
30874
- const n = asObj3(attrs.notification);
30875
- attrs.notification = { body: n.body ?? "", title: `TEST: ${str4(n.title) ?? ""}`.trim(), userType: "user", redirectPage: n.redirectPage ?? "contact", type: n.type ?? "send_notification", selectedUser: owner };
31048
+ const n = asObj4(attrs.notification);
31049
+ attrs.notification = { body: n.body ?? "", title: `TEST: ${str5(n.title) ?? ""}`.trim(), userType: "user", redirectPage: n.redirectPage ?? "contact", type: n.type ?? "send_notification", selectedUser: owner };
30876
31050
  delete attrs.email;
30877
31051
  delete attrs.sms;
30878
31052
  return void 0;
30879
31053
  }
30880
31054
  if (channel === "email") {
30881
- const e = asObj3(attrs.email);
30882
- attrs.email = { isCloned: false, html: e.html ?? "", subject: `TEST: ${str4(e.subject) ?? ""}`.trim(), selectedUser: [owner], userType: "user", attachments: [] };
31055
+ const e = asObj4(attrs.email);
31056
+ attrs.email = { isCloned: false, html: e.html ?? "", subject: `TEST: ${str5(e.subject) ?? ""}`.trim(), selectedUser: [owner], userType: "user", attachments: [] };
30883
31057
  delete attrs.notification;
30884
31058
  delete attrs.sms;
30885
31059
  return void 0;
@@ -30887,55 +31061,55 @@ function rerouteAlert(attrs, owner) {
30887
31061
  return channel;
30888
31062
  }
30889
31063
  function staffRecipients(step) {
30890
- const type = str4(step.type) ?? "";
30891
- const a = asObj3(step.attributes);
31064
+ const type = str5(step.type) ?? "";
31065
+ const a = asObj4(step.attributes);
30892
31066
  if (type === "internal_notification") {
30893
31067
  const out = [];
30894
31068
  for (const k of ["notification", "email", "sms"]) {
30895
- const ch = asObj3(a[k]);
31069
+ const ch = asObj4(a[k]);
30896
31070
  if (!Object.keys(ch).length) continue;
30897
- if (str4(ch.userType) !== "user") out.push(`everyone (${k})`);
31071
+ if (str5(ch.userType) !== "user") out.push(`everyone (${k})`);
30898
31072
  for (const u of Array.isArray(ch.selectedUser) ? ch.selectedUser : [ch.selectedUser]) if (typeof u === "string") out.push(u);
30899
31073
  }
30900
31074
  return out;
30901
31075
  }
30902
- if (type === "task-notification") return [str4(a.assignedTo) ?? "nobody"];
31076
+ if (type === "task-notification") return [str5(a.assignedTo) ?? "nobody"];
30903
31077
  if (type === "assign_user") return [...asArr2(a.user_list), ...asArr2(a.customUserList)].map((u) => typeof u === "string" ? u : JSON.stringify(u));
30904
31078
  if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
30905
- return [...a.assignedTo !== void 0 ? [String(a.assignedTo)] : [], ...asArr2(a.__customInputFields__).map(asObj3).filter((f) => /assign/i.test(str4(f.filterField) ?? "")).map((f) => String(f.value))];
31079
+ return [...a.assignedTo !== void 0 ? [String(a.assignedTo)] : [], ...asArr2(a.__customInputFields__).map(asObj4).filter((f) => /assign/i.test(str5(f.filterField) ?? "")).map((f) => String(f.value))];
30906
31080
  }
30907
31081
  return [];
30908
31082
  }
30909
31083
  function waitLength(a) {
30910
- const s = asObj3(a.startAfter);
31084
+ const s = asObj4(a.startAfter);
30911
31085
  const v = Number(s.value);
30912
- const unit = (str4(s.type) ?? "").toLowerCase();
31086
+ const unit = (str5(s.type) ?? "").toLowerCase();
30913
31087
  const word = unit.startsWith("min") ? "minute" : unit.startsWith("hour") ? "hour" : unit.startsWith("day") ? "day" : unit;
30914
31088
  return `${v} ${word}${v === 1 ? "" : "s"}`;
30915
31089
  }
30916
31090
  function webhookDestinations(sources) {
30917
31091
  const out = [];
30918
31092
  for (const s of sources) {
30919
- for (const n of s.templates.map(asObj3)) {
30920
- if (str4(n.type) !== "webhook") continue;
30921
- const url = str4(asObj3(n.attributes).url) ?? "";
31093
+ for (const n of s.templates.map(asObj4)) {
31094
+ if (str5(n.type) !== "webhook") continue;
31095
+ const url = str5(asObj4(n.attributes).url) ?? "";
30922
31096
  const h = hostOf(url);
30923
31097
  const merged = /\{\{|%7b/i.test(url.split(/[/?#]/).slice(0, 3).join("/")) || h !== void 0 && /[{}]|%7b|%7d/i.test(h);
30924
31098
  const problem = merged ? "merge_field" : !h ? "no_address" : void 0;
30925
- out.push({ ...problem ? { problem } : { host: h }, stepName: str4(n.name) ?? "Webhook", workflowName: s.name });
31099
+ out.push({ ...problem ? { problem } : { host: h }, stepName: str5(n.name) ?? "Webhook", workflowName: s.name });
30926
31100
  }
30927
31101
  }
30928
31102
  return out;
30929
31103
  }
30930
31104
  function planCopy(src, opts) {
30931
31105
  const allowed = opts.allowedHosts.map(normalizeHost).filter(Boolean);
30932
- const nodes = src.templates.map(asObj3);
31106
+ const nodes = src.templates.map(asObj4);
30933
31107
  if (nodes.length === 0) return { ok: false, refusal: `"${src.name}" has no steps to test.` };
30934
31108
  if (nodes.length > 40) return { ok: false, refusal: `"${src.name}" has ${nodes.length} steps; fast-forward copies at most 40.` };
30935
- if (str4(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.` };
31109
+ if (str5(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.` };
30936
31110
  const idMap = /* @__PURE__ */ new Map();
30937
31111
  for (const n of nodes) {
30938
- const id = str4(n.id);
31112
+ const id = str5(n.id);
30939
31113
  if (id) idMap.set(id, import_node_crypto8.default.randomUUID());
30940
31114
  }
30941
31115
  const remap = (id) => typeof id === "string" ? idMap.get(id) ?? id : id;
@@ -30943,9 +31117,9 @@ function planCopy(src, opts) {
30943
31117
  const hosts = /* @__PURE__ */ new Set();
30944
31118
  const actions = [];
30945
31119
  for (const n of nodes) {
30946
- const type = str4(n.type) ?? "";
30947
- const name = str4(n.name) ?? type;
30948
- if (type === "if_else" || str4(n.nodeType)?.startsWith("branch") || str4(n.cat) === "conditions") {
31120
+ const type = str5(n.type) ?? "";
31121
+ const name = str5(n.name) ?? type;
31122
+ if (type === "if_else" || str5(n.nodeType)?.startsWith("branch") || str5(n.cat) === "conditions") {
30949
31123
  return { ok: false, refusal: `"${src.name}" has an if/else branch ("${name}"). Fast-forward handles straight-line workflows for now; branches come next, with test leads that book, reply or stay silent.` };
30950
31124
  }
30951
31125
  if (type === "add_to_workflow" || type === "goto") {
@@ -30954,9 +31128,9 @@ function planCopy(src, opts) {
30954
31128
  if (!ALLOWED.has(type)) {
30955
31129
  return { ok: false, refusal: `"${src.name}" has a "${type}" step ("${name}") that fast-forward does not know how to copy safely yet, so nothing was copied.` };
30956
31130
  }
30957
- const attrs = { ...asObj3(n.attributes) };
31131
+ const attrs = { ...asObj4(n.attributes) };
30958
31132
  if (type === "wait") {
30959
- if (str4(attrs.type) !== "time" || !asObj3(attrs.startAfter).type) {
31133
+ if (str5(attrs.type) !== "time" || !asObj4(attrs.startAfter).type) {
30960
31134
  return { ok: false, refusal: `"${src.name}" has a wait that is not a plain time wait ("${name}"). Fast-forward handles time waits only for now.` };
30961
31135
  }
30962
31136
  waits.push(`${name}: ${waitLength(attrs)} in real life`);
@@ -30974,20 +31148,20 @@ function planCopy(src, opts) {
30974
31148
  Object.assign(attrs, { user_list: [o], traffic_split: "equally", traffic_weightage: { [o]: 1 }, traffic_index: [{ id: o, indexes: [1] }], total_index: 1 });
30975
31149
  }
30976
31150
  if (type === "internal_create_opportunity" || type === "internal_update_opportunity") {
30977
- const assigns = attrs.assignedTo !== void 0 || asArr2(attrs.__customInputFields__).map(asObj3).some((f) => /assign/i.test(str4(f.filterField) ?? ""));
31151
+ const assigns = attrs.assignedTo !== void 0 || asArr2(attrs.__customInputFields__).map(asObj4).some((f) => /assign/i.test(str5(f.filterField) ?? ""));
30978
31152
  if (assigns) {
30979
31153
  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).' };
30980
31154
  if (attrs.assignedTo !== void 0) attrs.assignedTo = opts.ownerUserId;
30981
- attrs.__customInputFields__ = asArr2(attrs.__customInputFields__).map(asObj3).map((f) => /assign/i.test(str4(f.filterField) ?? "") ? { ...f, value: opts.ownerUserId } : f);
31155
+ attrs.__customInputFields__ = asArr2(attrs.__customInputFields__).map(asObj4).map((f) => /assign/i.test(str5(f.filterField) ?? "") ? { ...f, value: opts.ownerUserId } : f);
30982
31156
  }
30983
31157
  }
30984
31158
  if (type === "task-notification") {
30985
31159
  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).' };
30986
31160
  attrs.assignedTo = opts.ownerUserId;
30987
- attrs.title = `TEST: ${str4(attrs.title) ?? ""}`.trim();
31161
+ attrs.title = `TEST: ${str5(attrs.title) ?? ""}`.trim();
30988
31162
  }
30989
31163
  if (type === "webhook") {
30990
- const url = str4(attrs.url) ?? "";
31164
+ const url = str5(attrs.url) ?? "";
30991
31165
  if (/^[^/]*\/\/[^/?#]*\{\{/.test(url) || /^\{\{/.test(url)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
30992
31166
  const h = hostOf(url);
30993
31167
  if (h && /[{}]|%7b|%7d/i.test(h)) return { ok: false, refusal: `"${src.name}" has a webhook ("${name}") whose address is filled in from a merge field, so where the test lead would go cannot be checked. Nothing was copied.` };
@@ -31004,7 +31178,7 @@ function planCopy(src, opts) {
31004
31178
  if (Array.isArray(n.sibling)) copy.sibling = n.sibling.map(remap);
31005
31179
  actions.push(copy);
31006
31180
  }
31007
- const w = asObj3(src.window);
31181
+ const w = asObj4(src.window);
31008
31182
  return {
31009
31183
  ok: true,
31010
31184
  plan: {
@@ -31058,12 +31232,12 @@ function openCopies(locationId2, base) {
31058
31232
  return [...byName.values()].filter((x) => !x.deleted).map(({ deleted: _d, ...x }) => x);
31059
31233
  }
31060
31234
  function toSource(id, raw) {
31061
- const wf = asObj3(raw);
31235
+ const wf = asObj4(raw);
31062
31236
  return {
31063
31237
  id,
31064
- name: str4(wf.name) ?? id,
31065
- status: str4(wf.status),
31066
- templates: asArr2(asObj3(wf.workflowData).templates),
31238
+ name: str5(wf.name) ?? id,
31239
+ status: str5(wf.status),
31240
+ templates: asArr2(asObj4(wf.workflowData).templates),
31067
31241
  triggers: asArr2(wf.triggers),
31068
31242
  stopOnResponse: typeof wf.stopOnResponse === "boolean" ? wf.stopOnResponse : void 0,
31069
31243
  allowMultiple: typeof wf.allowMultiple === "boolean" ? wf.allowMultiple : void 0,
@@ -31100,13 +31274,13 @@ async function makeCopies(admin, locationId2, sourceIds, opts) {
31100
31274
  const { copyId, copyName } = copies[i];
31101
31275
  try {
31102
31276
  await admin.save(copyId, { actions: remapWorkflows(p.actions, sourceToCopy), triggers: [], status: "draft", window: null, ...p.settings });
31103
- const back = asObj3(await admin.get(copyId));
31104
- const steps = asArr2(asObj3(back.workflowData).templates).map(asObj3);
31105
- const longWait = steps.find((s) => str4(s.type) === "wait" && Number(asObj3(asObj3(s.attributes).startAfter).value) !== 1);
31277
+ const back = asObj4(await admin.get(copyId));
31278
+ const steps = asArr2(asObj4(back.workflowData).templates).map(asObj4);
31279
+ const longWait = steps.find((s) => str5(s.type) === "wait" && Number(asObj4(asObj4(s.attributes).startAfter).value) !== 1);
31106
31280
  if (asArr2(back.triggers).length !== 0) throw new Error(`the copy of "${p.sourceName}" came back with a trigger, so it was not switched on.`);
31107
31281
  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}.`);
31108
31282
  if (longWait) throw new Error(`a wait in the copy of "${p.sourceName}" did not shorten.`);
31109
- if (!(str4(back.name) ?? "").startsWith(tokenPrefix(copyName))) throw new Error("the copy's name did not save.");
31283
+ if (!(str5(back.name) ?? "").startsWith(tokenPrefix(copyName))) throw new Error("the copy's name did not save.");
31110
31284
  const stray = steps.flatMap(staffRecipients).find((u) => u !== opts.ownerUserId);
31111
31285
  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.`);
31112
31286
  if (steps.some((s) => sourceIds.some((id) => JSON.stringify(s).includes(`"${id}"`)))) throw new Error(`a step in the copy of "${p.sourceName}" still points at an original workflow, so it was not switched on.`);
@@ -31122,8 +31296,8 @@ async function removeCopies(admin, locationId2, copies, base, now = Date.now) {
31122
31296
  for (const c of copies) {
31123
31297
  if (!c.copyId) continue;
31124
31298
  try {
31125
- const wf = asObj3(await admin.get(c.copyId));
31126
- const name = str4(wf.name) ?? "";
31299
+ const wf = asObj4(await admin.get(c.copyId));
31300
+ const name = str5(wf.name) ?? "";
31127
31301
  const ours = TOKEN_NAME.test(c.name) && (c.matchedByName ? name === c.name : name.startsWith(tokenPrefix(c.name)));
31128
31302
  if (!ours) {
31129
31303
  problems.push(`Left alone: workflow ${c.copyId} is not a fast-forward copy this check made (named "${name}").`);
@@ -31170,18 +31344,18 @@ function builderAdmin(b) {
31170
31344
  list: async () => {
31171
31345
  const out = [];
31172
31346
  for (let skip = 0; skip < 1e3; skip += 100) {
31173
- const rows2 = asArr2(asObj3(await b.listWorkflows(100, skip)).rows).map(asObj3);
31347
+ const rows2 = asArr2(asObj4(await b.listWorkflows(100, skip)).rows).map(asObj4);
31174
31348
  for (const r of rows2) {
31175
- const id = str4(r._id) ?? str4(r.id);
31176
- if (id) out.push({ id, name: str4(r.name) ?? "", createdAt: str4(r.createdAt) });
31349
+ const id = str5(r._id) ?? str5(r.id);
31350
+ if (id) out.push({ id, name: str5(r.name) ?? "", createdAt: str5(r.createdAt) });
31177
31351
  }
31178
31352
  if (rows2.length < 100) break;
31179
31353
  }
31180
31354
  return out;
31181
31355
  },
31182
31356
  create: async (name) => {
31183
- const w = asObj3(await b.createWorkflow(name));
31184
- return { id: str4(w._id) ?? str4(w.id) ?? "" };
31357
+ const w = asObj4(await b.createWorkflow(name));
31358
+ return { id: str5(w._id) ?? str5(w.id) ?? "" };
31185
31359
  },
31186
31360
  save: (id, body) => b.updateWorkflow(id, body),
31187
31361
  publish: (id) => b.publishWorkflow(id),
@@ -31189,7 +31363,7 @@ function builderAdmin(b) {
31189
31363
  remove: (id, ack) => b.deleteWorkflow(id, ack ? { acknowledgeContacts: ack } : void 0)
31190
31364
  };
31191
31365
  }
31192
- var import_node_fs2, import_node_path2, import_node_crypto8, asObj3, asArr2, str4, FF_PREFIX, ALLOWED, hostOf, normalizeHost, newToken, TOKEN_NAME, tokenPrefix, DAY_NAMES, remapSelf, journalFile, SWEEP_MIN_AGE_MS;
31366
+ var import_node_fs2, import_node_path2, import_node_crypto8, asObj4, asArr2, str5, FF_PREFIX, ALLOWED, hostOf, normalizeHost, newToken, TOKEN_NAME, tokenPrefix, DAY_NAMES, remapSelf, journalFile, SWEEP_MIN_AGE_MS;
31193
31367
  var init_fastforward = __esm({
31194
31368
  "src/command-os/shopper/fastforward.ts"() {
31195
31369
  "use strict";
@@ -31197,9 +31371,9 @@ var init_fastforward = __esm({
31197
31371
  import_node_path2 = __toESM(require("node:path"));
31198
31372
  import_node_crypto8 = __toESM(require("node:crypto"));
31199
31373
  init_run();
31200
- asObj3 = (v) => v && typeof v === "object" ? v : {};
31374
+ asObj4 = (v) => v && typeof v === "object" ? v : {};
31201
31375
  asArr2 = (v) => Array.isArray(v) ? v : [];
31202
- str4 = (v) => typeof v === "string" ? v : void 0;
31376
+ str5 = (v) => typeof v === "string" ? v : void 0;
31203
31377
  FF_PREFIX = "ZZ TEST FF";
31204
31378
  ALLOWED = /* @__PURE__ */ new Set([
31205
31379
  "email",
@@ -31316,7 +31490,7 @@ function clampMinutes(n) {
31316
31490
  return Math.min(1440, Math.max(1, Math.round(n)));
31317
31491
  }
31318
31492
  function channelOf(msg3) {
31319
- const mt = (str5(msg3.messageType) ?? "").toUpperCase();
31493
+ const mt = (str6(msg3.messageType) ?? "").toUpperCase();
31320
31494
  if (mt === "TYPE_SMS") return "sms";
31321
31495
  if (mt === "TYPE_EMAIL") return "email";
31322
31496
  if (!mt) {
@@ -31327,30 +31501,30 @@ function channelOf(msg3) {
31327
31501
  return null;
31328
31502
  }
31329
31503
  async function readSentMessages(client, locationId2, contactId, since) {
31330
- const convs = asArr3(asObj4(await client.get("/conversations/search", { params: { locationId: locationId2, contactId, limit: 20 } })).conversations);
31504
+ const convs = asArr3(asObj5(await client.get("/conversations/search", { params: { locationId: locationId2, contactId, limit: 20 } })).conversations);
31331
31505
  const out = [];
31332
31506
  for (const c of convs) {
31333
- const convId = str5(asObj4(c).id);
31507
+ const convId = str6(asObj5(c).id);
31334
31508
  if (!convId) continue;
31335
- const raw = asObj4(await client.get(`/conversations/${convId}/messages`, { params: { limit: 100 } }));
31336
- const list3 = asArr3(raw.messages).length ? asArr3(raw.messages) : asArr3(asObj4(raw.messages).messages);
31509
+ const raw = asObj5(await client.get(`/conversations/${convId}/messages`, { params: { limit: 100 } }));
31510
+ const list3 = asArr3(raw.messages).length ? asArr3(raw.messages) : asArr3(asObj5(raw.messages).messages);
31337
31511
  for (const item of list3) {
31338
- const m = asObj4(item);
31339
- if (str5(m.direction)?.toLowerCase() !== "outbound") continue;
31512
+ const m = asObj5(item);
31513
+ if (str6(m.direction)?.toLowerCase() !== "outbound") continue;
31340
31514
  const channel = channelOf(m);
31341
31515
  if (!channel) continue;
31342
- const at = Date.parse(str5(m.dateAdded) ?? "");
31516
+ const at = Date.parse(str6(m.dateAdded) ?? "");
31343
31517
  if (Number.isNaN(at) || at < since - 5e3) continue;
31344
- const id = str5(m.id);
31518
+ const id = str6(m.id);
31345
31519
  if (!id) continue;
31346
31520
  out.push({
31347
31521
  id,
31348
31522
  channel,
31349
31523
  at,
31350
- subject: str5(asObj4(asObj4(m.meta).email).subject),
31351
- body: htmlToText(str5(m.body) ?? ""),
31352
- raw: str5(m.body) ?? "",
31353
- source: str5(m.source)
31524
+ subject: str6(asObj5(asObj5(m.meta).email).subject),
31525
+ body: htmlToText(str6(m.body) ?? ""),
31526
+ raw: str6(m.body) ?? "",
31527
+ source: str6(m.source)
31354
31528
  });
31355
31529
  }
31356
31530
  }
@@ -31358,9 +31532,9 @@ async function readSentMessages(client, locationId2, contactId, since) {
31358
31532
  }
31359
31533
  async function findContactIdByEmail(client, locationId2, email) {
31360
31534
  const raw = await client.get("/contacts/", { params: { locationId: locationId2, query: email, limit: 10 } });
31361
- for (const c of asArr3(asObj4(raw).contacts)) {
31362
- const co = asObj4(c);
31363
- if (str5(co.email)?.toLowerCase() === email) return str5(co.id);
31535
+ for (const c of asArr3(asObj5(raw).contacts)) {
31536
+ const co = asObj5(c);
31537
+ if (str6(co.email)?.toLowerCase() === email) return str6(co.id);
31364
31538
  }
31365
31539
  return void 0;
31366
31540
  }
@@ -31381,9 +31555,9 @@ async function startShopper(input, deps) {
31381
31555
  const refused = protectedRefusalFor(input.locationId);
31382
31556
  if (refused) throw new Error(refused);
31383
31557
  }
31384
- const loc = asObj4(asObj4(await deps.client.get(`/locations/${input.locationId}`)).location);
31385
- const businessName = str5(loc.name) ?? "";
31386
- const websiteHost = siteHost(str5(loc.website));
31558
+ const loc = asObj5(asObj5(await deps.client.get(`/locations/${input.locationId}`)).location);
31559
+ const businessName = str6(loc.name) ?? "";
31560
+ const websiteHost = siteHost(str6(loc.website));
31387
31561
  const testPhone = input.testPhone?.trim() || void 0;
31388
31562
  let expect = input.expect ?? [];
31389
31563
  const planNote = { ...input.planNote ?? { source: expect.length ? "given" : "none" } };
@@ -31398,7 +31572,7 @@ async function startShopper(input, deps) {
31398
31572
  const watchMinutes = auto ? auto.minutes : clampMinutes(input.watchMinutes);
31399
31573
  const watchReason = auto ? auto.reason : `You chose ${watchMinutes} minute${watchMinutes === 1 ? "" : "s"}.`;
31400
31574
  const firstName = (input.firstName ?? "").trim();
31401
- const entryNote = input.fastForward?.length ? `Fast-forward: added to temporary copies of ${input.fastForward.length} workflow${input.fastForward.length === 1 ? "" : "s"} with every wait cut to 1 minute and every step kept; the originals were only read. Staff alerts, tasks and lead assignments came to you, marked TEST. The sign-up form itself was not submitted.` : input.entry.kind === "workflows" ? `Added straight to ${input.entry.workflowIds.length} workflow${input.entry.workflowIds.length === 1 ? "" : "s"}. The sign-up form itself was not submitted.` : input.entry.kind === "tag" ? `Created with the tag "${input.entry.tag}", which starts the workflows that listen for it. The sign-up form itself was not submitted.` : "Sent in through the lead-capture address, the way a form sends it.";
31575
+ const entryNote = input.fastForward?.length ? `Fast-forward: added to temporary copies of ${input.fastForward.length} workflow${input.fastForward.length === 1 ? "" : "s"} with every wait cut to 1 minute and every step kept; the originals were only read. Staff alerts, tasks and lead assignments came to you, marked TEST. The sign-up form itself was not submitted.` : input.entry.kind === "workflows" ? `Added straight to ${input.entry.workflowIds.length} workflow${input.entry.workflowIds.length === 1 ? "" : "s"}. The sign-up form itself was not submitted.` : input.entry.kind === "tag" ? `Sent with the tag "${input.entry.tag}", which starts the workflows that listen for it. The sign-up form itself was not submitted.` : "Sent in through the lead-capture address, the way a form sends it.";
31402
31576
  const run = {
31403
31577
  v: 1,
31404
31578
  runId: `${new Date(now()).toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${token2}`,
@@ -31449,9 +31623,9 @@ async function startShopper(input, deps) {
31449
31623
  };
31450
31624
  if (firstName) body.firstName = firstName;
31451
31625
  if (testPhone) body.phone = testPhone;
31452
- const created = asObj4(await deps.client.post("/contacts/", { body, noRetry: true }));
31453
- run.contactId = str5(asObj4(created.contact).id) ?? str5(created.id);
31454
- if (!run.contactId) throw new Error("GoHighLevel did not return the new test lead's id.");
31626
+ const created = asObj5(await deps.client.post("/contacts/", { body, noRetry: true }));
31627
+ run.contactId = str6(asObj5(created.contact).id) ?? str6(created.id);
31628
+ if (!run.contactId) throw new Error("GoHighLevel did not return the test lead's id.");
31455
31629
  if (input.entry.kind === "workflows") {
31456
31630
  for (const wid of input.entry.workflowIds) {
31457
31631
  await deps.client.post(`/contacts/${run.contactId}/workflow/${wid}`, { body: {}, noRetry: true });
@@ -31464,11 +31638,7 @@ async function startShopper(input, deps) {
31464
31638
  run.status = "failed";
31465
31639
  run.error = e instanceof Error ? e.message : String(e);
31466
31640
  run.finishedAt = now();
31467
- if (run.contactId && !run.keepLead) {
31468
- run.cleanup = await removeOwnLead(deps.client, run);
31469
- } else if (!run.contactId) {
31470
- run.cleanup = { removed: false, detail: `If the test contact named "Secret Shopper" with the address ${leadEmail} appears in the account, delete it. It is the test lead from this check.` };
31471
- }
31641
+ run.cleanup = await listLeadCarriers(deps.client, run);
31472
31642
  saveRun(run, deps.base);
31473
31643
  return run;
31474
31644
  }
@@ -31477,28 +31647,29 @@ async function postJson(url, body) {
31477
31647
  const r = await guardedPost(url, JSON.stringify(body), { "Content-Type": "application/json", Accept: "application/json" });
31478
31648
  return r.status;
31479
31649
  }
31480
- async function removeOwnLead(client, run) {
31481
- const id = run.contactId;
31482
- if (!id) return { removed: false, detail: "No test lead was recorded for this check." };
31483
- let contact;
31650
+ async function listLeadCarriers(client, run) {
31651
+ const carriers = /* @__PURE__ */ new Map();
31652
+ let partial = false;
31484
31653
  try {
31485
- contact = asObj4(asObj4(await client.get(`/contacts/${id}`)).contact);
31486
- } catch (e) {
31487
- if (notFound(e)) return { removed: true, detail: "The test lead is already gone from the account." };
31488
- return { removed: false, detail: `The test lead ${run.leadEmail} could not be checked before removal (${e instanceof Error ? e.message.split("\n")[0] : String(e)}). Delete it in GoHighLevel.` };
31489
- }
31490
- const email = str5(contact.email)?.toLowerCase();
31491
- const added = Date.parse(str5(contact.dateAdded) ?? "");
31492
- if (email !== run.leadEmail || !Number.isNaN(added) && added < run.startedAt - 10 * 6e4) {
31493
- return { removed: false, detail: `Left alone on purpose: the contact this check ended up with is not the test lead it created (its address or creation time does not match ${run.leadEmail}). Look for the test contact named "Secret Shopper" in GoHighLevel.` };
31654
+ const paged = await readContactPages((path45, o) => client.get(path45, o), { locationId: run.locationId, query: run.leadEmail, limit: 20 });
31655
+ if (!paged || !paged.complete) partial = true;
31656
+ for (const c of paged?.rows ?? []) {
31657
+ const k = carrierOf(c);
31658
+ if (k && k.email === run.leadEmail) carriers.set(k.id, k);
31659
+ }
31660
+ } catch {
31661
+ partial = true;
31494
31662
  }
31495
- try {
31496
- await client.delete(`/contacts/${id}`);
31497
- } catch (e) {
31498
- if (notFound(e)) return { removed: true, detail: "The test lead is already gone from the account." };
31499
- return { removed: false, detail: `The test lead ${run.leadEmail} could not be removed (${e instanceof Error ? e.message.split("\n")[0] : String(e)}). Delete it in GoHighLevel.` };
31663
+ if (run.contactId && !carriers.has(run.contactId)) {
31664
+ try {
31665
+ const k = carrierOf(asObj5(await client.get(`/contacts/${run.contactId}`)).contact);
31666
+ if (k && k.email === run.leadEmail) carriers.set(k.id, k);
31667
+ } catch (e) {
31668
+ if (!notFound(e)) partial = true;
31669
+ }
31500
31670
  }
31501
- return { removed: true, detail: "The test lead was removed from the account after the check." };
31671
+ const contacts = [...carriers.values()];
31672
+ return { removed: false, detail: carrierText(contacts, partial), contacts, partial };
31502
31673
  }
31503
31674
  function pidAlive(pid) {
31504
31675
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -31604,7 +31775,7 @@ async function finishShopper(locationId2, runId, deps) {
31604
31775
  if (closed) {
31605
31776
  run.status = "done";
31606
31777
  run.finishedAt = t;
31607
- run.cleanup = run.keepLead ? { removed: false, detail: `Kept on request: the test lead ${run.leadEmail} is still in the account for you to look at. Delete it when you're done.` } : await removeOwnLead(deps.client, run);
31778
+ run.cleanup = await listLeadCarriers(deps.client, run);
31608
31779
  if (run.fastForward && deps.ffAdmin) {
31609
31780
  const { removeCopies: removeCopies2 } = await Promise.resolve().then(() => (init_fastforward(), fastforward_exports));
31610
31781
  const problems = await removeCopies2(deps.ffAdmin, run.locationId, run.fastForward.copies.map((c) => ({ copyId: c.copyId, name: c.copyName })), deps.base, now);
@@ -31642,7 +31813,7 @@ function summarizeRun(run) {
31642
31813
  if (run.status === "watching") return `So far: ${head4}${stepNote} Still watching until ${new Date(run.closesAt).toISOString()}.`;
31643
31814
  return gradeLine + head4 + stepNote;
31644
31815
  }
31645
- var import_node_fs3, import_node_path3, import_node_crypto9, MAX_EXTEND_MINUTES, asObj4, asArr3, str5, SAFE_ID, notFound;
31816
+ var import_node_fs3, import_node_path3, import_node_crypto9, MAX_EXTEND_MINUTES, asObj5, asArr3, str6, SAFE_ID, notFound;
31646
31817
  var init_run = __esm({
31647
31818
  "src/command-os/shopper/run.ts"() {
31648
31819
  "use strict";
@@ -31657,10 +31828,11 @@ var init_run = __esm({
31657
31828
  init_score();
31658
31829
  init_steps();
31659
31830
  init_net_guard();
31831
+ init_test_contacts();
31660
31832
  MAX_EXTEND_MINUTES = 10;
31661
- asObj4 = (v) => v && typeof v === "object" ? v : {};
31833
+ asObj5 = (v) => v && typeof v === "object" ? v : {};
31662
31834
  asArr3 = (v) => Array.isArray(v) ? v : [];
31663
- str5 = (v) => typeof v === "string" ? v : void 0;
31835
+ str6 = (v) => typeof v === "string" ? v : void 0;
31664
31836
  SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/;
31665
31837
  notFound = (e) => {
31666
31838
  const m = e instanceof Error ? e.message : String(e);
@@ -31897,12 +32069,12 @@ mark{background:var(--surface-sunk);color:var(--bad);font-weight:700;border-radi
31897
32069
 
31898
32070
  // src/command-os/shopper/plan.ts
31899
32071
  function waitMinutes(node) {
31900
- const a = asObj5(node.attributes);
31901
- if (str6(a.type) && str6(a.type) !== "time") return null;
31902
- const s = asObj5(a.startAfter);
32072
+ const a = asObj6(node.attributes);
32073
+ if (str7(a.type) && str7(a.type) !== "time") return null;
32074
+ const s = asObj6(a.startAfter);
31903
32075
  const v = Number(s.value);
31904
32076
  if (!Number.isFinite(v) || v < 0) return null;
31905
- const unit = (str6(s.type) ?? "").toLowerCase();
32077
+ const unit = (str7(s.type) ?? "").toLowerCase();
31906
32078
  if (unit.startsWith("min")) return v;
31907
32079
  if (unit.startsWith("hour")) return v * 60;
31908
32080
  if (unit.startsWith("day")) return v * 1440;
@@ -31920,27 +32092,27 @@ function plannedMessages(workflows) {
31920
32092
  for (const wf of workflows) {
31921
32093
  const nodes = /* @__PURE__ */ new Map();
31922
32094
  for (const t of wf.templates) {
31923
- const n = asObj5(t);
31924
- const id = str6(n.id);
32095
+ const n = asObj6(t);
32096
+ const id = str7(n.id);
31925
32097
  if (id) nodes.set(id, n);
31926
32098
  }
31927
32099
  for (const node of nodes.values()) {
31928
- const type = str6(node.type);
32100
+ const type = str7(node.type);
31929
32101
  if (type !== "sms" && type !== "email") continue;
31930
32102
  let minutes = 0;
31931
- let conditional = Boolean(str6(node.parent) && nodes.get(str6(node.parent) ?? "") && isBranch(nodes.get(str6(node.parent) ?? "")));
32103
+ let conditional = Boolean(str7(node.parent) && nodes.get(str7(node.parent) ?? "") && isBranch(nodes.get(str7(node.parent) ?? "")));
31932
32104
  const seen = /* @__PURE__ */ new Set();
31933
32105
  const afterWaits = [];
31934
- let cur = str6(node.parentKey);
32106
+ let cur = str7(node.parentKey);
31935
32107
  while (cur && !conditional && !seen.has(cur)) {
31936
32108
  seen.add(cur);
31937
32109
  const p = nodes.get(cur);
31938
32110
  if (!p) break;
31939
- if (isBranch(p) || str6(p.parent) && isBranch(nodes.get(str6(p.parent) ?? "") ?? {})) {
32111
+ if (isBranch(p) || str7(p.parent) && isBranch(nodes.get(str7(p.parent) ?? "") ?? {})) {
31940
32112
  conditional = true;
31941
32113
  break;
31942
32114
  }
31943
- if (str6(p.type) === "wait") {
32115
+ if (str7(p.type) === "wait") {
31944
32116
  const m = waitMinutes(p);
31945
32117
  if (m === null) {
31946
32118
  conditional = true;
@@ -31949,20 +32121,20 @@ function plannedMessages(workflows) {
31949
32121
  minutes += m;
31950
32122
  afterWaits.push(cur);
31951
32123
  }
31952
- cur = str6(p.parentKey);
32124
+ cur = str7(p.parentKey);
31953
32125
  }
31954
32126
  if (conditional) {
31955
32127
  leftOut.conditional++;
31956
32128
  continue;
31957
32129
  }
31958
- const a = asObj5(node.attributes);
31959
- const key = type === "email" ? wordingKey(str6(a.subject) ?? "") || wordingKey(str6(a.html) ?? "") : wordingKey(str6(a.body) ?? "");
32130
+ const a = asObj6(node.attributes);
32131
+ const key = type === "email" ? wordingKey(str7(a.subject) ?? "") || wordingKey(str7(a.html) ?? "") : wordingKey(str7(a.body) ?? "");
31960
32132
  if (!key) {
31961
32133
  leftOut.noWording++;
31962
32134
  continue;
31963
32135
  }
31964
- const stepName = str6(node.name) || (type === "sms" ? "text" : "email");
31965
- expect.push({ label: `${wf.name}: ${stepName}`, channel: type, match: key, byMinute: minutes + 1, stepName, ...wf.id ? { workflowId: wf.id } : {}, ...str6(node.id) ? { stepId: str6(node.id) } : {}, afterWaits });
32136
+ const stepName = str7(node.name) || (type === "sms" ? "text" : "email");
32137
+ expect.push({ label: `${wf.name}: ${stepName}`, channel: type, match: key, byMinute: minutes + 1, stepName, ...wf.id ? { workflowId: wf.id } : {}, ...str7(node.id) ? { stepId: str7(node.id) } : {}, afterWaits });
31966
32138
  }
31967
32139
  }
31968
32140
  return { expect, leftOut };
@@ -31975,45 +32147,45 @@ async function planFromWorkflowIds(reader, ids) {
31975
32147
  const draftIds = [];
31976
32148
  for (const id of ids) {
31977
32149
  try {
31978
- const wf = asObj5(await reader.getWorkflow(id));
31979
- const name = str6(wf.name) ?? id;
32150
+ const wf = asObj6(await reader.getWorkflow(id));
32151
+ const name = str7(wf.name) ?? id;
31980
32152
  names[id] = name;
31981
- if (str6(wf.status)?.toLowerCase() === "draft") {
32153
+ if (str7(wf.status)?.toLowerCase() === "draft") {
31982
32154
  drafts.push(name);
31983
32155
  draftIds.push(id);
31984
32156
  }
31985
- workflows.push({ id, name, templates: asArr4(asObj5(wf.workflowData).templates) });
32157
+ workflows.push({ id, name, templates: asArr4(asObj6(wf.workflowData).templates) });
31986
32158
  } catch {
31987
32159
  unread.push(id);
31988
32160
  }
31989
32161
  }
31990
32162
  return { ...plannedMessages(workflows), unread, names, drafts, draftIds };
31991
32163
  }
31992
- var asObj5, asArr4, str6, isBranch;
32164
+ var asObj6, asArr4, str7, isBranch;
31993
32165
  var init_plan2 = __esm({
31994
32166
  "src/command-os/shopper/plan.ts"() {
31995
32167
  "use strict";
31996
32168
  init_links();
31997
- asObj5 = (v) => v && typeof v === "object" ? v : {};
32169
+ asObj6 = (v) => v && typeof v === "object" ? v : {};
31998
32170
  asArr4 = (v) => Array.isArray(v) ? v : [];
31999
- str6 = (v) => typeof v === "string" ? v : void 0;
32000
- isBranch = (n) => /^branch-/.test(str6(n.nodeType) ?? "") || str6(n.type) === "if_else";
32171
+ str7 = (v) => typeof v === "string" ? v : void 0;
32172
+ isBranch = (n) => /^branch-/.test(str7(n.nodeType) ?? "") || str7(n.type) === "if_else";
32001
32173
  }
32002
32174
  });
32003
32175
 
32004
32176
  // src/command-os/shopper/ff-start.ts
32005
32177
  async function ownerUserId(client, locationId2, ownerEmail) {
32006
- const raw = asObj6(await client.get("/users/", { params: { locationId: locationId2 } }));
32178
+ const raw = asObj7(await client.get("/users/", { params: { locationId: locationId2 } }));
32007
32179
  const want = baseEmail(ownerEmail);
32008
- for (const u of asArr5(raw.users).map(asObj6)) if (str7(u.email) && baseEmail(str7(u.email)) === want) return str7(u.id);
32180
+ for (const u of asArr5(raw.users).map(asObj7)) if (str8(u.email) && baseEmail(str8(u.email)) === want) return str8(u.id);
32009
32181
  return void 0;
32010
32182
  }
32011
32183
  async function accountUsers(client, locationId2) {
32012
- const raw = asObj6(await client.get("/users/", { params: { locationId: locationId2 } }));
32013
- return asArr5(raw.users).map(asObj6).filter((u) => str7(u.id) && !u.deleted).map((u) => ({
32014
- id: str7(u.id),
32015
- name: str7(u.name) ?? ([str7(u.firstName), str7(u.lastName)].filter(Boolean).join(" ") || str7(u.email) || str7(u.id)),
32016
- email: str7(u.email)
32184
+ const raw = asObj7(await client.get("/users/", { params: { locationId: locationId2 } }));
32185
+ return asArr5(raw.users).map(asObj7).filter((u) => str8(u.id) && !u.deleted).map((u) => ({
32186
+ id: str8(u.id),
32187
+ name: str8(u.name) ?? ([str8(u.firstName), str8(u.lastName)].filter(Boolean).join(" ") || str8(u.email) || str8(u.id)),
32188
+ email: str8(u.email)
32017
32189
  }));
32018
32190
  }
32019
32191
  async function sweepLeftovers(admin, locationId2, base, now) {
@@ -32063,16 +32235,16 @@ async function startFastForward(s) {
32063
32235
  return { ok: false, error: e instanceof Error ? e.message.split("\n")[0] : String(e) };
32064
32236
  }
32065
32237
  }
32066
- var asObj6, asArr5, str7, baseEmail;
32238
+ var asObj7, asArr5, str8, baseEmail;
32067
32239
  var init_ff_start = __esm({
32068
32240
  "src/command-os/shopper/ff-start.ts"() {
32069
32241
  "use strict";
32070
32242
  init_run();
32071
32243
  init_fastforward();
32072
32244
  init_plan2();
32073
- asObj6 = (v) => v && typeof v === "object" ? v : {};
32245
+ asObj7 = (v) => v && typeof v === "object" ? v : {};
32074
32246
  asArr5 = (v) => Array.isArray(v) ? v : [];
32075
- str7 = (v) => typeof v === "string" ? v : void 0;
32247
+ str8 = (v) => typeof v === "string" ? v : void 0;
32076
32248
  baseEmail = (e) => {
32077
32249
  const [l, d] = e.trim().toLowerCase().split("@");
32078
32250
  return `${(l ?? "").split("+")[0]}@${d ?? ""}`;
@@ -35475,25 +35647,25 @@ var init_brief_validation = __esm({
35475
35647
  // src/command-os/design/run-jobs.ts
35476
35648
  function validateRunRequest(input) {
35477
35649
  const a = input ?? {};
35478
- const str8 = (k) => typeof a[k] === "string" ? a[k].trim() : "";
35479
- const brand = str8("brand");
35650
+ const str9 = (k) => typeof a[k] === "string" ? a[k].trim() : "";
35651
+ const brand = str9("brand");
35480
35652
  if (!SLUG2.test(brand)) return { ok: false, error: "brand must be a stored brand's slug (lowercase letters, digits and dashes)." };
35481
- const medium = str8("medium");
35653
+ const medium = str9("medium");
35482
35654
  if (!MEDIA.includes(medium)) return { ok: false, error: `medium must be one of ${MEDIA.join(", ")}.` };
35483
- const headline3 = str8("headline");
35655
+ const headline3 = str9("headline");
35484
35656
  if (headline3.length < 3 || headline3.length > 300) return { ok: false, error: "headline must be between 3 and 300 characters." };
35485
35657
  const points = Array.isArray(a.points) ? a.points.filter((p) => typeof p === "string" && p.trim().length > 0).map((p) => p.trim()) : [];
35486
35658
  if (points.length === 0) return { ok: false, error: "points must be a non-empty list." };
35487
35659
  if (points.length > DESIGN_FORM_LIMITS.pointsMax) return { ok: false, error: POINTS_MESSAGE };
35488
35660
  if (points.some((p) => p.includes("|"))) return { ok: false, error: "a point may not contain the | character; it separates them on the command line." };
35489
35661
  if (points.some((p) => p.length > 400)) return { ok: false, error: "a point may be 400 characters at most." };
35490
- const model = str8("model");
35662
+ const model = str9("model");
35491
35663
  if (model && !MODEL.test(model)) return { ok: false, error: "model must be a plain engine name." };
35492
35664
  const slides = a.slides;
35493
35665
  if (slides !== void 0 && (typeof slides !== "number" || !Number.isInteger(slides) || slides < DESIGN_FORM_LIMITS.slidesMin || slides > DESIGN_FORM_LIMITS.slidesMax)) return { ok: false, error: SLIDES_MESSAGE };
35494
35666
  let refusal = null;
35495
35667
  const optional2 = (k, max = 400) => {
35496
- const v = str8(k);
35668
+ const v = str9(k);
35497
35669
  if (!v) return void 0;
35498
35670
  if (v.length > max) {
35499
35671
  if (refusal === null) refusal = `${k} must be ${max} characters at most.`;
@@ -57415,7 +57587,7 @@ function registerSecretShopperTools(server2, client, registry2) {
57415
57587
  safeTool(
57416
57588
  server2,
57417
57589
  "run_secret_shopper",
57418
- `Workflow Audit Pro: an audit of a client account you manage. Signs up as a clearly marked TEST lead on the ACTIVE sub-account, waits, then reads every text and email the account actually sent that lead, word for word, and reports what a real customer would have seen wrong: a blank name ("Hi , "), template code left showing ({{\u2026}}), another business's name left over from a template, a link that doesn't open, a message sent twice, and a planned message that never came. Two halves: action "start" creates the lead and returns a runId and the time the watch window closes (default 10 minutes); action "finish" reads, judges and removes the test lead (finishing early gives a partial look and keeps watching); action "status" shows the latest run. WRITES: start creates one contact (the owner's own email, plus-addressed, never a customer's) and enters it into the chosen workflows, which will really send to that address and may notify staff; finish deletes that contact unless keepLead is true. The client's sign-up form itself is not submitted: entry is by adding the lead to named workflows, by a tag, or through a lead-capture https address. Tell the owner all of this before starting. Call it Workflow Audit Pro to the owner; an owner who says "secret shopper" (its old name) means this tool.`,
57590
+ `Workflow Audit Pro: an audit of a client account you manage. Signs up as a clearly marked TEST lead on the ACTIVE sub-account, waits, then reads every text and email the account actually sent that lead, word for word, and reports what a real customer would have seen wrong: a blank name ("Hi , "), template code left showing ({{\u2026}}), another business's name left over from a template, a link that doesn't open, a message sent twice, and a planned message that never came. Two halves: action "start" sends the test lead and returns a runId and the time the watch window closes (default 10 minutes); action "finish" reads, judges and lists every contact that now carries the test address (it never deletes one; you check them before deleting any) (finishing early gives a partial look and keeps watching); action "status" shows the latest run. WRITES: start sends one test lead (the owner's own email, plus-addressed, never a customer's) and enters it into the chosen workflows, which will really send to that address and may notify staff; nothing is ever deleted. The client's sign-up form itself is not submitted: entry is by adding the lead to named workflows, by a tag, or through a lead-capture https address. Tell the owner all of this before starting. Call it Workflow Audit Pro to the owner; an owner who says "secret shopper" (its old name) means this tool.`,
57419
57591
  {
57420
57592
  action: import_zod83.z.enum(["start", "finish", "status"]),
57421
57593
  workflowIds: import_zod83.z.array(import_zod83.z.string()).optional().describe("start: the workflows the test lead enters directly (usually the speed-to-lead and welcome workflows). Use list_workflows_full to find ids."),
@@ -57426,7 +57598,7 @@ function registerSecretShopperTools(server2, client, registry2) {
57426
57598
  firstName: import_zod83.z.string().optional().describe("start: leave empty (the default) to test what happens when a lead gives no first name."),
57427
57599
  watchMinutes: import_zod83.z.number().optional().describe("start: how long to watch, 1 to 1440 minutes. Leave it out to watch until the last planned message is due plus 2 minutes (10 when no plan is known)."),
57428
57600
  expect: import_zod83.z.array(expectation).optional().describe("start: messages the build plan says the lead should get, so a missing one is reported."),
57429
- keepLead: import_zod83.z.boolean().optional().describe("Keep the test lead in the account after finishing, so the owner can look at it. Default false."),
57601
+ keepLead: import_zod83.z.boolean().optional().describe("No longer used: the test lead is never removed. The result lists every contact that now carries the test address, for you to check before deleting any."),
57430
57602
  fastForward: import_zod83.z.boolean().optional().describe("start with workflowIds: test long workflows in minutes. Makes a temporary 'ZZ TEST FF' copy of each (every step kept, every time wait cut to 1 minute, no trigger), runs the test lead through the copies, then deletes them; the originals are only read. Staff alerts, tasks and lead assignments go to one chosen user (alertUserId) marked TEST. Straight-line workflows with time waits only for now (if/else, hand-offs to other workflows and other waits are refused with the reason). Tell the owner before starting."),
57431
57603
  alertUserId: import_zod83.z.string().optional().describe("fastForward: the GoHighLevel user (get_users) who receives the staff alerts, tasks and lead assignments instead of the staff. Defaults to the user whose email matches ownerEmail; if none matches, ask the owner who should get them."),
57432
57604
  allowWebhookHosts: import_zod83.z.array(import_zod83.z.string()).optional().describe("fastForward: website hosts (e.g. hooks.zapier.com) the owner allows the test lead to be sent to by webhook steps. Any other webhook host makes the check refuse and name it."),
@@ -61207,8 +61379,8 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61207
61379
  io.out(USAGE2);
61208
61380
  return argv.length === 0 ? 2 : 0;
61209
61381
  }
61210
- const str8 = (k) => typeof a[k] === "string" ? a[k] : void 0;
61211
- const brand = str8("brand"), medium = str8("medium"), headline3 = str8("headline"), points = str8("points");
61382
+ const str9 = (k) => typeof a[k] === "string" ? a[k] : void 0;
61383
+ const brand = str9("brand"), medium = str9("medium"), headline3 = str9("headline"), points = str9("points");
61212
61384
  if (!brand || !medium || !headline3 || !points) {
61213
61385
  io.err("design-run: --brand, --medium, --headline and --points are required\n" + USAGE2);
61214
61386
  return 2;
@@ -61222,7 +61394,7 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61222
61394
  return 2;
61223
61395
  }
61224
61396
  let conversion;
61225
- const convFile = str8("conversion");
61397
+ const convFile = str9("conversion");
61226
61398
  if (convFile) {
61227
61399
  try {
61228
61400
  conversion = JSON.parse(require("node:fs").readFileSync(convFile, "utf8"));
@@ -61235,7 +61407,7 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61235
61407
  return 2;
61236
61408
  }
61237
61409
  }
61238
- const gate2 = str8("gate");
61410
+ const gate2 = str9("gate");
61239
61411
  if (gate2 && gate2 !== "draft" && gate2 !== "ship") {
61240
61412
  io.err("design-run: --gate must be draft or ship");
61241
61413
  return 2;
@@ -61245,8 +61417,8 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61245
61417
  io.err("design-run: --passes must be 1, 2 or 3");
61246
61418
  return 2;
61247
61419
  }
61248
- const outDir = str8("out") ?? (0, import_node_path10.join)((0, import_node_os2.tmpdir)(), `design-run-${brand}-${medium}-${Date.now()}`);
61249
- const model = str8("model") ?? DEFAULT_MODEL;
61420
+ const outDir = str9("out") ?? (0, import_node_path10.join)((0, import_node_os2.tmpdir)(), `design-run-${brand}-${medium}-${Date.now()}`);
61421
+ const model = str9("model") ?? DEFAULT_MODEL;
61250
61422
  io.out(`design-run: ${medium} for ${brand} on your Claude (${model}), up to ${passes} pass(es) \u2192 ${outDir}`);
61251
61423
  let r;
61252
61424
  try {
@@ -61254,11 +61426,11 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61254
61426
  brand,
61255
61427
  brief: {
61256
61428
  medium,
61257
- content: { headline: headline3, points: points.split("|").map((s) => s.trim()).filter(Boolean), audience: str8("audience"), job: str8("job"), ctaLabel: str8("cta"), proof: str8("proof") },
61429
+ content: { headline: headline3, points: points.split("|").map((s) => s.trim()).filter(Boolean), audience: str9("audience"), job: str9("job"), ctaLabel: str9("cta"), proof: str9("proof") },
61258
61430
  options: {
61259
- ...str8("mode") && { mode: str8("mode") },
61431
+ ...str9("mode") && { mode: str9("mode") },
61260
61432
  ...a.slides && { slides: Number(a.slides) },
61261
- theme: str8("theme") ?? "both",
61433
+ theme: str9("theme") ?? "both",
61262
61434
  ...conversion ? { pageMode: "conversion" } : {}
61263
61435
  },
61264
61436
  ...conversion ? { conversion } : {}
@@ -61270,7 +61442,7 @@ async function runDesignCli(argv, io = { out: (s) => process.stdout.write(s + "\
61270
61442
  headless: !a.headed,
61271
61443
  onPass: (p) => io.out(` pass ${p.pass}: ${p.lint ? p.verdict === "PASS" ? "PASS" : `${p.verdict}${p.failed.length ? " \u2014 " + p.failed.join(" | ") : ""}` : `contract: ${p.lintProblems.join("; ")}`} ($${p.costUsd.toFixed(3)}, ${Math.round(p.ms / 1e3)}s)`),
61272
61444
  // Two verdicts, never one: craft is measured, copy is judged.
61273
- ...str8("grade-copy") ? { gradeCopyFor: str8("grade-copy") } : {}
61445
+ ...str9("grade-copy") ? { gradeCopyFor: str9("grade-copy") } : {}
61274
61446
  });
61275
61447
  } catch (error) {
61276
61448
  io.err(error instanceof import_zod89.z.ZodError ? designValidationMessage(error) : error instanceof Error ? error.message : "The design run could not finish. Check the design details before trying again.");