@flowapt/flowiq-cli 0.9.9 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -0
- package/TEAM-GUIDE.md +25 -0
- package/package.json +1 -1
- package/src/batch-cde.test.mjs +73 -0
- package/src/batch-f.test.mjs +63 -0
- package/src/commands/agent-test.js +15 -3
- package/src/commands/agents.js +31 -0
- package/src/commands/broadcast.js +9 -0
- package/src/commands/contacts.js +173 -0
- package/src/commands/flows.js +82 -0
- package/src/commands/links.js +63 -20
- package/src/commands/members.js +95 -0
- package/src/commands/messages.js +106 -0
- package/src/commands/notes.js +63 -0
- package/src/commands/org.js +190 -5
- package/src/commands/products.js +58 -0
- package/src/commands/prompts.js +46 -1
- package/src/commands/rules.js +67 -0
- package/src/commands/segments.js +47 -14
- package/src/commands/tag.js +77 -7
- package/src/commands/templates.js +66 -5
- package/src/commands/tickets.js +69 -0
- package/src/commands/widgets.js +97 -0
- package/src/index.js +286 -6
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `flowiq steer list|add|clear <contact_id>` + `flowiq notes list|add
|
|
2
|
+
// <contact_id>` — the internal rows in a contact's chat (server: api/cli/notes.js).
|
|
3
|
+
|
|
4
|
+
import { http } from "../http.js";
|
|
5
|
+
import { fmtSast } from "./audit.js";
|
|
6
|
+
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
9
|
+
const requireContact = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid contact UUID.`); process.exit(1); } };
|
|
10
|
+
|
|
11
|
+
async function listKind(contactId, kind, opts) {
|
|
12
|
+
requireContact(contactId);
|
|
13
|
+
let resp;
|
|
14
|
+
try { resp = await http.get("notes", { contact_id: contactId, kind, limit: opts.limit }); } catch (e) { fail("List failed", e); }
|
|
15
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
16
|
+
const label = kind === "notes" ? "internal note" : "steer";
|
|
17
|
+
console.log(`${resp.contact_name || "(no name)"} ${resp.contact_whatsapp_id || ""} on ${resp.organization_name}: ${resp.count} ${label}(s)${kind === "steers" ? ` · AI ${resp.bot_status === false ? "OFF" : "on"}${resp.steer_messages_flag ? "" : " · steer_messages flag off (inbox hides the control; vert still applies rows)"}` : ""}`);
|
|
18
|
+
for (const r of resp.rows) {
|
|
19
|
+
const head = kind === "notes" ? `${r.author ?? "?"}` : `${r.mode}${r.mode === "once" ? (r.consumed ? ", consumed" : ", pending") : ""}`;
|
|
20
|
+
console.log(`\n ${fmtSast(r.created_at)} SAST · ${head}${kind === "steers" && !r.active ? " · inactive" : ""}\n ${r.id}\n ${String(r.text).split("\n").join("\n ")}`);
|
|
21
|
+
}
|
|
22
|
+
if (kind === "steers" && resp.rows.some((r) => r.active)) console.log(`\n Remove: flowiq steer clear ${contactId} [--id <id>] --confirm`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const steerList = (contactId, opts = {}) => listKind(contactId, "steers", opts);
|
|
26
|
+
export const notesList = (contactId, opts = {}) => listKind(contactId, "notes", opts);
|
|
27
|
+
|
|
28
|
+
export async function steerAdd(contactId, textParts, opts = {}) {
|
|
29
|
+
requireContact(contactId);
|
|
30
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
31
|
+
if (!text) { console.error("Error: flowiq steer add <contact_id> \"instruction for the agent\" [--once]"); process.exit(1); }
|
|
32
|
+
let resp;
|
|
33
|
+
try { resp = await http.post("notes", { action: "add_steer", contact_id: contactId, text, mode: opts.once ? "once" : "persistent" }); } catch (e) { fail("Steer failed", e); }
|
|
34
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
35
|
+
console.log(`✓ ${resp.mode === "once" ? "one-shot" : "ongoing"} steer added (${resp.id}) as ${resp.author}. ${resp.mode === "once" ? "The agent applies it on the next reply, then it is spent." : "The agent applies it on EVERY reply until: flowiq steer clear " + contactId + " --id " + resp.id + " --confirm"}`);
|
|
36
|
+
for (const w of resp.warnings || []) console.error(` ⚠ ${w}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function steerClear(contactId, opts = {}) {
|
|
40
|
+
requireContact(contactId);
|
|
41
|
+
const ids = opts.id ? [].concat(opts.id) : undefined;
|
|
42
|
+
let resp;
|
|
43
|
+
try { resp = await http.post("notes", { action: "clear_steers", contact_id: contactId, steer_ids: ids, confirm: !!opts.confirm }); } catch (e) { fail("Clear failed", e); }
|
|
44
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
45
|
+
if (resp.dry_run) {
|
|
46
|
+
if (!resp.count) { console.log("No steers to remove."); return; }
|
|
47
|
+
console.log(`Would delete ${resp.count} steer(s):`);
|
|
48
|
+
for (const s of resp.steers) console.log(` ${s.id} ${s.mode}${s.consumed ? " (consumed)" : ""} ${String(s.text).slice(0, 80)}`);
|
|
49
|
+
console.log("Add --confirm to delete (the texts are kept in the audit row).");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
console.log(`✓ deleted ${resp.deleted} steer(s).`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function notesAdd(contactId, textParts, opts = {}) {
|
|
56
|
+
requireContact(contactId);
|
|
57
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
58
|
+
if (!text) { console.error("Error: flowiq notes add <contact_id> \"note for the team\""); process.exit(1); }
|
|
59
|
+
let resp;
|
|
60
|
+
try { resp = await http.post("notes", { action: "add_note", contact_id: contactId, text }); } catch (e) { fail("Note failed", e); }
|
|
61
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
62
|
+
console.log(`✓ internal note added (${resp.id}) as ${resp.author}; it shows on the Customer panel, never to the customer.`);
|
|
63
|
+
}
|
package/src/commands/org.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// are stripped server-side; this only prints derived booleans + public urls.
|
|
4
4
|
|
|
5
5
|
import { http } from "../http.js";
|
|
6
|
+
import { fmtSast } from "./audit.js";
|
|
6
7
|
|
|
7
8
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
9
|
|
|
@@ -12,31 +13,78 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
12
13
|
export async function list(search, opts = {}) {
|
|
13
14
|
let resp;
|
|
14
15
|
try {
|
|
15
|
-
resp = await http.get("org", { list: "1", search: search || undefined });
|
|
16
|
+
resp = await http.get("org", { list: "1", search: search || undefined, number: opts.number || undefined });
|
|
16
17
|
} catch (e) {
|
|
17
18
|
console.error(`Lookup failed: ${e.message}`);
|
|
19
|
+
if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
18
20
|
process.exit(1);
|
|
19
21
|
}
|
|
20
22
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
// A number lookup shows parked orgs too: the number is the question.
|
|
25
|
+
const orgs = (resp.orgs || []).filter((o) => (opts.all || opts.number ? true : !o.inactive));
|
|
23
26
|
if (!orgs.length) {
|
|
24
|
-
console.log(search ? `No organizations match "${search}".` : "No organizations found.");
|
|
27
|
+
console.log(opts.number ? `No organization has WhatsApp number / phone id / WABA ${opts.number}.` : search ? `No organizations match "${search}".` : "No organizations found.");
|
|
25
28
|
return;
|
|
26
29
|
}
|
|
27
30
|
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
28
31
|
const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
|
|
29
|
-
|
|
32
|
+
const byNumber = !!opts.number;
|
|
33
|
+
console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${byNumber ? `${pad("NUMBER", 16)}${pad("PHONE ID", 18)}${pad("WABA", 18)}` : `${pad("PLATFORM", 9)}STORE`}`);
|
|
30
34
|
for (const o of orgs) {
|
|
31
35
|
console.log(
|
|
32
36
|
`${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
|
|
33
|
-
|
|
37
|
+
(byNumber
|
|
38
|
+
? `${pad(o.phone_number || "—", 16)}${pad(o.phone_id || "—", 18)}${pad(o.waba_id || "—", 18)}`
|
|
39
|
+
: `${pad(o.platform || "—", 9)}${o.store || ""}`) +
|
|
40
|
+
`${o.inactive ? " (inactive)" : ""}`
|
|
34
41
|
);
|
|
35
42
|
}
|
|
36
43
|
const hidden = (resp.orgs || []).length - orgs.length;
|
|
37
44
|
console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
// `flowiq org agent <org> on|off [--commit] [--yes]` — the agent master switch
|
|
48
|
+
// (organizations.wati_webhook_status). Off = no customer gets the AI. On with
|
|
49
|
+
// vert.active:false + an allowlist = test mode. On with vert.active true =
|
|
50
|
+
// live for everyone, which is the shape that needs --yes.
|
|
51
|
+
export async function agentSwitch(orgId, state, opts = {}) {
|
|
52
|
+
requireOrg(orgId);
|
|
53
|
+
const s = String(state || "").toLowerCase();
|
|
54
|
+
if (!["on", "off"].includes(s)) { console.error('Error: pass "on" or "off", e.g. flowiq org agent <org> off --commit'); process.exit(1); }
|
|
55
|
+
let resp;
|
|
56
|
+
try { resp = await http.post("org", { action: "agent_switch", organization_id: orgId, enabled: s === "on", dry_run: !opts.commit, confirm: !!opts.yes }); }
|
|
57
|
+
catch (e) { fail("Switch failed", e); }
|
|
58
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
59
|
+
console.log(`${resp.organization_name} — agent master switch`);
|
|
60
|
+
console.log(` now: ${resp.from ? "ON" : "OFF"} · vert.active ${resp.vert_active ?? "unset"}${resp.allowed_whatsapp_ids?.length ? ` · allowlist ${resp.allowed_whatsapp_ids.join(", ")}` : ""} · active agent ${resp.active_whatsapp_agent ?? "NONE"}`);
|
|
61
|
+
if (resp.unchanged) { console.log(` already ${resp.to ? "ON" : "OFF"} — nothing to do.`); return; }
|
|
62
|
+
console.log(` ${resp.dry_run ? "would set" : "✓ set"}: ${resp.to ? "ON" : "OFF"} → ${resp.mode}`);
|
|
63
|
+
if (resp.dry_run) {
|
|
64
|
+
console.log(`\nDRY RUN — add --commit to switch${resp.needs_confirm ? ", and --yes because this makes the agent LIVE for every customer (set vert.allowed_whatsapp_ids + vert.active false first for a test mode)" : ""}.`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(`\nUndo: flowiq org agent ${orgId} ${resp.from ? "on" : "off"} --commit${resp.from ? " --yes" : ""}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `flowiq org park <org> [--unpark] --confirm` — organizations.inactive.
|
|
71
|
+
export async function park(orgId, opts = {}) {
|
|
72
|
+
requireOrg(orgId);
|
|
73
|
+
let resp;
|
|
74
|
+
try { resp = await http.post("org", { action: "park", organization_id: orgId, inactive: !opts.unpark, confirm: !!opts.confirm }); }
|
|
75
|
+
catch (e) { fail("Park failed", e); }
|
|
76
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
77
|
+
if (resp.unchanged) { console.log(`${resp.organization_name} is already ${resp.inactive ? "parked (inactive)" : "active"}.`); return; }
|
|
78
|
+
const verb = opts.unpark ? "unpark" : "park";
|
|
79
|
+
if (resp.dry_run) {
|
|
80
|
+
console.log(`Would ${verb} ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " Its agent master switch is still ON — parking hides the org from censuses and crons, it does not switch the agent off (flowiq org agent <org> off --commit)." : ""}`);
|
|
81
|
+
console.log("Add --confirm to write.");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
console.log(`✓ ${verb}ed ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " ⚠ agent master switch still ON." : ""}`);
|
|
85
|
+
console.log(`Undo: flowiq org park ${orgId}${opts.unpark ? "" : " --unpark"} --confirm`);
|
|
86
|
+
}
|
|
87
|
+
|
|
40
88
|
export async function info(orgId, opts = {}) {
|
|
41
89
|
if (!UUID_RE.test(orgId)) {
|
|
42
90
|
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
@@ -97,3 +145,140 @@ export async function create(opts = {}) {
|
|
|
97
145
|
console.log(` flowiq agent create ${resp.id} --name "<AgentName>" --make-active`);
|
|
98
146
|
console.log(` flowiq prompts pull ${resp.id} # then edit + push`);
|
|
99
147
|
}
|
|
148
|
+
|
|
149
|
+
// ── views (1 Oct 2026): channels · whatsapp · health · automations · abandoned ──
|
|
150
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
151
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
152
|
+
const yn = (v) => (v ? "yes" : "no");
|
|
153
|
+
const when = (v) => (v ? `${fmtSast(v)} SAST` : "—");
|
|
154
|
+
|
|
155
|
+
async function view(orgId, name) {
|
|
156
|
+
requireOrg(orgId);
|
|
157
|
+
try { return await http.get("org", { organization_id: orgId, view: name }); } catch (e) { fail(`${name} read failed`, e); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function channels(orgId, opts = {}) {
|
|
161
|
+
if (opts.all) return channelsCensus(opts);
|
|
162
|
+
const resp = await view(orgId, "channels");
|
|
163
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
164
|
+
const c = resp.channels;
|
|
165
|
+
console.log(`${resp.organization_name} — channels`);
|
|
166
|
+
const w = c.whatsapp;
|
|
167
|
+
console.log(` whatsapp ${w.connected ? "connected" : "NOT connected"} · provider ${w.provider}${w.phone_number ? ` · ${w.phone_number}` : ""}${w.phone_id ? ` (phone ${w.phone_id})` : ""}${w.connection_type ? ` · ${w.connection_type}` : ""}`);
|
|
168
|
+
console.log(` master switch ${w.master_switch ? "ON" : "OFF"} · vert.active ${w.vert_active ? "yes" : "no"}${w.allowed_whatsapp_ids ? ` (only ${w.allowed_whatsapp_ids})` : ""} · connected ${when(w.connected_at)} · registered ${when(w.last_registered_at)}`);
|
|
169
|
+
const m = c.messenger;
|
|
170
|
+
console.log(` messenger ${m.connected ? "connected" : "not connected"} · via ${m.provider}${m.page_name ? ` · ${m.page_name} (${m.page_id})` : m.page_id ? ` · page ${m.page_id}` : ""} · agent ${yn(m.use_agent)}${m.connected_at ? ` · since ${when(m.connected_at)}` : ""}${m.token_expires_at ? ` · token expires ${when(m.token_expires_at)}` : ""}${m.disconnected_at ? ` · disconnected ${when(m.disconnected_at)} (${m.disconnect_reason ?? "?"})` : ""}`);
|
|
171
|
+
const i = c.instagram;
|
|
172
|
+
console.log(` instagram ${i.connected ? "connected" : "not connected"} · via ${i.provider}${i.username ? ` · @${i.username}` : ""}${i.account_id ? ` (${i.account_id})` : ""}${i.login_method ? ` · ${i.login_method}` : ""} · agent ${yn(i.use_agent)}${i.connected_at ? ` · since ${when(i.connected_at)}` : ""}${i.token_expires_at ? ` · token expires ${when(i.token_expires_at)}` : ""}${i.disconnected_at ? ` · disconnected ${when(i.disconnected_at)} (${i.disconnect_reason ?? "?"})` : ""}`);
|
|
173
|
+
const t = c.tiktok;
|
|
174
|
+
console.log(` tiktok ${t.connected ? "connected" : "not connected"}${t.username ? ` · @${t.username}` : ""} · agent ${yn(t.use_agent)}${t.connected_at ? ` · since ${when(t.connected_at)}` : ""}${t.disconnected_at ? ` · disconnected ${when(t.disconnected_at)} (${t.disconnect_reason ?? "?"})` : ""}`);
|
|
175
|
+
const e = c.email;
|
|
176
|
+
console.log(` email ${e.provider ? `${e.provider}, ${e.connected ? "connected" : "not connected"}` : "no provider"}${e.mailbox ? ` · ${e.mailbox}` : ""} · agent ${yn(e.use_agent)}${e.connected_at ? ` · since ${when(e.connected_at)}` : ""}`);
|
|
177
|
+
console.log(` meta ads ${c.meta_ads.connected ? `connected${c.meta_ads.account_id ? ` · ${c.meta_ads.account_id}` : ""}` : "not connected"}`);
|
|
178
|
+
if (c.zernio_profile_id) console.log(` zernio profile ${c.zernio_profile_id}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function channelsCensus(opts) {
|
|
182
|
+
let resp;
|
|
183
|
+
try { resp = await http.get("org", { census: "channels", all: opts.inactive ? "1" : undefined }); } catch (e) { fail("Census failed", e); }
|
|
184
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
185
|
+
console.log(`${resp.count} org(s): whatsapp ${Object.entries(resp.providers).map(([k, v]) => `${k} ${v}`).join(", ")} · messenger ${resp.messenger_connected} · instagram ${resp.instagram_connected} · tiktok ${resp.tiktok_connected} · email ${resp.email_connected} · meta ads ${resp.meta_ads_connected}`);
|
|
186
|
+
console.log("");
|
|
187
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
188
|
+
console.log(` ${pad("ORG", 30)} ${pad("WHATSAPP", 12)} ${pad("SWITCH", 7)} ${pad("MESSENGER", 10)} ${pad("INSTAGRAM", 10)} ${pad("TIKTOK", 7)} ${pad("EMAIL", 8)} ADS`);
|
|
189
|
+
for (const o of resp.orgs) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(`${o.whatsapp}${o.whatsapp_connected ? "" : " (off)"}`, 12)} ${pad(o.master_switch ? "on" : "OFF", 7)} ${pad(o.messenger ?? "—", 10)} ${pad(o.instagram ?? "—", 10)} ${pad(o.tiktok ? "yes" : "—", 7)} ${pad(o.email ?? "—", 8)} ${o.meta_ads ? "yes" : "—"}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function whatsapp(orgId, opts = {}) {
|
|
193
|
+
if (opts.all) {
|
|
194
|
+
let resp;
|
|
195
|
+
try { resp = await http.get("org", { census: "whatsapp", all: opts.inactive ? "1" : undefined }); } catch (e) { fail("Census failed", e); }
|
|
196
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
197
|
+
console.log(`${resp.checked} Meta number(s) checked at Meta, ${resp.unhealthy} with a problem (${resp.skipped_not_meta_or_unconnected} org(s) skipped: not Meta or not connected)`);
|
|
198
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
199
|
+
console.log("");
|
|
200
|
+
console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("TIER", 12)} PROBLEMS`);
|
|
201
|
+
for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "?", 12)} ${(o.problems || []).join("; ")}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const resp = await view(orgId, "whatsapp");
|
|
205
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
206
|
+
const w = resp.whatsapp;
|
|
207
|
+
console.log(`${resp.organization_name} — WhatsApp number health (from Meta)`);
|
|
208
|
+
if (w.note && !w.phone) { console.log(` ${w.note}`); return; }
|
|
209
|
+
const p = w.phone || {};
|
|
210
|
+
console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
|
|
211
|
+
console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
|
|
212
|
+
console.log(` quality: ${p.quality_rating ?? "?"} · limit tier ${p.messaging_limit_tier ?? "?"}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
|
|
213
|
+
console.log(` name: ${p.name_status ?? "?"}${p.new_name_status && p.new_name_status !== "NONE" ? ` · pending "${p.new_display_name}" (${p.new_name_status})` : ""} · OBA ${yn(p.is_official_business_account)} · code verification ${p.code_verification_status ?? "?"}`);
|
|
214
|
+
console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
|
|
215
|
+
if (w.waba && !w.waba.error) console.log(` waba: ${w.waba.name ?? ""} · review ${w.waba.account_review_status ?? "?"} · ${w.waba.ownership_type ?? ""}`);
|
|
216
|
+
console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function health(orgId, opts = {}) {
|
|
220
|
+
const resp = await view(orgId, "health");
|
|
221
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
222
|
+
const h = resp.health;
|
|
223
|
+
console.log(`${resp.organization_name} — is the agent ready? ${h.ready ? "✓ yes" : `✗ ${h.problems.length} problem(s)`}`);
|
|
224
|
+
for (const p of h.problems) console.log(` ✗ ${p}`);
|
|
225
|
+
for (const w of h.warnings || []) console.log(` ⚠ ${w}`);
|
|
226
|
+
console.log(` switch: master ${h.master_switch ? "ON" : "OFF"} · vert.active ${yn(h.vert_active)}${h.allowed_whatsapp_ids ? ` (only ${h.allowed_whatsapp_ids})` : ""} · provider ${h.provider}${h.coexistence ? ` · ${h.coexistence}` : ""}${h.phone_number ? ` · ${h.phone_number}` : ""}`);
|
|
227
|
+
const spare = h.keys.openai_spare;
|
|
228
|
+
console.log(` keys: OpenAI ${h.keys.openai.state}${h.keys.openai.detail ? ` (${h.keys.openai.detail})` : ""}${spare ? ` · spare ${spare.state}${spare.detail ? ` (${spare.detail})` : ""}` : ""} · Claude ${h.keys.anthropic_present ? "present" : "none"}`);
|
|
229
|
+
console.log(` agent: ${h.agent ? `${h.agent.name} · ${h.agent.prompt_chars.toLocaleString()} prompt chars · prompt ${h.agent.use_settings_prompt === false ? "OFF" : "on"} · ${h.agent.model ?? "house model"} · product_lookup ${yn(h.agent.product_lookup)} · tickets ${yn(h.agent.ticket_tool_status)}` : "NONE"}`);
|
|
230
|
+
console.log(` catalogue: ${h.catalogue.products} product rows · ${h.catalogue.embedded} with embeddings${h.catalogue.last_sync ? ` · last sync ${when(h.catalogue.last_sync.created_at)} ${h.catalogue.last_sync.success === false ? "FAILED" : "ok"} (+${h.catalogue.last_sync.added ?? 0} ~${h.catalogue.last_sync.updated ?? 0} -${h.catalogue.last_sync.deleted ?? 0})` : " · no sync recorded"}`);
|
|
231
|
+
const s = h.store;
|
|
232
|
+
console.log(` store: ${s.platform ?? "none"}${s.shopify_domain ? ` ${s.shopify_domain}` : ""}${s.flowiq_webhooks != null ? ` · ${s.flowiq_webhooks} FlowIQ webhooks` : ""}${s.webhooks_note ? ` · ${s.webhooks_note}` : ""}`);
|
|
233
|
+
if (s.newest_synced_order || s.store_newest_order) console.log(` newest synced order ${s.newest_synced_order ? `#${s.newest_synced_order.order_number} ${when(s.newest_synced_order.created_at)}` : "none"} · store newest ${s.store_newest_order && !s.store_newest_order.error ? `${s.store_newest_order.name} ${when(s.store_newest_order.created_at)}` : s.store_newest_order?.error ? `read failed (${s.store_newest_order.error})` : "—"}`);
|
|
234
|
+
console.log(` traffic: ${h.traffic.contacts} contacts (${h.traffic.bot_on} with AI on) · inbound 24h ${h.traffic.inbound_24h} · 7d ${h.traffic.inbound_7d} · last bot reply ${when(h.traffic.last_bot_reply_at)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function automations(orgId, opts = {}) {
|
|
238
|
+
requireOrg(orgId);
|
|
239
|
+
if (opts.enable || opts.disable) {
|
|
240
|
+
const type = opts.enable || opts.disable;
|
|
241
|
+
let resp;
|
|
242
|
+
try { resp = await http.post("org", { action: "automations_set", organization_id: orgId, type: type === true ? undefined : type, enabled: !!opts.enable, dry_run: !opts.commit }); }
|
|
243
|
+
catch (e) { fail("Set failed", e); }
|
|
244
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
245
|
+
if (resp.unchanged) { console.log(`${resp.field} is already ${resp.value}.`); return; }
|
|
246
|
+
console.log(`${resp.dry_run ? "Would set" : "✓ set"} ${resp.field}: ${resp.from} → ${resp.to} on ${resp.organization_name}${resp.dry_run ? "\nDRY RUN — add --commit to write." : ""}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const resp = await view(orgId, "automations");
|
|
250
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
251
|
+
const a = resp.automations;
|
|
252
|
+
if (!a.configured) { console.log(`${resp.organization_name} has no automated_messages_config.`); return; }
|
|
253
|
+
console.log(`${resp.organization_name} — automated order messages: ${a.enabled ? "ON" : "OFF"} (${a.types.filter((t) => t.enabled).length} of ${a.types.length} types on)`);
|
|
254
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
255
|
+
for (const t of a.types) {
|
|
256
|
+
console.log(` ${t.enabled ? "ON " : "off"} ${pad(t.type, 26)} ${pad(t.template_name ?? "(no template)", 34)} ${pad(t.template_state ?? "", 14)} ${t.trigger_event ?? ""}${t.body_mapping ? ` · body ${JSON.stringify(t.body_mapping)}` : ""}`);
|
|
257
|
+
if (t.warning) console.log(` ⚠ ${t.warning}`);
|
|
258
|
+
}
|
|
259
|
+
console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function abandoned(orgId, opts = {}) {
|
|
263
|
+
requireOrg(orgId);
|
|
264
|
+
if (opts.enable !== undefined || opts.disable !== undefined) {
|
|
265
|
+
const seq = opts.enable ?? opts.disable;
|
|
266
|
+
let resp;
|
|
267
|
+
try { resp = await http.post("org", { action: "abandoned_set", organization_id: orgId, sequence: seq === true ? undefined : Number(seq), enabled: opts.enable !== undefined, dry_run: !opts.commit }); }
|
|
268
|
+
catch (e) { fail("Set failed", e); }
|
|
269
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
270
|
+
if (resp.unchanged) { console.log(`${resp.field} is already ${resp.value}.`); return; }
|
|
271
|
+
console.log(`${resp.dry_run ? "Would set" : "✓ set"} ${resp.field}: ${resp.from} → ${resp.to} on ${resp.organization_name}${resp.dry_run ? "\nDRY RUN — add --commit to write." : ""}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const resp = await view(orgId, "abandoned");
|
|
275
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
276
|
+
const a = resp.abandoned;
|
|
277
|
+
if (!a.configured) { console.log(`${resp.organization_name} has no abandoned_checkout_config (no recovery messages).`); return; }
|
|
278
|
+
console.log(`${resp.organization_name} — abandoned-cart recovery: ${a.enabled ? "ON" : "OFF"}${a.use_utm != null ? ` · utm ${yn(a.use_utm)}` : ""}`);
|
|
279
|
+
for (const s of a.sequences) {
|
|
280
|
+
console.log(` ${s.enabled ? "ON " : "off"} sequence ${s.sequence}: ${s.template_name ?? "(no template)"} (${s.template_state ?? "?"}) · after ${s.delay_minutes} min${s.cart_filter ? ` · ${s.cart_filter}` : ""}${s.include_discount ? ` · discount ${s.discount_code ?? (s.auto_create_discount ? "auto" : "?")}` : ""}${s.include_image ? " · image" : ""}${s.link_mode ? ` · ${s.link_mode}` : ""}`);
|
|
281
|
+
console.log(` last 30 days: ${s.last_30_days.sent} sent, ${s.last_30_days.not_sent} not sent`);
|
|
282
|
+
}
|
|
283
|
+
console.log(`\nflowiq org abandoned ${orgId} --enable <n> | --disable <n> [--commit] (the whole feature: --enable/--disable with no number)`);
|
|
284
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `flowiq products status|search|reconcile <org>` — the synced catalogue as
|
|
2
|
+
// the agent sees it (server: api/cli/products.js).
|
|
3
|
+
|
|
4
|
+
import { http } from "../http.js";
|
|
5
|
+
import { fmtSast } from "./audit.js";
|
|
6
|
+
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
9
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
10
|
+
const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
|
|
11
|
+
|
|
12
|
+
export async function status(orgId, opts = {}) {
|
|
13
|
+
requireOrg(orgId);
|
|
14
|
+
let resp;
|
|
15
|
+
try { resp = await http.get("products", { organization_id: orgId, status: "1" }); } catch (e) { fail("Status failed", e); }
|
|
16
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
17
|
+
const c = resp.counts;
|
|
18
|
+
console.log(`${resp.organization_name} — catalogue (${resp.platform ?? "no store"})${resp.sync_inactive_products ? " · sync_inactive_products on" : ""}`);
|
|
19
|
+
console.log(` rows: ${c.total} total · ${c.active} active · ${c.archived} archived · ${c.draft} draft${c.other ? ` · ${c.other} other` : ""}`);
|
|
20
|
+
console.log(` embeddings: ${c.embedded} with · ${c.missing_embedding} MISSING${c.missing_embedding ? " ⚠ the agent cannot find these by meaning (products reconcile re-embeds)" : ""}`);
|
|
21
|
+
console.log(` images: ${c.with_image} with an image · prices: ${c.with_price} with a price · ${c.zero_stock} at zero stock`);
|
|
22
|
+
console.log(` newest row: ${resp.newest_row_updated_at ? `${fmtSast(resp.newest_row_updated_at)} SAST` : "—"}`);
|
|
23
|
+
if (resp.last_syncs.length) {
|
|
24
|
+
console.log(` last syncs:`);
|
|
25
|
+
for (const s of resp.last_syncs) console.log(` ${fmtSast(s.created_at)} SAST ${(s.success ?? s.successful) === false ? "FAILED" : "ok "} +${s.added ?? 0} ~${s.updated ?? 0} -${s.deleted ?? 0}${s.source ? ` ${s.source}` : ""}${s.summary ? ` ${String(s.summary).slice(0, 70)}` : ""}`);
|
|
26
|
+
} else console.log(` last syncs: none recorded`);
|
|
27
|
+
if (resp.missing_embedding_sample.length) {
|
|
28
|
+
console.log(` missing embedding (sample):`);
|
|
29
|
+
for (const p of resp.missing_embedding_sample) console.log(` ${pad(p.variant_id, 16)} ${pad(p.status ?? "", 9)} ${String(p.product_title || "").slice(0, 60)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function search(orgId, textParts, opts = {}) {
|
|
34
|
+
requireOrg(orgId);
|
|
35
|
+
const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
|
|
36
|
+
if (text.length < 2) { console.error("Error: give at least 2 characters, e.g. flowiq products search <org> whey protein"); process.exit(1); }
|
|
37
|
+
let resp;
|
|
38
|
+
try { resp = await http.get("products", { organization_id: orgId, search: text, limit: opts.limit, min_similarity: opts.minSimilarity }); } catch (e) { fail("Search failed", e); }
|
|
39
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
40
|
+
console.log(`${resp.organization_name}: ${resp.count} match(es) for "${text}" via ${resp.rpc} (min similarity ${resp.min_similarity}) — what the agent's product_lookup tool sees`);
|
|
41
|
+
for (const p of resp.results) {
|
|
42
|
+
const sim = p.similarity ?? p.score ?? p.sim;
|
|
43
|
+
const stock = p.stockcount && typeof p.stockcount === "object" ? Object.values(p.stockcount).reduce((a, b) => a + (Number(b) || 0), 0) : p.stockcount;
|
|
44
|
+
console.log(` ${pad(sim != null ? Number(sim).toFixed(2) : "", 5)} ${pad(p.variant_id ?? "", 16)} ${pad(p.price != null ? String(p.price) : "", 9)} ${pad(stock != null ? `stock ${stock}` : "", 11)} ${pad(p.status ?? "", 8)} ${String(p.product_title ?? p.title ?? "").slice(0, 70)}`);
|
|
45
|
+
}
|
|
46
|
+
if (!resp.count) console.log(` (nothing within similarity ${resp.min_similarity}; try --min-similarity 0.05, or check embeddings with: flowiq products status ${orgId})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function reconcile(orgId, opts = {}) {
|
|
50
|
+
requireOrg(orgId);
|
|
51
|
+
let resp;
|
|
52
|
+
try { resp = await http.post("products", { action: "reconcile", organization_id: orgId, platform: opts.platform, confirm: !!opts.confirm }); } catch (e) { fail("Reconcile failed", e); }
|
|
53
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
54
|
+
if (resp.dry_run) { console.log(`Would trigger a ${resp.platform} reconcile for ${resp.organization_name} (${resp.note}). Add --confirm.`); return; }
|
|
55
|
+
console.log(`${resp.ok ? "✓" : "✗"} products-reconcile-scheduler answered ${resp.http_status} for ${resp.organization_name} (${resp.platform})`);
|
|
56
|
+
console.log(` ${JSON.stringify(resp.response).slice(0, 600)}`);
|
|
57
|
+
console.log(`\n Watch it land: flowiq products status ${orgId}`);
|
|
58
|
+
}
|
package/src/commands/prompts.js
CHANGED
|
@@ -96,7 +96,34 @@ export function mintSectionIds(sections) {
|
|
|
96
96
|
return touched;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
// --dry-run: what a push WOULD change, section by section, against the live
|
|
100
|
+
// agent, without writing. Compares by section id (title for sections that
|
|
101
|
+
// have no id yet) and reports added / removed / changed sections with the
|
|
102
|
+
// character delta, plus title / hidden / channels / window changes.
|
|
103
|
+
export function diffSections(live, local) {
|
|
104
|
+
const keyOf = (s) => s.id || `title:${s.title}`;
|
|
105
|
+
const liveBy = new Map((live || []).map((s) => [keyOf(s), s]));
|
|
106
|
+
const localBy = new Map((local || []).map((s) => [keyOf(s), s]));
|
|
107
|
+
const added = [], removed = [], changed = [];
|
|
108
|
+
for (const [k, s] of localBy) if (!liveBy.has(k)) added.push({ title: s.title, chars: (s.content || "").length });
|
|
109
|
+
for (const [k, s] of liveBy) if (!localBy.has(k)) removed.push({ title: s.title, chars: (s.content || "").length });
|
|
110
|
+
for (const [k, s] of localBy) {
|
|
111
|
+
const l = liveBy.get(k);
|
|
112
|
+
if (!l) continue;
|
|
113
|
+
const what = [];
|
|
114
|
+
if ((l.content || "") !== (s.content || "")) what.push(`content ${(l.content || "").length} → ${(s.content || "").length} chars`);
|
|
115
|
+
if ((l.title || "") !== (s.title || "")) what.push(`title "${l.title}" → "${s.title}"`);
|
|
116
|
+
if (!!l.hidden !== !!s.hidden) what.push(`hidden ${!!l.hidden} → ${!!s.hidden}`);
|
|
117
|
+
if (JSON.stringify(l.channels ?? null) !== JSON.stringify(s.channels ?? null)) what.push(`channels ${JSON.stringify(l.channels ?? "all")} → ${JSON.stringify(s.channels ?? "all")}`);
|
|
118
|
+
for (const f of ["active_from", "active_until", "active_tz"]) if ((l[f] ?? null) !== (s[f] ?? null)) what.push(`${f} ${l[f] ?? "unset"} → ${s[f] ?? "unset"}`);
|
|
119
|
+
if (what.length) changed.push({ title: s.title, what });
|
|
120
|
+
}
|
|
121
|
+
const order = (list) => list.map(keyOf).join("|");
|
|
122
|
+
const reordered = !added.length && !removed.length && order(live || []) !== order(local || []);
|
|
123
|
+
return { added, removed, changed, reordered, unchanged: !added.length && !removed.length && !changed.length && !reordered };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function push(identifier, opts = {}) {
|
|
100
127
|
let filePath;
|
|
101
128
|
try {
|
|
102
129
|
filePath = await resolvePushPath(identifier);
|
|
@@ -121,6 +148,24 @@ export async function push(identifier) {
|
|
|
121
148
|
process.exit(1);
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
if (opts.dryRun) {
|
|
152
|
+
let live;
|
|
153
|
+
try { live = await http.get("prompts", { organization_id: data.organization_id, agent_id: data.agent_id }); }
|
|
154
|
+
catch (e) { console.error(`Dry run failed (could not read the live prompt): ${e.message}`); process.exit(1); }
|
|
155
|
+
const d = diffSections(live.prompt_sections, data.prompt_sections);
|
|
156
|
+
const liveChars = (live.prompt_sections || []).reduce((a, s) => a + (s.content || "").length, 0);
|
|
157
|
+
const localChars = data.prompt_sections.reduce((a, s) => a + (s.content || "").length, 0);
|
|
158
|
+
console.log(`DRY RUN — ${filePath} against ${live.agent_name} (${live.agent_id}) on ${live.organization_name}`);
|
|
159
|
+
console.log(` live: ${(live.prompt_sections || []).length} sections, ${liveChars.toLocaleString()} chars · file: ${data.prompt_sections.length} sections, ${localChars.toLocaleString()} chars`);
|
|
160
|
+
if (d.unchanged) { console.log(" no differences — a push would change nothing."); return; }
|
|
161
|
+
for (const a of d.added) console.log(` + add "${a.title}" (${a.chars} chars)`);
|
|
162
|
+
for (const r of d.removed) console.log(` - REMOVE "${r.title}" (${r.chars} chars)`);
|
|
163
|
+
for (const c of d.changed) console.log(` ~ change "${c.title}": ${c.what.join("; ")}`);
|
|
164
|
+
if (d.reordered) console.log(" ~ sections reordered");
|
|
165
|
+
console.log(`\nNothing written. Push for real: flowiq prompts push ${identifier}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
124
169
|
// A section added by hand has no id yet. Mint one and write it back to the
|
|
125
170
|
// file BEFORE pushing, so a later pull or push keeps the same id.
|
|
126
171
|
const minted = mintSectionIds(data.prompt_sections);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `flowiq rules list|show|results|enable|disable` — the Automations rules
|
|
2
|
+
// engine, custom_notification_rules (server: api/cli/rules.js).
|
|
3
|
+
|
|
4
|
+
import { http } from "../http.js";
|
|
5
|
+
import { fmtSast } from "./audit.js";
|
|
6
|
+
|
|
7
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
9
|
+
function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); if (e.body?.needs_confirm) console.error(" Re-run with --confirm."); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
export async function list(orgId, opts = {}) {
|
|
12
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
13
|
+
let resp;
|
|
14
|
+
try { resp = await http.get("rules", { organization_id: orgId, trigger: opts.trigger }); } catch (e) { fail("List failed", e); }
|
|
15
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
16
|
+
if (!resp.count) { console.log(`${resp.organization_name} has no automation rules${opts.trigger ? ` with trigger ${opts.trigger}` : ""}.`); return; }
|
|
17
|
+
console.log(`${resp.organization_name}: ${resp.count} rule(s), ${resp.enabled} enabled`);
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log(` ${pad("", 3)} ${pad("NAME", 32)} ${pad("TRIGGER", 34)} ${pad("SENDS", 30)} WHEN`);
|
|
20
|
+
for (const r of resp.rules) {
|
|
21
|
+
console.log(` ${r.enabled ? "ON " : "off"} ${pad(r.name, 32)} ${pad(`${r.trigger_type}=${r.trigger_value ?? ""}`, 34)} ${pad(r.template_name ?? r.action_type, 30)} ${r.send_at}${r.conditions.length ? ` · conditions: ${r.conditions.join(", ")}` : ""}`);
|
|
22
|
+
console.log(` ${r.id}`);
|
|
23
|
+
}
|
|
24
|
+
console.log(`\nflowiq rules show <rule_id> · rules results <rule_id> --since 30d`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function show(ruleId, opts = {}) {
|
|
28
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
29
|
+
let resp;
|
|
30
|
+
try { resp = await http.get("rules", { rule_id: ruleId }); } catch (e) { fail("Read failed", e); }
|
|
31
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
32
|
+
const r = resp.rule;
|
|
33
|
+
console.log(`${r.name} ${r.id} on ${resp.organization_name}: ${r.enabled ? "ENABLED" : "disabled"}`);
|
|
34
|
+
console.log(` trigger: ${r.trigger_type} = ${r.trigger_value ?? "(any)"}`);
|
|
35
|
+
console.log(` action: ${r.action_type}${r.template_name ? ` · template ${r.template_name}` : ""}`);
|
|
36
|
+
console.log(` send at: ${r.send_at}`);
|
|
37
|
+
if (r.body_parameters) console.log(` body: ${JSON.stringify(r.body_parameters)}`);
|
|
38
|
+
if (r.button_parameters) console.log(` buttons: ${JSON.stringify(r.button_parameters)}`);
|
|
39
|
+
if (r.conditions_raw) console.log(` conditions: ${JSON.stringify(r.conditions_raw)}`);
|
|
40
|
+
if (r.action_config) console.log(` config: ${JSON.stringify(r.action_config)}`);
|
|
41
|
+
console.log(` priority: ${r.priority} · created ${fmtSast(r.created_at)} SAST`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function results(ruleId, opts = {}) {
|
|
45
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
46
|
+
let resp;
|
|
47
|
+
try { resp = await http.get("rules", { rule_id: ruleId, results: "1", since: opts.since }); } catch (e) { fail("Results failed", e); }
|
|
48
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
49
|
+
console.log(`${resp.rule.name} (${resp.organization_name}): ${resp.queued} queued send(s) since ${fmtSast(resp.since)} SAST${resp.capped ? " (capped)" : ""}`);
|
|
50
|
+
console.log(` by status: ${Object.entries(resp.by_status).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
|
|
51
|
+
for (const e of resp.error_rollup) console.log(` ${String(e.n).padStart(5)} × ${e.message}`);
|
|
52
|
+
if (resp.recent.length) {
|
|
53
|
+
console.log("\n recent:");
|
|
54
|
+
for (const q of resp.recent) console.log(` ${fmtSast(q.scheduled_for)} ${pad(q.status, 10)} ${q.template ?? ""} → ${(q.contact_ids || []).join(",")}${q.error_message ? ` ${String(q.error_message).slice(0, 80)}` : ""}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function toggle(ruleId, opts, action) {
|
|
59
|
+
if (!UUID_RE.test(ruleId || "")) { console.error(`Error: "${ruleId}" is not a valid rule UUID.`); process.exit(1); }
|
|
60
|
+
let resp;
|
|
61
|
+
try { resp = await http.post("rules", { action, rule_id: ruleId, confirm: !!opts.confirm }); } catch (e) { fail(`${action} failed`, e); }
|
|
62
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
63
|
+
if (resp.unchanged) { console.log(`Rule is already ${resp.enabled ? "enabled" : "disabled"}.`); return; }
|
|
64
|
+
console.log(`✓ "${resp.name}" ${resp.enabled ? "ENABLED" : "disabled"} (was ${resp.was ? "enabled" : "disabled"}).`);
|
|
65
|
+
}
|
|
66
|
+
export const enable = (id, opts) => toggle(id, opts, "enable");
|
|
67
|
+
export const disable = (id, opts) => toggle(id, opts, "disable");
|
package/src/commands/segments.js
CHANGED
|
@@ -288,7 +288,20 @@ export async function plan(orgId, opts = {}) {
|
|
|
288
288
|
console.log(`⚠ could not check existing batch tags (${e.message}) — verify with \`seg list --prefix ${prefix}\` before apply.`);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
// A continuation (--start-index N) is a NEW plan, not a rewrite of the first
|
|
292
|
+
// one: the earlier file is the record of who got the earlier batches, so it
|
|
293
|
+
// is never overwritten. The continuation lands in <slug>-from-NN.json
|
|
294
|
+
// unless --segment names it.
|
|
295
|
+
const baseSlug = slugify(opts.segment || prefix, prefix);
|
|
296
|
+
const slug = !opts.segment && startIndex > 1 ? `${baseSlug}-from-${String(startIndex).padStart(2, "0")}` : baseSlug;
|
|
297
|
+
if (await fs.access(planPath(slug)).then(() => true).catch(() => false)) {
|
|
298
|
+
const prev = JSON.parse(await fs.readFile(planPath(slug), "utf8").catch(() => "{}"));
|
|
299
|
+
if (prev.applied) {
|
|
300
|
+
console.error(`Error: ${planPath(slug)} already exists AND was applied on ${prev.applied.at} — it is the record of who got those batches. Pass --segment <new-name> to write a different file.`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
console.log(`⚠ overwriting ${planPath(slug)} (never applied)`);
|
|
304
|
+
}
|
|
292
305
|
const planDoc = {
|
|
293
306
|
schema_version: 1,
|
|
294
307
|
segment: slug,
|
|
@@ -373,24 +386,44 @@ export async function apply(orgId, identifier, opts = {}) {
|
|
|
373
386
|
if (a !== planDoc.segment) { console.log("Mismatch — aborted, nothing tagged."); process.exit(0); }
|
|
374
387
|
}
|
|
375
388
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
389
|
+
// One HTTP request per chunk of ≤ APPLY_CHUNK ids. The server already splits
|
|
390
|
+
// its DB calls at 8,000 rows, but a big cohort in ONE request still ran past
|
|
391
|
+
// the function timeout (Zero BS 18,614 on 30 Sep 2026: two timeouts, one
|
|
392
|
+
// batch half-tagged). Smaller requests finish, and a failure names the chunk
|
|
393
|
+
// so a re-run (idempotent) picks up where it stopped.
|
|
394
|
+
const APPLY_CHUNK = 4000;
|
|
395
|
+
const chunks = [];
|
|
396
|
+
for (const b of planDoc.batches) {
|
|
397
|
+
for (let i = 0; i < b.contact_ids.length; i += APPLY_CHUNK) chunks.push({ tag: b.tag, contact_ids: b.contact_ids.slice(i, i + APPLY_CHUNK) });
|
|
398
|
+
}
|
|
399
|
+
const byTag = new Map();
|
|
400
|
+
for (const [ci, chunk] of chunks.entries()) {
|
|
401
|
+
let resp;
|
|
402
|
+
try {
|
|
403
|
+
resp = await http.post("segments", { mode: "apply", organization_id: orgId, batches: [chunk] });
|
|
404
|
+
} catch (e) {
|
|
405
|
+
const done = [...byTag.values()].reduce((a, r) => a + r.tagged, 0);
|
|
406
|
+
console.error(`Apply failed on request ${ci + 1}/${chunks.length} (${chunk.tag}, ${chunk.contact_ids.length} ids): ${e.message}`);
|
|
407
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
408
|
+
console.error(` ${done} contact(s) tagged by the earlier requests. Re-run the same apply: it is idempotent and only the missing ones get tagged.`);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
for (const r of resp.results || []) {
|
|
412
|
+
const prev = byTag.get(r.tag) ?? { tag: r.tag, tagged: 0, already_had: 0, not_found: 0 };
|
|
413
|
+
prev.tagged += r.tagged ?? 0; prev.already_had += r.already_had ?? 0; prev.not_found += r.not_found ?? 0;
|
|
414
|
+
byTag.set(r.tag, prev);
|
|
415
|
+
}
|
|
416
|
+
if (chunks.length > 1) console.log(` request ${ci + 1}/${chunks.length}: ${chunk.tag} ${chunk.contact_ids.length} ids → tagged ${(resp.results || []).reduce((a, r) => a + (r.tagged ?? 0), 0)}`);
|
|
386
417
|
}
|
|
418
|
+
const results = [...byTag.values()];
|
|
419
|
+
const totals = results.reduce((a, r) => ({ tagged: a.tagged + r.tagged, already_had: a.already_had + r.already_had, not_found: a.not_found + r.not_found }), { tagged: 0, already_had: 0, not_found: 0 });
|
|
387
420
|
|
|
388
|
-
for (const r of
|
|
421
|
+
for (const r of results) {
|
|
389
422
|
console.log(` ${r.tag.padEnd(40)} tagged ${r.tagged}${r.already_had ? ` · already_had ${r.already_had}` : ""}${r.not_found ? ` · not_found ${r.not_found}` : ""}`);
|
|
390
423
|
}
|
|
391
|
-
console.log(`Totals: tagged ${
|
|
424
|
+
console.log(`Totals: tagged ${totals.tagged} · already_had ${totals.already_had} · not_found ${totals.not_found}`);
|
|
392
425
|
|
|
393
|
-
planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries(
|
|
426
|
+
planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries(results.map((r) => [r.tag, r.tagged + r.already_had])) };
|
|
394
427
|
await fs.writeFile(filePath, JSON.stringify(planDoc, null, 2) + "\n", "utf8");
|
|
395
428
|
console.log("");
|
|
396
429
|
console.log(`Send per tag, one batch at a time: flowiq broadcast send ${orgId} --tag ${planDoc.batches[0].tag} --template <name> …`);
|