@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/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/index.js +509 -337
- package/package.json +1 -1
- package/skills/blueprint/references/external-funnel.md +4 -2
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(
|
|
3474
|
-
return
|
|
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
|
|
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 = () => !
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
9296
|
-
if (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
9316
|
-
if (!
|
|
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 =
|
|
18308
|
-
const 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 =
|
|
18313
|
-
const fid =
|
|
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:
|
|
18321
|
-
phone:
|
|
18322
|
-
firstName:
|
|
18323
|
-
lastName:
|
|
18324
|
-
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:
|
|
18327
|
-
assignedTo:
|
|
18371
|
+
source: str2(c.source),
|
|
18372
|
+
assignedTo: str2(c.assignedTo),
|
|
18328
18373
|
custom
|
|
18329
18374
|
};
|
|
18330
18375
|
}
|
|
18331
|
-
async function
|
|
18332
|
-
const
|
|
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
|
-
|
|
18336
|
-
|
|
18337
|
-
|
|
18338
|
-
|
|
18339
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
18432
|
+
const conversations = asArr(asObj2(convRaw).conversations);
|
|
18374
18433
|
let best;
|
|
18375
18434
|
for (const c of conversations) {
|
|
18376
|
-
const convId =
|
|
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 =
|
|
18385
|
-
const list3 = asArr(mo.messages).length ? asArr(mo.messages) : asArr(
|
|
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 =
|
|
18388
|
-
const dir =
|
|
18446
|
+
const msg3 = asObj2(m);
|
|
18447
|
+
const dir = str2(msg3.direction)?.toLowerCase();
|
|
18389
18448
|
if (dir !== "outbound") continue;
|
|
18390
|
-
const at = Date.parse(
|
|
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:
|
|
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 =
|
|
18400
|
-
const status =
|
|
18401
|
-
const templates = asArr(
|
|
18402
|
-
const sendsSms = templates.some((t) =>
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
18471
|
-
|
|
18472
|
-
|
|
18473
|
-
|
|
18474
|
-
|
|
18475
|
-
|
|
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
|
-
|
|
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 (
|
|
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: `
|
|
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:
|
|
18505
|
-
phone:
|
|
18506
|
-
first_name:
|
|
18507
|
-
last_name:
|
|
18508
|
-
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 (
|
|
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(
|
|
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 ||
|
|
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 ${
|
|
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
|
-
|
|
18599
|
-
|
|
18600
|
-
|
|
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
|
-
|
|
18603
|
-
|
|
18604
|
-
|
|
18605
|
-
|
|
18606
|
-
|
|
18607
|
-
|
|
18608
|
-
|
|
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
|
|
18612
|
-
const
|
|
18613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18621
|
-
|
|
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
|
-
|
|
18627
|
-
|
|
18628
|
-
const
|
|
18629
|
-
if (
|
|
18630
|
-
|
|
18631
|
-
|
|
18632
|
-
|
|
18633
|
-
checks.push({ id: "
|
|
18634
|
-
} else {
|
|
18635
|
-
|
|
18636
|
-
|
|
18637
|
-
|
|
18638
|
-
|
|
18639
|
-
|
|
18640
|
-
|
|
18641
|
-
|
|
18642
|
-
}
|
|
18643
|
-
|
|
18644
|
-
|
|
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
|
-
|
|
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
|
|
18656
|
-
const
|
|
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,
|
|
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
|
-
|
|
18836
|
+
asObj2 = (v) => v && typeof v === "object" ? v : {};
|
|
18667
18837
|
asArr = (v) => Array.isArray(v) ? v : [];
|
|
18668
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
19052
|
-
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:
|
|
19058
|
-
replyTo:
|
|
19059
|
-
phone:
|
|
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 =
|
|
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:
|
|
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 =
|
|
19248
|
+
const name = str3(v.name, 80);
|
|
19077
19249
|
if (!name) return void 0;
|
|
19078
|
-
const idRaw =
|
|
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:
|
|
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:
|
|
19261
|
+
currency: str3(pricing?.currency, 8)
|
|
19090
19262
|
}),
|
|
19091
19263
|
delivery: drop({
|
|
19092
|
-
sops: list(delivery?.sops, (s) =>
|
|
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 =
|
|
19269
|
+
const name = str3(v.name, 80);
|
|
19098
19270
|
if (!name) return void 0;
|
|
19099
19271
|
return {
|
|
19100
19272
|
name,
|
|
19101
|
-
role:
|
|
19102
|
-
email:
|
|
19103
|
-
mayApprove: list(v.mayApprove, (a) =>
|
|
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:
|
|
19108
|
-
industry:
|
|
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:
|
|
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) =>
|
|
19292
|
+
const lines = (x) => list(x, (s) => str3(s, MAX_STR * 2));
|
|
19121
19293
|
const out = drop({
|
|
19122
|
-
story:
|
|
19123
|
-
method:
|
|
19124
|
-
voice:
|
|
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:
|
|
19298
|
+
goals: str3(r.goals, 400),
|
|
19127
19299
|
proof: lines(r.proof),
|
|
19128
19300
|
ica: drop({
|
|
19129
|
-
who:
|
|
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:
|
|
19140
|
-
promise:
|
|
19311
|
+
name: str3(offer.name, 120),
|
|
19312
|
+
promise: str3(offer.promise, 400),
|
|
19141
19313
|
valueStack: lines(offer.valueStack),
|
|
19142
|
-
price:
|
|
19143
|
-
guarantee:
|
|
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:
|
|
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:
|
|
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:
|
|
21844
|
-
offer:
|
|
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:
|
|
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:
|
|
21852
|
-
sops: list(r.sops, (s) =>
|
|
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 =
|
|
22026
|
+
const v = str3(s, 254)?.trim().toLowerCase();
|
|
21855
22027
|
return isEmailAddress(v) ? v : void 0;
|
|
21856
22028
|
}),
|
|
21857
|
-
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 ?? []) +
|
|
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.
|
|
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 ?? "
|
|
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
|
-
|
|
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)}`, "
|
|
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)),
|
|
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 =
|
|
30670
|
-
const data =
|
|
30671
|
-
const cands = [
|
|
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(
|
|
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 = (
|
|
30702
|
-
const type =
|
|
30875
|
+
const status = (str4(r.status) ?? "").toLowerCase();
|
|
30876
|
+
const type = str4(r.type) ?? "";
|
|
30703
30877
|
if (status === "retry") continue;
|
|
30704
|
-
const wfId =
|
|
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(
|
|
30709
|
-
const retryAt = Date.parse(
|
|
30710
|
-
const reason3 = reasonOf(
|
|
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:
|
|
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
|
|
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
|
-
|
|
30823
|
-
|
|
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 =
|
|
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 =
|
|
30869
|
-
return `runs only ${
|
|
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 =
|
|
31046
|
+
const channel = str5(attrs.type) ?? "notification";
|
|
30873
31047
|
if (channel === "notification") {
|
|
30874
|
-
const n =
|
|
30875
|
-
attrs.notification = { body: n.body ?? "", title: `TEST: ${
|
|
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 =
|
|
30882
|
-
attrs.email = { isCloned: false, html: e.html ?? "", subject: `TEST: ${
|
|
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 =
|
|
30891
|
-
const a =
|
|
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 =
|
|
31069
|
+
const ch = asObj4(a[k]);
|
|
30896
31070
|
if (!Object.keys(ch).length) continue;
|
|
30897
|
-
if (
|
|
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 [
|
|
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(
|
|
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 =
|
|
31084
|
+
const s = asObj4(a.startAfter);
|
|
30911
31085
|
const v = Number(s.value);
|
|
30912
|
-
const unit = (
|
|
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(
|
|
30920
|
-
if (
|
|
30921
|
-
const 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:
|
|
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(
|
|
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 (
|
|
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 =
|
|
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 =
|
|
30947
|
-
const name =
|
|
30948
|
-
if (type === "if_else" ||
|
|
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 = { ...
|
|
31131
|
+
const attrs = { ...asObj4(n.attributes) };
|
|
30958
31132
|
if (type === "wait") {
|
|
30959
|
-
if (
|
|
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(
|
|
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(
|
|
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: ${
|
|
31161
|
+
attrs.title = `TEST: ${str5(attrs.title) ?? ""}`.trim();
|
|
30988
31162
|
}
|
|
30989
31163
|
if (type === "webhook") {
|
|
30990
|
-
const 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 =
|
|
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 =
|
|
31235
|
+
const wf = asObj4(raw);
|
|
31062
31236
|
return {
|
|
31063
31237
|
id,
|
|
31064
|
-
name:
|
|
31065
|
-
status:
|
|
31066
|
-
templates: asArr2(
|
|
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 =
|
|
31104
|
-
const steps = asArr2(
|
|
31105
|
-
const longWait = steps.find((s) =>
|
|
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 (!(
|
|
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 =
|
|
31126
|
-
const 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(
|
|
31347
|
+
const rows2 = asArr2(asObj4(await b.listWorkflows(100, skip)).rows).map(asObj4);
|
|
31174
31348
|
for (const r of rows2) {
|
|
31175
|
-
const id =
|
|
31176
|
-
if (id) out.push({ id, name:
|
|
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 =
|
|
31184
|
-
return { 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,
|
|
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
|
-
|
|
31374
|
+
asObj4 = (v) => v && typeof v === "object" ? v : {};
|
|
31201
31375
|
asArr2 = (v) => Array.isArray(v) ? v : [];
|
|
31202
|
-
|
|
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 = (
|
|
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(
|
|
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 =
|
|
31507
|
+
const convId = str6(asObj5(c).id);
|
|
31334
31508
|
if (!convId) continue;
|
|
31335
|
-
const raw =
|
|
31336
|
-
const list3 = asArr3(raw.messages).length ? asArr3(raw.messages) : asArr3(
|
|
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 =
|
|
31339
|
-
if (
|
|
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(
|
|
31516
|
+
const at = Date.parse(str6(m.dateAdded) ?? "");
|
|
31343
31517
|
if (Number.isNaN(at) || at < since - 5e3) continue;
|
|
31344
|
-
const 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:
|
|
31351
|
-
body: htmlToText(
|
|
31352
|
-
raw:
|
|
31353
|
-
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(
|
|
31362
|
-
const co =
|
|
31363
|
-
if (
|
|
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 =
|
|
31385
|
-
const businessName =
|
|
31386
|
-
const websiteHost = siteHost(
|
|
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" ? `
|
|
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 =
|
|
31453
|
-
run.contactId =
|
|
31454
|
-
if (!run.contactId) throw new Error("GoHighLevel did not return the
|
|
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
|
-
|
|
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
|
|
31481
|
-
const
|
|
31482
|
-
|
|
31483
|
-
let contact;
|
|
31650
|
+
async function listLeadCarriers(client, run) {
|
|
31651
|
+
const carriers = /* @__PURE__ */ new Map();
|
|
31652
|
+
let partial = false;
|
|
31484
31653
|
try {
|
|
31485
|
-
|
|
31486
|
-
|
|
31487
|
-
|
|
31488
|
-
|
|
31489
|
-
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
|
|
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
|
-
|
|
31496
|
-
|
|
31497
|
-
|
|
31498
|
-
|
|
31499
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
31833
|
+
asObj5 = (v) => v && typeof v === "object" ? v : {};
|
|
31662
31834
|
asArr3 = (v) => Array.isArray(v) ? v : [];
|
|
31663
|
-
|
|
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 =
|
|
31901
|
-
if (
|
|
31902
|
-
const s =
|
|
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 = (
|
|
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 =
|
|
31924
|
-
const 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 =
|
|
32100
|
+
const type = str7(node.type);
|
|
31929
32101
|
if (type !== "sms" && type !== "email") continue;
|
|
31930
32102
|
let minutes = 0;
|
|
31931
|
-
let conditional = Boolean(
|
|
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 =
|
|
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) ||
|
|
32111
|
+
if (isBranch(p) || str7(p.parent) && isBranch(nodes.get(str7(p.parent) ?? "") ?? {})) {
|
|
31940
32112
|
conditional = true;
|
|
31941
32113
|
break;
|
|
31942
32114
|
}
|
|
31943
|
-
if (
|
|
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 =
|
|
32124
|
+
cur = str7(p.parentKey);
|
|
31953
32125
|
}
|
|
31954
32126
|
if (conditional) {
|
|
31955
32127
|
leftOut.conditional++;
|
|
31956
32128
|
continue;
|
|
31957
32129
|
}
|
|
31958
|
-
const a =
|
|
31959
|
-
const key = type === "email" ? wordingKey(
|
|
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 =
|
|
31965
|
-
expect.push({ label: `${wf.name}: ${stepName}`, channel: type, match: key, byMinute: minutes + 1, stepName, ...wf.id ? { workflowId: wf.id } : {}, ...
|
|
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 =
|
|
31979
|
-
const name =
|
|
32150
|
+
const wf = asObj6(await reader.getWorkflow(id));
|
|
32151
|
+
const name = str7(wf.name) ?? id;
|
|
31980
32152
|
names[id] = name;
|
|
31981
|
-
if (
|
|
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(
|
|
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
|
|
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
|
-
|
|
32169
|
+
asObj6 = (v) => v && typeof v === "object" ? v : {};
|
|
31998
32170
|
asArr4 = (v) => Array.isArray(v) ? v : [];
|
|
31999
|
-
|
|
32000
|
-
isBranch = (n) => /^branch-/.test(
|
|
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 =
|
|
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(
|
|
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 =
|
|
32013
|
-
return asArr5(raw.users).map(
|
|
32014
|
-
id:
|
|
32015
|
-
name:
|
|
32016
|
-
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
|
|
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
|
-
|
|
32245
|
+
asObj7 = (v) => v && typeof v === "object" ? v : {};
|
|
32074
32246
|
asArr5 = (v) => Array.isArray(v) ? v : [];
|
|
32075
|
-
|
|
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
|
|
35479
|
-
const 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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"
|
|
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("
|
|
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
|
|
61211
|
-
const brand =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
61249
|
-
const 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:
|
|
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
|
-
...
|
|
61431
|
+
...str9("mode") && { mode: str9("mode") },
|
|
61260
61432
|
...a.slides && { slides: Number(a.slides) },
|
|
61261
|
-
theme:
|
|
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
|
-
...
|
|
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.");
|