@elitedcs/ghl-mcp 3.89.0-beta.1 → 3.89.0-beta.3
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 +31 -1
- package/README.md +1 -1
- package/dist/index.js +1641 -327
- package/package.json +1 -1
- package/skills/blueprint/SKILL.md +1 -1
- package/skills/blueprint/presets/clinic.preset.json +8 -0
- package/skills/blueprint/presets/coach.preset.json +9 -0
- package/skills/blueprint/presets/ecommerce.preset.json +6 -0
- package/skills/blueprint/presets/generic-client.preset.json +9 -0
- package/skills/blueprint/presets/local-service.preset.json +9 -0
- package/skills/blueprint/presets/med-spa.preset.json +8 -0
- package/skills/blueprint/references/preset-format.md +1 -1
package/dist/index.js
CHANGED
|
@@ -2185,6 +2185,128 @@ var init_workflow_publish_gate = __esm({
|
|
|
2185
2185
|
});
|
|
2186
2186
|
|
|
2187
2187
|
// src/workflow-builder-client.ts
|
|
2188
|
+
function firebaseIdTokenStore() {
|
|
2189
|
+
const g = globalThis;
|
|
2190
|
+
let store2 = g[FIREBASE_ID_TOKEN_STORE_KEY];
|
|
2191
|
+
if (!store2) {
|
|
2192
|
+
store2 = { cache: /* @__PURE__ */ new Map(), inflight: /* @__PURE__ */ new Map() };
|
|
2193
|
+
g[FIREBASE_ID_TOKEN_STORE_KEY] = store2;
|
|
2194
|
+
}
|
|
2195
|
+
return store2;
|
|
2196
|
+
}
|
|
2197
|
+
function firebaseIdTokenKey(firebaseApiKey, userId, refreshToken) {
|
|
2198
|
+
return `${firebaseApiKey}\0${userId}\0${refreshToken}`;
|
|
2199
|
+
}
|
|
2200
|
+
function idTokenFilePath() {
|
|
2201
|
+
return path4.join(appDataDir(), ID_TOKEN_FILE_NAME);
|
|
2202
|
+
}
|
|
2203
|
+
function idTokenFileKey(firebaseApiKey, userId, refreshToken) {
|
|
2204
|
+
return (0, import_crypto2.createHash)("sha256").update(`${firebaseApiKey}|${userId}|${refreshToken}`).digest("hex");
|
|
2205
|
+
}
|
|
2206
|
+
function validFileEntry(e, now = Date.now()) {
|
|
2207
|
+
const v = e;
|
|
2208
|
+
return !!v && typeof v === "object" && typeof v.idToken === "string" && v.idToken.length > 0 && typeof v.expiresAt === "number" && Number.isFinite(v.expiresAt) && now < v.expiresAt;
|
|
2209
|
+
}
|
|
2210
|
+
function readIdTokenFile() {
|
|
2211
|
+
try {
|
|
2212
|
+
const parsed2 = JSON.parse(fs4.readFileSync(idTokenFilePath(), "utf8"));
|
|
2213
|
+
if (!parsed2 || typeof parsed2 !== "object" || Array.isArray(parsed2)) return {};
|
|
2214
|
+
const out = {};
|
|
2215
|
+
for (const [k, v] of Object.entries(parsed2)) {
|
|
2216
|
+
if (validFileEntry(v)) out[k] = { idToken: v.idToken, expiresAt: v.expiresAt };
|
|
2217
|
+
}
|
|
2218
|
+
return out;
|
|
2219
|
+
} catch {
|
|
2220
|
+
return {};
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
function readIdTokenFileEntry(key) {
|
|
2224
|
+
return readIdTokenFile()[key];
|
|
2225
|
+
}
|
|
2226
|
+
function sleepSync(ms) {
|
|
2227
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2228
|
+
}
|
|
2229
|
+
function withIdTokenFileLock(file2, fn) {
|
|
2230
|
+
const lock = `${file2}.lock`;
|
|
2231
|
+
const deadline = Date.now() + ID_TOKEN_LOCK_WAIT_MS;
|
|
2232
|
+
let fd;
|
|
2233
|
+
let ino;
|
|
2234
|
+
while (fd === void 0 && Date.now() <= deadline) {
|
|
2235
|
+
try {
|
|
2236
|
+
fd = fs4.openSync(lock, "wx", 384);
|
|
2237
|
+
try {
|
|
2238
|
+
ino = fs4.fstatSync(fd).ino;
|
|
2239
|
+
} catch {
|
|
2240
|
+
ino = void 0;
|
|
2241
|
+
}
|
|
2242
|
+
break;
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
if (error.code !== "EEXIST") break;
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
if (Date.now() - fs4.statSync(lock).mtimeMs > ID_TOKEN_LOCK_STALE_MS) {
|
|
2248
|
+
const moved = `${lock}.${process.pid}.${(0, import_crypto2.randomBytes)(4).toString("hex")}.stale`;
|
|
2249
|
+
fs4.renameSync(lock, moved);
|
|
2250
|
+
try {
|
|
2251
|
+
fs4.unlinkSync(moved);
|
|
2252
|
+
} catch {
|
|
2253
|
+
}
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
} catch {
|
|
2257
|
+
}
|
|
2258
|
+
sleepSync(15);
|
|
2259
|
+
}
|
|
2260
|
+
try {
|
|
2261
|
+
fn();
|
|
2262
|
+
} finally {
|
|
2263
|
+
if (fd !== void 0) {
|
|
2264
|
+
let ours = false;
|
|
2265
|
+
try {
|
|
2266
|
+
ours = ino === void 0 || fs4.statSync(lock).ino === ino;
|
|
2267
|
+
} catch {
|
|
2268
|
+
ours = false;
|
|
2269
|
+
}
|
|
2270
|
+
try {
|
|
2271
|
+
fs4.closeSync(fd);
|
|
2272
|
+
} catch {
|
|
2273
|
+
}
|
|
2274
|
+
if (ours) {
|
|
2275
|
+
try {
|
|
2276
|
+
fs4.unlinkSync(lock);
|
|
2277
|
+
} catch {
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
function updateIdTokenFile(change) {
|
|
2284
|
+
let tmp;
|
|
2285
|
+
try {
|
|
2286
|
+
const file2 = idTokenFilePath();
|
|
2287
|
+
fs4.mkdirSync(path4.dirname(file2), { recursive: true, mode: 448 });
|
|
2288
|
+
withIdTokenFileLock(file2, () => {
|
|
2289
|
+
const entries = readIdTokenFile();
|
|
2290
|
+
change(entries);
|
|
2291
|
+
tmp = `${file2}.${process.pid}.${(0, import_crypto2.randomBytes)(6).toString("hex")}.tmp`;
|
|
2292
|
+
fs4.writeFileSync(tmp, JSON.stringify(entries), { mode: 384 });
|
|
2293
|
+
fs4.renameSync(tmp, file2);
|
|
2294
|
+
tmp = void 0;
|
|
2295
|
+
try {
|
|
2296
|
+
fs4.chmodSync(file2, 384);
|
|
2297
|
+
} catch {
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
} catch {
|
|
2301
|
+
} finally {
|
|
2302
|
+
if (tmp) {
|
|
2303
|
+
try {
|
|
2304
|
+
fs4.unlinkSync(tmp);
|
|
2305
|
+
} catch {
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2188
2310
|
function isParkedContactsError(e) {
|
|
2189
2311
|
return typeof e === "object" && e !== null && e.code === "PARKED_CONTACTS";
|
|
2190
2312
|
}
|
|
@@ -2507,13 +2629,16 @@ function validateActionChain(actions, existingIds) {
|
|
|
2507
2629
|
async function builderRequest(client, method, url, opts = {}) {
|
|
2508
2630
|
assertWriteAllowed(method, url, { body: opts.body, defaultLocationId: client.locationId });
|
|
2509
2631
|
const headers = { ...await client.buildHeaders(), ...opts.headers ?? {} };
|
|
2632
|
+
const usedIdToken = headers["token-id"];
|
|
2510
2633
|
const init = { method, headers };
|
|
2511
2634
|
if (opts.body !== void 0) init.body = JSON.stringify(opts.body);
|
|
2512
2635
|
const controller = new AbortController();
|
|
2513
2636
|
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
2514
2637
|
init.signal = controller.signal;
|
|
2515
2638
|
try {
|
|
2516
|
-
|
|
2639
|
+
const response = await fetch(url, init);
|
|
2640
|
+
if (response.status === 401) client.invalidateIdToken?.(usedIdToken ?? null);
|
|
2641
|
+
return response;
|
|
2517
2642
|
} catch (error) {
|
|
2518
2643
|
if (error instanceof Error && error.name === "AbortError") {
|
|
2519
2644
|
throw new Error(`Request timeout (30s): ${method} ${url}`);
|
|
@@ -2523,7 +2648,7 @@ async function builderRequest(client, method, url, opts = {}) {
|
|
|
2523
2648
|
clearTimeout(timeout);
|
|
2524
2649
|
}
|
|
2525
2650
|
}
|
|
2526
|
-
var fs4, path4, dotenv, import_zod4, GATED_PUBLISH, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, NodeLossError, CARRIED_WORKFLOW_SETTINGS, UUID_V4, StaleWorkflowError, WorkflowStatusChangedError, WorkflowBuilderClient;
|
|
2651
|
+
var fs4, path4, dotenv, import_zod4, import_crypto2, GATED_PUBLISH, BACKEND_BASE, FIREBASE_TOKEN_URL, MAX_RETRIES2, BASE_DELAY_MS2, FirebaseTokenSchema, FIREBASE_ID_TOKEN_STORE_KEY, ID_TOKEN_EXPIRY_MARGIN_MS, ID_TOKEN_FALLBACK_TTL_MS, FIREBASE_429_DELAYS_MS, FIREBASE_EXCHANGE_TIMEOUT_MS, ID_TOKEN_FILE_NAME, ID_TOKEN_LOCK_WAIT_MS, ID_TOKEN_LOCK_STALE_MS, WorkflowActionSchema, WorkflowFullSchema, CreateWorkflowResponseSchema, ParkedContactsError, NodeLossError, CARRIED_WORKFLOW_SETTINGS, UUID_V4, StaleWorkflowError, WorkflowStatusChangedError, WorkflowBuilderClient;
|
|
2527
2652
|
var init_workflow_builder_client = __esm({
|
|
2528
2653
|
"src/workflow-builder-client.ts"() {
|
|
2529
2654
|
"use strict";
|
|
@@ -2533,6 +2658,7 @@ var init_workflow_builder_client = __esm({
|
|
|
2533
2658
|
import_zod4 = require("zod");
|
|
2534
2659
|
init_account_rules();
|
|
2535
2660
|
init_credentials_store();
|
|
2661
|
+
import_crypto2 = require("crypto");
|
|
2536
2662
|
init_account_read_retry();
|
|
2537
2663
|
init_retry();
|
|
2538
2664
|
init_firebase_claims();
|
|
@@ -2551,6 +2677,14 @@ var init_workflow_builder_client = __esm({
|
|
|
2551
2677
|
refresh_token: import_zod4.z.string(),
|
|
2552
2678
|
expires_in: import_zod4.z.string()
|
|
2553
2679
|
});
|
|
2680
|
+
FIREBASE_ID_TOKEN_STORE_KEY = /* @__PURE__ */ Symbol.for("ghl-mcp.firebaseIdTokenCache");
|
|
2681
|
+
ID_TOKEN_EXPIRY_MARGIN_MS = 5 * 60 * 1e3;
|
|
2682
|
+
ID_TOKEN_FALLBACK_TTL_MS = 55 * 60 * 1e3;
|
|
2683
|
+
FIREBASE_429_DELAYS_MS = [2e3, 5e3, 1e4];
|
|
2684
|
+
FIREBASE_EXCHANGE_TIMEOUT_MS = 15e3;
|
|
2685
|
+
ID_TOKEN_FILE_NAME = "firebase-id-tokens.json";
|
|
2686
|
+
ID_TOKEN_LOCK_WAIT_MS = 2e3;
|
|
2687
|
+
ID_TOKEN_LOCK_STALE_MS = 1e4;
|
|
2554
2688
|
WorkflowActionSchema = import_zod4.z.custom(
|
|
2555
2689
|
(value) => typeof value === "object" && value !== null && "type" in value
|
|
2556
2690
|
);
|
|
@@ -2637,6 +2771,9 @@ var init_workflow_builder_client = __esm({
|
|
|
2637
2771
|
cachedIdToken = null;
|
|
2638
2772
|
tokenExpiry = 0;
|
|
2639
2773
|
tokenRefreshPromise = null;
|
|
2774
|
+
// Bumped whenever the credentials change (company switch). An exchange that
|
|
2775
|
+
// started under an older generation never lands on this client.
|
|
2776
|
+
tokenGeneration = 0;
|
|
2640
2777
|
// The company_id claim from the most recently minted ID token. Lets
|
|
2641
2778
|
// health_check report the company we're ACTUALLY authenticated as (vs. the
|
|
2642
2779
|
// one we intended), and backs PIT/Firebase mismatch detection.
|
|
@@ -2741,11 +2878,34 @@ var init_workflow_builder_client = __esm({
|
|
|
2741
2878
|
this.cachedIdToken = null;
|
|
2742
2879
|
this.tokenExpiry = 0;
|
|
2743
2880
|
this.tokenRefreshPromise = null;
|
|
2881
|
+
this.tokenGeneration++;
|
|
2744
2882
|
}
|
|
2745
2883
|
/**
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2884
|
+
* GHL answered 401 to a request sent with `usedToken`: forget THAT token
|
|
2885
|
+
* everywhere (this client, the process-wide cache and the cross-process
|
|
2886
|
+
* file), so no client or process reuses it. Only entries holding exactly
|
|
2887
|
+
* that token go: a late 401 from a request that carried an older token (a
|
|
2888
|
+
* parallel request, or the company before a switch) never wipes the fresh
|
|
2889
|
+
* one that replaced it. Without an argument it means this client's current
|
|
2890
|
+
* token.
|
|
2891
|
+
*/
|
|
2892
|
+
invalidateIdToken(usedToken = this.cachedIdToken) {
|
|
2893
|
+
if (!usedToken) return;
|
|
2894
|
+
const store2 = firebaseIdTokenStore();
|
|
2895
|
+
for (const [k, e] of store2.cache) if (e.idToken === usedToken) store2.cache.delete(k);
|
|
2896
|
+
updateIdTokenFile((entries) => {
|
|
2897
|
+
for (const k of Object.keys(entries)) if (entries[k].idToken === usedToken) delete entries[k];
|
|
2898
|
+
});
|
|
2899
|
+
if (this.cachedIdToken === usedToken) {
|
|
2900
|
+
this.cachedIdToken = null;
|
|
2901
|
+
this.tokenExpiry = 0;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Probe Firebase auth by getting an ID token. Used by the health_check
|
|
2906
|
+
* diagnostic tool. Does not touch GHL backend. A fresh cached ID token (this
|
|
2907
|
+
* process's or another's) answers without an exchange; otherwise it exchanges
|
|
2908
|
+
* the refresh token at securetoken.googleapis.com.
|
|
2749
2909
|
*/
|
|
2750
2910
|
async checkAuth() {
|
|
2751
2911
|
try {
|
|
@@ -2812,8 +2972,9 @@ var init_workflow_builder_client = __esm({
|
|
|
2812
2972
|
return null;
|
|
2813
2973
|
}
|
|
2814
2974
|
/**
|
|
2815
|
-
* Get a
|
|
2816
|
-
*
|
|
2975
|
+
* Get a Firebase ID token: this client's own copy, then the process-wide
|
|
2976
|
+
* cache, and only then an exchange at securetoken (shared by every client
|
|
2977
|
+
* asking for the same credential at the same moment).
|
|
2817
2978
|
*/
|
|
2818
2979
|
async getIdToken() {
|
|
2819
2980
|
if (this.cachedIdToken && Date.now() < this.tokenExpiry) {
|
|
@@ -2822,31 +2983,74 @@ var init_workflow_builder_client = __esm({
|
|
|
2822
2983
|
if (this.tokenRefreshPromise) {
|
|
2823
2984
|
return this.tokenRefreshPromise;
|
|
2824
2985
|
}
|
|
2825
|
-
|
|
2986
|
+
const refresh = this.obtainIdToken();
|
|
2987
|
+
this.tokenRefreshPromise = refresh;
|
|
2826
2988
|
try {
|
|
2827
|
-
return await
|
|
2989
|
+
return await refresh;
|
|
2828
2990
|
} finally {
|
|
2829
|
-
this.tokenRefreshPromise = null;
|
|
2991
|
+
if (this.tokenRefreshPromise === refresh) this.tokenRefreshPromise = null;
|
|
2830
2992
|
}
|
|
2831
2993
|
}
|
|
2832
|
-
async
|
|
2833
|
-
const
|
|
2834
|
-
const
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
if (!response.ok) {
|
|
2841
|
-
this.cachedIdToken = null;
|
|
2842
|
-
this.tokenExpiry = 0;
|
|
2843
|
-
const text2 = await response.text();
|
|
2844
|
-
throw new Error(`Firebase token refresh failed (${response.status}): ${text2}`);
|
|
2994
|
+
async obtainIdToken() {
|
|
2995
|
+
const generation = this.tokenGeneration;
|
|
2996
|
+
const store2 = firebaseIdTokenStore();
|
|
2997
|
+
let key = firebaseIdTokenKey(this.firebaseApiKey, this.userId, this.refreshToken);
|
|
2998
|
+
const known = store2.cache.get(key);
|
|
2999
|
+
if (known && known.refreshToken !== this.refreshToken) {
|
|
3000
|
+
this.followRotation(known.refreshToken);
|
|
3001
|
+
key = firebaseIdTokenKey(this.firebaseApiKey, this.userId, this.refreshToken);
|
|
2845
3002
|
}
|
|
2846
|
-
const
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
3003
|
+
const hit = store2.cache.get(key);
|
|
3004
|
+
if (hit && Date.now() < hit.expiresAt) {
|
|
3005
|
+
return this.adoptIdToken(hit);
|
|
3006
|
+
}
|
|
3007
|
+
const shared = readIdTokenFileEntry(idTokenFileKey(this.firebaseApiKey, this.userId, this.refreshToken));
|
|
3008
|
+
if (shared) {
|
|
3009
|
+
const entry2 = { idToken: shared.idToken, expiresAt: shared.expiresAt, refreshToken: this.refreshToken };
|
|
3010
|
+
store2.cache.set(key, entry2);
|
|
3011
|
+
return this.adoptIdToken(entry2);
|
|
3012
|
+
}
|
|
3013
|
+
let pending = store2.inflight.get(key);
|
|
3014
|
+
if (!pending) {
|
|
3015
|
+
const started = this.performTokenRefresh();
|
|
3016
|
+
pending = started;
|
|
3017
|
+
store2.inflight.set(key, started);
|
|
3018
|
+
const clear = () => {
|
|
3019
|
+
if (store2.inflight.get(key) === started) store2.inflight.delete(key);
|
|
3020
|
+
};
|
|
3021
|
+
started.then(clear, clear);
|
|
3022
|
+
}
|
|
3023
|
+
let entry;
|
|
3024
|
+
try {
|
|
3025
|
+
entry = await pending;
|
|
3026
|
+
} catch (error) {
|
|
3027
|
+
if (generation === this.tokenGeneration) {
|
|
3028
|
+
this.cachedIdToken = null;
|
|
3029
|
+
this.tokenExpiry = 0;
|
|
3030
|
+
}
|
|
3031
|
+
throw error;
|
|
3032
|
+
}
|
|
3033
|
+
if (generation !== this.tokenGeneration) return this.getIdToken();
|
|
3034
|
+
return this.adoptIdToken(entry);
|
|
3035
|
+
}
|
|
3036
|
+
/** Take on a rotated refresh token another client already persisted. */
|
|
3037
|
+
followRotation(newToken2) {
|
|
3038
|
+
if (this.refreshToken === this.homeRefreshToken && this.currentCompanyId === this.homeCompanyId) {
|
|
3039
|
+
this.homeRefreshToken = newToken2;
|
|
3040
|
+
}
|
|
3041
|
+
this.refreshToken = newToken2;
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* Make a (fresh or shared) ID token this client's own: cache it on the
|
|
3045
|
+
* instance, follow a rotation, and run the company check below.
|
|
3046
|
+
*/
|
|
3047
|
+
adoptIdToken(entry) {
|
|
3048
|
+
this.cachedIdToken = entry.idToken;
|
|
3049
|
+
this.tokenExpiry = entry.expiresAt;
|
|
3050
|
+
if (entry.refreshToken !== this.refreshToken) {
|
|
3051
|
+
this.followRotation(entry.refreshToken);
|
|
3052
|
+
}
|
|
3053
|
+
this.tokenCompanyId = decodeFirebaseClaims(entry.idToken).companyId;
|
|
2850
3054
|
const expectedCompanyId = this.intendedCompanyId ?? this.currentCompanyId;
|
|
2851
3055
|
if (expectedCompanyId && this.tokenCompanyId && expectedCompanyId !== this.tokenCompanyId) {
|
|
2852
3056
|
const warnKey = `${expectedCompanyId}->${this.tokenCompanyId}`;
|
|
@@ -2858,11 +3062,65 @@ var init_workflow_builder_client = __esm({
|
|
|
2858
3062
|
);
|
|
2859
3063
|
}
|
|
2860
3064
|
}
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
3065
|
+
return entry.idToken;
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Exchange the refresh token at securetoken and store the result in the
|
|
3069
|
+
* process-wide cache. A 429 (Google's per-minute exchange quota) is retried
|
|
3070
|
+
* after 2s, 5s and 10s; any other failure, a bad token included, is not.
|
|
3071
|
+
*/
|
|
3072
|
+
async performTokenRefresh() {
|
|
3073
|
+
const firebaseApiKey = this.firebaseApiKey;
|
|
3074
|
+
const userId = this.userId;
|
|
3075
|
+
const oldRefreshToken = this.refreshToken;
|
|
3076
|
+
const company = this.currentCompanyId;
|
|
3077
|
+
const generation = this.tokenGeneration;
|
|
3078
|
+
const url = `${FIREBASE_TOKEN_URL}?key=${encodeURIComponent(firebaseApiKey)}`;
|
|
3079
|
+
const body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(oldRefreshToken)}`;
|
|
3080
|
+
let response;
|
|
3081
|
+
for (let attempt = 0; ; attempt++) {
|
|
3082
|
+
try {
|
|
3083
|
+
response = await fetch(url, {
|
|
3084
|
+
method: "POST",
|
|
3085
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
3086
|
+
body,
|
|
3087
|
+
signal: AbortSignal.timeout(FIREBASE_EXCHANGE_TIMEOUT_MS)
|
|
3088
|
+
});
|
|
3089
|
+
} catch (error) {
|
|
3090
|
+
const name = error?.name;
|
|
3091
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
3092
|
+
throw new Error(`Firebase token refresh timed out after ${FIREBASE_EXCHANGE_TIMEOUT_MS / 1e3}s`);
|
|
3093
|
+
}
|
|
3094
|
+
throw error;
|
|
3095
|
+
}
|
|
3096
|
+
if (response.status !== 429 || attempt >= FIREBASE_429_DELAYS_MS.length) break;
|
|
3097
|
+
const delay5 = FIREBASE_429_DELAYS_MS[attempt];
|
|
3098
|
+
process.stderr.write(`[ghl-mcp] Firebase token exchange rate-limited (429), retry ${attempt + 1}/${FIREBASE_429_DELAYS_MS.length} in ${delay5}ms
|
|
3099
|
+
`);
|
|
3100
|
+
await new Promise((r) => setTimeout(r, delay5));
|
|
3101
|
+
}
|
|
3102
|
+
if (!response.ok) {
|
|
3103
|
+
const text2 = await response.text();
|
|
3104
|
+
throw new Error(`Firebase token refresh failed (${response.status}): ${text2}`);
|
|
3105
|
+
}
|
|
3106
|
+
const data = FirebaseTokenSchema.parse(await response.json());
|
|
3107
|
+
const expiresInMs = Number(data.expires_in) * 1e3;
|
|
3108
|
+
const ttl = Number.isFinite(expiresInMs) && expiresInMs > 0 ? Math.max(expiresInMs - ID_TOKEN_EXPIRY_MARGIN_MS, 0) : ID_TOKEN_FALLBACK_TTL_MS;
|
|
3109
|
+
const newRefreshToken = data.refresh_token || oldRefreshToken;
|
|
3110
|
+
const entry = { idToken: data.id_token, expiresAt: Date.now() + ttl, refreshToken: newRefreshToken };
|
|
3111
|
+
const store2 = firebaseIdTokenStore();
|
|
3112
|
+
store2.cache.set(firebaseIdTokenKey(firebaseApiKey, userId, oldRefreshToken), entry);
|
|
3113
|
+
const shared = { idToken: entry.idToken, expiresAt: entry.expiresAt };
|
|
3114
|
+
updateIdTokenFile((entries) => {
|
|
3115
|
+
entries[idTokenFileKey(firebaseApiKey, userId, oldRefreshToken)] = shared;
|
|
3116
|
+
if (newRefreshToken !== oldRefreshToken) entries[idTokenFileKey(firebaseApiKey, userId, newRefreshToken)] = shared;
|
|
3117
|
+
});
|
|
3118
|
+
if (newRefreshToken !== oldRefreshToken) {
|
|
3119
|
+
store2.cache.set(firebaseIdTokenKey(firebaseApiKey, userId, newRefreshToken), entry);
|
|
3120
|
+
if (generation === this.tokenGeneration) this.refreshToken = newRefreshToken;
|
|
3121
|
+
this.persistRotatedToken(newRefreshToken, company);
|
|
2864
3122
|
}
|
|
2865
|
-
return
|
|
3123
|
+
return entry;
|
|
2866
3124
|
}
|
|
2867
3125
|
/**
|
|
2868
3126
|
* Persist a rotated refresh token to the slot that owns the ACTIVE company.
|
|
@@ -2873,8 +3131,7 @@ var init_workflow_builder_client = __esm({
|
|
|
2873
3131
|
* keep the in-memory home token in sync so resetToHomeFirebase() restores
|
|
2874
3132
|
* the fresh value rather than a stale one.
|
|
2875
3133
|
*/
|
|
2876
|
-
persistRotatedToken(newToken2) {
|
|
2877
|
-
const company = this.currentCompanyId;
|
|
3134
|
+
persistRotatedToken(newToken2, company = this.currentCompanyId) {
|
|
2878
3135
|
const hasCompanySlot = company !== void 0 && this.registry?.getCompanyFirebase(company) !== void 0;
|
|
2879
3136
|
if (hasCompanySlot) {
|
|
2880
3137
|
this.registry?.updateCompanyFirebaseRefreshToken(company, newToken2);
|
|
@@ -2968,8 +3225,10 @@ var init_workflow_builder_client = __esm({
|
|
|
2968
3225
|
return retryAccountRead(() => this.parkedContacts(workflowId), "workflow parked contacts");
|
|
2969
3226
|
}
|
|
2970
3227
|
const url = `https://backend.leadconnectorhq.com/workflows/status/search/count-per-step?workflowId=${encodeURIComponent(workflowId)}&locationId=${encodeURIComponent(this.locationId)}`;
|
|
2971
|
-
const
|
|
3228
|
+
const headers = await this.buildHeaders();
|
|
3229
|
+
const res = await fetch(url, { headers });
|
|
2972
3230
|
if (!res.ok) {
|
|
3231
|
+
if (res.status === 401) this.invalidateIdToken(headers["token-id"] ?? null);
|
|
2973
3232
|
const upstream = inBuildRequestScope() ? ` ${redactText(await res.text())}` : "";
|
|
2974
3233
|
throw new Error(
|
|
2975
3234
|
`Could not check whether anyone is part-way through this workflow (HTTP ${res.status}). Nothing was written. This check has to succeed before a step can be changed, because changing a step somebody is waiting on drops them from the sequence permanently.${upstream}`
|
|
@@ -3006,6 +3265,7 @@ var init_workflow_builder_client = __esm({
|
|
|
3006
3265
|
}
|
|
3007
3266
|
const url = `${BACKEND_BASE}${urlPath}`;
|
|
3008
3267
|
const headers = await this.buildHeaders();
|
|
3268
|
+
const usedIdToken = headers["token-id"];
|
|
3009
3269
|
const options = { method, headers };
|
|
3010
3270
|
if (body && (method === "POST" || method === "PUT")) {
|
|
3011
3271
|
options.body = JSON.stringify(body);
|
|
@@ -3040,6 +3300,7 @@ var init_workflow_builder_client = __esm({
|
|
|
3040
3300
|
return this.request(method, urlPath, body, attempt + 1);
|
|
3041
3301
|
}
|
|
3042
3302
|
if (!response.ok) {
|
|
3303
|
+
if (response.status === 401) this.invalidateIdToken(usedIdToken ?? null);
|
|
3043
3304
|
let errorBody = "";
|
|
3044
3305
|
try {
|
|
3045
3306
|
errorBody = await response.text();
|
|
@@ -13071,11 +13332,13 @@ __export(page_reads_exports, {
|
|
|
13071
13332
|
});
|
|
13072
13333
|
async function readPageFull(client, pageId, fetcher = fetch) {
|
|
13073
13334
|
const headers = await client.buildHeaders();
|
|
13335
|
+
const usedIdToken = headers["token-id"];
|
|
13074
13336
|
headers.Origin = "https://app.gohighlevel.com";
|
|
13075
13337
|
headers.Referer = "https://app.gohighlevel.com/";
|
|
13076
13338
|
const path50 = `/page/${pageId}?locationId=${client.locationId}`;
|
|
13077
13339
|
const response = await fetcher(`https://backend.leadconnectorhq.com/funnels${path50}`, { method: "GET", headers });
|
|
13078
13340
|
if (!response.ok) {
|
|
13341
|
+
if (response.status === 401) client.invalidateIdToken?.(usedIdToken);
|
|
13079
13342
|
const text3 = await response.text();
|
|
13080
13343
|
throw new Error(`Funnel API Error ${response.status}: GET ${path50}
|
|
13081
13344
|
${text3}`);
|
|
@@ -24127,7 +24390,9 @@ var init_client_engagements = __esm({
|
|
|
24127
24390
|
function readBoardState() {
|
|
24128
24391
|
try {
|
|
24129
24392
|
const raw = JSON.parse(fs15.readFileSync(path14.join(appDataDir(), "cockpit-state.json"), "utf8"));
|
|
24130
|
-
if (raw && typeof raw === "object" &&
|
|
24393
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
24394
|
+
return { ...raw, clients: raw.clients && typeof raw.clients === "object" ? raw.clients : {} };
|
|
24395
|
+
}
|
|
24131
24396
|
} catch {
|
|
24132
24397
|
}
|
|
24133
24398
|
return { clients: {} };
|
|
@@ -24741,7 +25006,7 @@ var init_question_set = __esm({
|
|
|
24741
25006
|
field: { kind: "custom", dataType: "LARGE_TEXT" },
|
|
24742
25007
|
placeholder: "The problems they come to you with (one per line)",
|
|
24743
25008
|
briefPath: "audience.painPoints",
|
|
24744
|
-
briefKind: "
|
|
25009
|
+
briefKind: "textList"
|
|
24745
25010
|
},
|
|
24746
25011
|
{
|
|
24747
25012
|
key: "objections",
|
|
@@ -24752,7 +25017,7 @@ var init_question_set = __esm({
|
|
|
24752
25017
|
field: { kind: "custom", dataType: "LARGE_TEXT" },
|
|
24753
25018
|
placeholder: "Why prospects hesitate (one per line)",
|
|
24754
25019
|
briefPath: "audience.objections",
|
|
24755
|
-
briefKind: "
|
|
25020
|
+
briefKind: "textList"
|
|
24756
25021
|
},
|
|
24757
25022
|
// ── Section D: Goal and sales process ──
|
|
24758
25023
|
{
|
|
@@ -27949,6 +28214,77 @@ var init_claude_locate = __esm({
|
|
|
27949
28214
|
}
|
|
27950
28215
|
});
|
|
27951
28216
|
|
|
28217
|
+
// src/result-text.ts
|
|
28218
|
+
function hasParseableResult(text2) {
|
|
28219
|
+
const m = RESULT_RE.exec(text2);
|
|
28220
|
+
if (!m) return false;
|
|
28221
|
+
try {
|
|
28222
|
+
JSON.parse(m[1]);
|
|
28223
|
+
return true;
|
|
28224
|
+
} catch {
|
|
28225
|
+
return false;
|
|
28226
|
+
}
|
|
28227
|
+
}
|
|
28228
|
+
function pickResultText(texts, firstEligible = 0) {
|
|
28229
|
+
for (let i = texts.length - 1; i >= firstEligible; i--) if (hasParseableResult(texts[i])) return texts[i];
|
|
28230
|
+
for (let i = texts.length - 1; i >= firstEligible; i--) if (/RESULT:\s*\{/.test(texts[i])) return texts[i];
|
|
28231
|
+
if (!texts.length) return "";
|
|
28232
|
+
if (texts.length <= firstEligible) return texts[texts.length - 1].replace(/RESULT:\s*\{[\s\S]*\}/, "").trim();
|
|
28233
|
+
return texts[texts.length - 1];
|
|
28234
|
+
}
|
|
28235
|
+
function resultEventFailure(ev) {
|
|
28236
|
+
if (ev?.type !== "result") return void 0;
|
|
28237
|
+
if (typeof ev.subtype === "string" && ev.subtype !== "success") return ev.subtype;
|
|
28238
|
+
if (ev.is_error === true) return "error";
|
|
28239
|
+
return void 0;
|
|
28240
|
+
}
|
|
28241
|
+
var NO_MEMBER_HOOKS, RESULT_RE, RunTexts;
|
|
28242
|
+
var init_result_text = __esm({
|
|
28243
|
+
"src/result-text.ts"() {
|
|
28244
|
+
"use strict";
|
|
28245
|
+
NO_MEMBER_HOOKS = ["--settings", '{"disableAllHooks":true}'];
|
|
28246
|
+
RESULT_RE = /RESULT:\s*(\{[\s\S]*\})/;
|
|
28247
|
+
RunTexts = class {
|
|
28248
|
+
texts = [];
|
|
28249
|
+
afterLastTool = 0;
|
|
28250
|
+
/** Set when the result event reported the run did not finish (e.g. error_max_turns). */
|
|
28251
|
+
failure;
|
|
28252
|
+
/** Record an assistant text block or the result string (blank ones are ignored). */
|
|
28253
|
+
text(t) {
|
|
28254
|
+
if (typeof t === "string" && t.trim()) this.texts.push(t);
|
|
28255
|
+
}
|
|
28256
|
+
/** Record a tool_use block, in stream order. */
|
|
28257
|
+
tool() {
|
|
28258
|
+
this.afterLastTool = this.texts.length;
|
|
28259
|
+
}
|
|
28260
|
+
/** Record every block of one stream-json event. */
|
|
28261
|
+
event(ev) {
|
|
28262
|
+
if (ev?.type === "assistant") {
|
|
28263
|
+
for (const b of ev.message?.content ?? []) {
|
|
28264
|
+
if (b?.type === "tool_use") this.tool();
|
|
28265
|
+
else if (b?.type === "text") this.text(b.text);
|
|
28266
|
+
}
|
|
28267
|
+
} else if (ev?.type === "result") this.result(ev);
|
|
28268
|
+
}
|
|
28269
|
+
/**
|
|
28270
|
+
* Record the result event: its failure, and its text only when that text was
|
|
28271
|
+
* not already said (it normally repeats the last assistant text, and a repeat
|
|
28272
|
+
* of a RESULT line said before a tool call must not become eligible).
|
|
28273
|
+
*/
|
|
28274
|
+
result(ev) {
|
|
28275
|
+
this.failure = resultEventFailure(ev) ?? this.failure;
|
|
28276
|
+
if (typeof ev.result === "string" && !this.texts.includes(ev.result)) this.text(ev.result);
|
|
28277
|
+
}
|
|
28278
|
+
get empty() {
|
|
28279
|
+
return this.texts.length === 0;
|
|
28280
|
+
}
|
|
28281
|
+
pick() {
|
|
28282
|
+
return pickResultText(this.texts, this.afterLastTool);
|
|
28283
|
+
}
|
|
28284
|
+
};
|
|
28285
|
+
}
|
|
28286
|
+
});
|
|
28287
|
+
|
|
27952
28288
|
// src/claude-cli.ts
|
|
27953
28289
|
function parseVersion(text2) {
|
|
27954
28290
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text2 || "");
|
|
@@ -28028,6 +28364,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28028
28364
|
const cmd = claudeCommand(bin, [
|
|
28029
28365
|
"-p",
|
|
28030
28366
|
SMOKE_PROMPT,
|
|
28367
|
+
...NO_MEMBER_HOOKS,
|
|
28031
28368
|
"--output-format",
|
|
28032
28369
|
"stream-json",
|
|
28033
28370
|
"--verbose",
|
|
@@ -28045,7 +28382,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28045
28382
|
done({ ok: false, error: "the engine check did not finish in time" });
|
|
28046
28383
|
}, timeoutMs);
|
|
28047
28384
|
let buffered = "";
|
|
28048
|
-
|
|
28385
|
+
const texts = new RunTexts();
|
|
28049
28386
|
let version;
|
|
28050
28387
|
let model;
|
|
28051
28388
|
let costUsd;
|
|
@@ -28062,13 +28399,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28062
28399
|
if (found.version) version = found.version;
|
|
28063
28400
|
if (found.model) model = found.model;
|
|
28064
28401
|
if (found.costUsd !== void 0) costUsd = found.costUsd;
|
|
28065
|
-
|
|
28066
|
-
for (const b of ev.message?.content ?? []) {
|
|
28067
|
-
if (b.type === "text" && b.text?.trim()) lastText = b.text;
|
|
28068
|
-
}
|
|
28069
|
-
} else if (ev.type === "result" && typeof ev.result === "string" && ev.result.trim()) {
|
|
28070
|
-
lastText = ev.result;
|
|
28071
|
-
}
|
|
28402
|
+
texts.event(ev);
|
|
28072
28403
|
} catch {
|
|
28073
28404
|
}
|
|
28074
28405
|
}
|
|
@@ -28083,7 +28414,7 @@ function runSmoke(bin, timeoutMs = 9e4) {
|
|
|
28083
28414
|
});
|
|
28084
28415
|
child.on("close", () => {
|
|
28085
28416
|
clearTimeout(timer);
|
|
28086
|
-
if (/RESULT:\s*\{/.test(
|
|
28417
|
+
if (/RESULT:\s*\{/.test(texts.pick())) done({ ok: true, version, model, costUsd });
|
|
28087
28418
|
else done({
|
|
28088
28419
|
ok: false,
|
|
28089
28420
|
version,
|
|
@@ -28142,6 +28473,7 @@ var init_claude_cli = __esm({
|
|
|
28142
28473
|
path19 = __toESM(require("path"));
|
|
28143
28474
|
init_credentials_store();
|
|
28144
28475
|
init_claude_locate();
|
|
28476
|
+
init_result_text();
|
|
28145
28477
|
MIN_CLAUDE_VERSION = "2.1.0";
|
|
28146
28478
|
SMOKE_PROMPT = 'Reply with exactly this one line and nothing else: RESULT: {"ok":true,"summary":"engine check"}';
|
|
28147
28479
|
}
|
|
@@ -28168,6 +28500,9 @@ function clientKeysInLocation(keys, locationId2) {
|
|
|
28168
28500
|
}
|
|
28169
28501
|
return [...hasDefault ? [void 0] : [], ...others.sort()];
|
|
28170
28502
|
}
|
|
28503
|
+
function secondClientChooseMessage(clientKey, name) {
|
|
28504
|
+
return `Nobody has chosen what ${name ? name : `the second clinic "${clientKey}"`} bought, so nothing was approved or built. The cockpit has no What they bought screen for a second clinic in a sub-account yet, so the choice is saved for it directly: send the cockpit POST /api/scope with this sub-account's locationId, clientKey "${clientKey}" and the features they bought (features: null for everything in the plan). Then approve and build again.`;
|
|
28505
|
+
}
|
|
28171
28506
|
var import_zod58, CLIENT_RECORD_SEPARATOR, CLIENT_KEY_ENV, clientKeySchema;
|
|
28172
28507
|
var init_client_record = __esm({
|
|
28173
28508
|
"src/client-record.ts"() {
|
|
@@ -28244,7 +28579,7 @@ function verifyReadbackPrompt(ctx) {
|
|
|
28244
28579
|
function buildClaudeArgs(spec, locationId2, locationName, ctx) {
|
|
28245
28580
|
return [
|
|
28246
28581
|
"-p",
|
|
28247
|
-
spec.prompt(locationId2, locationName, ctx) + (spec.stage === 4 ? verifyReadbackPrompt(ctx) : "") + (spec.stage === 4 ? "\nCheck EACH carried manual obligation with an account read. Return manualChecks:[{ref,type,ok,evidence}] in RESULT, where evidence names the actual read-back. Set ok:false for any obligation you cannot verify. Carried obligations: " + JSON.stringify(ctx?.manualObligations ?? []) : ""),
|
|
28582
|
+
spec.prompt(locationId2, locationName, ctx) + (spec.stage === 4 ? verifyReadbackPrompt(ctx) : "") + (spec.stage === 4 ? "\nCheck EACH carried manual obligation with an account read. Return manualChecks:[{ref,type,ok,evidence}] in RESULT, where evidence names the actual read-back. Set ok:false for any obligation you cannot verify. Carried obligations: " + JSON.stringify(ctx?.manualObligations ?? []) : "") + RESULT_LAST_RULE,
|
|
28248
28583
|
"--output-format",
|
|
28249
28584
|
"stream-json",
|
|
28250
28585
|
"--verbose",
|
|
@@ -28259,7 +28594,10 @@ function buildClaudeArgs(spec, locationId2, locationName, ctx) {
|
|
|
28259
28594
|
// tools. The deny list is COMPUTED: every built-in that touches the machine
|
|
28260
28595
|
// plus every registered GHL tool outside this stage's allowlist.
|
|
28261
28596
|
"--disallowedTools",
|
|
28262
|
-
denyListFor(verifyTools(spec, ctx)).join(",")
|
|
28597
|
+
denyListFor(verifyTools(spec, ctx)).join(","),
|
|
28598
|
+
// The member's own hooks never run inside a stage: a Stop hook added a turn
|
|
28599
|
+
// after the RESULT line and ended build steps early (2026-09-29).
|
|
28600
|
+
...NO_MEMBER_HOOKS
|
|
28263
28601
|
];
|
|
28264
28602
|
}
|
|
28265
28603
|
function parseResultLine(text2) {
|
|
@@ -28325,11 +28663,13 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
|
|
|
28325
28663
|
};
|
|
28326
28664
|
const progressPoll = progressDir ? setInterval(pollProgress, 1e3) : void 0;
|
|
28327
28665
|
progressPoll?.unref?.();
|
|
28666
|
+
let timedOut = false;
|
|
28328
28667
|
const timeout = setTimeout(() => {
|
|
28668
|
+
timedOut = true;
|
|
28329
28669
|
onEvent({ kind: "error", line: "Run timed out \u2014 stopping it." });
|
|
28330
28670
|
child.kill("SIGKILL");
|
|
28331
28671
|
}, opts.timeoutMs ?? spec.timeoutMs ?? 10 * 60 * 1e3);
|
|
28332
|
-
|
|
28672
|
+
const texts = new RunTexts();
|
|
28333
28673
|
let buffered = "";
|
|
28334
28674
|
let engineVersion;
|
|
28335
28675
|
let engineModel;
|
|
@@ -28353,14 +28693,17 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
|
|
|
28353
28693
|
if (facts.durationMs !== void 0) durationMs = facts.durationMs;
|
|
28354
28694
|
if (ev.type === "assistant") {
|
|
28355
28695
|
for (const block of ev.message?.content ?? []) {
|
|
28356
|
-
if (block.type === "tool_use")
|
|
28696
|
+
if (block.type === "tool_use") {
|
|
28697
|
+
texts.tool();
|
|
28698
|
+
onEvent({ kind: "tool", line: `\u2192 ${String(block.name).replace(/^mcp__ghl__/, "")}` });
|
|
28699
|
+
}
|
|
28357
28700
|
if (block.type === "text" && block.text?.trim()) {
|
|
28358
|
-
|
|
28701
|
+
texts.text(block.text);
|
|
28359
28702
|
onEvent({ kind: "text", line: block.text.trim().slice(0, 200) });
|
|
28360
28703
|
}
|
|
28361
28704
|
}
|
|
28362
28705
|
} else if (ev.type === "result") {
|
|
28363
|
-
|
|
28706
|
+
texts.result(ev);
|
|
28364
28707
|
}
|
|
28365
28708
|
} catch {
|
|
28366
28709
|
}
|
|
@@ -28387,13 +28730,13 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
|
|
|
28387
28730
|
noteObservedEngine({ version: engineVersion, model: engineModel });
|
|
28388
28731
|
} catch {
|
|
28389
28732
|
}
|
|
28390
|
-
if (code !== 0 &&
|
|
28733
|
+
if (!timedOut && code !== 0 && texts.empty) {
|
|
28391
28734
|
const outcome2 = { ok: false, error: `claude exited ${code}: ${stderrTail.trim().slice(-200) || "no output"}`, ...engine };
|
|
28392
28735
|
onEvent({ kind: "error", line: outcome2.error });
|
|
28393
28736
|
resolve13(outcome2);
|
|
28394
28737
|
return;
|
|
28395
28738
|
}
|
|
28396
|
-
const outcome = { ...parseResultLine(
|
|
28739
|
+
const outcome = timedOut ? { ok: false, error: "the run timed out and was stopped before it finished", ...engine } : texts.failure ? { ok: false, error: `the run did not finish (${texts.failure})`, raw: texts.pick().slice(-400), ...engine } : { ...parseResultLine(texts.pick()), ...engine };
|
|
28397
28740
|
if (stage === 3) {
|
|
28398
28741
|
try {
|
|
28399
28742
|
outcome.runId = findBuildRun(locationId2)?.runId;
|
|
@@ -28419,7 +28762,7 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
|
|
|
28419
28762
|
});
|
|
28420
28763
|
});
|
|
28421
28764
|
}
|
|
28422
|
-
var import_child_process3, fs21, STAFF_NOTIFICATION_RULE, STAFF_PAUSE_RULE, MISSING_STEP_RULE, NO_INVENTED_CAUSE_RULE, STAGE_SPECS, HUMAN_GATE_STAGES;
|
|
28765
|
+
var import_child_process3, fs21, STAFF_NOTIFICATION_RULE, STAFF_PAUSE_RULE, MISSING_STEP_RULE, NO_INVENTED_CAUSE_RULE, STAGE_SPECS, HUMAN_GATE_STAGES, RESULT_LAST_RULE;
|
|
28423
28766
|
var init_stage_runner = __esm({
|
|
28424
28767
|
"src/stage-runner.ts"() {
|
|
28425
28768
|
"use strict";
|
|
@@ -28434,6 +28777,7 @@ var init_stage_runner = __esm({
|
|
|
28434
28777
|
init_build_journal();
|
|
28435
28778
|
init_claude_cli();
|
|
28436
28779
|
init_claude_locate();
|
|
28780
|
+
init_result_text();
|
|
28437
28781
|
init_account_rules();
|
|
28438
28782
|
init_account_rules();
|
|
28439
28783
|
init_client_record();
|
|
@@ -28513,7 +28857,7 @@ If anything fails, finish with: RESULT: {"ok":false,"error":"<short reason>"}`
|
|
|
28513
28857
|
2. Find the answers. They arrive one of TWO ways and you must check BOTH:
|
|
28514
28858
|
(a) a real form submission \u2014 get_forms then get_form_submissions_full; or
|
|
28515
28859
|
(b) an intake completed in the cockpit \u2014 search_contacts for the tag "blueprint-intake-received" and read that contact's custom fields (get_contact), which hold the answers under "Intake: <question>" fields.
|
|
28516
|
-
3. Turn whichever you found into a Brief with normalize_submission_to_brief (for a form submission), then check it with validate_brief. If the answers came from the tagged contact, build the Brief from those field values directly and call validate_brief on it too. Report validate_brief gaps in issues as warnings and finish ok:true.
|
|
28860
|
+
3. Turn whichever you found into a Brief with normalize_submission_to_brief (for a form submission), then check it with validate_brief. If the answers came from the tagged contact, build the Brief from those field values directly and call validate_brief on it too. Booking calendars go at the TOP level of the Brief as calendars:[{"name":"...","type":"one_on_one"|"round_robin"|"class","staffNames":["..."],"durationMinutes":30}], one entry per calendar, never inside goal. Report validate_brief gaps in issues as warnings and finish ok:true.
|
|
28517
28861
|
Use these authoritative normalized intake answers when provided: ${JSON.stringify(ctx?.normalizedAnswers ?? {})}.
|
|
28518
28862
|
Do NOT create, update or delete anything \u2014 this step only reads and normalizes.
|
|
28519
28863
|
Only if BOTH paths come up empty, finish with RESULT: {"ok":false,"error":"Open the real form, submit it, then refresh."}.
|
|
@@ -28609,6 +28953,7 @@ RESULT: {"ok":true,"summary":"<what you checked and the headline result>","issue
|
|
|
28609
28953
|
5: "Manual tasks only a human can do (A2P, Google auth, staff, phone)",
|
|
28610
28954
|
6: "Go-live sign-off"
|
|
28611
28955
|
};
|
|
28956
|
+
RESULT_LAST_RULE = "\nThe RESULT line must be the very last thing you print: make no tool calls after it, and print nothing after it.";
|
|
28612
28957
|
}
|
|
28613
28958
|
});
|
|
28614
28959
|
|
|
@@ -29631,10 +29976,10 @@ function askNoTools(prompt, opts = {}) {
|
|
|
29631
29976
|
resolve13("");
|
|
29632
29977
|
return;
|
|
29633
29978
|
}
|
|
29634
|
-
const cmd = claudeCommand(bin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns ?? 3), "--allowedTools", "", "--disallowedTools", NO_TOOLS_DENY]);
|
|
29979
|
+
const cmd = claudeCommand(bin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns ?? 3), "--allowedTools", "", "--disallowedTools", NO_TOOLS_DENY, ...NO_MEMBER_HOOKS]);
|
|
29635
29980
|
const child = (0, import_node_child_process2.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } });
|
|
29636
29981
|
const timeout = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? 3 * 60 * 1e3);
|
|
29637
|
-
|
|
29982
|
+
const texts = new RunTexts();
|
|
29638
29983
|
let buffered = "";
|
|
29639
29984
|
let costUsd;
|
|
29640
29985
|
let reported = false;
|
|
@@ -29654,11 +29999,7 @@ function askNoTools(prompt, opts = {}) {
|
|
|
29654
29999
|
try {
|
|
29655
30000
|
const ev = JSON.parse(line3);
|
|
29656
30001
|
if (ev.type === "result" && typeof ev.total_cost_usd === "number") costUsd = ev.total_cost_usd;
|
|
29657
|
-
|
|
29658
|
-
for (const b of ev.message?.content ?? []) if (b.type === "text" && b.text?.trim()) lastText = b.text;
|
|
29659
|
-
} else if (ev.type === "result" && typeof ev.result === "string" && ev.result.trim()) {
|
|
29660
|
-
lastText = ev.result;
|
|
29661
|
-
}
|
|
30002
|
+
texts.event(ev);
|
|
29662
30003
|
} catch {
|
|
29663
30004
|
}
|
|
29664
30005
|
}
|
|
@@ -29666,12 +30007,12 @@ function askNoTools(prompt, opts = {}) {
|
|
|
29666
30007
|
child.on("error", () => {
|
|
29667
30008
|
clearTimeout(timeout);
|
|
29668
30009
|
report3();
|
|
29669
|
-
resolve13(
|
|
30010
|
+
resolve13(texts.pick());
|
|
29670
30011
|
});
|
|
29671
30012
|
child.on("close", () => {
|
|
29672
30013
|
clearTimeout(timeout);
|
|
29673
30014
|
report3();
|
|
29674
|
-
resolve13(
|
|
30015
|
+
resolve13(texts.pick());
|
|
29675
30016
|
});
|
|
29676
30017
|
});
|
|
29677
30018
|
}
|
|
@@ -29682,6 +30023,7 @@ var init_handoff_run = __esm({
|
|
|
29682
30023
|
import_node_child_process2 = require("node:child_process");
|
|
29683
30024
|
init_stage_runner();
|
|
29684
30025
|
init_claude_locate();
|
|
30026
|
+
init_result_text();
|
|
29685
30027
|
init_handoff_pack();
|
|
29686
30028
|
CLIENT_SENTENCE_RULES = "Write for the business owner, not a developer. Complete sentences that end with a period; never trail off. One or two sentences per item, no more. Everyday words: say 'text messaging registration (A2P)' not 'A2P'; 'the calendar is not connected to Google or Outlook yet' not 'OAuth'; 'nothing was wrong' not 'status ok'. No tool names, no ids, no field names in code style, no JSON, no arrows. Keep the names of workflows, pipelines and forms because the client will recognise them. If a list is long, name the first few and say how many in total. State what is in place, or what is missing and why, plainly. An item marked 'done but not confirmed' has NOT been confirmed: never describe it as having happened or been observed; say what is still to be confirmed and how (for example, 'this is confirmed on the handoff call, when you complete one task in the account without help').";
|
|
29687
30029
|
NO_TOOLS_DENY = "Bash,Write,Edit,MultiEdit,NotebookEdit,WebFetch,WebSearch,Task,TodoWrite,Read,Glob,Grep";
|
|
@@ -30078,6 +30420,9 @@ function findingsFor(outcome) {
|
|
|
30078
30420
|
const list4 = outcome.plain?.issues?.length ? outcome.plain.issues : outcome.issues;
|
|
30079
30421
|
return (list4 ?? []).map((s) => String(s).trim()).filter(Boolean);
|
|
30080
30422
|
}
|
|
30423
|
+
function openTasks(tasks2) {
|
|
30424
|
+
return tasks2.filter((t) => !(t.claimed === true && typeof t.text === "string" && SELF_REPORTED.test(t.text)));
|
|
30425
|
+
}
|
|
30081
30426
|
function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
30082
30427
|
const before = new Map(previous.map((t) => [t.id, t]));
|
|
30083
30428
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -30087,7 +30432,9 @@ function mergeTasks(previous, findings, now = (/* @__PURE__ */ new Date()).toISO
|
|
|
30087
30432
|
if (seen.has(id)) continue;
|
|
30088
30433
|
seen.add(id);
|
|
30089
30434
|
const prior = before.get(id);
|
|
30090
|
-
if (prior?.claimed) {
|
|
30435
|
+
if (prior?.claimed && SELF_REPORTED.test(text2)) {
|
|
30436
|
+
out.push(prior);
|
|
30437
|
+
} else if (prior?.claimed) {
|
|
30091
30438
|
out.push({ id, text: text2, reappeared: true });
|
|
30092
30439
|
} else {
|
|
30093
30440
|
out.push({ id, text: text2, ...prior?.reappeared ? { reappeared: true } : {} });
|
|
@@ -30105,7 +30452,7 @@ function manualProgress(tasks2, verifyHasRun) {
|
|
|
30105
30452
|
const total = tasks2.length;
|
|
30106
30453
|
const claimed = tasks2.filter((t) => t.claimed).length;
|
|
30107
30454
|
const reappeared = tasks2.filter((t) => t.reappeared).length;
|
|
30108
|
-
const clear = verifyHasRun &&
|
|
30455
|
+
const clear = verifyHasRun && openTasks(tasks2).length === 0;
|
|
30109
30456
|
let line3;
|
|
30110
30457
|
if (!verifyHasRun) {
|
|
30111
30458
|
line3 = "Run the check first. It writes this list for you.";
|
|
@@ -30139,9 +30486,11 @@ function manualSignOffRefusal(tasks2, verifyHasRun) {
|
|
|
30139
30486
|
}
|
|
30140
30487
|
return "Everything is ticked, but the check has not confirmed it yet. Run the check again and this will clear on its own.";
|
|
30141
30488
|
}
|
|
30489
|
+
var SELF_REPORTED;
|
|
30142
30490
|
var init_manual_tasks = __esm({
|
|
30143
30491
|
"src/manual-tasks.ts"() {
|
|
30144
30492
|
"use strict";
|
|
30493
|
+
SELF_REPORTED = /^[^:]+: Bought, but not built automatically yet\./;
|
|
30145
30494
|
}
|
|
30146
30495
|
});
|
|
30147
30496
|
|
|
@@ -30323,7 +30672,7 @@ function deriveQueue(input) {
|
|
|
30323
30672
|
if (stages.every((s) => s === "done")) {
|
|
30324
30673
|
const proof2 = lastProof(state, locationId2, QUEUE_STEPS.length);
|
|
30325
30674
|
const claim = liveClaim(true, state.goLive?.[locationId2], state.outcomes?.[`${locationId2}:3`]?.at);
|
|
30326
|
-
const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !state.manualTasks?.[locationId2]
|
|
30675
|
+
const signedLive = claim.live && state.outcomes?.[`${locationId2}:4`]?.ok === true && !state.outcomes?.[`${locationId2}:4`]?.issues?.length && !openTasks(state.manualTasks?.[locationId2] ?? []).length && !state.staleClients?.includes(locationId2);
|
|
30327
30676
|
finished.push({
|
|
30328
30677
|
locationId: locationId2,
|
|
30329
30678
|
clientName: name,
|
|
@@ -30457,6 +30806,7 @@ var init_work_queue = __esm({
|
|
|
30457
30806
|
"use strict";
|
|
30458
30807
|
init_stage_runner();
|
|
30459
30808
|
init_plain_outcome();
|
|
30809
|
+
init_manual_tasks();
|
|
30460
30810
|
init_go_live();
|
|
30461
30811
|
QUEUE_STEPS = [
|
|
30462
30812
|
{ label: "Intake", todo: "Set up the intake questions for {client}", why: "Command OS puts the questionnaire into their account." },
|
|
@@ -32152,7 +32502,7 @@ var require_package = __commonJS({
|
|
|
32152
32502
|
"package.json"(exports2, module2) {
|
|
32153
32503
|
module2.exports = {
|
|
32154
32504
|
name: "@elitedcs/ghl-mcp",
|
|
32155
|
-
version: "3.89.0-beta.
|
|
32505
|
+
version: "3.89.0-beta.3",
|
|
32156
32506
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
32157
32507
|
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.",
|
|
32158
32508
|
main: "dist/index.js",
|
|
@@ -33376,10 +33726,12 @@ function judgeSteps(rawRows, contactId, workflowNames) {
|
|
|
33376
33726
|
}
|
|
33377
33727
|
};
|
|
33378
33728
|
}
|
|
33379
|
-
function liveStepLogReader(locationId2, headers, fetchFn = fetch) {
|
|
33729
|
+
function liveStepLogReader(locationId2, headers, fetchFn = fetch, onUnauthorized) {
|
|
33380
33730
|
return async (workflowId, fromMs, toMs) => {
|
|
33381
33731
|
const q3 = new URLSearchParams({ action: "first", dateType: "custom", fromDate: String(Math.floor(fromMs)), toDate: String(Math.ceil(toMs)), limit: String(STEP_LOG_LIMIT), locationId: locationId2, workflowId });
|
|
33382
|
-
const
|
|
33732
|
+
const used = await headers();
|
|
33733
|
+
const res = await fetchFn(`https://backend.leadconnectorhq.com/workflows/logs/v2?${q3}`, { headers: used });
|
|
33734
|
+
if (res.status === 401) onUnauthorized?.(used);
|
|
33383
33735
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
33384
33736
|
const body = await res.json();
|
|
33385
33737
|
if (!Array.isArray(body)) throw new Error("the log came back in a shape this version does not know");
|
|
@@ -35892,7 +36244,7 @@ function workflowReadConnection(registry2, client, companyId) {
|
|
|
35892
36244
|
const home = companyId && companyId === process.env.GHL_COMPANY_ID ? registry2.getFirebase() : void 0;
|
|
35893
36245
|
const config3 = fb ?? home ?? (companyId && companyId === process.env.GHL_COMPANY_ID && process.env.GHL_FIREBASE_REFRESH_TOKEN ? { apiKey: process.env.GHL_FIREBASE_API_KEY, refreshToken: process.env.GHL_FIREBASE_REFRESH_TOKEN, userId: process.env.GHL_USER_ID } : void 0);
|
|
35894
36246
|
if (!config3?.apiKey || !config3.userId) throw new Error(BUILDER_REPAIR_MESSAGE);
|
|
35895
|
-
return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId });
|
|
36247
|
+
return new WorkflowBuilderClient({ firebaseApiKey: config3.apiKey, refreshToken: config3.refreshToken, userId: config3.userId, apiKey: client.getApiKey(), locationId: locationId2, companyId, registry: registry2 });
|
|
35896
36248
|
}
|
|
35897
36249
|
var BUILDER_REPAIR_MESSAGE;
|
|
35898
36250
|
var init_workflow_read_connection = __esm({
|
|
@@ -38194,6 +38546,13 @@ function saveBuildObligations(id, items) {
|
|
|
38194
38546
|
fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...merged.values()], null, 2), { mode: 384 });
|
|
38195
38547
|
fs30.renameSync(file2 + ".tmp", file2);
|
|
38196
38548
|
}
|
|
38549
|
+
function replaceBuildObligationsOfType(id, type, items) {
|
|
38550
|
+
const kept = readBuildObligations(id).filter((m) => m.type !== type);
|
|
38551
|
+
const file2 = fileFor(id);
|
|
38552
|
+
fs30.mkdirSync(path28.dirname(file2), { recursive: true });
|
|
38553
|
+
fs30.writeFileSync(file2 + ".tmp", JSON.stringify([...kept, ...items.map((i) => ({ ...i, type }))], null, 2), { mode: 384 });
|
|
38554
|
+
fs30.renameSync(file2 + ".tmp", file2);
|
|
38555
|
+
}
|
|
38197
38556
|
function planObjectRefs(plan) {
|
|
38198
38557
|
const refs2 = /* @__PURE__ */ new Set();
|
|
38199
38558
|
const visit = (value) => {
|
|
@@ -38212,7 +38571,7 @@ function planObjectRefs(plan) {
|
|
|
38212
38571
|
function reconcileBuildObligations(id, checks, currentPlan) {
|
|
38213
38572
|
const refs2 = currentPlan && typeof currentPlan === "object" ? planObjectRefs(currentPlan) : void 0;
|
|
38214
38573
|
const workflows = currentPlan?.workflows;
|
|
38215
|
-
const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || // The producer keys each manual job by workflow name and local job index.
|
|
38574
|
+
const stillPlanned = (item) => !refs2 || refs2.has(item.ref) || NOT_FROM_PLAN.has(item.type) || // The producer keys each manual job by workflow name and local job index.
|
|
38216
38575
|
// Its index can change or disappear from a later preview without completion.
|
|
38217
38576
|
item.type === "workflow-manual" && Array.isArray(workflows) && workflows.some((workflow) => item.ref.startsWith(`${workflow.name}:`) && /^\d+$/.test(item.ref.slice(workflow.name.length + 1)));
|
|
38218
38577
|
const remaining = readBuildObligations(id).filter((item) => stillPlanned(item) && !checks.some((c) => c.ref === item.ref && c.type === item.type && c.ok === true && typeof c.evidence === "string" && c.evidence.trim()));
|
|
@@ -38221,13 +38580,14 @@ function reconcileBuildObligations(id, checks, currentPlan) {
|
|
|
38221
38580
|
fs30.writeFileSync(file2 + ".tmp", JSON.stringify(remaining, null, 2), { mode: 384 });
|
|
38222
38581
|
fs30.renameSync(file2 + ".tmp", file2);
|
|
38223
38582
|
}
|
|
38224
|
-
var fs30, path28;
|
|
38583
|
+
var fs30, path28, NOT_FROM_PLAN;
|
|
38225
38584
|
var init_build_obligations = __esm({
|
|
38226
38585
|
"src/build-obligations.ts"() {
|
|
38227
38586
|
"use strict";
|
|
38228
38587
|
fs30 = __toESM(require("node:fs"));
|
|
38229
38588
|
path28 = __toESM(require("node:path"));
|
|
38230
38589
|
init_credentials_store();
|
|
38590
|
+
NOT_FROM_PLAN = /* @__PURE__ */ new Set(["bought-not-built"]);
|
|
38231
38591
|
}
|
|
38232
38592
|
});
|
|
38233
38593
|
|
|
@@ -38873,12 +39233,12 @@ var init_run_cost = __esm({
|
|
|
38873
39233
|
function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
38874
39234
|
}) {
|
|
38875
39235
|
return new Promise((resolve13, reject) => {
|
|
38876
|
-
|
|
39236
|
+
const run = new RunTexts();
|
|
38877
39237
|
let buffer = "";
|
|
38878
39238
|
let stderr = "";
|
|
38879
39239
|
let facts = {};
|
|
38880
39240
|
let settled = false;
|
|
38881
|
-
const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose"]);
|
|
39241
|
+
const cmd = claudeCommand(bin, [...argv, "--output-format", "stream-json", "--verbose", ...NO_MEMBER_HOOKS]);
|
|
38882
39242
|
const child = (0, import_node_child_process5.spawn)(cmd.command, cmd.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
38883
39243
|
const timer = setTimeout(() => child.kill("SIGKILL"), 6 * 60 * 1e3);
|
|
38884
39244
|
const finish3 = (ok) => {
|
|
@@ -38891,17 +39251,14 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
|
38891
39251
|
onCost("The cost record could not be saved.");
|
|
38892
39252
|
}
|
|
38893
39253
|
onCost(`${context.kind}: ${facts.costUsd === void 0 ? "cost not reported" : formatUsd(facts.costUsd)}.`);
|
|
38894
|
-
if (ok) resolve13(
|
|
39254
|
+
if (ok) resolve13(run.pick());
|
|
38895
39255
|
else reject(new Error("Check your Claude authentication in Your agency before reading this source."));
|
|
38896
39256
|
};
|
|
38897
39257
|
const consume = (line3) => {
|
|
38898
39258
|
try {
|
|
38899
39259
|
const ev = JSON.parse(line3);
|
|
38900
39260
|
facts = { ...facts, ...readEngineFacts(ev) };
|
|
38901
|
-
|
|
38902
|
-
for (const block of ev.message?.content ?? []) if (block.type === "text") text2 = block.text;
|
|
38903
|
-
}
|
|
38904
|
-
if (ev.type === "result" && typeof ev.result === "string") text2 = ev.result;
|
|
39261
|
+
run.event(ev);
|
|
38905
39262
|
} catch {
|
|
38906
39263
|
}
|
|
38907
39264
|
};
|
|
@@ -38919,7 +39276,7 @@ function callClaudeRecorded(bin, argv, context, onCost = () => {
|
|
|
38919
39276
|
child.on("error", () => finish3(false));
|
|
38920
39277
|
child.on("close", (code) => {
|
|
38921
39278
|
if (buffer.trim()) consume(buffer);
|
|
38922
|
-
finish3(code === 0 &&
|
|
39279
|
+
finish3(code === 0 && !run.empty);
|
|
38923
39280
|
});
|
|
38924
39281
|
});
|
|
38925
39282
|
}
|
|
@@ -38931,6 +39288,7 @@ var init_claude_call = __esm({
|
|
|
38931
39288
|
init_run_cost();
|
|
38932
39289
|
init_claude_cli();
|
|
38933
39290
|
init_claude_locate();
|
|
39291
|
+
init_result_text();
|
|
38934
39292
|
}
|
|
38935
39293
|
});
|
|
38936
39294
|
|
|
@@ -39151,10 +39509,40 @@ function briefWarnings(brief) {
|
|
|
39151
39509
|
}
|
|
39152
39510
|
return out;
|
|
39153
39511
|
}
|
|
39512
|
+
function liftGoalCalendars(input) {
|
|
39513
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return { input, notes: [] };
|
|
39514
|
+
const brief = input;
|
|
39515
|
+
const goal = brief.goal;
|
|
39516
|
+
if (!goal || typeof goal !== "object" || Array.isArray(goal)) return { input, notes: [] };
|
|
39517
|
+
const misplaced = goal.calendars;
|
|
39518
|
+
if (!Array.isArray(misplaced)) return { input, notes: [] };
|
|
39519
|
+
const top = brief.calendars;
|
|
39520
|
+
if (Array.isArray(top) && top.length > 0) {
|
|
39521
|
+
if (misplaced.length === 0) return { input, notes: [] };
|
|
39522
|
+
const names = misplaced.map((c, i) => {
|
|
39523
|
+
const n = c?.name;
|
|
39524
|
+
return typeof n === "string" && n.trim() ? `"${n.trim()}"` : `entry ${i + 1}`;
|
|
39525
|
+
});
|
|
39526
|
+
return {
|
|
39527
|
+
input,
|
|
39528
|
+
notes: [`The brief lists calendars at the top level, so the ${misplaced.length === 1 ? "calendar" : "calendars"} under goal.calendars (${names.join(", ")}) ${misplaced.length === 1 ? "was" : "were"} ignored; move any that are needed into the top-level calendars list.`]
|
|
39529
|
+
};
|
|
39530
|
+
}
|
|
39531
|
+
const lifted = [];
|
|
39532
|
+
const notes = [];
|
|
39533
|
+
misplaced.forEach((c, i) => {
|
|
39534
|
+
const r = briefCalendarSchema.safeParse(c);
|
|
39535
|
+
if (r.success) lifted.push(r.data);
|
|
39536
|
+
else notes.push(`Calendar ${i + 1} under goal.calendars could not be read (${r.error.issues.map((x) => `${x.path.join(".") || "entry"}: ${x.message}`).join("; ")}); give it a name, a type (${BRIEF_CALENDAR_TYPES.join(", ")}) and staffNames.`);
|
|
39537
|
+
});
|
|
39538
|
+
const { calendars: _moved, ...restGoal } = goal;
|
|
39539
|
+
return { input: { ...brief, goal: restGoal, ...lifted.length ? { calendars: lifted } : {} }, notes };
|
|
39540
|
+
}
|
|
39154
39541
|
function validateBrief(input) {
|
|
39155
|
-
const
|
|
39542
|
+
const lifted = liftGoalCalendars(input);
|
|
39543
|
+
const parsed2 = briefSchema.safeParse(lifted.input);
|
|
39156
39544
|
if (parsed2.success) {
|
|
39157
|
-
return { valid: true, errors: [], warnings: briefWarnings(parsed2.data), brief: parsed2.data };
|
|
39545
|
+
return { valid: true, errors: [], warnings: [...lifted.notes, ...briefWarnings(parsed2.data)], brief: parsed2.data };
|
|
39158
39546
|
}
|
|
39159
39547
|
return {
|
|
39160
39548
|
valid: false,
|
|
@@ -39302,8 +39690,12 @@ function firstString(v) {
|
|
|
39302
39690
|
}
|
|
39303
39691
|
return void 0;
|
|
39304
39692
|
}
|
|
39305
|
-
function splitList(text2) {
|
|
39306
|
-
|
|
39693
|
+
function splitList(text2, opts = {}) {
|
|
39694
|
+
let lines = text2.split(/\r?\n/).filter((l) => l.trim());
|
|
39695
|
+
if (lines.length > 1 && /:\s*$/.test(lines[0])) lines = lines.slice(1);
|
|
39696
|
+
const oneLine = (line3) => line3.split(/;|•/).flatMap((part) => opts.commas !== false && part.includes(",") && !/\d,\d/.test(part) ? part.split(",") : [part]);
|
|
39697
|
+
const items = lines.length > 1 ? lines : lines.flatMap(oneLine);
|
|
39698
|
+
return items.map((s) => s.replace(/^[\s\-*•\d.)]+/, "").replace(/[\s,;]+$/, "").trim()).filter(Boolean);
|
|
39307
39699
|
}
|
|
39308
39700
|
function parsePricePoints(text2) {
|
|
39309
39701
|
const out = [];
|
|
@@ -39316,10 +39708,13 @@ function parsePricePoints(text2) {
|
|
|
39316
39708
|
const t = segment.trim().replace(/\.$/, "");
|
|
39317
39709
|
if (!t) continue;
|
|
39318
39710
|
const m = t.match(ENTRY);
|
|
39711
|
+
const withTerms = m ? null : t.match(/^(.*?)\s*[-–—:|]?\s*\$\s*([\d,]+(?:\.\d+)?)\s*(.+)$/);
|
|
39319
39712
|
if (m && m[2]) {
|
|
39320
39713
|
const name = clean3(m[1]);
|
|
39321
39714
|
const price = (m[3] ? `${num3(m[2])}-${num3(m[3])}` : num3(m[2])) + recur(m[4]);
|
|
39322
39715
|
out.push({ name: name || t, price });
|
|
39716
|
+
} else if (withTerms && clean3(withTerms[1])) {
|
|
39717
|
+
out.push({ name: clean3(withTerms[1]), price: num3(withTerms[2]) + (withTerms[3].trim().startsWith("/") ? "" : " ") + withTerms[3].trim() });
|
|
39323
39718
|
} else {
|
|
39324
39719
|
out.push({ name: t, price: "" });
|
|
39325
39720
|
}
|
|
@@ -39335,7 +39730,16 @@ function cleanLines(text2) {
|
|
|
39335
39730
|
function parseStaffLines(text2) {
|
|
39336
39731
|
const staff = [];
|
|
39337
39732
|
const warnings = [];
|
|
39338
|
-
|
|
39733
|
+
const emailsIn = (t) => splitFields(t).filter((f) => EMAIL_RE2.test(f.replace(/^e-?mail\s*[:=]\s*/i, ""))).length;
|
|
39734
|
+
const lines = cleanLines(text2).flatMap((line3) => {
|
|
39735
|
+
if (!line3.includes(";") || emailsIn(line3) < 2) return [line3];
|
|
39736
|
+
const pieces = line3.split(";").map((p) => p.trim()).filter(Boolean);
|
|
39737
|
+
return pieces.every((p) => emailsIn(p) === 1) ? pieces : [line3];
|
|
39738
|
+
});
|
|
39739
|
+
lines.forEach((line3, i) => {
|
|
39740
|
+
if (emailsIn(line3) > 1) {
|
|
39741
|
+
warnings.push(`Staff line ${i + 1} ("${line3}") lists more than one email address, so only the first person was read. Put one person per line.`);
|
|
39742
|
+
}
|
|
39339
39743
|
const fields = splitFields(line3);
|
|
39340
39744
|
let email;
|
|
39341
39745
|
let mobile;
|
|
@@ -39473,12 +39877,29 @@ function cleanPersonCandidate(s) {
|
|
|
39473
39877
|
if (durationMinutesFromWords(t) !== void 0) return void 0;
|
|
39474
39878
|
return t;
|
|
39475
39879
|
}
|
|
39476
|
-
function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames) {
|
|
39477
|
-
|
|
39880
|
+
function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames, sharedPeople = []) {
|
|
39881
|
+
let bracketMinutes;
|
|
39882
|
+
const bracketNotes = [];
|
|
39883
|
+
const bracketPeople = [];
|
|
39884
|
+
const staffIn = (part) => knownStaffNames.length ? matchesStaffName(part.replace(/^(?:with|by)\s+/i, ""), knownStaffNames) : void 0;
|
|
39885
|
+
const unbracketed = entry.replace(/\s*\(([^()]*)\)/g, (whole, inner) => {
|
|
39886
|
+
const hasLength = countDurations(inner) > 0;
|
|
39887
|
+
if (!hasLength && !/[,$;]/.test(inner) && !staffIn(inner)) return whole;
|
|
39888
|
+
const parts = inner.split(/[,;]/).map((x) => x.trim()).filter(Boolean);
|
|
39889
|
+
for (const part of parts) {
|
|
39890
|
+
const d = durationInProse(part);
|
|
39891
|
+
const person = staffIn(part);
|
|
39892
|
+
if (d && bracketMinutes === void 0 && !d.rest.replace(/[\s.,;:-]+/g, "")) bracketMinutes = d.minutes;
|
|
39893
|
+
else if (person) bracketPeople.push(person);
|
|
39894
|
+
else bracketNotes.push(part);
|
|
39895
|
+
}
|
|
39896
|
+
return " ";
|
|
39897
|
+
}).replace(/\s{2,}/g, " ").trim();
|
|
39898
|
+
const fields = splitFields(unbracketed);
|
|
39478
39899
|
let nameField = fields.shift() ?? "";
|
|
39479
39900
|
let type;
|
|
39480
|
-
let durationMinutes;
|
|
39481
|
-
const candidates = [];
|
|
39901
|
+
let durationMinutes = bracketMinutes;
|
|
39902
|
+
const candidates = [...sharedPeople, ...bracketPeople];
|
|
39482
39903
|
let proseName = false;
|
|
39483
39904
|
const inName = durationInProse(nameField);
|
|
39484
39905
|
if (inName) {
|
|
@@ -39539,6 +39960,9 @@ function parseCalendarEntry(entry, index, calendars, warnings, knownStaffNames)
|
|
|
39539
39960
|
warnings.push(`Calendar line ${index + 1} ("${entry}") was skipped: no calendar name found.`);
|
|
39540
39961
|
return;
|
|
39541
39962
|
}
|
|
39963
|
+
if (bracketNotes.length) {
|
|
39964
|
+
warnings.push(`Calendar "${name}": Review the details "${bracketNotes.join(", ")}". They were kept as a note, not as calendar settings.`);
|
|
39965
|
+
}
|
|
39542
39966
|
const people = [];
|
|
39543
39967
|
for (const c of candidates) {
|
|
39544
39968
|
const cleaned = cleanPersonCandidate(c);
|
|
@@ -39581,10 +40005,18 @@ function parseCalendarLines(text2, knownStaffNames = []) {
|
|
|
39581
40005
|
const calendars = [];
|
|
39582
40006
|
const warnings = [];
|
|
39583
40007
|
const entries = [];
|
|
40008
|
+
const sharedPeople = [];
|
|
40009
|
+
const SHARED = /(?:^|[.;]\s*)(?:both|all|each)(?:\s+(?:of\s+them|calendars?|are|is))*\s+(?:with|by|for)\s+([^.;\n]+)[.;]?\s*$/i;
|
|
39584
40010
|
for (const rawLine of text2.split(/\r?\n/)) {
|
|
39585
40011
|
let line3 = rawLine.replace(/^[\s\-*•]+/, "").trim();
|
|
39586
40012
|
if (!line3) continue;
|
|
39587
|
-
|
|
40013
|
+
const shared = SHARED.exec(line3);
|
|
40014
|
+
if (shared) {
|
|
40015
|
+
sharedPeople.push(...splitNames(shared[1]));
|
|
40016
|
+
line3 = line3.slice(0, shared.index).trim();
|
|
40017
|
+
if (!line3) continue;
|
|
40018
|
+
}
|
|
40019
|
+
line3 = line3.replace(LEADING_COUNT_WITH_NOUN, "").replace(LEADING_COUNT_BEFORE_MARKER, "").replace(LEADING_BARE_COUNT, "");
|
|
39588
40020
|
for (const marked3 of splitOnMarkers(line3)) {
|
|
39589
40021
|
for (const clause of splitOnAnd(marked3)) {
|
|
39590
40022
|
const cleaned = clause.replace(/^\(?\d{1,2}[).]\s+/, "").replace(/^[\s\-*•]+/, "").trim();
|
|
@@ -39592,7 +40024,7 @@ function parseCalendarLines(text2, knownStaffNames = []) {
|
|
|
39592
40024
|
}
|
|
39593
40025
|
}
|
|
39594
40026
|
}
|
|
39595
|
-
entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames));
|
|
40027
|
+
entries.forEach((entry, i) => parseCalendarEntry(entry, i, calendars, warnings, knownStaffNames, sharedPeople));
|
|
39596
40028
|
return { calendars, warnings };
|
|
39597
40029
|
}
|
|
39598
40030
|
function coerceTriState(v) {
|
|
@@ -39620,6 +40052,10 @@ function coerceValue(raw, kind, warnings) {
|
|
|
39620
40052
|
const s = firstString(raw);
|
|
39621
40053
|
return s ? splitList(s) : void 0;
|
|
39622
40054
|
}
|
|
40055
|
+
case "textList": {
|
|
40056
|
+
const s = firstString(raw);
|
|
40057
|
+
return s ? splitList(s, { commas: false }) : void 0;
|
|
40058
|
+
}
|
|
39623
40059
|
case "pricePoints": {
|
|
39624
40060
|
const s = firstString(raw);
|
|
39625
40061
|
return s ? parsePricePoints(s) : void 0;
|
|
@@ -39807,7 +40243,7 @@ function normalizeSubmissionToBrief(opts) {
|
|
|
39807
40243
|
brief.flags = deriveFlags(brief);
|
|
39808
40244
|
return brief;
|
|
39809
40245
|
}
|
|
39810
|
-
var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, ASSIGNMENT_SLOTS;
|
|
40246
|
+
var EMAIL_RE2, PHONE_RE, CALENDAR_TYPE_WORDS, DURATION_IN_PROSE, COUNT_WORD, LEADING_COUNT_BEFORE_MARKER, LEADING_COUNT_WITH_NOUN, LEADING_BARE_COUNT, ASSIGNMENT_SLOTS;
|
|
39811
40247
|
var init_normalizer = __esm({
|
|
39812
40248
|
"src/intake-to-build/normalizer.ts"() {
|
|
39813
40249
|
"use strict";
|
|
@@ -39826,6 +40262,7 @@ var init_normalizer = __esm({
|
|
|
39826
40262
|
`^\\s*(?:(?:we|i)(?:'d| would| will)?\\s*(?:need|want|like|use)\\s+)?${COUNT_WORD}\\s+(?:booking\\s+)?calendars?\\s*[.:;,\u2013\u2014-]+\\s*`,
|
|
39827
40263
|
"i"
|
|
39828
40264
|
);
|
|
40265
|
+
LEADING_BARE_COUNT = new RegExp(`^\\s*${COUNT_WORD}\\s*:\\s+`, "i");
|
|
39829
40266
|
ASSIGNMENT_SLOTS = [
|
|
39830
40267
|
{ key: "notifyName", label: "who should be notified about new leads" },
|
|
39831
40268
|
{ key: "callsName", label: "who takes booking and follow-up calls" }
|
|
@@ -40034,18 +40471,33 @@ function keptByCondition(items, ctx) {
|
|
|
40034
40471
|
function isFillBlock(v) {
|
|
40035
40472
|
return !!v && typeof v === "object" && !Array.isArray(v) && ("fillFrom" in v || "transform" in v);
|
|
40036
40473
|
}
|
|
40037
|
-
function
|
|
40474
|
+
function slugHasPhrase(slug4, phrase) {
|
|
40475
|
+
return `_${slug4}_`.includes(`_${slugify2(phrase)}_`);
|
|
40476
|
+
}
|
|
40477
|
+
function isTerminalStage(name) {
|
|
40478
|
+
const s = slugify2(name);
|
|
40479
|
+
return TERMINAL_STAGE_WORDS.some((w) => slugHasPhrase(s, w));
|
|
40480
|
+
}
|
|
40481
|
+
function stringList(v) {
|
|
40482
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
40483
|
+
}
|
|
40484
|
+
function composeStages(block, ctx, uses, remaps) {
|
|
40038
40485
|
const defaults = isFillBlock(block) ? keptByCondition(block.default ?? [], ctx) : keptByCondition(block, ctx);
|
|
40039
|
-
const defaultList = defaults.map((d) => ({
|
|
40486
|
+
const defaultList = defaults.map((d) => ({
|
|
40487
|
+
ref: String(d.ref),
|
|
40488
|
+
name: fillString(String(d.name), ctx),
|
|
40489
|
+
aliases: stringList(d.aliases)
|
|
40490
|
+
}));
|
|
40040
40491
|
const briefStages = (ctx.brief.goal.salesStages ?? []).map((s) => s.trim()).filter(Boolean);
|
|
40041
40492
|
if (!isFillBlock(block) || briefStages.length === 0) {
|
|
40042
|
-
return defaultList.map((d, i) => ({
|
|
40493
|
+
return defaultList.map((d, i) => ({ ref: d.ref, name: d.name, position: i }));
|
|
40043
40494
|
}
|
|
40044
40495
|
const bySlug = /* @__PURE__ */ new Map();
|
|
40045
40496
|
for (const d of defaultList) {
|
|
40046
40497
|
bySlug.set(slugify2(d.name), d);
|
|
40047
40498
|
bySlug.set(d.ref.replace(/^stage\./, ""), d);
|
|
40048
40499
|
}
|
|
40500
|
+
const defaultRefs = new Set(defaultList.map((d) => d.ref));
|
|
40049
40501
|
const out = [];
|
|
40050
40502
|
const usedRefs = /* @__PURE__ */ new Set();
|
|
40051
40503
|
for (const name of briefStages) {
|
|
@@ -40056,39 +40508,160 @@ function composeStages(block, ctx, referencedStageRefs) {
|
|
|
40056
40508
|
} else {
|
|
40057
40509
|
let ref = `stage.${slugify2(name)}`;
|
|
40058
40510
|
let n = 2;
|
|
40059
|
-
while (usedRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
|
|
40511
|
+
while (usedRefs.has(ref) || defaultRefs.has(ref)) ref = `stage.${slugify2(name)}_${n++}`;
|
|
40060
40512
|
usedRefs.add(ref);
|
|
40061
40513
|
out.push({ ref, name });
|
|
40062
40514
|
}
|
|
40063
40515
|
}
|
|
40064
|
-
|
|
40065
|
-
|
|
40066
|
-
|
|
40516
|
+
const findClientStage = (phrases, allowTerminal, skipOwned) => {
|
|
40517
|
+
for (const phrase of phrases) {
|
|
40518
|
+
for (const s of out) {
|
|
40519
|
+
if (!allowTerminal && isTerminalStage(s.name)) continue;
|
|
40520
|
+
if (skipOwned && defaultRefs.has(s.ref)) continue;
|
|
40521
|
+
if (slugHasPhrase(slugify2(s.name), phrase)) return s;
|
|
40522
|
+
}
|
|
40523
|
+
}
|
|
40524
|
+
return void 0;
|
|
40525
|
+
};
|
|
40526
|
+
const defaultName = (ref) => defaultList.find((d) => d.ref === ref)?.name ?? ref.replace(/^stage\./, "");
|
|
40527
|
+
const stillReferenced = /* @__PURE__ */ new Set();
|
|
40528
|
+
const preferred = /* @__PURE__ */ new Set();
|
|
40529
|
+
for (const u of uses) {
|
|
40530
|
+
const key = `${u.workflowRef}\0${u.ref}`;
|
|
40531
|
+
if (preferred.has(key)) continue;
|
|
40532
|
+
const target = u.prefer?.length ? findClientStage(u.prefer, true, false) : void 0;
|
|
40533
|
+
if (!target) {
|
|
40534
|
+
stillReferenced.add(u.ref);
|
|
40535
|
+
continue;
|
|
40536
|
+
}
|
|
40537
|
+
preferred.add(key);
|
|
40538
|
+
if (target.ref === u.ref) continue;
|
|
40539
|
+
let m = remaps.byWorkflow.get(u.workflowRef);
|
|
40540
|
+
if (!m) {
|
|
40541
|
+
m = /* @__PURE__ */ new Map();
|
|
40542
|
+
remaps.byWorkflow.set(u.workflowRef, m);
|
|
40543
|
+
}
|
|
40544
|
+
m.set(u.ref, target.ref);
|
|
40545
|
+
ctx.warnings.push(
|
|
40546
|
+
`Mapped "${defaultName(u.ref)}" automations in ${u.workflowName} onto your "${target.name}" stage.`
|
|
40547
|
+
);
|
|
40548
|
+
}
|
|
40549
|
+
const pending = defaultList.filter((d) => !usedRefs.has(d.ref) && stillReferenced.has(d.ref));
|
|
40550
|
+
const candidates = [];
|
|
40551
|
+
for (const d of pending) {
|
|
40552
|
+
const terminalRef = isTerminalStage(d.name);
|
|
40553
|
+
const phrases = [d.name, ...d.aliases];
|
|
40554
|
+
for (const [sIdx, s] of out.entries()) {
|
|
40555
|
+
if (defaultRefs.has(s.ref)) continue;
|
|
40556
|
+
if (!terminalRef && isTerminalStage(s.name)) continue;
|
|
40557
|
+
const slug4 = slugify2(s.name);
|
|
40558
|
+
const aliasIdx = phrases.findIndex((p) => slugHasPhrase(slug4, p));
|
|
40559
|
+
if (aliasIdx < 0) continue;
|
|
40560
|
+
const best = phrases.filter((p) => slugHasPhrase(slug4, p)).map(slugify2).sort((a, b) => b.split("_").length - a.split("_").length || b.length - a.length)[0];
|
|
40561
|
+
candidates.push({ d, s, words: best.split("_").length, len: best.length, aliasIdx, dIdx: defaultList.indexOf(d), sIdx });
|
|
40562
|
+
}
|
|
40563
|
+
}
|
|
40564
|
+
candidates.sort((a, b) => b.words - a.words || b.len - a.len || a.aliasIdx - b.aliasIdx || a.dIdx - b.dIdx || a.sIdx - b.sIdx);
|
|
40565
|
+
const taken = /* @__PURE__ */ new Set();
|
|
40566
|
+
const mapped = /* @__PURE__ */ new Set();
|
|
40567
|
+
for (const c of candidates) {
|
|
40568
|
+
if (mapped.has(c.d.ref) || taken.has(c.s.ref)) continue;
|
|
40569
|
+
mapped.add(c.d.ref);
|
|
40570
|
+
taken.add(c.s.ref);
|
|
40571
|
+
remaps.global.set(c.d.ref, c.s.ref);
|
|
40572
|
+
}
|
|
40573
|
+
const first2 = defaultList[0];
|
|
40574
|
+
if (first2 && pending.includes(first2) && !mapped.has(first2.ref) && out[0] && !defaultRefs.has(out[0].ref) && !taken.has(out[0].ref) && !isTerminalStage(out[0].name)) {
|
|
40575
|
+
mapped.add(first2.ref);
|
|
40576
|
+
taken.add(out[0].ref);
|
|
40577
|
+
remaps.global.set(first2.ref, out[0].ref);
|
|
40578
|
+
}
|
|
40579
|
+
for (const d of pending) {
|
|
40580
|
+
const to = remaps.global.get(d.ref);
|
|
40581
|
+
const s = to ? out.find((x) => x.ref === to) : void 0;
|
|
40582
|
+
if (s) ctx.warnings.push(`Mapped "${d.name}" automations onto your "${s.name}" stage.`);
|
|
40583
|
+
}
|
|
40584
|
+
const placedAt = (ref) => {
|
|
40585
|
+
const target = remaps.global.get(ref) ?? ref;
|
|
40586
|
+
return out.findIndex((x) => x.ref === target);
|
|
40587
|
+
};
|
|
40588
|
+
for (const d of pending) {
|
|
40589
|
+
if (mapped.has(d.ref)) continue;
|
|
40590
|
+
const i = defaultList.indexOf(d);
|
|
40591
|
+
const firstTerminal = out.findIndex((s) => isTerminalStage(s.name));
|
|
40592
|
+
let at = -1;
|
|
40593
|
+
let where = "";
|
|
40594
|
+
for (let j = i - 1; j >= 0 && at < 0; j--) {
|
|
40595
|
+
const k = placedAt(defaultList[j].ref);
|
|
40596
|
+
if (k >= 0 && !isTerminalStage(out[k].name)) {
|
|
40597
|
+
at = k + 1;
|
|
40598
|
+
where = `just after your "${out[k].name}" stage`;
|
|
40599
|
+
}
|
|
40600
|
+
}
|
|
40601
|
+
for (let j = i + 1; j < defaultList.length && at < 0; j++) {
|
|
40602
|
+
const k = placedAt(defaultList[j].ref);
|
|
40603
|
+
if (k >= 0 && !isTerminalStage(out[k].name)) {
|
|
40604
|
+
at = k;
|
|
40605
|
+
where = `just before your "${out[k].name}" stage`;
|
|
40606
|
+
}
|
|
40607
|
+
}
|
|
40608
|
+
if (at >= 0 && firstTerminal >= 0 && at > firstTerminal) at = -1;
|
|
40609
|
+
if (at < 0 && firstTerminal >= 0) {
|
|
40610
|
+
at = firstTerminal;
|
|
40611
|
+
where = `just before your "${out[firstTerminal].name}" stage`;
|
|
40612
|
+
}
|
|
40613
|
+
if (at < 0) {
|
|
40614
|
+
at = out.length;
|
|
40615
|
+
where = "at the end of your stages";
|
|
40616
|
+
}
|
|
40617
|
+
out.splice(at, 0, { ref: d.ref, name: d.name });
|
|
40067
40618
|
usedRefs.add(d.ref);
|
|
40068
|
-
out.push(d);
|
|
40069
40619
|
ctx.warnings.push(
|
|
40070
|
-
`Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it
|
|
40620
|
+
`Kept the "${d.name}" pipeline stage even though the intake's stage list did not mention it and none of its stages matches it; the automations move deals into it. It sits ${where}.`
|
|
40071
40621
|
);
|
|
40072
40622
|
}
|
|
40073
40623
|
return out.map((s, i) => ({ ...s, position: i }));
|
|
40074
40624
|
}
|
|
40075
|
-
function
|
|
40076
|
-
const
|
|
40077
|
-
const scanActions = (actions) => {
|
|
40078
|
-
for (const a of keptByCondition(actions, ctx)) {
|
|
40079
|
-
if (typeof a.stageRef === "string") refs2.add(a.stageRef);
|
|
40080
|
-
if (a.type === "find_opportunity") {
|
|
40081
|
-
scanActions(a.found);
|
|
40082
|
-
scanActions(a.notFound);
|
|
40083
|
-
}
|
|
40084
|
-
}
|
|
40085
|
-
};
|
|
40625
|
+
function collectStageUses(workflows, ctx) {
|
|
40626
|
+
const uses = [];
|
|
40086
40627
|
for (const w of workflows) {
|
|
40628
|
+
const workflowRef = String(w.ref ?? "");
|
|
40629
|
+
const workflowName = typeof w.name === "string" ? fillString(w.name, ctx) : workflowRef;
|
|
40630
|
+
const pref = w.stagePreference && typeof w.stagePreference === "object" ? w.stagePreference : {};
|
|
40631
|
+
const add2 = (ref) => {
|
|
40632
|
+
const prefer = stringList(pref[ref]);
|
|
40633
|
+
uses.push({ workflowRef, workflowName, ref, ...prefer.length ? { prefer } : {} });
|
|
40634
|
+
};
|
|
40635
|
+
const scanActions = (actions) => {
|
|
40636
|
+
for (const a of keptByCondition(actions, ctx)) {
|
|
40637
|
+
if (typeof a.stageRef === "string") add2(a.stageRef);
|
|
40638
|
+
if (a.type === "find_opportunity") {
|
|
40639
|
+
scanActions(a.found);
|
|
40640
|
+
scanActions(a.notFound);
|
|
40641
|
+
}
|
|
40642
|
+
}
|
|
40643
|
+
};
|
|
40087
40644
|
const trig = w.trigger;
|
|
40088
|
-
if (trig && typeof trig.stageRef === "string")
|
|
40645
|
+
if (trig && typeof trig.stageRef === "string") add2(trig.stageRef);
|
|
40089
40646
|
scanActions(w.actions);
|
|
40090
40647
|
}
|
|
40091
|
-
return
|
|
40648
|
+
return uses;
|
|
40649
|
+
}
|
|
40650
|
+
function remapWorkflowStages(w, remaps) {
|
|
40651
|
+
const own = remaps.byWorkflow.get(String(w.ref));
|
|
40652
|
+
const to = (ref) => own?.get(ref) ?? remaps.global.get(ref) ?? ref;
|
|
40653
|
+
const walk2 = (actions) => {
|
|
40654
|
+
for (const a of actions ?? []) {
|
|
40655
|
+
if (typeof a.stageRef === "string") a.stageRef = to(a.stageRef);
|
|
40656
|
+
if (a.type === "find_opportunity") {
|
|
40657
|
+
walk2(a.found);
|
|
40658
|
+
walk2(a.notFound);
|
|
40659
|
+
}
|
|
40660
|
+
}
|
|
40661
|
+
};
|
|
40662
|
+
const trig = w.trigger;
|
|
40663
|
+
if (trig && typeof trig.stageRef === "string") trig.stageRef = to(trig.stageRef);
|
|
40664
|
+
walk2(w.actions);
|
|
40092
40665
|
}
|
|
40093
40666
|
function calendarTypeForBrief(briefType, calName, teamSize) {
|
|
40094
40667
|
const mapped = BRIEF_CALENDAR_TYPE_MAP[briefType] ?? "event";
|
|
@@ -40246,12 +40819,13 @@ function planFromPreset(input) {
|
|
|
40246
40819
|
...omitted.length ? { omitted } : {}
|
|
40247
40820
|
};
|
|
40248
40821
|
})() : void 0;
|
|
40249
|
-
const
|
|
40822
|
+
const stageUses = collectStageUses(keptWorkflowSkeletons, ctx);
|
|
40823
|
+
const stageRemaps = { global: /* @__PURE__ */ new Map(), byWorkflow: /* @__PURE__ */ new Map() };
|
|
40250
40824
|
const users = ctx.users;
|
|
40251
40825
|
const pipelines = keptByCondition(sk.pipelines, ctx).map((pl) => ({
|
|
40252
40826
|
ref: String(pl.ref),
|
|
40253
40827
|
name: fillString(String(pl.name), ctx),
|
|
40254
|
-
stages: composeStages(pl.stages, ctx,
|
|
40828
|
+
stages: composeStages(pl.stages, ctx, stageUses, stageRemaps)
|
|
40255
40829
|
}));
|
|
40256
40830
|
const customFields = keptByCondition(sk.customFields, ctx).map((f) => fillNode(f, ctx));
|
|
40257
40831
|
const tags = keptByCondition(sk.tags, ctx).map((t) => fillNode(t, ctx));
|
|
@@ -40303,7 +40877,9 @@ function planFromPreset(input) {
|
|
|
40303
40877
|
const workflows = keptWorkflowSkeletons.map((raw) => {
|
|
40304
40878
|
const { actions, ...rest } = raw;
|
|
40305
40879
|
const w = fillNode(rest, ctx);
|
|
40880
|
+
delete w.stagePreference;
|
|
40306
40881
|
w.actions = composeActions(actions, ctx, templateRefByAsset);
|
|
40882
|
+
remapWorkflowStages(w, stageRemaps);
|
|
40307
40883
|
return w;
|
|
40308
40884
|
});
|
|
40309
40885
|
if (scope) {
|
|
@@ -40362,6 +40938,8 @@ function planFromPreset(input) {
|
|
|
40362
40938
|
}
|
|
40363
40939
|
const handoffs = keptByCondition(sk.handoffs, ctx).map((raw) => {
|
|
40364
40940
|
const h = fillNode(raw, ctx);
|
|
40941
|
+
if (typeof h.produces === "string") h.produces = stageRemaps.global.get(h.produces) ?? h.produces;
|
|
40942
|
+
if (Array.isArray(h.blocks)) h.blocks = h.blocks.map((b) => stageRemaps.global.get(b) ?? b);
|
|
40365
40943
|
if (typeof h.produces === "string" && !definedRefs.has(h.produces)) delete h.produces;
|
|
40366
40944
|
if (Array.isArray(h.blocks)) {
|
|
40367
40945
|
h.blocks = h.blocks.filter((b) => b.endsWith(".*") || definedRefs.has(b));
|
|
@@ -40554,7 +41132,7 @@ function loadPresetChoice(requested, briefPreset) {
|
|
|
40554
41132
|
if (generic) return { ok: true, preset: generic, file: "generic-client.preset.json", note: "Using the generic preset." };
|
|
40555
41133
|
return { ok: false, error: "No usable preset file was found in the library." };
|
|
40556
41134
|
}
|
|
40557
|
-
var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, BRIEF_CALENDAR_TYPE_MAP;
|
|
41135
|
+
var fs33, path31, PRESET_TOKEN_ROOTS, FLAG_ALIASES, INLINE_TOKEN_RE, ADMIN_ROLE_RE, TERMINAL_STAGE_WORDS, BRIEF_CALENDAR_TYPE_MAP;
|
|
40558
41136
|
var init_plan_from_preset = __esm({
|
|
40559
41137
|
"src/intake-to-build/plan-from-preset.ts"() {
|
|
40560
41138
|
"use strict";
|
|
@@ -40571,6 +41149,28 @@ var init_plan_from_preset = __esm({
|
|
|
40571
41149
|
};
|
|
40572
41150
|
INLINE_TOKEN_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+|\[\d+\])*)\s*(?:\|\|([^}]*))?\}\}/g;
|
|
40573
41151
|
ADMIN_ROLE_RE = /\b(owner|founder|admin|administrator|ceo|principal|director)\b/i;
|
|
41152
|
+
TERMINAL_STAGE_WORDS = [
|
|
41153
|
+
"lost",
|
|
41154
|
+
"closed",
|
|
41155
|
+
"won",
|
|
41156
|
+
"no_show",
|
|
41157
|
+
"noshow",
|
|
41158
|
+
"no_sale",
|
|
41159
|
+
"not_interested",
|
|
41160
|
+
"not_a_fit",
|
|
41161
|
+
"no_fit",
|
|
41162
|
+
"disqualified",
|
|
41163
|
+
"unqualified",
|
|
41164
|
+
"dead",
|
|
41165
|
+
"cancelled",
|
|
41166
|
+
"canceled",
|
|
41167
|
+
"declined",
|
|
41168
|
+
"dnd",
|
|
41169
|
+
"do_not_disturb",
|
|
41170
|
+
"churned",
|
|
41171
|
+
"lapsed",
|
|
41172
|
+
"archived"
|
|
41173
|
+
];
|
|
40574
41174
|
BRIEF_CALENDAR_TYPE_MAP = {
|
|
40575
41175
|
one_on_one: "event",
|
|
40576
41176
|
round_robin: "round_robin",
|
|
@@ -41349,6 +41949,193 @@ var init_attachment = __esm({
|
|
|
41349
41949
|
}
|
|
41350
41950
|
});
|
|
41351
41951
|
|
|
41952
|
+
// src/bought-list.ts
|
|
41953
|
+
function isBoughtId(x) {
|
|
41954
|
+
return typeof x === "string" && BOUGHT_IDS.includes(x);
|
|
41955
|
+
}
|
|
41956
|
+
function boughtItemsFor(offered) {
|
|
41957
|
+
return ASSESSMENT_FEATURES.map((f) => {
|
|
41958
|
+
const id = f.id;
|
|
41959
|
+
const base = { id, name: f.name, means: BOUGHT_MEANS[id] };
|
|
41960
|
+
const feature = FEATURE_FOR_BOUGHT[id];
|
|
41961
|
+
if (feature) {
|
|
41962
|
+
const next = id === "missed" ? { next: CALL_ANSWERING_NEXT } : {};
|
|
41963
|
+
if (!offered.includes(feature)) {
|
|
41964
|
+
return { ...base, kind: "not-in-plan", feature, ...next, delivery: `Not built for this client: ${FEATURES[feature].name.toLowerCase()} is not in the plan for this industry.` };
|
|
41965
|
+
}
|
|
41966
|
+
return { ...base, kind: "built", feature, ...next, delivery: BUILT_LABEL[id] };
|
|
41967
|
+
}
|
|
41968
|
+
const other = OTHER_DELIVERY[id];
|
|
41969
|
+
return { ...base, kind: other.kind, delivery: other.text };
|
|
41970
|
+
});
|
|
41971
|
+
}
|
|
41972
|
+
function featuresFromBought(ticks, offered) {
|
|
41973
|
+
return FEATURE_IDS.filter((f) => ticks.includes(BUILD_ASSESSMENT_FEATURE[f]) && offered.includes(f));
|
|
41974
|
+
}
|
|
41975
|
+
function scopeFromBought(ticks, offered, pkg2) {
|
|
41976
|
+
const features = featuresFromBought(ticks, offered);
|
|
41977
|
+
if (!pkg2 && offered.length && offered.every((f) => features.includes(f))) return { wholePreset: true };
|
|
41978
|
+
return { ...pkg2 ? { package: pkg2 } : {}, features };
|
|
41979
|
+
}
|
|
41980
|
+
function isAssessmentChoice(x) {
|
|
41981
|
+
return typeof x === "string" && ASSESSMENT_CHOICES.includes(x);
|
|
41982
|
+
}
|
|
41983
|
+
function ticksFor(state, rk) {
|
|
41984
|
+
const saved = state.bought?.[rk];
|
|
41985
|
+
if (Array.isArray(saved)) return { ticks: BOUGHT_IDS.filter((id) => saved.includes(id)), source: "saved" };
|
|
41986
|
+
const sc = state.scope?.[rk];
|
|
41987
|
+
if (sc && !sc.wholePreset && Array.isArray(sc.features)) {
|
|
41988
|
+
const chosen = sc.features.filter(isFeatureId).map((f) => BUILD_ASSESSMENT_FEATURE[f]);
|
|
41989
|
+
return { ticks: BOUGHT_IDS.filter((id) => !FEATURE_FOR_BOUGHT[id] || chosen.includes(id)), source: "scope" };
|
|
41990
|
+
}
|
|
41991
|
+
if (sc) return { ticks: [...BOUGHT_IDS], source: "scope" };
|
|
41992
|
+
const purchases = state.assessmentPurchases?.[rk];
|
|
41993
|
+
if (Array.isArray(purchases)) return { ticks: BOUGHT_IDS.filter((id) => purchases.includes(id)), source: "assessment" };
|
|
41994
|
+
return { ticks: [...BOUGHT_IDS], source: "all" };
|
|
41995
|
+
}
|
|
41996
|
+
function assessmentAnswerOf(state, rk) {
|
|
41997
|
+
if (state.assessmentAttachments?.[rk]) return "attached";
|
|
41998
|
+
const c = state.assessmentChoice?.[rk]?.choice;
|
|
41999
|
+
return isAssessmentChoice(c) ? c : null;
|
|
42000
|
+
}
|
|
42001
|
+
function secondClinicAssessmentMessage(clientKey, name) {
|
|
42002
|
+
return `Nobody has answered the Assessment question for ${name ? name : `the second clinic "${clientKey}"`}, so nothing was approved or built. For a second clinic in a sub-account the only answer available for now is Build without an Assessment. Send the cockpit POST /api/assessment-choice with this sub-account's locationId, clientKey "${clientKey}" and choice "without". Then approve and build again.`;
|
|
42003
|
+
}
|
|
42004
|
+
function assessmentGateRefusal(state, rk, clientKey, name) {
|
|
42005
|
+
const answer2 = assessmentAnswerOf(state, rk);
|
|
42006
|
+
if (state.assessmentAttachments?.[rk] || answer2 === "without") return null;
|
|
42007
|
+
if (clientKey) return secondClinicAssessmentMessage(clientKey, name);
|
|
42008
|
+
if (answer2 === "attached") return ATTACH_THE_ASSESSMENT;
|
|
42009
|
+
if (answer2 === "run-first") return RUN_THE_ASSESSMENT_FIRST;
|
|
42010
|
+
return ANSWER_THE_ASSESSMENT_QUESTION;
|
|
42011
|
+
}
|
|
42012
|
+
function approvalBindingHash(scopeHash, state, rk) {
|
|
42013
|
+
const saved = state.bought?.[rk];
|
|
42014
|
+
const answer2 = assessmentAnswerOf(state, rk);
|
|
42015
|
+
if (!Array.isArray(saved) && !answer2) return scopeHash;
|
|
42016
|
+
const snapshot = state.assessmentAttachments?.[rk];
|
|
42017
|
+
return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null, assessment: answer2, attached: snapshot ? contentHash(snapshot) : null });
|
|
42018
|
+
}
|
|
42019
|
+
function boughtBindingHash(scopeHash, state, rk) {
|
|
42020
|
+
const saved = state.bought?.[rk];
|
|
42021
|
+
return contentHash({ scope: scopeHash, bought: Array.isArray(saved) ? [...new Set(saved)].sort() : null });
|
|
42022
|
+
}
|
|
42023
|
+
function boughtJobs(ticks, offered) {
|
|
42024
|
+
return boughtItemsFor(offered).filter((item) => ticks.includes(item.id) && (item.kind === "go-live" || item.kind === "separate" || item.kind === "not-in-plan")).map((item) => ({
|
|
42025
|
+
ref: `bought:${item.id}`,
|
|
42026
|
+
type: BOUGHT_JOB_TYPE,
|
|
42027
|
+
name: item.name,
|
|
42028
|
+
reason: item.kind === "not-in-plan" ? "Bought, but not built automatically yet. It is not in the plan for this client's industry." : `Bought, but not built automatically yet. ${OTHER_DELIVERY[item.id].job}`
|
|
42029
|
+
}));
|
|
42030
|
+
}
|
|
42031
|
+
function boughtJobLines(ticks, offered) {
|
|
42032
|
+
return boughtJobs(ticks, offered).map((j) => `${j.name}: ${j.reason}`);
|
|
42033
|
+
}
|
|
42034
|
+
function claudeSuggestions(ticks) {
|
|
42035
|
+
const out = [];
|
|
42036
|
+
if (ticks.includes("missed")) out.push("Call answering: next step with Claude, an AI voice agent to answer live.");
|
|
42037
|
+
if (ticks.includes("reporting")) out.push("Reporting: from Claude, a weekly client report. No account setup.");
|
|
42038
|
+
return out;
|
|
42039
|
+
}
|
|
42040
|
+
function assessmentChoiceLine(answer2, attached = answer2 === "attached") {
|
|
42041
|
+
if (attached) return "The Assessment was run and is attached.";
|
|
42042
|
+
if (answer2 === "attached") return "The Assessment was run. It is not attached yet.";
|
|
42043
|
+
if (answer2 === "without") return "No Assessment was run for this client.";
|
|
42044
|
+
if (answer2 === "run-first") return "The Assessment will be run first.";
|
|
42045
|
+
return "Not answered yet: has the Assessment been run for this client?";
|
|
42046
|
+
}
|
|
42047
|
+
function boughtViewFor(state, rk, offered) {
|
|
42048
|
+
const { ticks, source } = ticksFor(state, rk);
|
|
42049
|
+
return {
|
|
42050
|
+
items: boughtItemsFor(offered),
|
|
42051
|
+
ticks,
|
|
42052
|
+
source,
|
|
42053
|
+
answer: assessmentAnswerOf(state, rk),
|
|
42054
|
+
attached: Boolean(state.assessmentAttachments?.[rk]),
|
|
42055
|
+
ready: assessmentGateRefusal(state, rk) === null,
|
|
42056
|
+
suggestions: source === "saved" ? claudeSuggestions(ticks) : []
|
|
42057
|
+
};
|
|
42058
|
+
}
|
|
42059
|
+
function boughtDocumentFor(state, rk, offered) {
|
|
42060
|
+
const { ticks, source } = ticksFor(state, rk);
|
|
42061
|
+
const items = boughtItemsFor(offered);
|
|
42062
|
+
const confirmed = source === "saved" || source === "scope";
|
|
42063
|
+
const ticked = items.filter((i) => ticks.includes(i.id));
|
|
42064
|
+
return {
|
|
42065
|
+
confirmed,
|
|
42066
|
+
assessment: assessmentChoiceLine(assessmentAnswerOf(state, rk), Boolean(state.assessmentAttachments?.[rk])),
|
|
42067
|
+
bought: ticked.map((i) => ({ name: i.name, means: i.means, delivery: clientDelivery(i) })),
|
|
42068
|
+
// The client reads this: never the agency's job text, and never "with Claude" (review of ea3526f).
|
|
42069
|
+
stillToDo: ticked.flatMap((i) => {
|
|
42070
|
+
if (i.kind === "go-live") return [`${i.name}: being set up in your account.`];
|
|
42071
|
+
if (i.kind === "separate") return [`${i.name}: the form is ready. The pages are a separate piece of work.`];
|
|
42072
|
+
if (i.kind === "not-in-plan") return [`${i.name}: being set up separately from this build.`];
|
|
42073
|
+
if (i.kind === "claude") return [`${i.name}: a weekly report on what is working, sent to you.`];
|
|
42074
|
+
return [];
|
|
42075
|
+
}).concat(ticks.includes("missed") ? ["Call answering: live call answering by an AI voice agent is a possible next step."] : [])
|
|
42076
|
+
};
|
|
42077
|
+
}
|
|
42078
|
+
function clientDelivery(i) {
|
|
42079
|
+
if (i.kind === "go-live") return "Being set up in your account";
|
|
42080
|
+
if (i.kind === "separate") return "The form is built; the pages are separate work";
|
|
42081
|
+
if (i.kind === "not-in-plan") return "Not part of this build";
|
|
42082
|
+
if (i.kind === "claude") return "A weekly report on what is working";
|
|
42083
|
+
return i.delivery;
|
|
42084
|
+
}
|
|
42085
|
+
var BOUGHT_IDS, BOUGHT_MEANS, FEATURE_FOR_BOUGHT, BUILT_LABEL, OTHER_DELIVERY, CALL_ANSWERING_NEXT, ASSESSMENT_CHOICES, ANSWER_THE_ASSESSMENT_QUESTION, ATTACH_THE_ASSESSMENT, RUN_THE_ASSESSMENT_FIRST, BOUGHT_JOB_TYPE;
|
|
42086
|
+
var init_bought_list = __esm({
|
|
42087
|
+
"src/bought-list.ts"() {
|
|
42088
|
+
"use strict";
|
|
42089
|
+
init_attachment();
|
|
42090
|
+
init_features();
|
|
42091
|
+
init_build_contract();
|
|
42092
|
+
BOUGHT_IDS = ASSESSMENT_FEATURES.map((f) => f.id);
|
|
42093
|
+
BOUGHT_MEANS = {
|
|
42094
|
+
missed: "No call goes unanswered",
|
|
42095
|
+
waited: "Every new lead hears back in seconds",
|
|
42096
|
+
quotes: "No quote goes cold",
|
|
42097
|
+
noshow: "Bookings confirmed, reminded and rescued",
|
|
42098
|
+
reactivation: "Old leads and past clients brought back",
|
|
42099
|
+
nurture: "Leads kept warm until they are ready",
|
|
42100
|
+
recall: "Clients come back on schedule",
|
|
42101
|
+
deposits: "Fewer no-shows, cash up front",
|
|
42102
|
+
reviews: "More five-star reviews",
|
|
42103
|
+
referrals: "Happy clients send friends",
|
|
42104
|
+
lead_magnet: "A reason to give you their details",
|
|
42105
|
+
sms: "Texts that actually get delivered",
|
|
42106
|
+
pipeline: "Every lead tracked to a sale",
|
|
42107
|
+
reporting: "The owner sees what is working"
|
|
42108
|
+
};
|
|
42109
|
+
FEATURE_FOR_BOUGHT = Object.fromEntries(
|
|
42110
|
+
FEATURE_IDS.map((f) => [BUILD_ASSESSMENT_FEATURE[f], f])
|
|
42111
|
+
);
|
|
42112
|
+
BUILT_LABEL = {
|
|
42113
|
+
missed: "Built: missed-call text-back",
|
|
42114
|
+
waited: "Built: Speed to Lead",
|
|
42115
|
+
quotes: "Built: Quote follow-up",
|
|
42116
|
+
noshow: "Built: booking, reminders and no-show rescue",
|
|
42117
|
+
reactivation: "Built: Win-back",
|
|
42118
|
+
nurture: "Built: 30-day lead nurture",
|
|
42119
|
+
reviews: "Built: Review requests"
|
|
42120
|
+
};
|
|
42121
|
+
OTHER_DELIVERY = {
|
|
42122
|
+
pipeline: { kind: "always", text: "Always built: Sales pipeline" },
|
|
42123
|
+
recall: { kind: "go-live", text: "Go-live job for now: Rebooking and recall", job: "Set up rebooking and recall in the account." },
|
|
42124
|
+
referrals: { kind: "go-live", text: "Go-live job for now: Referral requests", job: "Set up referral requests in the account." },
|
|
42125
|
+
deposits: { kind: "go-live", text: "Go-live job: Deposits and prepayment (payment setup is done in the account)", job: "Payment setup is done in the account." },
|
|
42126
|
+
sms: { kind: "go-live", text: "Go-live job: SMS capability (phone number and text registration)", job: "Get the phone number and finish text registration." },
|
|
42127
|
+
lead_magnet: { kind: "separate", text: "Separate job: Lead magnet (the form is built; pages are separate work)", job: "The form is built. The pages are separate work." },
|
|
42128
|
+
reporting: { kind: "claude", text: "From Claude: Reporting (weekly client report, no account setup)" }
|
|
42129
|
+
};
|
|
42130
|
+
CALL_ANSWERING_NEXT = "Next step with Claude: an AI voice agent to answer live";
|
|
42131
|
+
ASSESSMENT_CHOICES = ["attached", "run-first", "without"];
|
|
42132
|
+
ANSWER_THE_ASSESSMENT_QUESTION = "Answer the Assessment question before approving: Blueprint Review, then Have you run the Assessment for this client.";
|
|
42133
|
+
ATTACH_THE_ASSESSMENT = "Attach the Assessment before approving, or choose Build without an Assessment.";
|
|
42134
|
+
RUN_THE_ASSESSMENT_FIRST = "You chose to run the Assessment first. Attach it when it is done, or choose Build without an Assessment.";
|
|
42135
|
+
BOUGHT_JOB_TYPE = "bought-not-built";
|
|
42136
|
+
}
|
|
42137
|
+
});
|
|
42138
|
+
|
|
41352
42139
|
// src/intake-contact.ts
|
|
41353
42140
|
async function pickIntakeContact(client, locationId2, fieldIdToLabel, knownContactId, formName) {
|
|
41354
42141
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -41747,6 +42534,21 @@ function prefillFormUrl(formUrl, queryKeys, answers, contact = {}, altQueryKeys
|
|
|
41747
42534
|
const qs = p.toString();
|
|
41748
42535
|
return { url: qs ? `${formUrl}?${qs}` : formUrl, leftovers };
|
|
41749
42536
|
}
|
|
42537
|
+
function isAuthFailure(error) {
|
|
42538
|
+
const text2 = error instanceof Error ? error.message : String(error ?? "");
|
|
42539
|
+
return /GHL API Error (?:401|403)\b/.test(text2);
|
|
42540
|
+
}
|
|
42541
|
+
function isRequestFailure(error) {
|
|
42542
|
+
if (isGHLRequestError(error)) return true;
|
|
42543
|
+
const text2 = error instanceof Error ? error.message : String(error ?? "");
|
|
42544
|
+
return /GHL API Error \d{3}|Request timeout|timed? ?out|fetch failed|ECONN|ETIMEDOUT|socket hang up|network/i.test(text2);
|
|
42545
|
+
}
|
|
42546
|
+
function unavailable(empty, reason3) {
|
|
42547
|
+
return { ...empty, status: "unavailable", unavailableReason: reason3 };
|
|
42548
|
+
}
|
|
42549
|
+
function unavailableFrom(empty, error) {
|
|
42550
|
+
return unavailable(empty, isAuthFailure(error) ? "auth" : isRequestFailure(error) ? "transient" : "error");
|
|
42551
|
+
}
|
|
41750
42552
|
async function readIntakeSnapshot(client, locationId2, sentMarker) {
|
|
41751
42553
|
const empty = { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
|
|
41752
42554
|
try {
|
|
@@ -41798,16 +42600,16 @@ async function readIntakeSnapshot(client, locationId2, sentMarker) {
|
|
|
41798
42600
|
contactId = hit2.id;
|
|
41799
42601
|
channel = isFormSubmission(hit2) ? "form" : "cockpit";
|
|
41800
42602
|
}
|
|
41801
|
-
} catch {
|
|
41802
|
-
return
|
|
42603
|
+
} catch (e) {
|
|
42604
|
+
return unavailableFrom(empty, e);
|
|
41803
42605
|
}
|
|
41804
42606
|
}
|
|
41805
42607
|
const hit = await pickIntakeContact(client, locationId2, Object.fromEntries(Object.entries(fields).map(([label2, id]) => [id, label2])), contactId, INTAKE_FORM_NAME2);
|
|
41806
|
-
if (submissions && !hit) return
|
|
42608
|
+
if (submissions && !hit) return unavailable(empty, "missing");
|
|
41807
42609
|
const status = submissions > 0 ? "received" : sentMarker ? "sent" : "installed";
|
|
41808
42610
|
return { status, answers: hit?.answers ?? {}, formId: form.id, formUrl: publicFormUrl(form.id), fields, queryKeys, altQueryKeys, multiFields, submissions, answeredAt, channel, contactId };
|
|
41809
|
-
} catch {
|
|
41810
|
-
return
|
|
42611
|
+
} catch (e) {
|
|
42612
|
+
return unavailableFrom(empty, e);
|
|
41811
42613
|
}
|
|
41812
42614
|
}
|
|
41813
42615
|
function recorderAllowPatterns(mcpListOutput) {
|
|
@@ -41937,6 +42739,7 @@ var init_client_intake = __esm({
|
|
|
41937
42739
|
"src/client-intake.ts"() {
|
|
41938
42740
|
"use strict";
|
|
41939
42741
|
init_intake_contact();
|
|
42742
|
+
init_ghl_client();
|
|
41940
42743
|
init_question_set();
|
|
41941
42744
|
init_form_template();
|
|
41942
42745
|
init_transcript();
|
|
@@ -41958,8 +42761,8 @@ function savePlanRecord(rec2, base) {
|
|
|
41958
42761
|
return file2;
|
|
41959
42762
|
}
|
|
41960
42763
|
function savedPlanMatchesScope(saved, current) {
|
|
41961
|
-
if (!saved) return false;
|
|
41962
|
-
if (
|
|
42764
|
+
if (!saved || !current) return false;
|
|
42765
|
+
if (current.wholePreset) return !saved.scope;
|
|
41963
42766
|
if (!saved.scope) return false;
|
|
41964
42767
|
const omitted = (saved.scope.omitted ?? []).map((f) => f.id);
|
|
41965
42768
|
const offered = /* @__PURE__ */ new Set([...saved.scope.built, ...saved.scope.notBuilt.map((f) => f.id), ...omitted]);
|
|
@@ -42764,7 +43567,7 @@ async function assessmentAttachmentAction(args, deps) {
|
|
|
42764
43567
|
if (!Array.isArray(chosen) || chosen.some((id2) => !ASSESSMENT_FEATURES.some((f) => f.id === id2))) throw new Error("Choose features from the list.");
|
|
42765
43568
|
const state2 = deps.read();
|
|
42766
43569
|
deps.write({ ...state2, assessmentPurchases: { ...state2.assessmentPurchases, [id]: [...new Set(chosen)] } });
|
|
42767
|
-
detail.message = state2.scope?.[id] ? "
|
|
43570
|
+
detail.message = state2.scope?.[id] ? "The Assessment record is saved. The build still follows the What they bought list saved on the Blueprint Review." : "The Assessment record is saved. Nothing is built until you save What they bought on the Blueprint Review, which is ticked from this.";
|
|
42768
43571
|
break;
|
|
42769
43572
|
}
|
|
42770
43573
|
default:
|
|
@@ -42803,23 +43606,17 @@ async function assessmentRequest(id,action,extra={}){
|
|
|
42803
43606
|
const response=await fetch('/api/client-assessment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientLocationId:id,action,...extra})});
|
|
42804
43607
|
const result=await response.json();if(!response.ok||!result.ok)throw new Error(result.error||'The assessment action did not finish.');
|
|
42805
43608
|
if(action==='list'){p.list=result.assessments;p.listed=true;p.selected='';if(!p.list.length)p.notice='There are no assessments in this account’s Assessments pipeline.'}
|
|
42806
|
-
else{p.data=result;p.
|
|
42807
|
-
if(action==='purchases')await load();
|
|
43609
|
+
else{p.data=result;p.notice=result.message||'';if(action==='fill'){p.fill=result;delete iData[id];if(typeof iOpen!=='undefined'&&iOpen[id])await iLoad(id)}}
|
|
43610
|
+
if(action==='purchases'||action==='attach'||action==='detach')await load();
|
|
42808
43611
|
}catch(e){p.notice=e.message||'The assessment could not be read. Try again.'}
|
|
42809
43612
|
p.busy=false;render(lastState,lastRun);
|
|
42810
43613
|
}
|
|
42811
43614
|
function assessmentPanel(id){
|
|
42812
|
-
const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:''
|
|
43615
|
+
const fresh=!assessmentCards[id];const p=assessmentCards[id]||(assessmentCards[id]={busy:false,data:null,list:[],source:'',selected:''});
|
|
42813
43616
|
const box=document.createElement('section');box.className='cds-stack';
|
|
42814
43617
|
const text=(tag,value)=>{const el=document.createElement(tag);if(tag==='h4')el.className='cds-panel-label';el.textContent=value;box.appendChild(el);return el};
|
|
42815
43618
|
const button=(label,run,disabled=false)=>{const b=text('button',label);b.className='cds-btn ghost';b.disabled=p.busy||disabled;b.onclick=run;return b};
|
|
42816
|
-
|
|
42817
|
-
text('p','Record the features they bought for their Blueprint document.');
|
|
42818
|
-
if(p.data){
|
|
42819
|
-
for(const feature of p.data.features){const label=text('label','');label.className='cds-check';const cb=document.createElement('input');cb.type='checkbox';cb.checked=p.choices.includes(feature.id);cb.disabled=p.busy;cb.onchange=()=>{p.choices=cb.checked?[...p.choices,feature.id]:p.choices.filter(v=>v!==feature.id)};label.appendChild(cb);const span=document.createElement('span');span.textContent=feature.name;label.appendChild(span)}
|
|
42820
|
-
button('Save what they bought',()=>assessmentRequest(id,'purchases',{purchasedFeatureIds:p.choices}));
|
|
42821
|
-
if(p.data.purchasedFeatureIds===null)text('p','What they bought has not been recorded yet.');
|
|
42822
|
-
}
|
|
43619
|
+
/* What they bought is ticked on the one list below this panel (2026-09-29); this panel attaches the Assessment. */
|
|
42823
43620
|
text('h4','Assessment');
|
|
42824
43621
|
text('p','The numbers from your sales conversation follow the client into the intake, the Review and their Blueprint.');
|
|
42825
43622
|
const select=text('select','');select.className='cds-select';select.setAttribute('aria-label','Your account where the assessment was saved');select.disabled=p.busy;
|
|
@@ -44713,7 +45510,7 @@ function errLine(msg3) {
|
|
|
44713
45510
|
function preflightWritable() {
|
|
44714
45511
|
try {
|
|
44715
45512
|
const dir = ensureAppDataDir();
|
|
44716
|
-
const probe = path36.join(dir, `.write-probe.${process.pid}.${(0,
|
|
45513
|
+
const probe = path36.join(dir, `.write-probe.${process.pid}.${(0, import_crypto3.randomBytes)(4).toString("hex")}`);
|
|
44717
45514
|
fs38.writeFileSync(probe, "ok");
|
|
44718
45515
|
fs38.unlinkSync(probe);
|
|
44719
45516
|
return true;
|
|
@@ -44997,14 +45794,14 @@ async function runCli(subcommand, argv) {
|
|
|
44997
45794
|
return EXIT_USAGE2;
|
|
44998
45795
|
}
|
|
44999
45796
|
}
|
|
45000
|
-
var import_node_util3, fs38, path36,
|
|
45797
|
+
var import_node_util3, fs38, path36, import_crypto3, EXIT_OK2, EXIT_USAGE2, EXIT_VALIDATION, EXIT_FS, USAGE;
|
|
45001
45798
|
var init_cli = __esm({
|
|
45002
45799
|
"src/cli.ts"() {
|
|
45003
45800
|
"use strict";
|
|
45004
45801
|
import_node_util3 = require("node:util");
|
|
45005
45802
|
fs38 = __toESM(require("fs"));
|
|
45006
45803
|
path36 = __toESM(require("path"));
|
|
45007
|
-
|
|
45804
|
+
import_crypto3 = require("crypto");
|
|
45008
45805
|
init_ghl_client();
|
|
45009
45806
|
init_token_registry();
|
|
45010
45807
|
init_credentials_store();
|
|
@@ -45662,10 +46459,14 @@ function reviewDocumentHtml(review, opts) {
|
|
|
45662
46459
|
${review.gaps?.length ? `<div class="head">What's leaking today (${review.gaps.length})</div>${review.gaps.map(gap).join("")}` : ""}
|
|
45663
46460
|
${review.build?.length ? `<div class="head">What we're building</div>${review.build.map((b) => `<div class="build"><b>${esc14(b.item)}</b><span>${esc14(b.because)}</span></div>`).join("")}` : ""}
|
|
45664
46461
|
${opts.scope?.awaitingConfirmation ? `<div class="head">What this build includes</div>
|
|
45665
|
-
<p>Not confirmed yet.
|
|
46462
|
+
<p>Not confirmed yet. The build waits until the features to build are confirmed.</p>
|
|
45666
46463
|
` : opts.scope ? `<div class="head">What this build includes</div>
|
|
45667
46464
|
<p>${opts.scope.package ? `Built to your <b>${esc14(opts.scope.package)}</b> package: ` : ""}${opts.scope.built.length ? esc14(opts.scope.built.map((b) => b.name).join(", ")) : "the foundation only"}. Always included: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.</p>
|
|
45668
46465
|
` : ""}
|
|
46466
|
+
${opts.bought ? `<div class="head">What you bought</div>
|
|
46467
|
+
<p>${esc14(opts.bought.assessment)}</p>
|
|
46468
|
+
${opts.bought.confirmed ? opts.bought.bought.map((b) => `<div class="build"><b>${esc14(b.name)}</b><span>${esc14(b.means)}. ${esc14(b.delivery)}.</span></div>`).join("") : "<p>Not confirmed yet. The build waits until what you bought is saved.</p>"}
|
|
46469
|
+
${opts.bought.confirmed && opts.bought.stillToDo.length ? `<div class="head">Still to do</div><div class="ask"><ul>${opts.bought.stillToDo.map((line3) => `<li>${esc14(line3)}</li>`).join("")}</ul></div>` : ""}` : ""}
|
|
45669
46470
|
${review.unknowns?.length ? `<div class="head">What we still need from you</div><div class="ask"><ul>${review.unknowns.map((u) => `<li>${esc14(u)}</li>`).join("")}</ul></div>` : ""}
|
|
45670
46471
|
${opts.assessment ? `<div class="head">Assessment</div><p>Snapshot attached on ${esc14(opts.assessment.attachedAt.slice(0, 10))}.</p>${assessmentFeatureLines(opts.assessment, [], false).filter((line3) => line3.includes("by your own numbers")).map((line3) => `<p>${esc14(line3)}</p>`).join("")}` : ""}
|
|
45671
46472
|
${available ? `<div class="head">Available next</div>${available}` : opts.assessment ? `<div class="head">Available next</div><p>${purchased ? "Every listed feature is included in what you bought." : "What you bought has not been recorded yet."}</p>` : ""}
|
|
@@ -47136,7 +47937,7 @@ function prefixReservations(locationId2, base) {
|
|
|
47136
47937
|
function refusal(prefix, owner) {
|
|
47137
47938
|
return `The clinic prefix "${prefix}" already belongs to another client in this sub-account (client key "${owner}"). Two clients sharing a prefix would reuse each other's workflows, tags and fields, so nothing was built. Choose a different prefix for this client.`;
|
|
47138
47939
|
}
|
|
47139
|
-
function
|
|
47940
|
+
function sleepSync2(ms) {
|
|
47140
47941
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
47141
47942
|
}
|
|
47142
47943
|
function locationLockPath(dir, locationId2) {
|
|
@@ -47165,7 +47966,7 @@ function withLocationLock(dir, locationId2, fn, opts = {}) {
|
|
|
47165
47966
|
age > STALE_LOCK_MS ? `A clinic prefix could not be reserved because an earlier build left this sub-account's prefix lock behind (it may have crashed), so nothing was built. If no build is running for this sub-account, delete the file ${lock} and run it again.` : "Another build in this sub-account is reserving a clinic prefix right now, so nothing was built. Run it again in a moment."
|
|
47166
47967
|
);
|
|
47167
47968
|
}
|
|
47168
|
-
|
|
47969
|
+
sleepSync2(15);
|
|
47169
47970
|
}
|
|
47170
47971
|
}
|
|
47171
47972
|
try {
|
|
@@ -47244,13 +48045,27 @@ var init_clinic_prefix_registry = __esm({
|
|
|
47244
48045
|
var dashboard_exports = {};
|
|
47245
48046
|
__export(dashboard_exports, {
|
|
47246
48047
|
ACCOUNT_TYPES: () => ACCOUNT_TYPES,
|
|
48048
|
+
APPROVAL_WITHDRAWN_FOR_ASSESSMENT: () => APPROVAL_WITHDRAWN_FOR_ASSESSMENT,
|
|
48049
|
+
APPROVAL_WITHDRAWN_FOR_SCOPE: () => APPROVAL_WITHDRAWN_FOR_SCOPE,
|
|
48050
|
+
ASSESSMENT_CHANGED_SINCE_APPROVAL: () => ASSESSMENT_CHANGED_SINCE_APPROVAL,
|
|
48051
|
+
CHOOSE_BEFORE_BUILD: () => CHOOSE_BEFORE_BUILD,
|
|
48052
|
+
CHOOSE_WHAT_THEY_BOUGHT: () => CHOOSE_WHAT_THEY_BOUGHT,
|
|
47247
48053
|
CLAUDE_REPAIR_MESSAGES: () => CLAUDE_REPAIR_MESSAGES,
|
|
48054
|
+
INTAKE_ANSWERS_MISSING: () => INTAKE_ANSWERS_MISSING,
|
|
47248
48055
|
INTAKE_CONNECT_REQUIRED: () => INTAKE_CONNECT_REQUIRED,
|
|
47249
48056
|
INTAKE_FORM_REQUIRED: () => INTAKE_FORM_REQUIRED,
|
|
47250
48057
|
INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
|
|
48058
|
+
INTAKE_READ_FAILED: () => INTAKE_READ_FAILED,
|
|
48059
|
+
INTAKE_READ_TRANSIENT: () => INTAKE_READ_TRANSIENT,
|
|
48060
|
+
INTAKE_REFRESH_RETRYING: () => INTAKE_REFRESH_RETRYING,
|
|
47251
48061
|
INTAKE_UNREADABLE: () => INTAKE_UNREADABLE,
|
|
48062
|
+
NOTHING_BOUGHT: () => NOTHING_BOUGHT,
|
|
48063
|
+
NOTHING_BUILT_FROM_TICKS: () => NOTHING_BUILT_FROM_TICKS,
|
|
48064
|
+
NOTHING_TICKED_IN_PLAN: () => NOTHING_TICKED_IN_PLAN,
|
|
47252
48065
|
NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
|
|
47253
48066
|
PARKED_NOT_BUILT: () => PARKED_NOT_BUILT,
|
|
48067
|
+
SCOPE_CHANGED_SINCE_APPROVAL: () => SCOPE_CHANGED_SINCE_APPROVAL,
|
|
48068
|
+
SCOPE_REFUSAL: () => SCOPE_REFUSAL,
|
|
47254
48069
|
SCREEN_HEADS: () => SCREEN_HEADS,
|
|
47255
48070
|
SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
|
|
47256
48071
|
SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
|
|
@@ -47259,8 +48074,11 @@ __export(dashboard_exports, {
|
|
|
47259
48074
|
STAGES: () => STAGES2,
|
|
47260
48075
|
accountTypeOf: () => accountTypeOf,
|
|
47261
48076
|
agencyConnectionReady: () => agencyConnectionReady,
|
|
48077
|
+
approvalBoughtHash: () => approvalBoughtHash,
|
|
48078
|
+
approvalScopeHash: () => approvalScopeHash,
|
|
47262
48079
|
assessmentAgencyTag: () => assessmentAgencyTag,
|
|
47263
48080
|
assessmentFiguresFor: () => assessmentFiguresFor,
|
|
48081
|
+
boughtUnchangedSinceApproval: () => boughtUnchangedSinceApproval,
|
|
47264
48082
|
callClaudeRecorded: () => callClaudeRecorded,
|
|
47265
48083
|
canonicalIndustry: () => canonicalIndustry,
|
|
47266
48084
|
checkMutationGuard: () => checkMutationGuard,
|
|
@@ -47278,9 +48096,11 @@ __export(dashboard_exports, {
|
|
|
47278
48096
|
ensureEngineReady: () => ensureEngineReady,
|
|
47279
48097
|
forgetAnthropicKey: () => forgetAnthropicKey,
|
|
47280
48098
|
guarded: () => guarded,
|
|
48099
|
+
hasChosenScope: () => hasChosenScope,
|
|
47281
48100
|
insideDir: () => insideDir,
|
|
47282
48101
|
intakeInput: () => intakeInput,
|
|
47283
48102
|
intakeRequestError: () => intakeRequestError,
|
|
48103
|
+
intakeUnavailableError: () => intakeUnavailableError,
|
|
47284
48104
|
isAllowedCockpitHost: () => isAllowedCockpitHost,
|
|
47285
48105
|
isLoopback: () => isLoopback,
|
|
47286
48106
|
isUpstreamFailure: () => isUpstreamFailure,
|
|
@@ -47289,10 +48109,13 @@ __export(dashboard_exports, {
|
|
|
47289
48109
|
loadingMainHtml: () => loadingMainHtml,
|
|
47290
48110
|
mayServeSeat: () => mayServeSeat,
|
|
47291
48111
|
memberFacingError: () => memberFacingError,
|
|
48112
|
+
mergeQueueIntakes: () => mergeQueueIntakes,
|
|
47292
48113
|
newClientFailure: () => newClientFailure,
|
|
47293
48114
|
normalizedIntake: () => normalizedIntake,
|
|
48115
|
+
offeredFeaturesFor: () => offeredFeaturesFor,
|
|
47294
48116
|
overlayPath: () => overlayPath,
|
|
47295
48117
|
prefillTeamAnswers: () => prefillTeamAnswers,
|
|
48118
|
+
queueScanFilter: () => queueScanFilter,
|
|
47296
48119
|
readBody: () => readBody,
|
|
47297
48120
|
readClientStages: () => readClientStages,
|
|
47298
48121
|
readCockpitState: () => readCockpitState,
|
|
@@ -47304,9 +48127,11 @@ __export(dashboard_exports, {
|
|
|
47304
48127
|
recoverStuckStages: () => recoverStuckStages,
|
|
47305
48128
|
renderCockpitHtml: () => renderCockpitHtml,
|
|
47306
48129
|
reportSeatAllowed: () => reportSeatAllowed,
|
|
48130
|
+
rereadQueueIntakes: () => rereadQueueIntakes,
|
|
47307
48131
|
resolvePlan: () => resolvePlan2,
|
|
47308
48132
|
runDashboard: () => runDashboard,
|
|
47309
48133
|
safeDocName: () => safeDocName,
|
|
48134
|
+
scopeHashOf: () => scopeHashOf,
|
|
47310
48135
|
scopePrefillFor: () => scopePrefillFor,
|
|
47311
48136
|
secondClientInputHash: () => secondClientInputHash,
|
|
47312
48137
|
setAccountIndustry: () => setAccountIndustry,
|
|
@@ -47318,7 +48143,9 @@ __export(dashboard_exports, {
|
|
|
47318
48143
|
uniqueDocName: () => uniqueDocName,
|
|
47319
48144
|
unknownCapabilityHtml: () => unknownCapabilityHtml,
|
|
47320
48145
|
verifyPanelOutcome: () => verifyPanelOutcome,
|
|
48146
|
+
withBoughtJobs: () => withBoughtJobs,
|
|
47321
48147
|
withCockpitShell: () => withCockpitShell,
|
|
48148
|
+
withdrawIfBindingChanged: () => withdrawIfBindingChanged,
|
|
47322
48149
|
writeCockpitState: () => writeCockpitState,
|
|
47323
48150
|
writeDocAtomically: () => writeDocAtomically,
|
|
47324
48151
|
writeOverlay: () => writeOverlay
|
|
@@ -47388,6 +48215,37 @@ function setAccountIndustry(state, locationId2, industry) {
|
|
|
47388
48215
|
if (!next.industry) delete next.industry;
|
|
47389
48216
|
return { ...state, accounts: { ...state.accounts ?? {}, [locationId2]: next } };
|
|
47390
48217
|
}
|
|
48218
|
+
function scopeHashOf(sel) {
|
|
48219
|
+
if (!sel) return contentHash({ none: true });
|
|
48220
|
+
if (sel.wholePreset) return contentHash({ wholePreset: true });
|
|
48221
|
+
return contentHash({ package: sel.package ?? "", features: [...new Set((sel.features ?? []).filter(isFeatureId))].sort() });
|
|
48222
|
+
}
|
|
48223
|
+
function approvalScopeHash(state, recordKey) {
|
|
48224
|
+
return approvalBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
|
|
48225
|
+
}
|
|
48226
|
+
function withdrawIfBindingChanged(state, recordKey) {
|
|
48227
|
+
const ap = state.planApprovals?.[recordKey];
|
|
48228
|
+
if (!ap || ap.scopeHash === approvalScopeHash(state, recordKey)) return { state, withdrawn: false };
|
|
48229
|
+
const approvals = { ...state.planApprovals };
|
|
48230
|
+
delete approvals[recordKey];
|
|
48231
|
+
return { state: { ...setBuildApproval(state, recordKey, null), planApprovals: approvals }, withdrawn: true };
|
|
48232
|
+
}
|
|
48233
|
+
function withBoughtJobs(tasks2, lines) {
|
|
48234
|
+
const kept = tasks2.filter((t) => !SELF_REPORTED.test(t.text));
|
|
48235
|
+
const prior = new Map(tasks2.map((t) => [t.id, t]));
|
|
48236
|
+
return [...kept, ...lines.map((text2) => prior.get(taskId(text2)) ?? { id: taskId(text2), text: text2 })];
|
|
48237
|
+
}
|
|
48238
|
+
function approvalBoughtHash(state, recordKey) {
|
|
48239
|
+
return boughtBindingHash(scopeHashOf(state.scope?.[recordKey]), state, recordKey);
|
|
48240
|
+
}
|
|
48241
|
+
function boughtUnchangedSinceApproval(ap, state, recordKey) {
|
|
48242
|
+
if (ap.boughtHash) return ap.boughtHash === approvalBoughtHash(state, recordKey);
|
|
48243
|
+
return ap.scopeHash === scopeHashOf(state.scope?.[recordKey]) && !Array.isArray(state.bought?.[recordKey]);
|
|
48244
|
+
}
|
|
48245
|
+
function hasChosenScope(state, recordKey) {
|
|
48246
|
+
const sel = state.scope?.[recordKey];
|
|
48247
|
+
return Boolean(sel && (sel.wholePreset || Array.isArray(sel.features) && sel.features.some(isFeatureId)));
|
|
48248
|
+
}
|
|
47391
48249
|
function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ new Date()) {
|
|
47392
48250
|
const all = { ...state.scope ?? {} };
|
|
47393
48251
|
if (!scope) {
|
|
@@ -47403,16 +48261,26 @@ function setClientScope(state, locationId2, scope, now = () => /* @__PURE__ */ n
|
|
|
47403
48261
|
if (!Object.keys(all).length) delete next.scope;
|
|
47404
48262
|
return next;
|
|
47405
48263
|
}
|
|
47406
|
-
function
|
|
47407
|
-
const
|
|
47408
|
-
if (!sc && Array.isArray(state.assessmentPurchases?.[locationId2])) return { awaitingConfirmation: true, built: [], notBuilt: [] };
|
|
47409
|
-
let offered = FEATURE_IDS;
|
|
48264
|
+
function offeredFeaturesFor(state, recordKey) {
|
|
48265
|
+
const locationId2 = recordKey.split("--")[0];
|
|
47410
48266
|
try {
|
|
47411
48267
|
const chosen = loadPresetChoice(void 0, presetForIndustry(state.accounts?.[locationId2]?.industry));
|
|
47412
|
-
if (chosen.ok)
|
|
48268
|
+
if (chosen.ok) return featuresInPreset(chosen.preset.skeleton?.workflows ?? []);
|
|
47413
48269
|
} catch {
|
|
47414
48270
|
}
|
|
47415
|
-
|
|
48271
|
+
return FEATURE_IDS;
|
|
48272
|
+
}
|
|
48273
|
+
function documentScopeFor(state, locationId2, savedPlan) {
|
|
48274
|
+
const sc = state.scope?.[locationId2];
|
|
48275
|
+
const offered = offeredFeaturesFor(state, locationId2);
|
|
48276
|
+
if (!sc) {
|
|
48277
|
+
const outcome = state.outcomes?.[`${locationId2}:3`];
|
|
48278
|
+
const built = state.clients?.[locationId2]?.stages?.[3] === "done" || outcome?.ok === true;
|
|
48279
|
+
const wholePresetPlan = Boolean(savedPlan && !savedPlan.scope && typeof savedPlan.plan?.preset === "string");
|
|
48280
|
+
if (built && wholePresetPlan) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
|
|
48281
|
+
return { awaitingConfirmation: true, built: [], notBuilt: [] };
|
|
48282
|
+
}
|
|
48283
|
+
if (sc.wholePreset) return { wholePreset: true, built: offered.map((id) => ({ id, name: FEATURES[id].name })), notBuilt: [] };
|
|
47416
48284
|
return {
|
|
47417
48285
|
...sc.package ? { package: sc.package } : {},
|
|
47418
48286
|
// Built = the confirmed scope, the one record the build follows. The purchase
|
|
@@ -47510,7 +48378,10 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
|
|
|
47510
48378
|
normalizedAnswers: meta2.normalizedAnswers,
|
|
47511
48379
|
dependencies: verificationPlan ? verificationPlan.dependencies ?? planDependencies(verificationPlan.plan) : readSavedPlan(rk)?.dependencies,
|
|
47512
48380
|
industry,
|
|
47513
|
-
|
|
48381
|
+
// Bought-but-not-built jobs are the member's to confirm: no check can read them, so
|
|
48382
|
+
// Verify never reports them as findings (review of ea3526f). They reach the card's
|
|
48383
|
+
// jobs from the obligations file directly.
|
|
48384
|
+
manualObligations: stage === 4 ? readBuildObligations(rk).filter((o) => o.type !== BOUGHT_JOB_TYPE) : void 0,
|
|
47514
48385
|
intakeSummary: describeComposition({ industry, agency }),
|
|
47515
48386
|
customLabels: composed.filter((q3) => {
|
|
47516
48387
|
const base = INTAKE_QUESTIONS.find((b) => b.key === q3.key);
|
|
@@ -47587,6 +48458,12 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
|
|
|
47587
48458
|
run.lines.push(`Could not write this run to the cost record: ${e instanceof Error ? e.message : String(e)}. The step itself is unaffected.`);
|
|
47588
48459
|
}
|
|
47589
48460
|
for (const line3 of costLines(outcome)) run.lines.push(line3);
|
|
48461
|
+
const scopeRefusal = stage === 3 && !outcome.ok && typeof outcome.error === "string" && SCOPE_REFUSAL.test(outcome.error);
|
|
48462
|
+
if (scopeRefusal) {
|
|
48463
|
+
outcome.summary = outcome.error;
|
|
48464
|
+
outcome.issues = [];
|
|
48465
|
+
outcome.plain = { summary: outcome.error, issues: [] };
|
|
48466
|
+
}
|
|
47590
48467
|
if (stage === 3 && !outcome.summary && !outcome.issues?.length) {
|
|
47591
48468
|
try {
|
|
47592
48469
|
const st = readCockpitState();
|
|
@@ -47600,8 +48477,8 @@ function startRun(locationId2, stage, onDone, meta2 = {}) {
|
|
|
47600
48477
|
const st = readCockpitState();
|
|
47601
48478
|
writeCockpitState({ ...st, outcomes: { ...st.outcomes ?? {}, [`${rk}:${stage}`]: { ok: outcome.ok, error: outcome.error, summary: outcome.summary, issues: outcome.issues, ...plain && { plain }, at, inputHash: runInputHash, ...stage === 4 ? { buildHash: runBuildHash, verification: verifyRaw?.verification } : {}, hash: contentHash({ inputHash: runInputHash, at, ok: outcome.ok, summary: outcome.summary, issues: outcome.issues }) } } });
|
|
47602
48479
|
};
|
|
47603
|
-
save();
|
|
47604
|
-
if (PLAIN_REPORT_STAGES.has(stage)) {
|
|
48480
|
+
save(scopeRefusal ? outcome.plain : void 0);
|
|
48481
|
+
if (PLAIN_REPORT_STAGES.has(stage) && !scopeRefusal) {
|
|
47605
48482
|
run.lines.push("Writing the plain-English version of what came back\u2026");
|
|
47606
48483
|
const plain = await rewriteOutcomePlain(STAGES2[stage], verifyRaw ?? outcome, { onEvent: (l) => run.lines.push(l), onCost: (costUsd) => {
|
|
47607
48484
|
recordRunCost({ at, locationId: locationId2, stage, kind: "plain rewrite", ok: true, costUsd });
|
|
@@ -47808,6 +48685,40 @@ function logQuietly(message) {
|
|
|
47808
48685
|
} catch {
|
|
47809
48686
|
}
|
|
47810
48687
|
}
|
|
48688
|
+
function queueScanFilter(body) {
|
|
48689
|
+
try {
|
|
48690
|
+
const b = body ? JSON.parse(body) : null;
|
|
48691
|
+
if (b && typeof b === "object" && b.only === "pending") {
|
|
48692
|
+
const open2 = b.open;
|
|
48693
|
+
return { only: "pending", ...typeof open2 === "string" && open2 ? { open: open2 } : {} };
|
|
48694
|
+
}
|
|
48695
|
+
} catch {
|
|
48696
|
+
}
|
|
48697
|
+
return void 0;
|
|
48698
|
+
}
|
|
48699
|
+
async function rereadQueueIntakes(opts) {
|
|
48700
|
+
const reread = {};
|
|
48701
|
+
const failed = [];
|
|
48702
|
+
for (const { locationId: locationId2 } of opts.roster) {
|
|
48703
|
+
if (opts.state.clients[locationId2]?.stages?.[0] !== "done") continue;
|
|
48704
|
+
if (opts.filter?.only === "pending" && locationId2 !== opts.filter.open && opts.state.intakeInputs?.[locationId2]?.status === "received") continue;
|
|
48705
|
+
try {
|
|
48706
|
+
const got = await opts.read(locationId2);
|
|
48707
|
+
if (got === null) continue;
|
|
48708
|
+
if (got === "unavailable") {
|
|
48709
|
+
failed.push(locationId2);
|
|
48710
|
+
continue;
|
|
48711
|
+
}
|
|
48712
|
+
reread[locationId2] = got;
|
|
48713
|
+
} catch {
|
|
48714
|
+
failed.push(locationId2);
|
|
48715
|
+
}
|
|
48716
|
+
}
|
|
48717
|
+
return { reread, failed };
|
|
48718
|
+
}
|
|
48719
|
+
function mergeQueueIntakes(latest, reread) {
|
|
48720
|
+
return { ...latest, intakeInputs: { ...latest.intakeInputs ?? {}, ...reread } };
|
|
48721
|
+
}
|
|
47811
48722
|
function readBody(req, res, max, onDone, tooLarge = "That request is too large.", sensitive = false) {
|
|
47812
48723
|
let body = "";
|
|
47813
48724
|
let tooBig = false;
|
|
@@ -47917,6 +48828,10 @@ function readableAccount(locationId2, roster = currentRosterRef(), state = readC
|
|
|
47917
48828
|
const t = state.accounts?.[locationId2]?.type;
|
|
47918
48829
|
return t !== "hidden" && t !== "template";
|
|
47919
48830
|
}
|
|
48831
|
+
function intakeUnavailableError(snap) {
|
|
48832
|
+
const reason3 = snap.unavailableReason;
|
|
48833
|
+
return new Error(reason3 === "auth" ? INTAKE_UNREADABLE : reason3 === "missing" ? INTAKE_ANSWERS_MISSING : reason3 === "error" ? INTAKE_READ_FAILED : INTAKE_READ_TRANSIENT);
|
|
48834
|
+
}
|
|
47920
48835
|
function intakeRequestError(e) {
|
|
47921
48836
|
const text2 = e instanceof Error ? e.message : "";
|
|
47922
48837
|
return INTAKE_OWN_MESSAGES.includes(text2) ? text2 : "The intake request did not finish. Retry this step.";
|
|
@@ -47957,6 +48872,9 @@ function html(roster, seatToken, screen = "board", cap) {
|
|
|
47957
48872
|
/* Board: four doors in one row (DoorTile), then the two-column grid */
|
|
47958
48873
|
.board{display:grid;gap:var(--space-6)}
|
|
47959
48874
|
.txt > .cds-chip{justify-self:start}
|
|
48875
|
+
/* A class that sets display (.cds-chip is inline-flex) beats the browser's own [hidden] rule, so
|
|
48876
|
+
"Not saved yet" stayed on screen after a save (rehearsal 2, finding 12). Hidden means hidden. */
|
|
48877
|
+
[hidden]{display:none!important}
|
|
47960
48878
|
.cds-door > .cds-callout{display:grid;gap:var(--space-2)}
|
|
47961
48879
|
|
|
47962
48880
|
/* Toolbars and rows of controls */
|
|
@@ -48041,7 +48959,7 @@ const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-liv
|
|
|
48041
48959
|
// What each step does: [the one line on its row, the fuller sentence in the next-step panel].
|
|
48042
48960
|
const STEP_ABOUT=[["Creates the intake form, its fields and one notification workflow.","Creates or repairs this client's intake form, its custom fields and one published notification workflow."],
|
|
48043
48961
|
["Reads the intake into a brief.","Reads this client's intake, from the submitted form or the one completed here, and turns it into the brief the plan is made from."],
|
|
48044
|
-
["You approve the
|
|
48962
|
+
["You choose what they bought, then approve the build.","Choose what the client bought, review the intake and the brief, then approve the build for this client. Only what they bought gets built."],
|
|
48045
48963
|
["Builds the approved plan into this account.","Builds the approved plan into this client's account. Anything it cannot build is listed as an issue."],
|
|
48046
48964
|
["Checks the build for broken links.","Checks the account for broken links between workflows, pipelines and fields. It only reads. Anything it cannot check is reported as unchecked."],
|
|
48047
48965
|
["Jobs only a person can do. You confirm them.","The jobs only a person can do, such as text-message registration, Google sign-in, staff and phone. Confirm them when they are finished."],
|
|
@@ -48057,7 +48975,6 @@ const SCREEN=document.body.getAttribute("data-screen")||"board";
|
|
|
48057
48975
|
const CAP=${scriptJson(capForPage)};const HUB=${scriptJson(HUB_URL)};const HEADS=${scriptJson(SCREEN_HEADS)};
|
|
48058
48976
|
const INDUSTRIES=${scriptJson([...INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })), { slug: "other", label: "Other" }])};
|
|
48059
48977
|
const INDUSTRIES2=${scriptJson(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
|
|
48060
|
-
const FEATS=${scriptJson(FEATURES)};
|
|
48061
48978
|
/* One account at a time (Jerry, 2026-08-26: "way too busy\u2026 tempting to work on multiple
|
|
48062
48979
|
accounts at once"). One card is ever expanded; every other account is a one-line row. */
|
|
48063
48980
|
let newClientId=new URLSearchParams(location.hash.slice(1)).get("client"),existingClientsOpen=false;
|
|
@@ -48066,7 +48983,7 @@ let registerNotice="",registerFailed=false,registerBusy=false;
|
|
|
48066
48983
|
let activeId=null;try{activeId=localStorage.getItem("cmdos-active")||null}catch(e){}
|
|
48067
48984
|
{const linkedClient=new URLSearchParams(location.hash.slice(1)).get("client");if(linkedClient)rememberClient(linkedClient)}
|
|
48068
48985
|
function rememberClient(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}catch(e){};document.querySelectorAll(".cds-side a[href]").forEach(a=>{const href=a.getAttribute("href");if(href&&href.startsWith("/")&&!href.startsWith("/api/")&&!href.includes("?")){a.href=href.split("#")[0]+"#client="+encodeURIComponent(id)}})}
|
|
48069
|
-
function setActive(id){rememberClient(id);render(lastState,lastRun)}
|
|
48986
|
+
function setActive(id){if(typeof iRetryStopExcept==="function")iRetryStopExcept(id);rememberClient(id);render(lastState,lastRun)}
|
|
48070
48987
|
function initials(name){const p=String(name||"").trim().split(/\\s+/);return ((p[0]||"?")[0]+(p[1]?p[1][0]:"")).toUpperCase()}
|
|
48071
48988
|
/* Command Design System pieces, built as text (never markup from data). */
|
|
48072
48989
|
function mk(tag,cls,text){const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}
|
|
@@ -48095,16 +49012,83 @@ function listRows(rows,key,noun){const frag=document.createDocumentFragment();co
|
|
|
48095
49012
|
function unnest(el){if(el&&el.classList){el.classList.remove("cds-panel","cds-card");el.classList.add("cds-stack")}return el}
|
|
48096
49013
|
function typeOf(state,id){const sandboxes=state.sandboxAccounts||Object.fromEntries(SANDBOX.map(id=>[id,true]));if(sandboxes[id])return "sandbox";const t=(state.accounts&&state.accounts[id]&&state.accounts[id].type);return t==="sandbox"?"client":(t||"client")}
|
|
48097
49014
|
function activityScore(state,id){const c=state.clients[id];if(!c)return 0;return c.stages.some(s=>s!=="pending")?1:0}
|
|
48098
|
-
async function load(){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}
|
|
48099
|
-
|
|
49015
|
+
async function load(quiet,freshQueue){if(SCREEN==="new"&&canCreateSubAccounts){const probe=await(await clientPageRequest("/api/agency-connection")).json();agencyReady=probe.ok===true}/* The queue first: a re-reading POST can take a minute, and state fetched before it would draw stale (review of c4b75c9). */
|
|
49016
|
+
await loadQueue(quiet===true?(freshQueue===true?"pending":"get"):undefined);const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();if(SCREEN==="new"&&!snapshots){try{const sr=await(await fetch("/api/snapshots")).json();snapshots=sr.ok?sr.snapshots:[];snapshotError=sr.ok?"":clientPageError(sr.error,"Check the agency key in GoHighLevel, then refresh the snapshot list.")}catch(e){snapshots=[];snapshotError="Could not load snapshots; check the connection and refresh this page."}}/* A background refresh (quiet) draws only when the data changed (2026-09-29: an unchanged redraw closed what Jerry had open). */
|
|
49017
|
+
if(!(quiet===true&&dataSig(s,r)===lastDrawnSig))render(s,r);hideFinishNote();
|
|
49018
|
+
if(r.running&&!polling){polling=true;const t=setInterval(async()=>{let rr;try{rr=await fetchRunStatus()}catch(e){return}
|
|
49019
|
+
if(!rr.running){clearInterval(t);polling=false}
|
|
49020
|
+
if(!shouldRedraw(lastRun,rr))return;
|
|
49021
|
+
try{await loadQueue("get");const ss=await(await fetch("/api/state")).json();if(dataSig(ss,rr)!==lastDrawnSig)render(ss,rr);hideFinishNote()}catch(e){}},1500)}}
|
|
48100
49022
|
// Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
|
|
48101
49023
|
// already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
|
|
48102
49024
|
// manual reload . a client watching would conclude the build died. Every 3s: if a run is live, hand over to
|
|
48103
49025
|
// the fast poller; every ~15s otherwise, refresh state so stages finished elsewhere flip to done.
|
|
48104
|
-
// Never rebuild the page under the operator's hands: while a form field has focus
|
|
48105
|
-
//
|
|
48106
|
-
|
|
48107
|
-
|
|
49026
|
+
// Never rebuild the page under the operator's hands: while a form field has focus the redraw waits,
|
|
49027
|
+
// and happens on focusout. (2026-08-25: the first heartbeat threw Jerry out of the intake field every refresh.)
|
|
49028
|
+
// Stale "Running" fix (2026-09-29 rehearsal): Brief finished on the server while the page showed
|
|
49029
|
+
// "Running now, working 5m" until a manual reload. The old guard also skipped the status FETCH whenever an
|
|
49030
|
+
// intake panel was open, and the clock ticks on the page alone, so a frozen page looked alive. Now:
|
|
49031
|
+
// the status is ALWAYS fetched; only the full redraw waits, and only while an input has focus; an open intake
|
|
49032
|
+
// panel alone never blocks it (its typed answers, pasted material and prepared-form link live in state, so a
|
|
49033
|
+
// redraw keeps them). A running-to-finished change always redraws, or, with an input focused, shows a bar
|
|
49034
|
+
// that says so. Returning to the tab refreshes. A status older than 15s freezes the clock on "checking\u2026".
|
|
49035
|
+
let lastStatusOkAt=0,redrawPending=false,pointerIsDown=false,flushTimer=0;
|
|
49036
|
+
/* Intake work in flight for a client (Fill the intake from this, a document upload, send, prepare, save): a redraw
|
|
49037
|
+
then would re-enable its button and drop its message, so it waits like a focused field. */
|
|
49038
|
+
let iBusy={};
|
|
49039
|
+
function intakeWorkBusy(){try{return Object.values(iBusy).some(n=>n>0)}catch(e){return false}}
|
|
49040
|
+
/* Inline confirm panels (see icPanel): one waiting on the server holds the redraw like intake work. */
|
|
49041
|
+
let icOpen={};
|
|
49042
|
+
function icBusy(){try{return Object.values(icOpen).some(x=>x&&x.busy)}catch(e){return false}}
|
|
49043
|
+
/* A counter, so two requests for one client overlapping never clear each other. */
|
|
49044
|
+
async function iWork(id,fn){iBusy[id]=(iBusy[id]||0)+1;try{return await fn()}finally{iBusy[id]=Math.max(0,(iBusy[id]||0)-1);if(redrawPending)scheduleFlush()}}
|
|
49045
|
+
function inputFocused(){const a=document.activeElement;return Boolean(a&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))}
|
|
49046
|
+
/* Holds back the full redraw, never the status fetch. */
|
|
49047
|
+
function userIsBusy(){if(SCREEN==="new")return true;return inputFocused()||intakeWorkBusy()||icBusy()}
|
|
49048
|
+
function intakeIsOpen(){try{return Object.values(iOpen||{}).some(Boolean)}catch(e){return false}}
|
|
49049
|
+
async function fetchRunStatus(){const rr=await(await fetch("/api/run/status")).json();lastStatusOkAt=Date.now();return rr}
|
|
49050
|
+
function statusIsStale(){return !lastStatusOkAt||Date.now()-lastStatusOkAt>15000}
|
|
49051
|
+
function runKey(r){return r?[r.running?1:0,r.locationId||"",r.stage==null?"":r.stage,r.capability||"",r.startedAt||"",r.reviewing?1:0,r.ok==null?"":r.ok?1:0].join("|"):""}
|
|
49052
|
+
function runJustFinished(prev,next){return Boolean(prev&&prev.running&&next&&!next.running)}
|
|
49053
|
+
function stepName(r){return r&&!r.capability&&LABELS[r.stage]?LABELS[r.stage]:"The step"}
|
|
49054
|
+
/* Decides whether a fresh status redraws the page now. A finished run always does, unless an input has focus:
|
|
49055
|
+
then the bar tells the operator and the redraw follows on focusout. With an intake panel open the page
|
|
49056
|
+
redraws only when the run itself changed (started, moved stage, finished), not on every tick. */
|
|
49057
|
+
function shouldRedraw(prev,next){const fin=runJustFinished(prev,next);
|
|
49058
|
+
if(userIsBusy()){if(SCREEN==="new")return false;if(fin)showFinishNote(prev);redrawPending=true;return false}
|
|
49059
|
+
if(fin)return true;
|
|
49060
|
+
if(intakeIsOpen())return runKey(prev)!==runKey(next);
|
|
49061
|
+
return true}
|
|
49062
|
+
function showFinishNote(prev){let b=document.getElementById("run-finished-note");
|
|
49063
|
+
if(!b){b=document.createElement("button");b.id="run-finished-note";b.type="button";b.className="cds-callout info";b.setAttribute("role","status");
|
|
49064
|
+
b.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:50;max-width:calc(100% - 32px);cursor:pointer";
|
|
49065
|
+
b.onclick=()=>{redrawPending=false;load(true).catch(()=>{})};document.body.appendChild(b)}
|
|
49066
|
+
b.textContent=stepName(prev)+" finished. Click to see the result."}
|
|
49067
|
+
function hideFinishNote(){const b=document.getElementById("run-finished-note");if(b)b.remove()}
|
|
49068
|
+
/* One retry timer at a time: focusout, pointer releases and finished intake work all ask, only one waits. */
|
|
49069
|
+
function scheduleFlush(){if(flushTimer)return;flushTimer=setTimeout(()=>{flushTimer=0;flushRedraw()},300)}
|
|
49070
|
+
function flushRedraw(){if(!redrawPending)return;if(SCREEN==="new"){redrawPending=false;return}if(userIsBusy()||pointerIsDown){scheduleFlush();return}redrawPending=false;load(true).catch(()=>{})}
|
|
49071
|
+
document.addEventListener("pointerdown",()=>{pointerIsDown=true},true);
|
|
49072
|
+
document.addEventListener("pointerup",()=>{pointerIsDown=false},true);
|
|
49073
|
+
/* A touch that is cancelled, or a release outside the window, never sends pointerup. */
|
|
49074
|
+
document.addEventListener("pointercancel",()=>{pointerIsDown=false},true);
|
|
49075
|
+
window.addEventListener("blur",()=>{pointerIsDown=false});
|
|
49076
|
+
/* Focus leaving an input: a held redraw happens now (after the field's change event has saved its value). */
|
|
49077
|
+
document.addEventListener("focusout",()=>{if(redrawPending)scheduleFlush()});
|
|
49078
|
+
/* Coming back to the tab or the window: fetch at once, rather than trusting what the page last drew. */
|
|
49079
|
+
function refreshOnReturn(){if(userIsBusy()){if(SCREEN!=="new")redrawPending=true;fetchRunStatus().then(rr=>{if(runJustFinished(lastRun,rr)&&SCREEN!=="new")showFinishNote(lastRun)}).catch(()=>{});return}load(true,true).catch(()=>{})}
|
|
49080
|
+
/* Focus and visibilitychange usually fire together on a return: one refresh, not two. */
|
|
49081
|
+
let returnTimer=0;function onReturn(){if(returnTimer)return;returnTimer=setTimeout(()=>{returnTimer=0;refreshOnReturn()},250)}
|
|
49082
|
+
document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="visible")onReturn()});
|
|
49083
|
+
window.addEventListener("focus",onReturn);
|
|
49084
|
+
/* The GET queue shows intake answers as last read; every 4th heartbeat load (about a minute) and a return to the
|
|
49085
|
+
tab use the POST, which re-reads each client's intake, so a submitted or edited intake shows without a reload. */
|
|
49086
|
+
let hbTick=0,hbLoads=0;setInterval(async()=>{if(polling)return;hbTick++;let rr;try{rr=await fetchRunStatus()}catch(e){return}
|
|
49087
|
+
const changed=runKey(rr)!==runKey(lastRun);
|
|
49088
|
+
if(!(rr.running||changed||hbTick%5===0))return;
|
|
49089
|
+
if(userIsBusy()){if(changed)shouldRedraw(lastRun,rr);return}
|
|
49090
|
+
if(intakeIsOpen()&&!changed&&!(rr.running&&!polling))return;
|
|
49091
|
+
hbLoads++;load(true,hbLoads%4===0).catch(()=>{})},3000);
|
|
48108
49092
|
let intakeOpen=false,intakeData=null,intakeIndustry="";
|
|
48109
49093
|
async function loadIntake(){const r=await(await clientPageRequest("/api/intake?industry="+encodeURIComponent(intakeIndustry))).json();intakeData=r;render(lastState,lastRun)}
|
|
48110
49094
|
async function saveOverlay(o){const r=await(await clientPageRequest("/api/overlay",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();if(!r.ok){showClientError(r.error);return}await loadIntake()}
|
|
@@ -48145,6 +49129,25 @@ function intakePanel(){const d=document.createElement("div");d.className="cds-pa
|
|
|
48145
49129
|
d.appendChild(reset);
|
|
48146
49130
|
return d}
|
|
48147
49131
|
let iOpen={},iData={},iMode={},iAnswers={},iDirty={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iDocsErrors={},iPreSrc={};
|
|
49132
|
+
/* What the operator typed into the intake's free boxes (pasted transcript, link, recorder note, the send
|
|
49133
|
+
form) and the prepared-form link live here, not only in the DOM, so a redraw while the intake is open
|
|
49134
|
+
keeps them (2026-09-29: an open intake no longer blocks the page from showing a finished step). */
|
|
49135
|
+
let iDraft={},iPrepared={};
|
|
49136
|
+
function preparedFormBlock(box,id){const p=iPrepared[id];
|
|
49137
|
+
const openLink=document.createElement("a");openLink.className="cds-link";openLink.href=p.url;openLink.target="_blank";openLink.rel="noopener";openLink.textContent="Open the prepared form";box.appendChild(openLink);
|
|
49138
|
+
const hint=document.createElement("p");hint.className="muted";
|
|
49139
|
+
const left=p.leftovers||[];
|
|
49140
|
+
hint.textContent=left.length
|
|
49141
|
+
?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
|
|
49142
|
+
:"Use Open the prepared form above, submit it, then return and press Refresh.";
|
|
49143
|
+
box.appendChild(hint);
|
|
49144
|
+
if(left.length){const ul=document.createElement("ul");ul.className="muted";
|
|
49145
|
+
const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
|
|
49146
|
+
for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
|
|
49147
|
+
box.appendChild(ul)}
|
|
49148
|
+
const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
|
|
49149
|
+
rf.onclick=()=>iLoad(id);box.appendChild(rf)}
|
|
49150
|
+
function keepDraft(el,id,name){const k=id+":"+name;if(iDraft[k]!=null)el.value=iDraft[k];el.addEventListener("input",()=>{iDraft[k]=el.value});return el}
|
|
48148
49151
|
async function iDocsLoad(id){const r=await(await clientPageRequest("/api/intake-documents?locationId="+encodeURIComponent(id))).json();iDocs[id]=(r&&r.files)||[];iDocsErrors[id]=r.ok?"":clientPageError(r.error,"Refresh the page to load this client\u2019s documents again.");render(lastState,lastRun)}
|
|
48149
49152
|
// Show the plain outcome here. The separate Step progress panel shows member-facing progress.
|
|
48150
49153
|
function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
|
|
@@ -48172,9 +49175,35 @@ function costLabel(kind){return ({stage:"Stage",review:"Client review","plain re
|
|
|
48172
49175
|
function iContact(id,questions){const out={};const keys={first_name:"firstName",last_name:"lastName",email:"email",phone:"phone"};for(const q of questions){if(keys[q.contactTag])out[keys[q.contactTag]]=String((iAnswers[id]||{})[q.label]||"")}return out}
|
|
48173
49176
|
function iEdit(id,label,value){(iAnswers[id]||(iAnswers[id]={}))[label]=value;(iDirty[id]||(iDirty[id]=new Set())).add(label)}
|
|
48174
49177
|
function iApplyProposals(id,proposals){const applied=[];const answers=iAnswers[id]||(iAnswers[id]={});for(const [label,value] of Object.entries(proposals||{})){if(answers[label]&&JSON.stringify(answers[label])!==JSON.stringify(value)){if(!confirm(label+String.fromCharCode(10)+"Current: "+answers[label]+String.fromCharCode(10)+"Proposed: "+value+String.fromCharCode(10)+"Replace this answer?"))continue}iEdit(id,label,value);applied.push(label)}return applied}
|
|
48175
|
-
|
|
49178
|
+
/* Rehearsal 2 (2026-09-29): a read that failed for a passing reason (a timeout, a rate limit) is not a broken
|
|
49179
|
+
key. The panel keeps what it last read, says so quietly, and retries; the next good read clears the note.
|
|
49180
|
+
Only a real refusal of the key (401/403) still says to paste it again. */
|
|
49181
|
+
/* Review of d407f04: the retry is a refresh only. It never changes the client on screen (iLoad does, for a
|
|
49182
|
+
click), it stops when the member moves to another client or closes the intake, a throw schedules the next
|
|
49183
|
+
try instead of ending them, and after I_RETRY_MAX tries it stops and says so. */
|
|
49184
|
+
let iRetry={};const I_RETRY_MAX=10;
|
|
49185
|
+
const I_RETRY_NOTE="Could not refresh just now; retrying.",I_RETRY_GAVE_UP="Could not refresh. Refresh status to try again.";
|
|
49186
|
+
function iRetryNote(id){const n=iRetry[id];if(!n)return null;const p=mk("p","muted iretry",n.gaveUp?I_RETRY_GAVE_UP:(n.note||I_RETRY_NOTE));p.setAttribute("role","status");
|
|
49187
|
+
if(n.gaveUp){const b=mk("button","cds-link","Refresh status");b.type="button";b.onclick=()=>iLoad(id);p.appendChild(document.createTextNode(" "));p.appendChild(b)}return p}
|
|
49188
|
+
function iRetryStop(id){const n=iRetry[id];if(n){if(n.t)clearTimeout(n.t);delete iRetry[id]}}
|
|
49189
|
+
function iRetryStopExcept(id){for(const k of Object.keys(iRetry))if(k!==id)iRetryStop(k)}
|
|
49190
|
+
/* Still worth refreshing: the client is the one on screen. */
|
|
49191
|
+
function iShown(id){return activeId===id}
|
|
49192
|
+
function iScheduleRetry(id,note){const n=((iRetry[id]&&iRetry[id].n)||0)+1;if(iRetry[id]&&iRetry[id].t)clearTimeout(iRetry[id].t);
|
|
49193
|
+
if(n>I_RETRY_MAX){iRetry[id]={n:n,gaveUp:true,t:0};return}
|
|
49194
|
+
iRetry[id]={n:n,note:note||I_RETRY_NOTE,t:setTimeout(()=>{const cur=iRetry[id];if(!cur||cur.gaveUp)return;cur.t=0;if(!iShown(id)){iRetryStop(id);return}iRefresh(id)},Math.min(30000,3000*n))}}
|
|
49195
|
+
/* The background read: no rememberClient, no change of client. */
|
|
49196
|
+
async function iRefresh(id){try{const res=await fetch("/api/client-intake?locationId="+encodeURIComponent(id));const r=await res.json();if(!res.ok&&r&&r.ok!==false)r.ok=false;iApply(id,r)}
|
|
49197
|
+
catch(e){if(!iShown(id)){iRetryStop(id);return}iScheduleRetry(id,I_RETRY_NOTE);try{render(lastState,lastRun)}catch(_){}}}
|
|
49198
|
+
async function iLoad(id){rememberClient(id);iRetryStop(id);const r=await(await clientPageRequest("/api/client-intake?locationId="+id)).json();iApply(id,r)}
|
|
49199
|
+
function iApply(id,r){
|
|
49200
|
+
if(r&&r.ok===false&&r.transient){iScheduleRetry(id,r.error);
|
|
49201
|
+
if(!iData[id]||!iData[id].ok)iData[id]={ok:false,transient:true,error:r.error};
|
|
49202
|
+
render(lastState,lastRun);return}
|
|
49203
|
+
iRetryStop(id);
|
|
49204
|
+
iData[id]=r;
|
|
48176
49205
|
// Only unsaved edits override the newest server submission, including edits made before Connect.
|
|
48177
|
-
if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits}}render(lastState,lastRun)}
|
|
49206
|
+
if(r.ok){const edits={};for(const label of iDirty[id]||[])edits[label]=(iAnswers[id]||{})[label];iAnswers[id]={...(r.answers||{}),...edits};if(r.status==="received")delete iPrepared[id]}render(lastState,lastRun)}
|
|
48178
49207
|
let iFieldN=0;
|
|
48179
49208
|
function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="cds-field ifield"+(prefilled?" prefilled":"");
|
|
48180
49209
|
const fid="ifield-"+(++iFieldN);
|
|
@@ -48199,8 +49228,10 @@ function iField(id,q,val,prefilled){const w=document.createElement("div");w.clas
|
|
|
48199
49228
|
w.appendChild(inp);return w}
|
|
48200
49229
|
function intakeWorkspace(id,name){const d=iData[id];const box=document.createElement("div");box.className="cds-panel ipanel";
|
|
48201
49230
|
if(!d){box.textContent="Loading intake\u2026";return box}
|
|
49231
|
+
if(!d.ok&&d.transient){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3>";box.appendChild(iRetryNote(id)||mk("p","muted",d.error||"Could not refresh just now; retrying."));return box}
|
|
48202
49232
|
if(!d.ok){box.innerHTML="<h3 class='cds-panel-title'>Intake</h3><p role='alert'>"+escT(clientPageError(d.error))+"</p>";const a=document.createElement("a");a.className="cds-link";a.href="/new";a.textContent="Reconnect this client";box.appendChild(a);return box}
|
|
48203
49233
|
box.innerHTML="<h3 class='cds-panel-title'>Intake for "+escT(name)+"</h3>";
|
|
49234
|
+
{const rn=iRetryNote(id);if(rn)box.appendChild(rn)}
|
|
48204
49235
|
if(!d.industry){const label=document.createElement("p");label.textContent="Choose this client\u2019s industry before preparing the intake.";box.appendChild(label);const select=document.createElement("select");select.className="cds-select";select.setAttribute("aria-label","Client industry");for(const item of [{slug:"",label:"Choose the client industry"},...INDUSTRIES]){const o=document.createElement("option");o.value=item.slug;o.textContent=item.label;select.appendChild(o)}box.appendChild(select);const save=document.createElement("button");save.className="cds-btn";save.textContent="Confirm industry";save.onclick=async()=>{if(!select.value)return;if(await postJSON("/api/industry",{locationId:id,industry:select.value}))await iLoad(id)};box.appendChild(save);return box}
|
|
48205
49236
|
if(d.status==="received"&&d.savedAt){const note=document.createElement("p");note.textContent="Submitted answers are loaded. A newer saved draft may include changes that have not been submitted.";box.appendChild(note)}
|
|
48206
49237
|
const sub=document.createElement("p");sub.className="muted";
|
|
@@ -48222,14 +49253,14 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48222
49253
|
box.appendChild(ch);
|
|
48223
49254
|
const mode=iMode[id];
|
|
48224
49255
|
if(mode==="send"&&hasForm){const w=document.createElement("div");w.className="cds-stack";
|
|
48225
|
-
const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");
|
|
48226
|
-
const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");
|
|
48227
|
-
const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");
|
|
49256
|
+
const em=document.createElement("input");em.placeholder="client@business.com";em.className="cds-input";em.setAttribute("aria-label","Client email");keepDraft(em,id,"email");
|
|
49257
|
+
const fn=document.createElement("input");fn.placeholder="Their first name (optional)";fn.className="cds-input";fn.setAttribute("aria-label","Client first name");keepDraft(fn,id,"firstName");
|
|
49258
|
+
const ag=document.createElement("input");ag.placeholder="Sign it from\u2026 (your name / agency)";ag.className="cds-input";ag.setAttribute("aria-label","Signed from");keepDraft(ag,id,"signedFrom");
|
|
48228
49259
|
const b=document.createElement("button");b.className="cds-btn";b.textContent="Send the questionnaire";
|
|
48229
49260
|
b.onclick=async()=>{if(!em.value.trim())return showClientError("Enter the client\u2019s email.",box);if(!confirm("Creates a contact for "+em.value.trim()+" in "+name+" and sends one email from "+(d.sender||"the sender configured in this client\u2019s GoHighLevel account")+". Sender not verified. Save a manual task to confirm the sender and send in GoHighLevel?"))return;b.disabled=true;b.textContent="Saving the task\u2026";
|
|
48230
|
-
const r=await(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json();
|
|
49261
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,email:em.value.trim(),firstName:fn.value.trim(),agencyName:ag.value.trim(),confirmed:true})})).json());
|
|
48231
49262
|
if(r.manual){showClientError(r.error,box);const a=document.createElement("a");a.href="https://app.gohighlevel.com/v2/location/"+encodeURIComponent(id)+"/conversations/conversations";a.target="_blank";a.rel="noopener";a.textContent="Open this client\u2019s conversations to confirm the sender and send the questionnaire";box.appendChild(a);b.disabled=false;b.textContent="Send the questionnaire";return}
|
|
48232
|
-
if(r.ok){iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
|
|
49263
|
+
if(r.ok){for(const n of ["email","firstName","signedFrom"])delete iDraft[id+":"+n];iMode[id]=null;await iLoad(id)}else{showClientError(r.error,box);b.disabled=false;b.textContent="Send the questionnaire"}};
|
|
48233
49264
|
w.appendChild(em);w.appendChild(fn);w.appendChild(ag);w.appendChild(b);
|
|
48234
49265
|
const note=document.createElement("p");note.className="muted";
|
|
48235
49266
|
note.textContent="Command OS cannot verify this account\u2019s sender. This button records a manual task to confirm the sender and send the questionnaire in GoHighLevel.";w.appendChild(note);box.appendChild(w)}
|
|
@@ -48241,9 +49272,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48241
49272
|
tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
|
|
48242
49273
|
w.appendChild(tabs);
|
|
48243
49274
|
const ta=document.createElement("textarea");ta.rows=6;ta.placeholder="Paste your discovery-call transcript (Plaud, Zoom, Otter, notes), or drop a .txt/.vtt/.srt file here";
|
|
48244
|
-
ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");
|
|
49275
|
+
ta.className="cds-textarea";ta.setAttribute("aria-label","Transcript");keepDraft(ta,id,"transcript");
|
|
48245
49276
|
ta.ondragover=(e)=>{e.preventDefault();ta.style.borderColor="var(--focus)"};
|
|
48246
|
-
ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text()}};
|
|
49277
|
+
ta.ondrop=async(e)=>{e.preventDefault();ta.style.borderColor="";const f=e.dataTransfer.files[0];if(f){if(!/[.](txt|md|csv|vtt|srt)$/i.test(f.name))return showClientError("Use plain text, .vtt or .srt.",box);ta.value=await f.text();iDraft[id+":transcript"]=ta.value}};
|
|
48247
49278
|
const b=document.createElement("button");b.className="cds-btn";b.textContent="Fill the intake from this";
|
|
48248
49279
|
const st=document.createElement("p");st.className="muted";
|
|
48249
49280
|
b.onclick=async()=>{
|
|
@@ -48256,10 +49287,10 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48256
49287
|
st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
|
|
48257
49288
|
:sub==="url"?"Your own Claude fetches that link, so that site sees the request; the page text then goes to your Claude account and nowhere else."
|
|
48258
49289
|
:"Your own Claude asks the recorder you connected (Plaud, Otter, Fireflies\u2026) for that call; the transcript then goes to your Claude account and nowhere else.";
|
|
48259
|
-
const r=await(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();
|
|
49290
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json());
|
|
48260
49291
|
b.disabled=false;b.textContent="Fill the intake from this";
|
|
48261
49292
|
if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
|
|
48262
|
-
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";
|
|
49293
|
+
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";for(const n of ["transcript","url","recorder"])delete iDraft[id+":"+n];
|
|
48263
49294
|
/* Codex F2 (2026-09-06): a HELD answer (the words were said, the value was worked out) is a
|
|
48264
49295
|
suggestion beside the field, never an answer. It reaches iAnswers only when the operator
|
|
48265
49296
|
clicks "Use it". Unverified answers (read by their Claude, not pasted here) are answers,
|
|
@@ -48270,9 +49301,9 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48270
49301
|
iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");
|
|
48271
49302
|
iMode[id]="fill";render(lastState,lastRun)};
|
|
48272
49303
|
const urlIn=document.createElement("input");urlIn.placeholder="https://\u2026 a shared transcript or recording page";
|
|
48273
|
-
urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");
|
|
49304
|
+
urlIn.className="cds-input";urlIn.setAttribute("aria-label","Link to the transcript or recording");keepDraft(urlIn,id,"url");
|
|
48274
49305
|
const recIn=document.createElement("input");recIn.placeholder='e.g. "grab my Plaud recording from this morning with Radiance Med Spa"';
|
|
48275
|
-
recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");
|
|
49306
|
+
recIn.className="cds-input";recIn.setAttribute("aria-label","Which recording to use");keepDraft(recIn,id,"recorder");
|
|
48276
49307
|
const hint=document.createElement("p");hint.className="muted";
|
|
48277
49308
|
if(iSub[id]==="paste"){w.appendChild(ta);hint.textContent="Plain text, .vtt and .srt only. The limit is 60,000 characters.";w.appendChild(hint)}
|
|
48278
49309
|
else if(iSub[id]==="doc"){
|
|
@@ -48287,7 +49318,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48287
49318
|
const up=async(f)=>{if(!f)return;if(f.size>20*1024*1024){showClientError("That file is over 20 MB.",box,"Split it or export the part that matters.");return}
|
|
48288
49319
|
drop.textContent="Saving "+f.name+"\u2026";
|
|
48289
49320
|
const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(String(rd.result).split(",")[1]||"");rd.onerror=rej;rd.readAsDataURL(f)});
|
|
48290
|
-
const r=await(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json();
|
|
49321
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json());
|
|
48291
49322
|
if(!r.ok){showClientError(r.error||"Could not save that file",box);drop.textContent="Drop plain text, .vtt or .srt here, or choose a file. The limit is 60,000 characters.";return}
|
|
48292
49323
|
iDocs[id]=r.files||[];render(lastState,lastRun)};
|
|
48293
49324
|
drop.ondragover=(e)=>{e.preventDefault();drop.style.outline="2px solid var(--focus)"};
|
|
@@ -48301,7 +49332,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48301
49332
|
const nm=document.createElement("span");nm.className="ttl";nm.textContent=f.name;nm.appendChild(mk("span","sub",Math.max(1,Math.round(f.bytes/1024))+" KB"));row.appendChild(nm);
|
|
48302
49333
|
const rb=document.createElement("button");rb.className="cds-btn ghost";rb.textContent="Fill the intake from this";
|
|
48303
49334
|
rb.onclick=async()=>{rb.disabled=true;rb.textContent="Reading it\u2026";st.textContent="The text of "+f.name+" goes to your own Claude account to be read, and nowhere else.";
|
|
48304
|
-
const r=await(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json();
|
|
49335
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json());
|
|
48305
49336
|
rb.disabled=false;rb.textContent="Fill the intake from this";
|
|
48306
49337
|
if(!r.ok){st.textContent=clientPageError(r.error,"Check the source and try reading it again.");return}
|
|
48307
49338
|
if(r.costs)iData[id].costs=r.costs;iPre[id]=iApplyProposals(id,r.answers);iPreSrc[id]="the document";iHeld[id]=r.held||{};iUnv[id]=r.unverified||[];const heldKeys=Object.keys(iHeld[id]),unv=iUnv[id];iNote[id]=(heldKeys.length?heldKeys.length+" answer"+(heldKeys.length===1?" was":"s were")+" worked out rather than heard and NOT filled in: "+heldKeys.join(", ")+". Each is shown beside its field with a Use it button. ":"")+(unv.length?unv.length+" could not be checked against the material because your Claude read it rather than you pasting it here: "+unv.join(", ")+". Check them.":"");iMode[id]="fill";render(lastState,lastRun)};
|
|
@@ -48342,60 +49373,88 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
48342
49373
|
save.textContent="Review and submit the real form";save.disabled=Boolean(d.connectionPending);
|
|
48343
49374
|
save.title="Opens this client's actual GHL form with your available answers filled in. One click submits it, so it lands as a genuine form submission.";
|
|
48344
49375
|
save.onclick=async()=>{const missing=d.questions.filter(q=>q.required&&(!(iAnswers[id]||{})[q.label]||(Array.isArray(iAnswers[id][q.label])&&!iAnswers[id][q.label].length)));if(missing.length&&!confirm("Required answers still missing: "+missing.map(q=>q.label).join(", ")+". Continue to the real form?"))return;save.disabled=true;save.textContent="Preparing the form\u2026";
|
|
48345
|
-
const r=await(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json();
|
|
49376
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-prefill-url",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:iAnswers[id]||{},contact:iContact(id,d.questions)})})).json());
|
|
48346
49377
|
save.disabled=false;save.textContent="Review and submit the real form";
|
|
48347
49378
|
if(!r.ok){showClientError(r.error,box);return}
|
|
48348
|
-
|
|
48349
|
-
const hint=document.createElement("p");hint.className="muted";
|
|
48350
|
-
const left=r.leftovers||[];
|
|
48351
|
-
hint.textContent=left.length
|
|
48352
|
-
?"Use Open the prepared form above to review your answers, except "+left.length+" listed below. Handle each as noted there, then click Submit and come back and press Refresh:"
|
|
48353
|
-
:"Use Open the prepared form above, submit it, then return and press Refresh.";
|
|
48354
|
-
box.appendChild(hint);
|
|
48355
|
-
if(left.length){const ul=document.createElement("ul");ul.className="muted";
|
|
48356
|
-
const WHY={"several answers":"pick each of these in the form","contains a plus sign":"type this in the form","no field for this question":"the form has no question for this, so it was not carried. Add it as a note on the contact after you submit"};
|
|
48357
|
-
for(const l of left){const li=document.createElement("li");li.textContent=l.label+": "+l.values.join(", ")+". "+(WHY[l.reason]||l.reason)+".";ul.appendChild(li)}
|
|
48358
|
-
box.appendChild(ul)}
|
|
48359
|
-
const rf=document.createElement("button");rf.className="cds-btn ghost";rf.textContent="Refresh status";
|
|
48360
|
-
rf.onclick=()=>iLoad(id);box.appendChild(rf)};
|
|
49379
|
+
iPrepared[id]={url:r.url,leftovers:r.leftovers||[]};render(lastState,lastRun)};
|
|
48361
49380
|
box.appendChild(save)}else if(formNote)box.appendChild(formNote);
|
|
48362
49381
|
const alt=document.createElement("button");alt.className="cds-btn ghost";
|
|
48363
49382
|
|
|
48364
49383
|
alt.textContent="Save draft on this computer";
|
|
48365
49384
|
alt.title="Saves your answers for this client on this computer. You can return to them later.";
|
|
48366
49385
|
alt.onclick=async()=>{alt.disabled=true;alt.textContent="Saving\u2026";const savedAnswers={...(iAnswers[id]||{})};
|
|
48367
|
-
const r=await(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json();
|
|
49386
|
+
const r=await iWork(id,async()=>(await clientPageRequest("/api/intake-submit",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,answers:savedAnswers,contact:iContact(id,d.questions)})})).json());
|
|
48368
49387
|
if(r.ok){for(const label of iDirty[id]||[])if(JSON.stringify(iAnswers[id][label])===JSON.stringify(savedAnswers[label]))iDirty[id].delete(label);iMode[id]=null;iPre[id]=[];await iLoad(id)}else{showClientError(r.error,box);alt.disabled=false;alt.textContent="Save draft on this computer"}};
|
|
48369
|
-
box.appendChild(alt)
|
|
49388
|
+
box.appendChild(alt);
|
|
49389
|
+
if(hasForm&&iPrepared[id])preparedFormBlock(box,id)}}
|
|
48370
49390
|
return box}
|
|
48371
49391
|
let iNote={},iHeld={},iUnv={};
|
|
48372
49392
|
${ASSESSMENT_PANEL_JS}
|
|
48373
49393
|
let rOpen={},rData={},rBusy={},rSeen={};
|
|
48374
49394
|
let profCache=null,profLoading=false;
|
|
48375
49395
|
async function profLoad(){if(profLoading)return;profLoading=true;try{profCache=((await(await fetch("/api/agency-profile")).json()).profile)||{}}catch(e){profCache={}}profLoading=false;render(lastState,lastRun)}
|
|
48376
|
-
/* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
|
|
49396
|
+
/* What they bought (2026-09-06). Foundation is always built; the ticks decide the features.
|
|
49397
|
+
One list since 2026-09-29 (Jerry's approved design): the fourteen Assessment items, each with
|
|
49398
|
+
what it means for the client and what ticking it does for this client's preset. The server
|
|
49399
|
+
derives the build scope from the ticks, so the build reads what it always read. Approve also
|
|
49400
|
+
waits for the Assessment question above the list. */
|
|
49401
|
+
/* The ticks and package chosen but not yet saved, kept outside the block so a redraw keeps them (review of c09e946). */
|
|
49402
|
+
let scopeDraft={},boughtNotice={};
|
|
49403
|
+
const BOUGHT_ITEMS=${scriptJson(boughtItemsFor(FEATURE_IDS))};
|
|
49404
|
+
function scopeChosen(state,id){const s=(state.scope||{})[id];const v=(state.boughtViews||{})[id];return Boolean(s&&(s.wholePreset||(Array.isArray(s.features)&&s.features.length)))&&!(v&&v.ready===false)}
|
|
49405
|
+
function openWhatTheyBought(id){rOpen[id]=true;rSeen[id]=true;const go=()=>{render(lastState,lastRun);requestAnimationFrame(()=>{const t=document.getElementById("wtb-"+id);if(t){t.scrollIntoView({behavior:"smooth",block:"start"});const f=t.querySelector("button,input,select");if(f)f.focus({preventScroll:true})}})};if(!rData[id])rLoad(id).then(go,go);else go()}
|
|
49406
|
+
function boughtView(state,id){return (state.boughtViews||{})[id]||{items:BOUGHT_ITEMS,ticks:BOUGHT_ITEMS.map(i=>i.id),source:"all",answer:null,attached:false,ready:false,suggestions:[]}}
|
|
49407
|
+
/* "Have you run the Assessment for <client>?" One click records the answer; Yes opens the attach flow. */
|
|
49408
|
+
function assessmentQuestion(id,v){
|
|
49409
|
+
const q=mk("div","cds-stack");const who=((typeof ROSTER!=="undefined"&&ROSTER.find(r=>r.locationId===id))||{}).name||"this client";
|
|
49410
|
+
q.appendChild(mk("h4","cds-panel-label","Have you run the Assessment for "+who+"?"));
|
|
49411
|
+
const row=mk("div","cds-row");
|
|
49412
|
+
const opt=(choice,label)=>{const on=v.answer===choice;const b=mk("button","cds-btn"+(on?"":" ghost"),label);b.setAttribute("aria-pressed",String(on));
|
|
49413
|
+
b.onclick=async()=>{b.disabled=true;const r=await postJSON("/api/assessment-choice",{locationId:id,choice});if(r){if(r.approvalWithdrawn)boughtNotice[id]=r.message;load()}else b.disabled=false};row.appendChild(b)};
|
|
49414
|
+
opt("attached","Yes, attach it");opt("run-first","Run it first (recommended)");opt("without","Build without an Assessment");q.appendChild(row);
|
|
49415
|
+
if(v.attached||v.answer==="attached")q.appendChild(assessmentPanel(id));
|
|
49416
|
+
else if(v.answer==="run-first"){const p=mk("p","");const a=document.createElement("a");a.className="cds-link";a.href="/assessment";a.target="_blank";a.rel="noopener";a.textContent="Run the Assessment";p.appendChild(a);p.appendChild(document.createTextNode(". When it is saved, come back and attach it here."));q.appendChild(p)}
|
|
49417
|
+
else if(v.answer==="without")q.appendChild(mk("p","muted","Recorded: no Assessment for this client. The client document says so."));
|
|
49418
|
+
else q.appendChild(mk("p","muted","Approve waits for an answer."));
|
|
49419
|
+
return q}
|
|
48377
49420
|
function scopeBlock(id,state){
|
|
48378
|
-
const sc=document.createElement("div");sc.className="cds-stack";
|
|
48379
|
-
|
|
49421
|
+
const sc=document.createElement("div");sc.className="cds-stack";sc.id="wtb-"+id;
|
|
49422
|
+
const v=boughtView(state,id);
|
|
49423
|
+
sc.appendChild(assessmentQuestion(id,v));
|
|
49424
|
+
sc.appendChild(mk("h4","cds-panel-label","What they bought"));
|
|
49425
|
+
sc.appendChild(mk("p","",v.source==="all"?"Everything starts ticked. Untick what this client did not buy.":v.source==="assessment"?"Ticked from what the Assessment recorded as bought. Untick anything they did not buy.":"Tick what the client bought. Only the items marked Built get built."));
|
|
48380
49426
|
if(!profCache){profLoad();const p=document.createElement("p");p.className="muted";p.textContent="Loading your packages\u2026";sc.appendChild(p);return sc}
|
|
48381
49427
|
const saved=(state.scope||{})[id]||null;const pk=(profCache.packages||[]);
|
|
48382
49428
|
const sel=document.createElement("select");sel.className="cds-select";sel.setAttribute("aria-label","Package they bought");
|
|
48383
|
-
const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"
|
|
49429
|
+
const o0=document.createElement("option");o0.value="";o0.textContent=pk.length?"No package: tick the items below":"No packages saved yet (add them on Your agency): tick the items below";sel.appendChild(o0);
|
|
48384
49430
|
for(const p of pk){const o=document.createElement("option");o.value=p.id;o.textContent=p.name+(p.price?" \xB7 "+moneyText(p.price):"");sel.appendChild(o)}
|
|
48385
|
-
|
|
49431
|
+
let ticked=new Set(v.ticks);if(saved&&saved.package)sel.value=saved.package;
|
|
49432
|
+
const base=JSON.stringify([saved,v.ticks]);if(scopeDraft[id]&&scopeDraft[id].base!==base)delete scopeDraft[id];const dr=scopeDraft[id];if(dr){ticked=new Set(dr.ticked);sel.value=dr.pkg}
|
|
49433
|
+
const keepScope=()=>{scopeDraft[id]={ticked:[...ticked],pkg:sel.value,base:base};unsaved.hidden=false};
|
|
49434
|
+
const everything=document.createElement("button");everything.className="cds-btn ghost";everything.textContent="Tick everything";
|
|
49435
|
+
everything.title="Ticks all fourteen. Nothing is saved until you press Save what they bought.";
|
|
49436
|
+
everything.onclick=()=>{ticked=new Set(v.items.map(i=>i.id));keepScope();drawBoxes();drawFigs()};
|
|
49437
|
+
sc.appendChild(everything);
|
|
48386
49438
|
const boxes=document.createElement("div");boxes.className="cds-stack";
|
|
48387
|
-
function drawBoxes(){boxes.innerHTML="";for(const
|
|
48388
|
-
const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(
|
|
48389
|
-
const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=
|
|
49439
|
+
function drawBoxes(){boxes.innerHTML="";for(const it of v.items){const l=document.createElement("label");l.className="cds-check";
|
|
49440
|
+
l.dataset.k=it.id;const c=document.createElement("input");c.type="checkbox";c.checked=ticked.has(it.id);c.onchange=()=>{if(c.checked)ticked.add(it.id);else ticked.delete(it.id);keepScope();drawFigs()};l.appendChild(c);
|
|
49441
|
+
const t=document.createElement("span");const tb=document.createElement("b");tb.textContent=it.name;t.appendChild(tb);t.appendChild(mk("small","",it.means));
|
|
49442
|
+
t.appendChild(mk("small","bought-delivery",it.delivery));if(it.next)t.appendChild(mk("small","bought-next",it.next));l.appendChild(t);boxes.appendChild(l)}}
|
|
48390
49443
|
const figs=((state.assessmentFigures||{})[id])||{};
|
|
48391
|
-
function drawFigs(){for(const l of boxes.querySelectorAll("label")){const
|
|
48392
|
-
|
|
48393
|
-
|
|
48394
|
-
const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing.
|
|
49444
|
+
function drawFigs(){for(const l of boxes.querySelectorAll("label")){const it=v.items.find(i=>i.id===l.dataset.k);const old=l.querySelector("em");if(old)old.remove();if(it&&it.feature&&figs[it.feature]){const e=document.createElement("em");e.className="cds-chip"+(ticked.has(it.id)?"":" warn");e.textContent="Assessment: "+figs[it.feature]+(ticked.has(it.id)?".":". Not ticked.");l.querySelector("span").appendChild(e)}}}
|
|
49445
|
+
/* A package names build features: it ticks their items and unticks the other built ones; the rest stay as they are. */
|
|
49446
|
+
sel.onchange=()=>{const p=pk.find(x=>x.id===sel.value);if(p)for(const it of v.items){if(!it.feature)continue;if((p.features||[]).includes(it.feature))ticked.add(it.id);else ticked.delete(it.id)}keepScope();drawBoxes();drawFigs()};drawBoxes();drawFigs();
|
|
49447
|
+
const note=document.createElement("p");note.className="muted";note.textContent="Always built, whatever is ticked: the pipeline and its stages, the fields, tags and custom values, the intake form, the calendar, and the reply routing. A ticked item that is not built automatically becomes one of your jobs in the account.";
|
|
48395
49448
|
const sv=document.createElement("button");sv.className="cds-btn ghost";sv.textContent="Save what they bought";
|
|
48396
|
-
|
|
48397
|
-
|
|
48398
|
-
|
|
49449
|
+
const status=document.createElement("p");status.className="muted";status.setAttribute("role","status");
|
|
49450
|
+
sv.onclick=async()=>{if(!ticked.size){status.textContent="Tick at least one item they bought, then save.";return}
|
|
49451
|
+
if(!v.items.some(it=>it.kind==="built"&&ticked.has(it.id))){status.textContent="None of the ticked items is built for this client's industry. Tick at least one item marked Built.";return}
|
|
49452
|
+
const body={locationId:id,bought:[...ticked]};if(sel.value)body.package=sel.value;const r=await postJSON("/api/scope",body);if(r){unsaved.hidden=true;if(r.approvalWithdrawn)boughtNotice[id]=r.message;else delete boughtNotice[id];delete scopeDraft[id];delete assessmentCards[id];load()}};
|
|
49453
|
+
const nBought=v.ticks.length,nBuilt=v.items.filter(it=>it.kind==="built"&&v.ticks.includes(it.id)).length;
|
|
49454
|
+
status.textContent=saved&&v.source==="saved"?"Saved: "+nBought+" of "+v.items.length+" bought, "+nBuilt+" of them built automatically"+(saved.package?", from the "+saved.package+" package":"")+". The build follows this.":saved?(saved.wholePreset?"Saved: everything in the plan. The build follows this.":"Saved before this list: "+saved.features.length+" features. Save again to record what they bought."):"Not saved yet. Save what they bought before approving.";
|
|
49455
|
+
const unsaved=mk("span","cds-chip warn","Not saved yet");unsaved.hidden=!scopeDraft[id];
|
|
49456
|
+
sc.appendChild(sel);sc.appendChild(boxes);sc.appendChild(note);sc.appendChild(sv);sc.appendChild(unsaved);sc.appendChild(status);
|
|
49457
|
+
if(boughtNotice[id]){const n=mk("p","cds-callout warn",boughtNotice[id]);n.setAttribute("role","alert");sc.appendChild(n)}
|
|
48399
49458
|
return sc}
|
|
48400
49459
|
async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)).json();rData[id]=r;render(lastState,lastRun)}
|
|
48401
49460
|
// Finding 20 (2026-08-26): writing the review takes 30\u201390 s and the page re-renders every few seconds meanwhile,
|
|
@@ -48413,7 +49472,6 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
48413
49472
|
{const sc=(state.staffChecks||{})[id];if(!sc||sc.status!=="ready"){const w=document.createElement("p");w.className="cds-callout warn";w.setAttribute("role","alert");w.textContent=sc?STAFF_REVIEW_WARNING:STAFF_REVIEW_UNCHECKED;d.appendChild(w)}}
|
|
48414
49473
|
/* Codex F1 (2026-09-06): the review is the model's output and the names are other businesses' records; both are TEXT. */
|
|
48415
49474
|
const bt=(el,label,text)=>{const b=document.createElement("b");b.textContent=label;el.appendChild(b);el.appendChild(document.createTextNode(moneyText(text)))};
|
|
48416
|
-
d.appendChild(assessmentPanel(id));
|
|
48417
49475
|
const busy=reviewBusy(id,run);
|
|
48418
49476
|
const gen=document.createElement("button");gen.className="cds-btn ghost";
|
|
48419
49477
|
gen.textContent=busy?"Thinking it through\u2026":(r&&r.ok)?"Regenerate":"Generate the review";gen.disabled=busy;
|
|
@@ -48426,7 +49484,9 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
48426
49484
|
if(!r||!r.ok){if(!busy){const p=document.createElement("p");if(r&&r.error&&r.error!=="not generated yet"){p.className="cds-callout warn";p.setAttribute("role","alert")}else p.className="muted";
|
|
48427
49485
|
p.textContent=r&&r.error?r.error:"Turn their intake answers into strategy: who they really sell to, whether the offer holds up, what the account is missing, and what to build first.";
|
|
48428
49486
|
d.appendChild(p)}
|
|
48429
|
-
d.appendChild(gen);
|
|
49487
|
+
d.appendChild(gen);
|
|
49488
|
+
/* What they bought decides the build, so it is here even before the review is written (2026-09-29). */
|
|
49489
|
+
d.appendChild(scopeBlock(id,state));return d}
|
|
48430
49490
|
if(r.headline){const h=document.createElement("div");h.className="cds-callout info headline";h.textContent=moneyText(r.headline);d.appendChild(h)}
|
|
48431
49491
|
if(r.ica){const s1=document.createElement("div");s1.className="cds-stack";s1.innerHTML="<h4 class='cds-panel-label'>Who they actually sell to</h4>";
|
|
48432
49492
|
const p1=document.createElement("p");p1.textContent=moneyText(r.ica.who||"");s1.appendChild(p1);
|
|
@@ -48466,17 +49526,13 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
48466
49526
|
const a=document.createElement("a");a.className="cds-link";a.href="/blueprint?locationId="+encodeURIComponent(id);a.target="_blank";a.rel="noopener";a.textContent="Open the prepared client document";brand.appendChild(a)};
|
|
48467
49527
|
brand.appendChild(an);brand.appendChild(ac);brand.appendChild(dl);d.appendChild(brand);
|
|
48468
49528
|
const row=document.createElement("div");row.className="cds-row";
|
|
48469
|
-
|
|
48470
|
-
|
|
48471
|
-
|
|
48472
|
-
|
|
48473
|
-
|
|
48474
|
-
|
|
48475
|
-
|
|
48476
|
-
hand mark goes through markStage(), which asks for the name once, keeps it, and shows any
|
|
48477
|
-
refusal instead of swallowing it. */
|
|
48478
|
-
ap.onclick=async()=>{await markStage(id,2,"done")};
|
|
48479
|
-
row.appendChild(ap);row.appendChild(gen);d.appendChild(row);
|
|
49529
|
+
/* Rehearsal 2, finding 15: this was a third approve button (labelled unlock the build) that only
|
|
49530
|
+
ticked the step, so Build then refused with "Approve building before running Build." It is now the
|
|
49531
|
+
same "Approve this plan" as the next step, and it appears only when approving can go through:
|
|
49532
|
+
once what they bought is saved, and not after approval. */
|
|
49533
|
+
if((state.buildApproved||{})[id]){const ap=document.createElement("button");ap.className="cds-btn ghost";ap.textContent="Approved";ap.disabled=true;row.appendChild(ap)}
|
|
49534
|
+
else if(scopeChosen(state,id)&&!["template","hidden"].includes(typeOf(state,id))&&!PROTECTED[id])row.appendChild(approveButton(id,name,state,"review","cds-btn"));
|
|
49535
|
+
row.appendChild(gen);d.appendChild(row);
|
|
48480
49536
|
return d}
|
|
48481
49537
|
function clientPageError(raw,next){
|
|
48482
49538
|
const sentence=value=>{const t=String(value||"").trim().replace(/\\s*\\u2014\\s*/g,". ").replace(/[.!?;:]+$/,"");return t?t.charAt(0).toUpperCase()+t.slice(1)+".":""};
|
|
@@ -48489,7 +49545,7 @@ function clientPageError(raw,next){
|
|
|
48489
49545
|
return text+(action?" "+action:"")}
|
|
48490
49546
|
function showClientError(raw,container,next){const parent=container||document.getElementById("main");let e=parent.querySelector('[role="alert"]');if(!e){e=document.createElement("p");e.setAttribute("role","alert");e.className="cds-callout warn";parent.appendChild(e)}e.textContent=clientPageError(raw,next)}
|
|
48491
49547
|
async function clientPageRequest(url,options){try{const response=await fetch(url,options);const data=await response.json();if(!response.ok&&data.ok!==false)data.ok=false;return {json:async()=>data}}catch(e){return {json:async()=>({ok:false,error:/^[/]api[/](?:client-)?intake/.test(url)?"GHL Command could not confirm the result. Refresh the intake to check its status.":"GHL Command could not confirm the result; check GoHighLevel before trying again to avoid repeating a change."})}}}
|
|
48492
|
-
function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";
|
|
49548
|
+
function pendingCard(c,state,intake=false){const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
|
|
48493
49549
|
const head=document.createElement("div");head.className="cds-panel-head client-head";
|
|
48494
49550
|
const txt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;txt.appendChild(h);head.appendChild(txt);
|
|
48495
49551
|
const pill=chip("warn","Needs connecting");head.appendChild(pill);
|
|
@@ -48636,7 +49692,7 @@ function card(c,state,run){const st=(state.clients[c.locationId]||{stages:STAGES
|
|
|
48636
49692
|
// A template or hidden account opens only so its owner can lock or unlock it and change its
|
|
48637
49693
|
// type: nothing on its card builds into it (review L-1; a template can be the live snapshot source).
|
|
48638
49694
|
const parkedType=["template","hidden"].includes(typeOf(state,c.locationId));
|
|
48639
|
-
const div=document.createElement("section");div.className="cds-panel client";
|
|
49695
|
+
const div=document.createElement("section");div.className="cds-panel client";div.setAttribute("data-key",c.locationId);
|
|
48640
49696
|
const head=document.createElement("div");head.className="cds-panel-head client-head";
|
|
48641
49697
|
const htxt=mk("div","txt");const h=document.createElement("h2");h.className="cds-panel-title";h.textContent=c.name;htxt.appendChild(h);head.appendChild(htxt);
|
|
48642
49698
|
if(sandbox){const sb=chip("info","");sb.title="Practice accounts skip typing the client name. Review and approve the plan before building.";sb.textContent="Practice account";htxt.appendChild(sb)}
|
|
@@ -48657,11 +49713,6 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48657
49713
|
else if(!st.some(s=>s==="done"))div.appendChild(unnest(preflightCard(c)));
|
|
48658
49714
|
/* Kept reachable after a finished Build while a re-run is held for staff (the gate saves its check). */
|
|
48659
49715
|
{const sc=(state.staffChecks||{})[c.locationId];if(!parkedType&&((st[3]||"pending")!=="done"||(sc&&sc.status!=="ready")))div.appendChild(unnest(staffCard(c,state)))}
|
|
48660
|
-
const approveBuilding=async()=>{const typed=prompt('Approve the Blueprint Review for "'+c.name+'" and let Command OS build in this account.'+String.fromCharCode(10,10)+'Type the client name exactly to confirm:');if(typed===null)return;
|
|
48661
|
-
try{const response=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,confirmName:typed})});const r=await response.json();
|
|
48662
|
-
if(!response.ok||r.ok!==true){r.ok=false;r.error=r.error||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
|
|
48663
|
-
if(!r.ok)alert(r.error);else load()
|
|
48664
|
-
}catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")}};
|
|
48665
49716
|
// The steps, laid out as the owner approved on 2026-09-25: a progress bar, the next step in
|
|
48666
49717
|
// full, then one row per step. Only the layout changed. Every button below keeps the condition
|
|
48667
49718
|
// and the handler it had in the seven-column layout; the next-step panel draws the SAME
|
|
@@ -48679,7 +49730,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48679
49730
|
const nextBox=document.createElement("div");nextBox.className="cds-stack snext";div.appendChild(nextBox);
|
|
48680
49731
|
const row=document.createElement("div");row.className="cds-flow stages";row.setAttribute("role","list");
|
|
48681
49732
|
let curLive=false;
|
|
48682
|
-
STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");
|
|
49733
|
+
STAGES.forEach((name,i)=>{const stale=staleOf(i);const el=document.createElement("div");el.className="cds-node stage "+(stale?"pending":(st[i]||"pending"))+(i===curI?" here hit":"");el.setAttribute("role","listitem");el.setAttribute("data-key",c.locationId+":"+i);
|
|
48683
49734
|
const gate=GATES[String(i)];
|
|
48684
49735
|
const oc0=(state.outcomes||{})[c.locationId+":"+i];const notPassed=(st[i]||"pending")==="pending"&&oc0&&(oc0.issues||[]).length>0;
|
|
48685
49736
|
if(notPassed)el.className+=" notpassed";
|
|
@@ -48713,7 +49764,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48713
49764
|
const clock=document.createElement("div");clock.className="cds-num";
|
|
48714
49765
|
|
|
48715
49766
|
const began=run.startedAt?new Date(run.startedAt).getTime():0;
|
|
48716
|
-
|
|
49767
|
+
/* The clock counts on the page alone, so it only counts while the status is fresh: a page that has
|
|
49768
|
+
not heard from the server for 15s freezes and says so, rather than looking busy (2026-09-29). */
|
|
49769
|
+
const tick=()=>{if(!began)return;if(statusIsStale()){clock.textContent="checking\u2026";return}const sec=Math.max(0,Math.round((Date.now()-began)/1000));
|
|
48717
49770
|
clock.textContent="working "+(sec<60?sec+"s":Math.floor(sec/60)+"m "+(sec%60)+"s")};
|
|
48718
49771
|
tick();if(began){const h=setInterval(()=>{if(!clock.isConnected){clearInterval(h);return}tick()},1000)}
|
|
48719
49772
|
box.appendChild(clock);
|
|
@@ -48752,23 +49805,37 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48752
49805
|
else if(!live){const note=mkNote();what.appendChild(note||about)}
|
|
48753
49806
|
if(POWERED.includes(i)&&(st[i]||"pending")!=="done"){el.style.cursor="default";
|
|
48754
49807
|
el.title="Command OS runs and checks this step. Its result comes from a run, never from a click."}
|
|
48755
|
-
|
|
48756
|
-
|
|
49808
|
+
/* Jerry, 2026-09-29: Approve waits for What they bought. Until a selection is saved the gate
|
|
49809
|
+
is "Choose what the client bought", which opens the Blueprint Review at that block. */
|
|
49810
|
+
const mustChoose=i===2&&!scopeChosen(state,c.locationId);
|
|
49811
|
+
const gateLabel=mustChoose?"Choose what the client bought":({2:"Approve this plan",5:"Confirm these tasks are finished",6:"Sign this client live"})[i];
|
|
49812
|
+
/* Approve opens the one approval panel; the other gates open a hand-mark panel with the name on it (rehearsal 2). */
|
|
49813
|
+
const mkGate=cls=>{if(i===2&&!mustChoose)return approveButton(c.locationId,c.name,state,"gate",cls);
|
|
49814
|
+
const action=document.createElement("button");action.className=cls;action.textContent=gateLabel;const key="mark:"+c.locationId+":"+i;
|
|
49815
|
+
action.onclick=()=>{if(mustChoose)return openWhatTheyBought(c.locationId);icShow(action,key,markSpec(c.locationId,i,gateLabel))};return icAfter(action,key)};
|
|
48757
49816
|
const canBuild=!parkedType&&(i<=1||sandbox||Boolean((state.buildApproved||{})[c.locationId]));
|
|
48758
|
-
|
|
49817
|
+
/* Starts the run; the answer is an error sentence, or "" when it started. */
|
|
49818
|
+
const startIt=async()=>{let j=null;try{const res=await fetch("/api/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,stage:i,intakeConfirmed:i===0})});j=await res.json()}catch(e){}
|
|
49819
|
+
return (j&&j.ok)?"":((j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.")};
|
|
49820
|
+
/* Intake, and Verify on an older page build, ask first: in a panel under the button, not a confirm() (rehearsal 2). */
|
|
49821
|
+
const runAsk=i===0?{title:"Create or repair the intake in "+c.name,text:["Intake writes custom fields, one form, and one published notification workflow."].concat(INTAKE_NOTIFICATION_REVIEW),ok:"Create or repair these intake items",busyLabel:"Starting\u2026",run:startIt}
|
|
49822
|
+
:(i===4&&PAGE_PLANS.includes(c.locationId))?{title:"Run the check on "+c.name,text:["Verify submits one test lead to the pages "+c.name+"\u2019s older build made, watches it arrive, and deletes the test contact afterwards. It may fire this client\u2019s own automations once."],ok:"Run the check",busyLabel:"Starting\u2026",run:startIt}:null;
|
|
49823
|
+
const runKeyOf="run:"+c.locationId+":"+i;
|
|
49824
|
+
const runIt=async(ev,btn)=>{ev.stopPropagation();if(runAsk)return icShow(btn,runKeyOf,runAsk);const err=await startIt();if(err)alert(err);load()};
|
|
48759
49825
|
const mkRun=(cls,label)=>{const b=document.createElement("button");b.className=cls;
|
|
48760
49826
|
const busy=run&&run.running;b.textContent=busy&&run.locationId===c.locationId&&run.stage===i?"Running\u2026":label;
|
|
48761
49827
|
b.disabled=Boolean(busy)||(!st.some(s=>s==="done")&&!preflights[c.locationId]?.ok);
|
|
48762
|
-
b.onclick=runIt;return b};
|
|
49828
|
+
b.onclick=ev=>runIt(ev,b);return runAsk?icAfter(b,runKeyOf):b};
|
|
48763
49829
|
if(!live&&nIss){const si=document.createElement("button");si.className="cds-link sactbtn";si.textContent=nIss>1?"See issues":"See issue";si.onclick=toggleIssues;act.appendChild(si)}
|
|
48764
49830
|
/* One way to act on a step: the current step's buttons live in the next-step block only; other rows keep their links.
|
|
48765
49831
|
A gate the owner has already answered offers nothing. */
|
|
48766
49832
|
if(gateOpen&&!anyBusy&&!parkedType&&i!==curI)act.appendChild(mkGate("cds-link sactbtn"));
|
|
48767
49833
|
if(canBuild&&POWERED.includes(i)&&i!==curI)act.appendChild(mkRun("cds-link sactbtn srun",(st[i]||"pending")==="done"?"Run again":"Run this step"));
|
|
48768
|
-
if(i===curI){const main=document.createElement("div");
|
|
49834
|
+
if(i===curI){const main=document.createElement("div");main.setAttribute("data-key",c.locationId+":"+i+":next");
|
|
48769
49835
|
const k=mk("div","cds-panel-label sk",parkedType?"Building is off":"Next step");main.appendChild(k);
|
|
48770
49836
|
const h3=document.createElement("h3");h3.className="cds-panel-title";h3.textContent=parkedType?"Nothing is built into this account":(i+1)+". "+LABELS[i];main.appendChild(h3);
|
|
48771
49837
|
const pa=document.createElement("p");pa.textContent=parkedType?"It opens here so you can lock or unlock it, or change its type to Client to build it.":stepAbout(i,c,true);main.appendChild(pa);
|
|
49838
|
+
if(mustChoose&&!parkedType){const pc=document.createElement("p");pc.className="swarn";pc.textContent="First choose what the client bought. Only those parts get built.";main.appendChild(pc)}
|
|
48772
49839
|
if(live)main.appendChild(mkLive());
|
|
48773
49840
|
if(stale&&!live){const w=document.createElement("div");w.className="cds-row";w.appendChild(chip(chipOf[1],chipOf[0]));w.appendChild(mk("span","swarn",stale.reason));main.appendChild(w)}
|
|
48774
49841
|
const pn=mkNote();if(pn){const w=document.createElement("div");w.className="snoteline";w.appendChild(pn);main.appendChild(w)}
|
|
@@ -48777,7 +49844,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48777
49844
|
if(canBuild&&POWERED.includes(i))btns.appendChild(mkRun("cds-btn runbtn sbig","Run "+LABELS[i]));
|
|
48778
49845
|
if(!parkedType&&canBuild&&POWERED.includes(i)&&run&&run.running&&run.locationId!==c.locationId){const why=document.createElement("p");why.className="muted swhy";why.textContent="A step is running for another client. This one can start when it finishes.";btns.appendChild(why)}
|
|
48779
49846
|
if(!btns.children.length&&!live&&!parkedType){const why=document.createElement("p");why.className="muted swhy";
|
|
48780
|
-
why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve
|
|
49847
|
+
why.textContent=anyBusy?"Another step is running for this client. This one opens when it finishes.":POWERED.includes(i)?"Approve this plan in step 3, and this step can run.":"";
|
|
48781
49848
|
if(why.textContent)btns.appendChild(why)}
|
|
48782
49849
|
nextBox.title=el.title||"";nextBox.appendChild(main);nextBox.appendChild(btns)}
|
|
48783
49850
|
row.appendChild(el)});
|
|
@@ -48804,7 +49871,9 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48804
49871
|
if(view.stale.length){const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const stale of view.stale){const p=document.createElement("p");p.textContent=LABELS[stale.stage]+": stale. "+stale.reason;d.appendChild(p)}div.appendChild(d)}
|
|
48805
49872
|
if(view.approvedAt){const p=document.createElement("p");p.textContent="Building approved on "+niceDate(view.approvedAt,true)+".";div.appendChild(p)}
|
|
48806
49873
|
for(const repair of view.repairs){const p=document.createElement("p");p.textContent="Live workflow repair: "+repair.name+". "+repair.changes.join(" ");div.appendChild(p)}
|
|
48807
|
-
|
|
49874
|
+
/* Rehearsal 2, finding 15: the second building-approval button that was here is gone. The one "Approve this plan"
|
|
49875
|
+
(step 3) approves the listed live workflow repairs too, and says so in its panel. */
|
|
49876
|
+
if(view.planHash&&!parkedType&&!scopeChosen(state,c.locationId)){const ch=document.createElement("button");ch.className="cds-btn ghost";ch.textContent="Choose what the client bought";ch.onclick=()=>openWhatTheyBought(c.locationId);div.appendChild(ch)}
|
|
48808
49877
|
}else{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));const p=document.createElement("p");p.textContent="The executable plan is prepared when Build runs.";d.appendChild(p);div.appendChild(d)}
|
|
48809
49878
|
if(view?.handoff)div.appendChild(unnest(handoffPanel(c,view)));
|
|
48810
49879
|
const _prot=PROTECTED[c.locationId];
|
|
@@ -48822,10 +49891,11 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48822
49891
|
rv.onclick=async()=>{await postJSON("/api/build-approval",{locationId:c.locationId,revoke:true});load()};
|
|
48823
49892
|
ab.appendChild(s1);ab.appendChild(rv);ab.appendChild(protectBtn(c))}
|
|
48824
49893
|
else if(parkedType){ab.appendChild(protectBtn(c))}
|
|
48825
|
-
else{
|
|
48826
|
-
const
|
|
48827
|
-
|
|
48828
|
-
|
|
49894
|
+
else{/* No second approve button here (rehearsal 2, finding 15): step 3's "Approve this plan" is the one. */
|
|
49895
|
+
const w=document.createElement("span");w.className="muted";w.textContent="Automated building is off for this client until you approve this plan in step 3. ";
|
|
49896
|
+
ab.appendChild(w);
|
|
49897
|
+
if(!scopeChosen(lastState,c.locationId)){const btn=document.createElement("button");btn.className="cds-btn ghost";btn.textContent="Choose what the client bought";btn.onclick=()=>openWhatTheyBought(c.locationId);ab.appendChild(btn)}
|
|
49898
|
+
ab.appendChild(protectBtn(c))}
|
|
48829
49899
|
div.appendChild(ab)}
|
|
48830
49900
|
// A "live" claim is re-derived from the signature, never trusted from the
|
|
48831
49901
|
// stage array: the gate runs on the write path, and this file can be restored
|
|
@@ -48867,6 +49937,13 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48867
49937
|
txt.textContent=t.text;if(t.reappeared){txt.textContent="";txt.appendChild(chip("warn","Came back after you ticked it"));txt.appendChild(document.createTextNode(" "+t.text))}
|
|
48868
49938
|
row.appendChild(cb);row.appendChild(txt);box.appendChild(row)});
|
|
48869
49939
|
div.appendChild(box)}}
|
|
49940
|
+
// Suggested next steps with Claude (2026-09-29): from what they bought, shown beside the
|
|
49941
|
+
// jobs once Verify has run, never a job to tick and never something go-live waits on.
|
|
49942
|
+
{const sg=((lastState.boughtViews||{})[c.locationId]||{}).suggestions||[];
|
|
49943
|
+
if(sg.length&&(Boolean((lastState.outcomes||{})[c.locationId+":4"])||((lastState.manualTasks||{})[c.locationId]||[]).length)){
|
|
49944
|
+
const box=mk("div","cds-stack");box.appendChild(mk("h3","cds-panel-title","Suggested next steps with Claude"));
|
|
49945
|
+
box.appendChild(mk("p","muted","From what they bought. These are not jobs in the account, and nothing waits on them."));
|
|
49946
|
+
const ul=mk("ul","cds-list");for(const s of sg)ul.appendChild(mk("li","",s));box.appendChild(ul);div.appendChild(box)}}
|
|
48870
49947
|
const _intakeIn=(iData[c.locationId]&&iData[c.locationId].status==="received");
|
|
48871
49948
|
const _writing=reviewBusy(c.locationId,run);
|
|
48872
49949
|
{ // Review is accessible before intake so an assessment can fill it.
|
|
@@ -48878,7 +49955,7 @@ title.textContent="Claude usage for this client";details.appendChild(title);for(
|
|
|
48878
49955
|
if(rOpen[c.locationId])div.appendChild(unnest(reviewPanel(c.locationId,c.name,state,run)))}
|
|
48879
49956
|
if(st[0]==="done"){const ir=document.createElement("div");ir.className="cds-row";
|
|
48880
49957
|
const ib2=document.createElement("button");ib2.className="cds-btn ghost";ib2.textContent=iOpen[c.locationId]?"Close the intake":"Complete the intake";ib2.setAttribute("aria-expanded",String(Boolean(iOpen[c.locationId])));
|
|
48881
|
-
ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
|
|
49958
|
+
ib2.onclick=async()=>{iOpen[c.locationId]=!iOpen[c.locationId];if(!iOpen[c.locationId])iRetryStop(c.locationId);if(iOpen[c.locationId]&&!iData[c.locationId])await iLoad(c.locationId);else render(lastState,lastRun)};
|
|
48882
49959
|
ir.appendChild(ib2);
|
|
48883
49960
|
const dd=iData[c.locationId];
|
|
48884
49961
|
if(dd&&dd.ok){const ic=document.createElement("span");ic.className="cds-chip istatus "+(dd.status==="received"?"ok":dd.status==="sent"?"warn":"");
|
|
@@ -48929,10 +50006,77 @@ function acctRow(c,state,run){const row=document.createElement("li");row.classNa
|
|
|
48929
50006
|
Derived server-side from the same board the cards show (/api/queue): no second
|
|
48930
50007
|
list to fall out of step with. Clicking a row opens that account's card. \u2500\u2500 */
|
|
48931
50008
|
let queueData=null,showQueueRest=false,showLive=false;
|
|
48932
|
-
|
|
48933
|
-
|
|
48934
|
-
|
|
48935
|
-
|
|
50009
|
+
/* \u2500\u2500 Inline confirm (rehearsal 2, 2026-09-29) \u2500\u2500
|
|
50010
|
+
A panel under the button instead of prompt(), confirm() or alert(): one stray keystroke dismissed a
|
|
50011
|
+
prompt, and an alert sent the member off the page to read why. The panel keeps its fields, says what
|
|
50012
|
+
went wrong in place, and is drawn again after a redraw with what was typed (its state lives here). */
|
|
50013
|
+
function icPanel(key){const st=icOpen[key],sp=st.spec;const p=document.createElement("div");p.className="cds-callout info cds-stack ic-panel";p.dataset.icp=key;p.setAttribute("role","group");
|
|
50014
|
+
if(sp.title)p.setAttribute("aria-label",sp.title);
|
|
50015
|
+
/* Clicks and keys stay in the panel: a queue row behind it opens a client on click. */
|
|
50016
|
+
p.onclick=e=>e.stopPropagation();
|
|
50017
|
+
p.onkeydown=e=>{e.stopPropagation();if(e.key==="Escape"&&!st.busy){delete icOpen[key];p.remove()}};
|
|
50018
|
+
if(sp.title)p.appendChild(mk("b","",sp.title));
|
|
50019
|
+
for(const line of [].concat(sp.text||[]))p.appendChild(mk("p","",line));
|
|
50020
|
+
const inputs={};let ok;
|
|
50021
|
+
for(const f of sp.fields||[]){const l=document.createElement("label");l.className="cds-field";l.appendChild(mk("span","",f.label));
|
|
50022
|
+
const inp=document.createElement("input");inp.className="cds-input";inp.autocomplete="off";inp.name="ic:"+key+":"+f.id;inp.value=st.values[f.id]!=null?st.values[f.id]:(f.value||"");
|
|
50023
|
+
inp.oninput=()=>{st.values[f.id]=inp.value};inp.onkeydown=e=>{if(e.key==="Enter"){e.preventDefault();ok.click()}};
|
|
50024
|
+
l.appendChild(inp);p.appendChild(l);inputs[f.id]=inp}
|
|
50025
|
+
const err=mk("p","cds-callout warn",st.error||"");err.setAttribute("role","alert");err.hidden=!st.error;p.appendChild(err);
|
|
50026
|
+
const row=document.createElement("div");row.className="cds-row";
|
|
50027
|
+
ok=document.createElement("button");ok.type="button";ok.className="cds-btn";ok.textContent=st.busy?(sp.busyLabel||"Working\u2026"):sp.ok;ok.disabled=Boolean(st.busy);
|
|
50028
|
+
const no=document.createElement("button");no.type="button";no.className="cds-btn ghost";no.textContent="Cancel";no.disabled=Boolean(st.busy);
|
|
50029
|
+
no.onclick=()=>{delete icOpen[key];p.remove()};
|
|
50030
|
+
ok.onclick=async()=>{if(st.busy)return;const vals={};for(const f of sp.fields||[])vals[f.id]=String(inputs[f.id].value||"").trim();
|
|
50031
|
+
st.busy=true;st.error="";err.hidden=true;ok.disabled=true;no.disabled=true;ok.textContent=sp.busyLabel||"Working\u2026";
|
|
50032
|
+
let msg="";try{msg=(await sp.run(vals))||""}catch(e){msg="Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
|
|
50033
|
+
st.busy=false;
|
|
50034
|
+
/* A redraw during the request drew a new panel from the state (showing "Approving\u2026"); this one may
|
|
50035
|
+
be detached. Every copy of the panel is replaced with a fresh one, or removed on success (review of d407f04). */
|
|
50036
|
+
const copies=new Set([p,...icLive(key)]);
|
|
50037
|
+
if(msg){st.error=msg;for(const x of copies)if(x.parentNode)x.replaceWith(icPanel(key));if(redrawPending)scheduleFlush();return}
|
|
50038
|
+
delete icOpen[key];for(const x of copies)x.remove();if(sp.after)await sp.after();else load()};
|
|
50039
|
+
row.appendChild(ok);row.appendChild(no);p.appendChild(row);return p}
|
|
50040
|
+
/* Opens the panel under the button (or focuses it when it is already open). */
|
|
50041
|
+
function icShow(btn,key,spec){const nx=btn.nextElementSibling;if(icOpen[key]&&nx&&nx.dataset&&nx.dataset.icp===key){const f=nx.querySelector("input")||nx.querySelector("button");if(f)f.focus();return}
|
|
50042
|
+
icOpen[key]={spec:spec,values:{},error:"",busy:false};const p=icPanel(key);btn.insertAdjacentElement("afterend",p);
|
|
50043
|
+
const f=p.querySelector("input")||p.querySelector("button");if(f)f.focus()}
|
|
50044
|
+
/* Marks a button as the owner of a panel, so a redraw puts an open panel back under it. */
|
|
50045
|
+
/* Put back synchronously by render() before it restores focus, so a field typed in keeps the cursor;
|
|
50046
|
+
the microtask covers a card drawn outside render(). */
|
|
50047
|
+
let icQueue=[];
|
|
50048
|
+
function icAfter(btn,key){if(icOpen[key]){icQueue.push([btn,key]);queueMicrotask(icFlush)}return btn}
|
|
50049
|
+
function icFlush(){const q=icQueue;icQueue=[];for(const [btn,key] of q){const nx=btn.nextElementSibling;if(btn.parentNode&&icOpen[key]&&!(nx&&nx.dataset&&nx.dataset.icp===key))btn.insertAdjacentElement("afterend",icPanel(key))}}
|
|
50050
|
+
/* Every panel for this key on the page now. */
|
|
50051
|
+
function icLive(key){return [...document.querySelectorAll(".ic-panel")].filter(x=>x.dataset.icp===key)}
|
|
50052
|
+
/* A panel whose question the state has answered elsewhere is dropped, so a later revoke or reset never
|
|
50053
|
+
reopens it with old typing (review of d407f04). */
|
|
50054
|
+
function icPrune(state){for(const k of Object.keys(icOpen)){const st=icOpen[k];if(!st||st.busy)continue;const [kind,id,step]=k.split(":");
|
|
50055
|
+
const stages=((state.clients||{})[id]||{}).stages||[];
|
|
50056
|
+
if(kind==="approve"&&(state.buildApproved||{})[id])delete icOpen[k];
|
|
50057
|
+
else if((kind==="mark"||kind==="qmark")&&stages[Number(step)]==="done")delete icOpen[k]}}
|
|
50058
|
+
/* The name Command OS records on a hand mark, as last saved on this computer. */
|
|
50059
|
+
function opName(){let n="";try{n=localStorage.getItem("cmdos-operator")||""}catch(e){}return n}
|
|
50060
|
+
function saveOpName(n){try{localStorage.setItem("cmdos-operator",n)}catch(e){}}
|
|
50061
|
+
const NAME_FIELD=()=>({id:"who",label:"Your name",value:opName()});
|
|
50062
|
+
function needName(v){if(!v.who)return "Type your name. Command OS records who did this, so nobody has to take it on trust.";saveOpName(v.who);return ""}
|
|
50063
|
+
/* The ONE approval (rehearsal 2, finding 15). The next-step button and the Blueprint Review both open this,
|
|
50064
|
+
with the same label and the same request; nothing else approves, and nothing refuses and points elsewhere. */
|
|
50065
|
+
function approveSpec(id,name,state){const sandbox=typeOf(state,id)==="sandbox";
|
|
50066
|
+
const view=(state.buildViews||{})[id];const repairs=(view&&view.planHash&&view.repairs)||[];
|
|
50067
|
+
return {title:"Approve this plan for "+name,
|
|
50068
|
+
text:["Command OS then builds in this account what the Blueprint Review lists"+(repairs.length?", and makes the "+repairs.length+" live workflow "+plural(repairs.length,"repair")+" listed with the plan":"")+"."],
|
|
50069
|
+
fields:[NAME_FIELD()].concat(sandbox?[]:[{id:"client",label:"Type the client name to confirm: "+name,value:""}]),
|
|
50070
|
+
ok:"Approve",busyLabel:"Approving\u2026",
|
|
50071
|
+
run:async v=>{const bad=needName(v);if(bad)return bad;
|
|
50072
|
+
if(!sandbox&&v.client.toLowerCase()!==String(name).trim().toLowerCase())return 'Type the client name exactly as shown: "'+name+'".';
|
|
50073
|
+
let res,j=null;try{res=await fetch("/api/build-approval",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,confirmName:sandbox?name:v.client,by:v.who,repairHashes:repairs.map(r=>r.hash)})})}
|
|
50074
|
+
catch(e){return "Command OS could not confirm the result. Refresh this client to read its saved state before retrying."}
|
|
50075
|
+
try{j=await res.json()}catch(e){}
|
|
50076
|
+
if(!res.ok||!j||j.ok!==true)return (j&&j.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
|
|
50077
|
+
return ""}}}
|
|
50078
|
+
function approveButton(id,name,state,place,cls){const b=document.createElement("button");b.className=cls;b.textContent="Approve this plan";
|
|
50079
|
+
const key="approve:"+id+":"+place;b.onclick=()=>icShow(b,key,approveSpec(id,name,lastState||state));return icAfter(b,key)}
|
|
48936
50080
|
/* Finding 35: a write whose reply nobody reads is a button that can start failing in
|
|
48937
50081
|
silence. Every cockpit write goes through here, so a refusal is shown rather than
|
|
48938
50082
|
swallowed. */
|
|
@@ -48942,17 +50086,35 @@ async function postJSON(url,body){
|
|
|
48942
50086
|
catch(e){alert("Command OS could not confirm the result. Refresh this client to read its saved state before retrying.");return false}
|
|
48943
50087
|
try{j=await r.json()}catch(e){}
|
|
48944
50088
|
if(!r.ok||(j&&j.ok===false)){alert((j&&j.error)||"That did not go through, and nothing was changed.");return false}
|
|
48945
|
-
return true}
|
|
50089
|
+
return j||true}
|
|
48946
50090
|
/* Nothing hand-markable that Command OS can check for itself. Steps it runs are marked
|
|
48947
50091
|
by the run; steps that need a person are marked by that person, on the record. */
|
|
48948
|
-
async function markStage(id,i,status){
|
|
48949
|
-
if(POWERED.includes(i)&&status!=="pending")
|
|
48950
|
-
|
|
48951
|
-
|
|
48952
|
-
|
|
48953
|
-
|
|
48954
|
-
|
|
48955
|
-
async
|
|
50092
|
+
async function markStage(id,i,status,who){
|
|
50093
|
+
if(POWERED.includes(i)&&status!=="pending")return LABELS[i]+" is a step Command OS runs and checks for you, so it cannot be ticked off by hand. Use the Run this step button on the card. If it went wrong, set it back to not started and run it again.";
|
|
50094
|
+
let r=null;try{r=await(await fetch("/api/stage",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,stage:i,status:status,by:who})})).json()}catch(e){}
|
|
50095
|
+
if(!r||!r.ok)return (r&&r.error)||"Command OS could not confirm the result. Refresh this client to read its saved state before retrying.";
|
|
50096
|
+
return ""}
|
|
50097
|
+
/* A hand mark opens a panel with the name that goes on it (prefilled from last time) instead of a prompt. */
|
|
50098
|
+
function markSpec(id,i,label,text){return {title:label,text:[].concat(text||[],"Marking this done puts your name and the time on it."),fields:[NAME_FIELD()],ok:label,busyLabel:"Saving\u2026",
|
|
50099
|
+
run:async v=>{const bad=needName(v);if(bad)return bad;return markStage(id,i,"done",v.who)},after:async()=>{await loadQueue();load()}}}
|
|
50100
|
+
/* cheap: a background refresh uses GET /api/queue, which derives the queue from the saved board without re-reading
|
|
50101
|
+
every client's intake from GoHighLevel; the POST (which does) stays for the page's own loads. */
|
|
50102
|
+
/* Three ways to read the queue:
|
|
50103
|
+
- undefined: the page's own load and the operator's actions. POST {}: re-reads every started client's intake.
|
|
50104
|
+
- "pending": a background re-read (a return to the tab, every 4th heartbeat load). POST {only:"pending"}: re-reads
|
|
50105
|
+
only clients whose saved intake is not yet "received", plus the one open here. At most one queue POST is in
|
|
50106
|
+
flight, and a background one is skipped (the GET is used) within 30s of the last one finishing.
|
|
50107
|
+
- "get": GET, derived from the saved board with no GoHighLevel reads.
|
|
50108
|
+
A background read that fails keeps the last good queue rather than blanking it. */
|
|
50109
|
+
let queuePostBusy=false,queuePostAt=0;
|
|
50110
|
+
async function loadQueue(mode){
|
|
50111
|
+
if(mode==="pending"&&(queuePostBusy||Date.now()-queuePostAt<30000))mode="get";
|
|
50112
|
+
const background=mode==="get"||mode==="pending";const post=mode!=="get";
|
|
50113
|
+
const init=mode==="get"?undefined:{method:"POST",headers:{"Content-Type":"application/json"},body:mode==="pending"?JSON.stringify({only:"pending",open:activeId||undefined}):"{}"};
|
|
50114
|
+
if(post)queuePostBusy=true;
|
|
50115
|
+
try{const r=await fetch("/api/queue",init);const q=await r.json();if(!r.ok||!q||q.ok===false)throw new Error((q&&q.error)||"The work queue could not be read.");queueData=q;if(window.cdsQueueBadge&&queueData.counts)window.cdsQueueBadge(queueData.counts.you||0)}
|
|
50116
|
+
catch(e){if(!(background&&queueData&&queueData.ok))queueData={ok:false,error:e.message||"The work queue could not be read."}}
|
|
50117
|
+
finally{if(post){queuePostBusy=false;queuePostAt=Date.now()}}}
|
|
48956
50118
|
/* A whole first sentence, never a line cut mid-thought. */
|
|
48957
50119
|
function firstSentence(s){const t=String(s||"").split(String.fromCharCode(10)).map(x=>x.trim()).filter(Boolean)[0]||"";const m=/^(.+?[.!?])(\\s|$)/.exec(t);return m?m[1]:t}
|
|
48958
50120
|
/* Build text an owner cannot read: quoted names, tag and custom value keys (business_phone), plus signs,
|
|
@@ -48994,10 +50156,9 @@ function queueRow(it){const row=document.createElement("li");row.className="q-ro
|
|
|
48994
50156
|
/* State in words: running is info; anything unfinished is warn. */
|
|
48995
50157
|
side.appendChild(it.running?chip("info","Running now"):it.needs==="you"?chip("warn","Your call"):(it.proof&&it.proof.issues&&it.proof.issues.length)?chip("warn","To sort out"):chip("warn","Not run yet"));
|
|
48996
50158
|
if(it.action==="mark"){const mb=document.createElement("button");mb.className="cds-btn ghost";mb.textContent="Mark done";
|
|
48997
|
-
|
|
48998
|
-
|
|
48999
|
-
|
|
49000
|
-
side.appendChild(mb)}
|
|
50159
|
+
const mkey="qmark:"+it.locationId+":"+it.stageIndex;
|
|
50160
|
+
mb.onclick=(e)=>{e.stopPropagation();icShow(mb,mkey,markSpec(it.locationId,it.stageIndex,"Mark done",it.title))};
|
|
50161
|
+
side.appendChild(icAfter(mb,mkey))}
|
|
49001
50162
|
else side.appendChild(mk("span","cds-link acct-open","Open"));
|
|
49002
50163
|
row.appendChild(side);
|
|
49003
50164
|
row.onclick=()=>{if(it.href){rememberClient(it.locationId);location.href=it.href}else queueOpen(it.locationId)};
|
|
@@ -49021,7 +50182,9 @@ function operatorLine(){const who=document.createElement("span");who.className="
|
|
|
49021
50182
|
let nm="";try{nm=localStorage.getItem("cmdos-operator")||""}catch(e){}
|
|
49022
50183
|
who.appendChild(document.createTextNode(nm?("Marking as "+nm):"Nobody set to mark steps"));
|
|
49023
50184
|
const ch=document.createElement("button");ch.className="cds-link";ch.textContent=nm?"Change":"Set your name";
|
|
49024
|
-
|
|
50185
|
+
/* A panel with the name field, not a prompt (rehearsal 2). */
|
|
50186
|
+
ch.onclick=()=>icShow(ch,"opname",{title:"Who marks steps done",fields:[NAME_FIELD()],ok:"Save",busyLabel:"Saving\u2026",run:async v=>needName(v),after:async()=>render(lastState,lastRun)});
|
|
50187
|
+
who.appendChild(icAfter(ch,"opname"));return who}
|
|
49025
50188
|
function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError();
|
|
49026
50189
|
const frag=document.createDocumentFragment();
|
|
49027
50190
|
const items=(q.items||[]).filter(i=>match({name:i.clientName,locationId:i.locationId}));
|
|
@@ -49036,11 +50199,11 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return queueReadError(
|
|
|
49036
50199
|
frag.appendChild(now);
|
|
49037
50200
|
/* The rest fold away (Disclosure: cds-more), each in a Panel of its own that says what is inside. */
|
|
49038
50201
|
if(later.length){const p=panel("Not started yet",later.length+" "+plural(later.length,"account")+" not started");p.classList.add("q-later");
|
|
49039
|
-
const d=mk("details","cds-more");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
|
|
50202
|
+
const d=mk("details","cds-more");d.setAttribute("data-key","queue-rest");d.open=showQueueRest;d.ontoggle=()=>{showQueueRest=d.open};
|
|
49040
50203
|
d.appendChild(mk("summary","","Show the "+later.length+" "+plural(later.length,"step")+" not started"));
|
|
49041
50204
|
d.appendChild(listRows(later.map(queueRow),"queue-later"));p.appendChild(d);frag.appendChild(p)}
|
|
49042
50205
|
if(fin.length){const p=panel("Finished",fin.length+" "+plural(fin.length,"account")+" finished");p.classList.add("q-finished");
|
|
49043
|
-
const d=mk("details","cds-more");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
|
|
50206
|
+
const d=mk("details","cds-more");d.setAttribute("data-key","queue-live");d.open=showLive;d.ontoggle=()=>{showLive=d.open};
|
|
49044
50207
|
d.appendChild(mk("summary","","Show the "+fin.length+" finished "+plural(fin.length,"account")));
|
|
49045
50208
|
for(const signed of [true,false]){const group=fin.filter(f=>Boolean(f.live)===signed);if(!group.length)continue;
|
|
49046
50209
|
d.appendChild(mk("div","cds-panel-label",signed?"Finished and live ("+group.length+")":"Finished, not signed live ("+group.length+")"));
|
|
@@ -49215,7 +50378,8 @@ const KEY_FACTS=[/website/i,/primary goal/i,/primary services|services you sell/
|
|
|
49215
50378
|
function capAnswer(v){return Array.isArray(v)?v.join(", "):String(v==null?"":v).trim()}
|
|
49216
50379
|
function renderIntakeCapability(m,c){const id=c.locationId;const data=iData[id];
|
|
49217
50380
|
if(!data){iLoad(id);const p=panel("Intake","The client\u2019s answers");p.appendChild(mk("p","muted","Reading the intake\u2026"));m.appendChild(p);return}
|
|
49218
|
-
if(data.ok===false){const
|
|
50381
|
+
if(data.ok===false&&data.transient){const p=mk("p","muted",data.error||"Could not refresh just now; retrying.");p.setAttribute("role","status");m.appendChild(p)}
|
|
50382
|
+
else if(data.ok===false){const w=mk("div","cds-callout warn");w.setAttribute("role","alert");w.appendChild(mk("span","ttl","Could not read the intake"));w.appendChild(mk("span","",data.error||"Refresh the page to try again."));m.appendChild(w)}
|
|
49219
50383
|
const answers=data.answers||{};const questions=data.questions||[];
|
|
49220
50384
|
const answerOf=q=>capAnswer(answers[q.label]!=null?answers[q.label]:answers[q.key]);
|
|
49221
50385
|
const answered=questions.length?questions.filter(q=>answerOf(q)).map(q=>[q.label,answerOf(q)]):Object.entries(answers).map(([k,v])=>[k,capAnswer(v)]).filter(x=>x[1]);
|
|
@@ -49266,7 +50430,7 @@ send.disabled=!published;
|
|
|
49266
50430
|
if(!confirm("Send one email from "+c.name+"\u2019s account to "+r.preview.email+" with the handoff at "+r.url+"?"))return;
|
|
49267
50431
|
const result=await(await fetch("/api/handoff-send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,hash:r.preview.hash,confirmed:true})})).json();note.textContent=result.note||result.error;
|
|
49268
50432
|
}catch(e){note.textContent=e.message||"The result could not be confirmed. Check Conversations before retrying."}finally{send.disabled=false}};box.appendChild(send);return box}
|
|
49269
|
-
function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await
|
|
50433
|
+
function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await fetchRunStatus();const s=await(await fetch("/api/state")).json();if(!r.running){clearInterval(capPoll);capPoll=null;capRun=null;capReads=null;capReadsFor=""}if(!shouldRedraw(lastRun,r))return;if(!r.running||dataSig(s,r)!==lastDrawnSig){render(s,r);hideFinishNote()}}catch(e){}},2000)}
|
|
49270
50434
|
|
|
49271
50435
|
/* PageHeader: kicker, the title as a sentence, one muted line, at most one primary action. */
|
|
49272
50436
|
function screenHead(title,sub,kicker,action){const d=mk("div","cds-page-head");const t=mk("div","txt");
|
|
@@ -49313,9 +50477,34 @@ function homeBlock(state,run){const wrap=mk("div","board");
|
|
|
49313
50477
|
li.onclick=go;li.onkeydown=(e)=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();go()}};return li}),"board-needs"))}
|
|
49314
50478
|
two.appendChild(needs);
|
|
49315
50479
|
const priv=panel("Privacy","What stays on this computer");priv.classList.add("privacy");for(const line of PRIVACY_SHORT)priv.appendChild(mk("p","muted small",line));
|
|
49316
|
-
{const d=mk("details","cds-details");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
|
|
50480
|
+
{const d=mk("details","cds-details");d.setAttribute("data-key","home-privacy");d.appendChild(mk("summary","","Details"));for(const line of PRIVACY_LINES)d.appendChild(mk("p","muted small",line));priv.appendChild(d)}two.appendChild(priv);
|
|
49317
50481
|
wrap.appendChild(two);return wrap}
|
|
49318
|
-
|
|
50482
|
+
/* Every redraw keeps the view (2026-09-29, Jerry: "anything I expand closes itself within 1 to 3 seconds").
|
|
50483
|
+
The page is rebuilt from data, so a disclosure the operator opened, the scroll position and the cursor in
|
|
50484
|
+
a field would all reset. Before drawing: note which <details> are open or closed, keyed by the nearest
|
|
50485
|
+
data-key (client, client:stage) plus the summary words, and where focus and the caret are. After: put
|
|
50486
|
+
them back. Toggles such as See issues already live in state (oOpen), and survive on their own. */
|
|
50487
|
+
let lastDrawnSig="";
|
|
50488
|
+
/* generatedAt is stamped fresh on every queue read (work-queue.ts), so it is left out: with it, no two reads ever matched
|
|
50489
|
+
and every background refresh redrew (review of c09e946). */
|
|
50490
|
+
function dataSig(state,run){try{return JSON.stringify([state,run,typeof queueData==="undefined"?null:queueData],(k,v)=>k==="generatedAt"?undefined:v)}catch(e){return String(Date.now())}}
|
|
50491
|
+
function keyOf(el,root){const a=el.closest("[data-key]");return a&&root.contains(a)?a.getAttribute("data-key"):"main"}
|
|
50492
|
+
function detailsKeys(root){const out=[];const seen={};for(const d of root.querySelectorAll("details")){const sm=d.querySelector("summary");
|
|
50493
|
+
const base=keyOf(d,root)+"|"+String(d.className||"")+"|"+String(sm?sm.textContent:"").replace(/[0-9]+/g,"#").trim();seen[base]=(seen[base]||0)+1;out.push([d,base+"#"+seen[base]])}return out}
|
|
50494
|
+
function fieldKey(el,root){return keyOf(el,root)+"|"+el.tagName+"|"+(el.getAttribute("aria-label")||el.getAttribute("name")||el.getAttribute("placeholder")||el.id||"")}
|
|
50495
|
+
function captureView(){const m=document.getElementById("main");if(!m)return null;const open=new Set(),closed=new Set();
|
|
50496
|
+
for(const [d,k] of detailsKeys(m))(d.open?open:closed).add(k);
|
|
50497
|
+
let focus=null;const a=document.activeElement;
|
|
50498
|
+
if(a&&m.contains(a)&&/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)){const k=fieldKey(a,m);const same=[...m.querySelectorAll(a.tagName)].filter(x=>fieldKey(x,m)===k);
|
|
50499
|
+
let st=null,en=null;try{st=a.selectionStart;en=a.selectionEnd}catch(e){}focus={key:k,tag:a.tagName,n:same.indexOf(a),st:st,en:en}}
|
|
50500
|
+
return {open:open,closed:closed,focus:focus,x:window.scrollX,y:window.scrollY}}
|
|
50501
|
+
function restoreView(v){const m=document.getElementById("main");if(!v||!m)return;
|
|
50502
|
+
for(const [d,k] of detailsKeys(m)){if(v.open.has(k)&&!d.open)d.open=true;else if(v.closed.has(k)&&d.open)d.open=false}
|
|
50503
|
+
if(v.focus){const el=[...m.querySelectorAll(v.focus.tag)].filter(x=>fieldKey(x,m)===v.focus.key)[v.focus.n];
|
|
50504
|
+
if(el&&document.activeElement!==el){try{el.focus({preventScroll:true})}catch(e){}try{if(v.focus.st!=null)el.setSelectionRange(v.focus.st,v.focus.en)}catch(e){}}}
|
|
50505
|
+
if(window.scrollX!==v.x||window.scrollY!==v.y){try{window.scrollTo(v.x,v.y)}catch(e){}}}
|
|
50506
|
+
function render(state,run){const v=captureView();try{if(state)icPrune(state);renderPage(state,run);icFlush()}finally{lastDrawnSig=dataSig(state,run);restoreView(v)}}
|
|
50507
|
+
function renderPage(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...state.roster)}syncReviews(state);lastState=state;lastRun=run;
|
|
49319
50508
|
if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
|
|
49320
50509
|
/* Four screens share this page's data and differ only in what they show. */
|
|
49321
50510
|
if(SCREEN==="new"){m.classList.add("ncflow");const head=screenHead(HEADS.new.title,HEADS.new.sub,HEADS.new.kicker);head.querySelector(".cds-page-sub").classList.add("nc-lead");m.appendChild(head);
|
|
@@ -49391,7 +50580,7 @@ function render(state,run){if(state.roster){ROSTER.splice(0,ROSTER.length,...sta
|
|
|
49391
50580
|
const list=panel("Client accounts",actC?"Your other client accounts":"Your client accounts");if(!actC)list.id="clients";
|
|
49392
50581
|
if(rest.length){list.appendChild(listRows(rest.map(c=>acctRow(c,state,run)),"clients-rest","accounts"));
|
|
49393
50582
|
list.appendChild(mk("p","switchnote","One account at a time. Opening another puts this one back in the list. Nothing is lost."))}
|
|
49394
|
-
if(parkedRest.length){const d=mk("details","cds-more");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
|
|
50583
|
+
if(parkedRest.length){const d=mk("details","cds-more");d.setAttribute("data-key","parked-rest");d.open=showParked;d.ontoggle=()=>{showParked=d.open};
|
|
49395
50584
|
d.appendChild(mk("summary","","Show the "+parkedRest.length+" template and hidden "+plural(parkedRest.length,"account")));
|
|
49396
50585
|
d.appendChild(listRows(parkedRest.map(c=>acctRow(c,state,run)),"clients-parked"));list.appendChild(d)}
|
|
49397
50586
|
m.appendChild(list)}}
|
|
@@ -49794,7 +50983,7 @@ async function runDashboard(argv) {
|
|
|
49794
50983
|
let ffAdmin;
|
|
49795
50984
|
try {
|
|
49796
50985
|
const b = workflowReadConnection(registry2, c);
|
|
49797
|
-
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
|
|
50986
|
+
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
|
|
49798
50987
|
ffAdmin = builderAdmin(b);
|
|
49799
50988
|
} catch {
|
|
49800
50989
|
}
|
|
@@ -50168,9 +51357,18 @@ async function runDashboard(argv) {
|
|
|
50168
51357
|
if (req.method === "POST" && url === "/api/client-assessment") {
|
|
50169
51358
|
readBody(req, res, 2e4, async (body) => {
|
|
50170
51359
|
try {
|
|
50171
|
-
const
|
|
51360
|
+
const request2 = JSON.parse(body);
|
|
51361
|
+
const result = await assessmentAttachmentAction(request2, attachmentDeps);
|
|
51362
|
+
let withdrawn = false;
|
|
51363
|
+
if (typeof request2?.clientLocationId === "string") {
|
|
51364
|
+
const after = withdrawIfBindingChanged(readCockpitState(), request2.clientLocationId);
|
|
51365
|
+
if (after.withdrawn) {
|
|
51366
|
+
writeCockpitState(after.state);
|
|
51367
|
+
withdrawn = true;
|
|
51368
|
+
}
|
|
51369
|
+
}
|
|
50172
51370
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
50173
|
-
res.end(JSON.stringify(result));
|
|
51371
|
+
res.end(JSON.stringify({ ...result, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
|
|
50174
51372
|
} catch (e) {
|
|
50175
51373
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
50176
51374
|
logQuietly(`client assessment: ${(0, import_node_util5.inspect)(e)}`);
|
|
@@ -50555,11 +51753,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50555
51753
|
}
|
|
50556
51754
|
const assessmentFigures = {};
|
|
50557
51755
|
for (const loc of Object.keys(st.assessmentAttachments ?? {})) assessmentFigures[loc] = assessmentFiguresFor(st, loc);
|
|
50558
|
-
const
|
|
50559
|
-
|
|
50560
|
-
const p = scopePrefillFor(st, loc);
|
|
50561
|
-
if (p) scopePrefill[loc] = p;
|
|
50562
|
-
}
|
|
51756
|
+
const scopeOffered = Object.fromEntries(currentRoster().map((c) => [c.locationId, offeredFeaturesFor(st, c.locationId)]));
|
|
51757
|
+
const boughtViews = Object.fromEntries(currentRoster().map((c) => [c.locationId, boughtViewFor(st, c.locationId, scopeOffered[c.locationId])]));
|
|
50563
51758
|
const buildViews = Object.fromEntries(currentRoster().map((c) => {
|
|
50564
51759
|
const saved = readSavedPlan(c.locationId);
|
|
50565
51760
|
const approval = st.planApprovals?.[c.locationId];
|
|
@@ -50590,9 +51785,9 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50590
51785
|
delivery: void 0
|
|
50591
51786
|
};
|
|
50592
51787
|
}
|
|
50593
|
-
for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || st.manualTasks?.[id]
|
|
51788
|
+
for (const [id, view2] of Object.entries(buildViews)) if (!view2.planHash || !view2.approvedAt || view2.stale.length || st.outcomes?.[`${id}:4`]?.ok !== true || openTasks(st.manualTasks?.[id] ?? []).length || st.outcomes?.[`${id}:4`]?.issues?.length) liveClaims[id] = { live: false };
|
|
50594
51789
|
const outcomes = Object.fromEntries(Object.entries(st.outcomes ?? {}).map(([key, value]) => [key, { ...value, ...key.endsWith(":4") && value.verification ? { plain: verifyPanelOutcome(value) } : { ...plainFallbackOutcome(value), plain: plainFallbackOutcome(value.plain ?? value) }, error: value.error ? "The step did not finish. Check the account before retrying." : void 0 }]));
|
|
50595
|
-
res.end(JSON.stringify({ ...st, assessmentFigures,
|
|
51790
|
+
res.end(JSON.stringify({ ...st, assessmentFigures, boughtViews, outcomes, manualTasks: Object.fromEntries(Object.entries(st.manualTasks ?? {}).map(([id, tasks2]) => {
|
|
50596
51791
|
const records = st.outcomes?.[`${id}:4`]?.verification;
|
|
50597
51792
|
const allowed = records ? verifyReadback(records).allowPublishing : false;
|
|
50598
51793
|
return [id, tasks2.map((t) => ({ ...t, text: verifyPublishingText(plainFallback(t.text, { nextStep: true }), allowed) })).filter((t) => t.text)];
|
|
@@ -50609,24 +51804,23 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50609
51804
|
return;
|
|
50610
51805
|
}
|
|
50611
51806
|
if (req.method === "POST" && url === "/api/queue") {
|
|
50612
|
-
readBody(req, res, 5e3, async () => {
|
|
51807
|
+
readBody(req, res, 5e3, async (body) => {
|
|
50613
51808
|
try {
|
|
50614
51809
|
const roster2 = currentRoster();
|
|
50615
51810
|
const state = readCockpitState();
|
|
50616
|
-
const
|
|
50617
|
-
|
|
50618
|
-
if (
|
|
50619
|
-
const
|
|
50620
|
-
if (
|
|
50621
|
-
const snap = await readIntakeSnapshot(c, entry.locationId);
|
|
50622
|
-
if (snap.status === "unavailable") throw new Error("The work queue could not read intake answers. Retry the queue.");
|
|
51811
|
+
const { reread, failed } = await rereadQueueIntakes({ roster: roster2, state, filter: queueScanFilter(body), read: async (locationId2) => {
|
|
51812
|
+
const c = clientFor(locationId2);
|
|
51813
|
+
if (!c) return null;
|
|
51814
|
+
const snap = await readIntakeSnapshot(c, locationId2);
|
|
51815
|
+
if (snap.status === "unavailable") return "unavailable";
|
|
50623
51816
|
const answers = normalizedIntake(snap);
|
|
50624
|
-
|
|
50625
|
-
}
|
|
50626
|
-
const latest = readCockpitState();
|
|
50627
|
-
writeCockpitState(
|
|
51817
|
+
return { answers, status: snap.status, hash: intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() })) };
|
|
51818
|
+
} });
|
|
51819
|
+
const latest = mergeQueueIntakes(readCockpitState(), reread);
|
|
51820
|
+
if (Object.keys(reread).length) writeCockpitState(latest);
|
|
51821
|
+
const inputs = latest.intakeInputs ?? {};
|
|
50628
51822
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
50629
|
-
res.end(JSON.stringify(deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 })));
|
|
51823
|
+
res.end(JSON.stringify({ ...deriveQueue({ roster: roster2, state: { ...latest, intakeInputs: inputs, staleClients: roster2.filter((c) => stageFreshness(inputs[c.locationId]?.hash, readSavedPlan(c.locationId), latest.planApprovals?.[c.locationId], latest.outcomes?.[`${c.locationId}:3`], latest.outcomes?.[`${c.locationId}:4`], latest.outcomes?.[`${c.locationId}:1`]).length).map((c) => c.locationId) }, run: activeRun2 }), ...failed.length ? { intakeUnread: failed } : {} }));
|
|
50630
51824
|
} catch {
|
|
50631
51825
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
50632
51826
|
res.end(JSON.stringify({ ok: false, error: "The work queue could not be read. Retry the queue." }));
|
|
@@ -50652,6 +51846,13 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50652
51846
|
let st0 = readCockpitState();
|
|
50653
51847
|
let inputHash;
|
|
50654
51848
|
let normalizedAnswers;
|
|
51849
|
+
if (stage === 3) {
|
|
51850
|
+
if (!hasChosenScope(st0, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_BEFORE_BUILD);
|
|
51851
|
+
const ap = st0.planApprovals?.[rk];
|
|
51852
|
+
const assessmentWait = assessmentGateRefusal(st0, rk, who.clientKey, who.name);
|
|
51853
|
+
if (assessmentWait) throw new Error(assessmentWait);
|
|
51854
|
+
if (ap && ap.scopeHash !== approvalScopeHash(st0, rk)) throw new Error(boughtUnchangedSinceApproval(ap, st0, rk) ? ASSESSMENT_CHANGED_SINCE_APPROVAL : SCOPE_CHANGED_SINCE_APPROVAL);
|
|
51855
|
+
}
|
|
50655
51856
|
if (who.clientKey && [3, 4].includes(stage)) {
|
|
50656
51857
|
inputHash = secondClientInputHash(readSavedPlan(rk));
|
|
50657
51858
|
assertBuildApproval(st0.planApprovals?.[rk], inputHash);
|
|
@@ -50669,7 +51870,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50669
51870
|
const client = clientFor(locationId2);
|
|
50670
51871
|
if (!client) throw new Error(INTAKE_UNREADABLE);
|
|
50671
51872
|
const snap = await readIntakeSnapshot(client, locationId2);
|
|
50672
|
-
if (snap.status === "unavailable") throw
|
|
51873
|
+
if (snap.status === "unavailable") throw intakeUnavailableError(snap);
|
|
50673
51874
|
normalizedAnswers = normalizedIntake(snap);
|
|
50674
51875
|
inputHash = intakeInput(normalizedAnswers, st0.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }));
|
|
50675
51876
|
st0 = { ...st0, intakeInputs: { ...st0.intakeInputs, [locationId2]: { hash: inputHash, answers: normalizedAnswers, status: snap.status } } };
|
|
@@ -50690,7 +51891,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50690
51891
|
const c = clientFor(locationId2);
|
|
50691
51892
|
if (!c) throw new Error(INTAKE_UNREADABLE);
|
|
50692
51893
|
const snap = await readIntakeSnapshot(c, locationId2);
|
|
50693
|
-
if (snap.status === "unavailable") throw
|
|
51894
|
+
if (snap.status === "unavailable") throw intakeUnavailableError(snap);
|
|
50694
51895
|
if (snap.status !== "received") throw new Error("Open the real form, submit it, then refresh.");
|
|
50695
51896
|
const required = composeQuestionSet({ industry: st0.accounts?.[locationId2]?.industry, agency: readOverlay() }).filter((q3) => q3.required && !String(normalizedAnswers?.[q3.label] ?? normalizedAnswers?.[q3.key] ?? "").trim());
|
|
50696
51897
|
if (required.length) throw new Error("Complete these required intake answers: " + required.map((q3) => q3.label).join(", ") + ".");
|
|
@@ -50776,7 +51977,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50776
51977
|
const st = readCockpitState();
|
|
50777
51978
|
if (!c && !st.pending?.[locationId2]) throw new Error(INTAKE_CONNECT_REQUIRED);
|
|
50778
51979
|
const snap = c ? await readIntakeSnapshot(c, locationId2, Boolean(st.intakeSent?.[locationId2])) : { status: "not_installed", fields: {}, queryKeys: {}, submissions: 0 };
|
|
50779
|
-
if (snap.status === "unavailable") throw
|
|
51980
|
+
if (snap.status === "unavailable") throw intakeUnavailableError(snap);
|
|
50780
51981
|
const industry = st.accounts?.[locationId2]?.industry;
|
|
50781
51982
|
const questions = composeQuestionSet({ industry, agency: readOverlay() }).map((q3) => ({ key: q3.key, contactTag: q3.field.kind === "standard" ? q3.field.tag : void 0, label: q3.label, sectionLabel: q3.sectionLabel, required: q3.required, options: q3.options ?? null, placeholder: q3.placeholder ?? "", multi: q3.field.kind === "custom" && q3.field.dataType === "MULTIPLE_OPTIONS" }));
|
|
50782
51983
|
const answers = prefillTeamAnswers(normalizedIntake(snap, st.intakeDrafts?.[locationId2]), st.staffRoster?.[locationId2], questions);
|
|
@@ -50789,7 +51990,8 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
50789
51990
|
} catch (e) {
|
|
50790
51991
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
50791
51992
|
logQuietly(`client-intake ${new URL(req.url || "/", "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
|
|
50792
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
51993
|
+
if (e instanceof Error && e.message === INTAKE_READ_TRANSIENT) res.end(JSON.stringify({ ok: false, transient: true, error: INTAKE_REFRESH_RETRYING }));
|
|
51994
|
+
else res.end(JSON.stringify({ ok: false, error: intakeRequestError(e) }));
|
|
50793
51995
|
}
|
|
50794
51996
|
})();
|
|
50795
51997
|
return;
|
|
@@ -51212,7 +52414,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51212
52414
|
}
|
|
51213
52415
|
const name = currentRoster().find((r) => r.locationId === locationId2)?.name || locationId2;
|
|
51214
52416
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
51215
|
-
res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
|
|
52417
|
+
res.end(reviewDocumentHtml(review, { scope: documentScopeFor(st, locationId2, readSavedPlan(locationId2)), assessment: st.assessmentAttachments?.[locationId2], purchasedFeatureIds: assessmentPurchasesFor(st, locationId2), bought: boughtDocumentFor(st, locationId2, offeredFeaturesFor(st, locationId2)), planLines: readSavedPlan(locationId2) ? executablePlanLines(readSavedPlan(locationId2)) : [], clientName: name, agencyName: q3.get("agency") || st.agencyName, accent: q3.get("accent") || st.agencyAccent }));
|
|
51216
52418
|
return;
|
|
51217
52419
|
}
|
|
51218
52420
|
if (req.method === "GET" && url === "/api/review") {
|
|
@@ -51224,27 +52426,31 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51224
52426
|
if (req.method === "POST" && url === "/api/build-approval") {
|
|
51225
52427
|
readBody(req, res, 5e3, async (body) => {
|
|
51226
52428
|
try {
|
|
51227
|
-
const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes } = JSON.parse(body);
|
|
52429
|
+
const { locationId: locationId2, clientKey, confirmName, revoke, repairHashes, by } = JSON.parse(body);
|
|
52430
|
+
const approvedBy = typeof by === "string" && by.trim() ? { by: by.trim().slice(0, 80) } : {};
|
|
51228
52431
|
const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
|
|
51229
52432
|
const rk = who.recordKey;
|
|
51230
52433
|
if (PROTECTED_LOCATIONS[who.locationId]) throw new Error(PROTECTED_LOCATIONS[who.locationId] + " can never be approved for building.");
|
|
52434
|
+
const current = readCockpitState();
|
|
52435
|
+
if (!revoke && !hasChosenScope(current, rk)) throw new Error(who.clientKey ? secondClientChooseMessage(who.clientKey, who.name) : CHOOSE_WHAT_THEY_BOUGHT);
|
|
52436
|
+
const assessmentWait = revoke ? null : assessmentGateRefusal(current, rk, who.clientKey, who.name);
|
|
52437
|
+
if (assessmentWait) throw new Error(assessmentWait);
|
|
51231
52438
|
if (!revoke && !readAccountRulesSafe().sandboxes[who.locationId] && String(confirmName || "").trim().toLowerCase() !== who.name.trim().toLowerCase()) {
|
|
51232
52439
|
throw new Error(`Type the client's name exactly ("${who.name}") to approve automated building.`);
|
|
51233
52440
|
}
|
|
51234
|
-
const current = readCockpitState();
|
|
51235
52441
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
51236
52442
|
const approvals = { ...current.planApprovals };
|
|
51237
52443
|
if (revoke) delete approvals[rk];
|
|
51238
52444
|
else if (who.clientKey) {
|
|
51239
|
-
approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
|
|
52445
|
+
approvals[rk] = { inputHash: secondClientInputHash(readSavedPlan(rk)), at, repairHashes: [], staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
|
|
51240
52446
|
} else {
|
|
51241
52447
|
const c = clientFor(who.locationId);
|
|
51242
52448
|
if (!c) throw new Error(INTAKE_UNREADABLE);
|
|
51243
52449
|
const snap = await readIntakeSnapshot(c, who.locationId);
|
|
51244
|
-
if (snap.status === "unavailable") throw
|
|
52450
|
+
if (snap.status === "unavailable") throw intakeUnavailableError(snap);
|
|
51245
52451
|
const hash = intakeInput(normalizedIntake(snap), current.accounts?.[who.locationId]?.industry, composeQuestionSet({ industry: current.accounts?.[who.locationId]?.industry, agency: readOverlay() }));
|
|
51246
52452
|
if (current.outcomes?.[`${rk}:1`]?.inputHash && current.outcomes[`${rk}:1`].inputHash !== hash) throw new Error("The intake changed. Run Brief again before approving building.");
|
|
51247
|
-
const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]) };
|
|
52453
|
+
const approval = { inputHash: hash, at, repairHashes: readWorkflowRepairs(who.locationId).filter((r) => Array.isArray(repairHashes) && repairHashes.includes(r.hash)).map((r) => r.hash), staffHash: staffListHash(current.staffRoster?.[who.locationId]), scopeHash: approvalScopeHash(current, rk), boughtHash: approvalBoughtHash(current, rk), ...approvedBy };
|
|
51248
52454
|
approvals[rk] = approval;
|
|
51249
52455
|
}
|
|
51250
52456
|
const latest = readCockpitState();
|
|
@@ -51602,16 +52808,63 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51602
52808
|
});
|
|
51603
52809
|
return;
|
|
51604
52810
|
}
|
|
52811
|
+
if (req.method === "POST" && url === "/api/assessment-choice") {
|
|
52812
|
+
readBody(req, res, 5e3, (body) => {
|
|
52813
|
+
try {
|
|
52814
|
+
const { locationId: locationId2, clientKey, choice } = JSON.parse(body);
|
|
52815
|
+
const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
|
|
52816
|
+
if (!isAssessmentChoice(choice)) throw new Error("Choose one of the three answers.");
|
|
52817
|
+
const rk = who.recordKey;
|
|
52818
|
+
const current = readCockpitState();
|
|
52819
|
+
const { state: nextState, withdrawn } = withdrawIfBindingChanged({ ...current, assessmentChoice: { ...current.assessmentChoice, [rk]: { choice, at: (/* @__PURE__ */ new Date()).toISOString() } } }, rk);
|
|
52820
|
+
writeCockpitState(nextState);
|
|
52821
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
52822
|
+
res.end(JSON.stringify({ ok: true, choice, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_ASSESSMENT } : {} }));
|
|
52823
|
+
} catch (e) {
|
|
52824
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
52825
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
|
|
52826
|
+
}
|
|
52827
|
+
});
|
|
52828
|
+
return;
|
|
52829
|
+
}
|
|
51605
52830
|
if (req.method === "POST" && url === "/api/scope") {
|
|
51606
52831
|
readBody(req, res, 1e4, (body) => {
|
|
51607
52832
|
try {
|
|
51608
|
-
const { locationId: locationId2, clientKey, package: pkg2, features } = JSON.parse(body);
|
|
52833
|
+
const { locationId: locationId2, clientKey, package: pkg2, features, bought } = JSON.parse(body);
|
|
51609
52834
|
const who = cockpitClient(readCockpitState(), currentRoster(), locationId2, clientKey);
|
|
51610
|
-
|
|
51611
|
-
|
|
51612
|
-
|
|
52835
|
+
let ticks;
|
|
52836
|
+
let derived;
|
|
52837
|
+
if (bought !== void 0) {
|
|
52838
|
+
if (!Array.isArray(bought) || bought.some((b) => !isBoughtId(b))) throw new Error("Tick items from the list.");
|
|
52839
|
+
ticks = [...new Set(bought.map(String))];
|
|
52840
|
+
if (!ticks.length) throw new Error(NOTHING_BOUGHT);
|
|
52841
|
+
derived = scopeFromBought(ticks, offeredFeaturesFor(readCockpitState(), who.recordKey), typeof pkg2 === "string" && pkg2.trim() ? pkg2 : void 0);
|
|
52842
|
+
if (!("wholePreset" in derived) && !derived.features.length) throw new Error(NOTHING_BUILT_FROM_TICKS);
|
|
52843
|
+
} else if (features !== null && !Array.isArray(features)) throw new Error("features must be a list of feature ids, or null to confirm the whole preset");
|
|
52844
|
+
const next = derived ?? (features === null ? { wholePreset: true } : { ...typeof pkg2 === "string" ? { package: pkg2 } : {}, features: features.map(String) });
|
|
52845
|
+
if (!derived && Array.isArray(features)) {
|
|
52846
|
+
const ticked = features.map(String).filter(isFeatureId);
|
|
52847
|
+
const offered = offeredFeaturesFor(readCockpitState(), who.recordKey);
|
|
52848
|
+
if (ticked.length && !ticked.some((f) => offered.includes(f))) throw new Error(NOTHING_TICKED_IN_PLAN);
|
|
52849
|
+
}
|
|
52850
|
+
const rk = who.recordKey;
|
|
52851
|
+
let nextState = setClientScope(readCockpitState(), rk, next);
|
|
52852
|
+
const savedTicks = { ...nextState.bought ?? {} };
|
|
52853
|
+
if (ticks) savedTicks[rk] = ticks;
|
|
52854
|
+
else delete savedTicks[rk];
|
|
52855
|
+
nextState = { ...nextState, bought: savedTicks };
|
|
52856
|
+
if (!Object.keys(savedTicks).length) delete nextState.bought;
|
|
52857
|
+
const { state: bound, withdrawn } = withdrawIfBindingChanged(nextState, rk);
|
|
52858
|
+
nextState = bound;
|
|
52859
|
+
{
|
|
52860
|
+
const offered = offeredFeaturesFor(nextState, rk);
|
|
52861
|
+
replaceBuildObligationsOfType(rk, BOUGHT_JOB_TYPE, ticks ? boughtJobs(ticks, offered) : []);
|
|
52862
|
+
const tasks2 = withBoughtJobs(nextState.manualTasks?.[rk] ?? [], ticks ? boughtJobLines(ticks, offered) : []);
|
|
52863
|
+
if (tasks2.length || nextState.manualTasks?.[rk]) nextState = { ...nextState, manualTasks: { ...nextState.manualTasks, [rk]: tasks2 } };
|
|
52864
|
+
}
|
|
52865
|
+
writeCockpitState(nextState);
|
|
51613
52866
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
51614
|
-
res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[
|
|
52867
|
+
res.end(JSON.stringify({ ok: true, scope: readCockpitState().scope?.[rk] ?? null, ...withdrawn ? { approvalWithdrawn: true, message: APPROVAL_WITHDRAWN_FOR_SCOPE } : {} }));
|
|
51615
52868
|
} catch (e) {
|
|
51616
52869
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
51617
52870
|
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : "bad request" }));
|
|
@@ -51666,7 +52919,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51666
52919
|
const client = clientFor(locationId2);
|
|
51667
52920
|
if (!client) throw new Error(INTAKE_UNREADABLE);
|
|
51668
52921
|
const snap = await readIntakeSnapshot(client, locationId2);
|
|
51669
|
-
if (snap.status === "unavailable") throw
|
|
52922
|
+
if (snap.status === "unavailable") throw intakeUnavailableError(snap);
|
|
51670
52923
|
const answers = normalizedIntake(snap);
|
|
51671
52924
|
hash = intakeInput(answers, state.accounts?.[locationId2]?.industry, composeQuestionSet({ industry: state.accounts?.[locationId2]?.industry, agency: readOverlay() }));
|
|
51672
52925
|
state = { ...state, intakeInputs: { ...state.intakeInputs, [locationId2]: { answers, hash, status: snap.status } } };
|
|
@@ -51748,7 +53001,7 @@ ${opportunityNoteFailure?.detail ?? ""}`);
|
|
|
51748
53001
|
process.on("SIGTERM", stop2);
|
|
51749
53002
|
});
|
|
51750
53003
|
}
|
|
51751
|
-
var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
|
|
53004
|
+
var import_zod67, import_node_util5, fs42, path40, http, import_child_process4, import_child_process5, osmod, STAGES2, ACCOUNT_TYPES, SECOND_CLIENT_NO_INTAKE, CHOOSE_WHAT_THEY_BOUGHT, CHOOSE_BEFORE_BUILD, SCOPE_REFUSAL, NOTHING_BOUGHT, NOTHING_BUILT_FROM_TICKS, ASSESSMENT_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_ASSESSMENT, NOTHING_TICKED_IN_PLAN, SCOPE_CHANGED_SINCE_APPROVAL, APPROVAL_WITHDRAWN_FOR_SCOPE, activeRun2, handoffEmailPreviews, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, PARKED_NOT_BUILT, NEEDS_AGENCY_KEY, INTAKE_UNREADABLE, INTAKE_READ_TRANSIENT, INTAKE_REFRESH_RETRYING, INTAKE_ANSWERS_MISSING, INTAKE_READ_FAILED, INTAKE_FORM_REQUIRED, INTAKE_CONNECT_REQUIRED, INTAKE_OWN_MESSAGES, currentRosterRef, staffGateRef, BOARD_ROUTES, BOARD_TITLES, SCREEN_HEADS, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS, ConnectionRequest, DisconnectRequest, ReportBrandSaveRequest;
|
|
51752
53005
|
var init_dashboard = __esm({
|
|
51753
53006
|
"src/dashboard.ts"() {
|
|
51754
53007
|
"use strict";
|
|
@@ -51816,6 +53069,7 @@ var init_dashboard = __esm({
|
|
|
51816
53069
|
init_plan_from_preset();
|
|
51817
53070
|
init_plan_guide();
|
|
51818
53071
|
init_attachment();
|
|
53072
|
+
init_bought_list();
|
|
51819
53073
|
init_intake_contact();
|
|
51820
53074
|
init_client_intake();
|
|
51821
53075
|
init_client_intake();
|
|
@@ -51870,6 +53124,16 @@ var init_dashboard = __esm({
|
|
|
51870
53124
|
];
|
|
51871
53125
|
ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
|
|
51872
53126
|
SECOND_CLIENT_NO_INTAKE = "This sub-account's intake form belongs to its default client, so a second client's intake and Brief are not run from here. Prepare this client's plan with its clinic prefix (the build preview), then approve and build it here.";
|
|
53127
|
+
CHOOSE_WHAT_THEY_BOUGHT = "Choose what the client bought before approving: Blueprint Review, then What they bought, then Save.";
|
|
53128
|
+
CHOOSE_BEFORE_BUILD = "Choose what the client bought before building: Blueprint Review, then What they bought, then Save.";
|
|
53129
|
+
SCOPE_REFUSAL = /Nobody has chosen what|what this client bought|features (are )?selected for this client|No features are selected|features nobody ticked/i;
|
|
53130
|
+
NOTHING_BOUGHT = "Tick at least one item they bought, then save.";
|
|
53131
|
+
NOTHING_BUILT_FROM_TICKS = "None of the ticked items is built for this client's industry, so nothing was saved. Tick at least one item marked Built.";
|
|
53132
|
+
ASSESSMENT_CHANGED_SINCE_APPROVAL = "The Assessment answer or the attached Assessment changed after the build was approved, so Build did not start. Approve this plan again, then build.";
|
|
53133
|
+
APPROVAL_WITHDRAWN_FOR_ASSESSMENT = "The Assessment answer changed, so the build approval was withdrawn. Approve this plan again before building.";
|
|
53134
|
+
NOTHING_TICKED_IN_PLAN = "None of the ticked features is in the plan for this client's industry, so nothing was saved. Tick features from the list, or choose Everything in the plan.";
|
|
53135
|
+
SCOPE_CHANGED_SINCE_APPROVAL = "What they bought changed after the build was approved, so Build did not start. Approve this plan again, then build.";
|
|
53136
|
+
APPROVAL_WITHDRAWN_FOR_SCOPE = "What they bought changed, so the build approval was withdrawn. Approve this plan again before building.";
|
|
51873
53137
|
activeRun2 = null;
|
|
51874
53138
|
handoffEmailPreviews = /* @__PURE__ */ new Map();
|
|
51875
53139
|
UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
|
|
@@ -51878,10 +53142,17 @@ var init_dashboard = __esm({
|
|
|
51878
53142
|
PARKED_NOT_BUILT = "Template and hidden accounts are not built from Command OS. Change the account's type to Client first.";
|
|
51879
53143
|
NEEDS_AGENCY_KEY = "No agency key on file. Run register_agency_key in GHL Command first.";
|
|
51880
53144
|
INTAKE_UNREADABLE = "Command OS could not read this client's account. Paste its key again in Build a client account.";
|
|
53145
|
+
INTAKE_READ_TRANSIENT = "Command OS could not reach this client's account just now. Try again in a moment.";
|
|
53146
|
+
INTAKE_REFRESH_RETRYING = "Could not refresh just now; retrying.";
|
|
53147
|
+
INTAKE_ANSWERS_MISSING = "The intake form shows a submission, but no contact in this client's account has the answers yet. If the client has not submitted the form, send it again. If they have, press Refresh status in a minute.";
|
|
53148
|
+
INTAKE_READ_FAILED = "The intake could not be read. Press Refresh status to try again.";
|
|
51881
53149
|
INTAKE_FORM_REQUIRED = "Run the intake step in Client builds to create the form, then come back and submit.";
|
|
51882
53150
|
INTAKE_CONNECT_REQUIRED = "Connect this sub-account by pasting its Private Integration key in Command OS.";
|
|
51883
53151
|
INTAKE_OWN_MESSAGES = [
|
|
51884
53152
|
INTAKE_UNREADABLE,
|
|
53153
|
+
INTAKE_READ_TRANSIENT,
|
|
53154
|
+
INTAKE_ANSWERS_MISSING,
|
|
53155
|
+
INTAKE_READ_FAILED,
|
|
51885
53156
|
...CLAUDE_REPAIR_MESSAGES,
|
|
51886
53157
|
"The limit is 60,000 characters. Paste an excerpt or shorten this text file.",
|
|
51887
53158
|
"That account is not on the board.",
|
|
@@ -52308,7 +53579,9 @@ WARNING: saved under ${canonicalCompanyId}, but switch_location resolves ${args.
|
|
|
52308
53579
|
locationId: args.test_location_id,
|
|
52309
53580
|
userId: foundViaRealPath.userId,
|
|
52310
53581
|
companyId: resolvedCompanyId,
|
|
52311
|
-
registry
|
|
53582
|
+
// The registry, so a token rotated by this probe is saved to the
|
|
53583
|
+
// company's own slot instead of being lost (review, 2026-09-29).
|
|
53584
|
+
registry: registry2
|
|
52312
53585
|
});
|
|
52313
53586
|
try {
|
|
52314
53587
|
await probe.listWorkflows(1, 0);
|
|
@@ -53162,11 +54435,11 @@ function registerAccountExportTools(server2, client, registry2, builderClient) {
|
|
|
53162
54435
|
};
|
|
53163
54436
|
const dataA = await fetchWithOwnKey(locationA);
|
|
53164
54437
|
const dataB = await fetchWithOwnKey(locationB);
|
|
53165
|
-
const { comparison, unavailable } = buildLocationComparison(dataA, dataB, SECTIONS);
|
|
54438
|
+
const { comparison, unavailable: unavailable2 } = buildLocationComparison(dataA, dataB, SECTIONS);
|
|
53166
54439
|
diff.comparison = comparison;
|
|
53167
54440
|
if (usedRegisteredKey.length) diff.usedRegisteredKeyFor = usedRegisteredKey;
|
|
53168
|
-
if (
|
|
53169
|
-
diff.unavailable =
|
|
54441
|
+
if (unavailable2.length) {
|
|
54442
|
+
diff.unavailable = unavailable2;
|
|
53170
54443
|
diff.warning = "Some sections could not be read. Those sections are marked unavailable \u2014 do NOT read them as empty. A Private Integration key only works on the sub-account it was created in, so register each account's own key (register_location) before comparing them.";
|
|
53171
54444
|
}
|
|
53172
54445
|
return jsonResponse(diff);
|
|
@@ -53435,13 +54708,13 @@ function discoverAssets(input) {
|
|
|
53435
54708
|
return assets;
|
|
53436
54709
|
}
|
|
53437
54710
|
function buildLocationComparison(dataA, dataB, sections) {
|
|
53438
|
-
const
|
|
54711
|
+
const unavailable2 = [];
|
|
53439
54712
|
const comparison = {};
|
|
53440
54713
|
for (const key of sections) {
|
|
53441
54714
|
const a = dataA[key] ?? { error: "not read" };
|
|
53442
54715
|
const b = dataB[key] ?? { error: "not read" };
|
|
53443
|
-
if ("error" in a)
|
|
53444
|
-
if ("error" in b)
|
|
54716
|
+
if ("error" in a) unavailable2.push(`locationA.${key}: ${a.error}`);
|
|
54717
|
+
if ("error" in b) unavailable2.push(`locationB.${key}: ${b.error}`);
|
|
53445
54718
|
if ("error" in a || "error" in b) {
|
|
53446
54719
|
comparison[key] = {
|
|
53447
54720
|
A: "error" in a ? "unavailable" : a.names.length,
|
|
@@ -53461,7 +54734,7 @@ function buildLocationComparison(dataA, dataB, sections) {
|
|
|
53461
54734
|
onlyInB: b.names.filter((n) => !setA.has(n))
|
|
53462
54735
|
};
|
|
53463
54736
|
}
|
|
53464
|
-
return { comparison, unavailable };
|
|
54737
|
+
return { comparison, unavailable: unavailable2 };
|
|
53465
54738
|
}
|
|
53466
54739
|
var import_zod70, OFF_PLATFORM_ASSETS;
|
|
53467
54740
|
var init_account_export = __esm({
|
|
@@ -54927,7 +56200,7 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
|
|
|
54927
56200
|
}
|
|
54928
56201
|
if (result.ok) {
|
|
54929
56202
|
const idNote = tokenCompany ? ` Authenticated as company ${tokenCompany}.` : "";
|
|
54930
|
-
return { name: "Firebase auth (workflow builder)", status: "pass", detail: `ID token
|
|
56203
|
+
return { name: "Firebase auth (workflow builder)", status: "pass", detail: `Got a valid ID token. Workflow builder tools are usable.${companyNote}${idNote}` };
|
|
54931
56204
|
}
|
|
54932
56205
|
return { name: "Firebase auth (workflow builder)", status: "fail", detail: `Token refresh failed: ${result.error}.${companyNote} If you're in a client's account, register its Firebase with register_company_firebase. Otherwise re-capture your Firebase values from GHL DevTools and re-run enable_workflow_builder.` };
|
|
54933
56206
|
})();
|
|
@@ -58120,7 +59393,7 @@ function extractHtml(text2) {
|
|
|
58120
59393
|
function claudeGenerate(model = DEFAULT_MODEL, bin = "claude") {
|
|
58121
59394
|
return (instruction, { cwd }) => new Promise((resolveP) => {
|
|
58122
59395
|
const t0 = Date.now();
|
|
58123
|
-
const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model]);
|
|
59396
|
+
const cmd = claudeCommand(bin, ["-p", "--output-format", "json", "--max-turns", "1", "--tools", "", "--strict-mcp-config", "--model", model, ...NO_MEMBER_HOOKS]);
|
|
58124
59397
|
const child = (0, import_node_child_process6.spawn)(cmd.command, cmd.args, {
|
|
58125
59398
|
cwd,
|
|
58126
59399
|
env: { ...process.env, CLAUDECODE: "", CLAUDE_CODE_ENTRYPOINT: "" },
|
|
@@ -58281,6 +59554,7 @@ var init_run2 = __esm({
|
|
|
58281
59554
|
"use strict";
|
|
58282
59555
|
import_node_child_process6 = require("node:child_process");
|
|
58283
59556
|
init_claude_locate();
|
|
59557
|
+
init_result_text();
|
|
58284
59558
|
import_node_fs12 = require("node:fs");
|
|
58285
59559
|
import_node_path11 = require("node:path");
|
|
58286
59560
|
init_brief();
|
|
@@ -61494,7 +62768,7 @@ function registerSecretShopperTools(server2, client, registry2) {
|
|
|
61494
62768
|
if (registry2) {
|
|
61495
62769
|
try {
|
|
61496
62770
|
const b = workflowReadConnection(registry2, client);
|
|
61497
|
-
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders());
|
|
62771
|
+
stepLog = liveStepLogReader(locationId2, () => b.buildHeaders(), fetch, (h) => b.invalidateIdToken(h["token-id"] ?? null));
|
|
61498
62772
|
} catch {
|
|
61499
62773
|
}
|
|
61500
62774
|
}
|
|
@@ -63639,8 +64913,8 @@ function registerAccountHealthTools(server2, client) {
|
|
|
63639
64913
|
conversations,
|
|
63640
64914
|
phoneNumbers
|
|
63641
64915
|
};
|
|
63642
|
-
const
|
|
63643
|
-
return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable };
|
|
64916
|
+
const unavailable2 = Object.entries(sections).filter(([, v]) => v.status === "unavailable").map(([k]) => k);
|
|
64917
|
+
return { locationId: loc, requestedWindow: { days, start: startISO, end: endISO }, sections, unavailable: unavailable2 };
|
|
63644
64918
|
}
|
|
63645
64919
|
);
|
|
63646
64920
|
}
|
|
@@ -65893,6 +67167,18 @@ async function readLocationName(client, locationId2) {
|
|
|
65893
67167
|
return locationId2;
|
|
65894
67168
|
}
|
|
65895
67169
|
}
|
|
67170
|
+
function cockpitSelectionRequired(board, locationId2, recordKey, env2 = process.env) {
|
|
67171
|
+
if (env2.GHL_COMMAND_STAGE) return true;
|
|
67172
|
+
const has = (field, key) => {
|
|
67173
|
+
const m = board[field];
|
|
67174
|
+
return Boolean(m && typeof m === "object" && m[key]);
|
|
67175
|
+
};
|
|
67176
|
+
const own = ["clients", "planApprovals", "buildApproved", "assessmentPurchases", "scope", "reviews", "intakeInputs", "assessmentAttachments", "clientProfiles"].some((f) => has(f, recordKey));
|
|
67177
|
+
return own || recordKey === locationId2 && has("accounts", locationId2);
|
|
67178
|
+
}
|
|
67179
|
+
function noSelectionMessage(clientKey) {
|
|
67180
|
+
return clientKey ? secondClientChooseMessage(clientKey) : NO_SELECTION_SCOPE;
|
|
67181
|
+
}
|
|
65896
67182
|
function resolvePlanSource(input) {
|
|
65897
67183
|
const { plan, useSavedPlan, replaceSavedPlan, saved } = input;
|
|
65898
67184
|
if (useSavedPlan) {
|
|
@@ -66027,9 +67313,10 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66027
67313
|
if (!briefArg) {
|
|
66028
67314
|
return jsonResponse({ ok: false, phase: "compose", error: "fromPreset needs the brief \u2014 pass the \xA74 Brief from normalize_submission_to_brief (or assembled from the cockpit intake answers)." });
|
|
66029
67315
|
}
|
|
66030
|
-
|
|
66031
|
-
let
|
|
66032
|
-
|
|
67316
|
+
const briefIn = process.env.GHL_COMMAND_STAGE && briefArg.scope ? (({ scope: _ignored, ...rest }) => rest)(briefArg) : briefArg;
|
|
67317
|
+
let briefForCompose = briefIn;
|
|
67318
|
+
let scopeSource = briefIn.scope ? "brief" : "none";
|
|
67319
|
+
const briefScope = briefIn.scope;
|
|
66033
67320
|
if (briefScope && Array.isArray(briefScope.features) && briefScope.features.length === 0) {
|
|
66034
67321
|
return jsonResponse({
|
|
66035
67322
|
ok: false,
|
|
@@ -66038,13 +67325,15 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66038
67325
|
error: "The brief's scope names no features, so nothing was built. Choose the features to build (prefilled from what they bought), confirm, and run again."
|
|
66039
67326
|
});
|
|
66040
67327
|
}
|
|
66041
|
-
if (!
|
|
67328
|
+
if (!briefIn.scope) {
|
|
66042
67329
|
const board = readBoardState();
|
|
66043
67330
|
const saved2 = board.scope?.[recordKey];
|
|
66044
67331
|
if (saved2?.wholePreset) {
|
|
66045
67332
|
scopeSource = "cockpit";
|
|
66046
67333
|
} else if (!saved2 && Array.isArray(board.assessmentPurchases?.[recordKey])) {
|
|
66047
67334
|
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
|
|
67335
|
+
} else if (!saved2 && cockpitSelectionRequired(board, activeLocation, recordKey)) {
|
|
67336
|
+
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
|
|
66048
67337
|
} else if (saved2 && Array.isArray(saved2.features)) {
|
|
66049
67338
|
const features = saved2.features.filter(isFeatureId);
|
|
66050
67339
|
if (!features.length) {
|
|
@@ -66055,7 +67344,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66055
67344
|
error: "No features are selected for this client, so nothing was built. Open the client's Review screen, tick the features to build (it is prefilled from what they bought), confirm, and run the build again."
|
|
66056
67345
|
});
|
|
66057
67346
|
}
|
|
66058
|
-
briefForCompose = { ...
|
|
67347
|
+
briefForCompose = { ...briefIn, scope: { ...saved2.package ? { package: saved2.package } : {}, features } };
|
|
66059
67348
|
scopeSource = "cockpit";
|
|
66060
67349
|
}
|
|
66061
67350
|
}
|
|
@@ -66108,6 +67397,30 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66108
67397
|
if (!current && Array.isArray(board.assessmentPurchases?.[recordKey])) {
|
|
66109
67398
|
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: notConfirmedAsk(recordKey) });
|
|
66110
67399
|
}
|
|
67400
|
+
if (!current && cockpitSelectionRequired(board, activeLocation, recordKey)) {
|
|
67401
|
+
return jsonResponse({ ok: false, phase: "scope", scopeSource: "cockpit", error: noSelectionMessage(clientKey) });
|
|
67402
|
+
}
|
|
67403
|
+
if (current && !current.wholePreset && Array.isArray(current.features) && !(saved && planSource !== "argument-replaces-saved")) {
|
|
67404
|
+
const chosen = new Set(current.features.filter(isFeatureId));
|
|
67405
|
+
const outside = [];
|
|
67406
|
+
for (const w of plan.workflows ?? []) {
|
|
67407
|
+
let f = null;
|
|
67408
|
+
try {
|
|
67409
|
+
f = featureOfWorkflow(String(w.ref ?? ""), w.feature);
|
|
67410
|
+
} catch {
|
|
67411
|
+
f = String(w.feature);
|
|
67412
|
+
}
|
|
67413
|
+
if (f !== null && !chosen.has(f)) outside.push(`${String(w.name ?? w.ref)} (${isFeatureId(f) ? FEATURES[f].name : f})`);
|
|
67414
|
+
}
|
|
67415
|
+
if (chosen.size && outside.length) {
|
|
67416
|
+
return jsonResponse({
|
|
67417
|
+
ok: false,
|
|
67418
|
+
phase: "scope",
|
|
67419
|
+
scopeSource: "cockpit",
|
|
67420
|
+
error: `This plan builds workflows for features nobody ticked for this client, so nothing was built: ${outside.join(", ")}. Build from the preset (fromPreset), which follows what they bought, or tick those features and save first.`
|
|
67421
|
+
});
|
|
67422
|
+
}
|
|
67423
|
+
}
|
|
66111
67424
|
if (current && !current.wholePreset && Array.isArray(current.features) && !current.features.some(isFeatureId)) {
|
|
66112
67425
|
return jsonResponse({
|
|
66113
67426
|
ok: false,
|
|
@@ -66116,7 +67429,7 @@ function registerIntakeToBuildTools(server2, client, builderClient, registry2) {
|
|
|
66116
67429
|
error: "No features are selected for this client, so nothing was built, not even from the saved plan. Open the client's Review screen, tick the features to build, confirm, and run the build again."
|
|
66117
67430
|
});
|
|
66118
67431
|
}
|
|
66119
|
-
if (saved && planSource !== "argument-replaces-saved" && !savedPlanMatchesScope(saved, current)) {
|
|
67432
|
+
if (saved && planSource !== "argument-replaces-saved" && (current ? !savedPlanMatchesScope(saved, current) : Boolean(saved.scope))) {
|
|
66120
67433
|
const planned = saved?.scope ? [...saved.scope.built, ...(saved.scope.omitted ?? []).map((f) => f.id)].join(", ") || "no features" : "everything in the preset";
|
|
66121
67434
|
const now = current?.wholePreset ? "everything in the preset (confirmed)" : current ? current.features.filter(isFeatureId).join(", ") : "everything in the preset (nothing is saved)";
|
|
66122
67435
|
return jsonResponse({
|
|
@@ -66896,7 +68209,7 @@ function intakeFormMatches(expected, actual) {
|
|
|
66896
68209
|
const keys = ["id", "tag", "type", "hiddenFieldQueryKey", "required", "label", "options", "picklistOptions"];
|
|
66897
68210
|
return expected.every((field, i) => keys.every((key) => field[key] === void 0 || JSON.stringify(field[key]) === JSON.stringify(actual[i]?.[key])));
|
|
66898
68211
|
}
|
|
66899
|
-
var import_zod93, customFieldItemSchema, sleep6, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
|
|
68212
|
+
var import_zod93, customFieldItemSchema, sleep6, NO_SELECTION_SCOPE, NOT_CONFIRMED_SCOPE, WHOLE_PRESET_NOT_KEPT, workflowInventorySchema, notConfirmedAsk;
|
|
66900
68213
|
var init_intake_to_build = __esm({
|
|
66901
68214
|
"src/tools/intake-to-build.ts"() {
|
|
66902
68215
|
"use strict";
|
|
@@ -66948,7 +68261,8 @@ var init_intake_to_build = __esm({
|
|
|
66948
68261
|
picklistOptions: import_zod93.z.array(import_zod93.z.string()).optional()
|
|
66949
68262
|
}).passthrough();
|
|
66950
68263
|
sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
66951
|
-
|
|
68264
|
+
NO_SELECTION_SCOPE = "Nobody has chosen what this client bought, so nothing was built. In the cockpit open Blueprint Review, then What they bought: tick what they bought, or choose Everything in the plan, then save and run the build again.";
|
|
68265
|
+
NOT_CONFIRMED_SCOPE = "What this client bought is recorded, but nobody has confirmed what to build, so nothing was built. In the cockpit open Blueprint Review, then What they bought: the features are ticked from what they bought. Change them if you need to, save to confirm, and run the build again.";
|
|
66952
68266
|
WHOLE_PRESET_NOT_KEPT = "Note: an earlier build on this client used everything in the preset, and that choice was not kept, so the Review screen ticks only what they bought. Tick everything again if that is still what you want.";
|
|
66953
68267
|
workflowInventorySchema = import_zod93.z.object({
|
|
66954
68268
|
workflows: import_zod93.z.array(import_zod93.z.object({ id: import_zod93.z.string().min(1), name: import_zod93.z.string() }).passthrough())
|