@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
package/src/commands/tag.js
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
// flowiq tag segment <org> --min-orders 2 --min-spent 1000 --region ZA-GP --tag gp-repeat
|
|
9
9
|
// flowiq tag attributes <org> --filter allow_broadcast_true --tag broadcastable
|
|
10
10
|
// flowiq tag messages <org> --min-count 3 --sender user-whatsapp --tag engaged
|
|
11
|
+
// flowiq tag ids <org> --ids a,b,c | --ids-file ids.txt --tag <name> [--commit]
|
|
12
|
+
// flowiq tag holders <org> <tag> [--limit 200] [--all] [--csv]
|
|
13
|
+
// flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact --tag x --commit
|
|
11
14
|
// flowiq tag list <org>
|
|
12
|
-
// flowiq tag remove <org> <tag>[,<tag>...] --confirm
|
|
15
|
+
// flowiq tag remove <org> <tag>[,<tag>...] --confirm (every contact)
|
|
16
|
+
// flowiq tag remove <org> <tag> --contacts a,b | --contacts-file f --confirm (those only)
|
|
13
17
|
//
|
|
14
18
|
// Every match mode prints the dry-run count + a sample; nothing writes until
|
|
15
19
|
// --commit AND (for large writes) a typed confirmation. Appends only — it
|
|
@@ -91,12 +95,76 @@ export async function field(orgId, opts = {}) {
|
|
|
91
95
|
requireOrg(orgId);
|
|
92
96
|
const keywords = splitList(opts.any || opts.all);
|
|
93
97
|
if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
|
|
98
|
+
if (opts.exact && opts.all) { console.error("Error: --exact matches whole values, so it is an OR over --any (drop --all)."); process.exit(1); }
|
|
94
99
|
const payload = {
|
|
95
100
|
mode: "field", organization_id: orgId, field_name: opts.field || "order_history",
|
|
96
|
-
keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive,
|
|
101
|
+
keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact: !!opts.exact,
|
|
97
102
|
};
|
|
98
103
|
await confirmLargeWrite(orgId, payload, opts, "Field search");
|
|
99
|
-
await runMatch("Field search", payload, opts);
|
|
104
|
+
await runMatch(opts.exact ? "Exact field match" : "Field search", payload, opts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readIds(csv, file) {
|
|
108
|
+
const fs = await import("node:fs/promises");
|
|
109
|
+
let ids = splitList(csv) || [];
|
|
110
|
+
if (file) {
|
|
111
|
+
const text = await fs.readFile(file, "utf8").catch((e) => { console.error(`Error: cannot read ${file}: ${e.message}`); process.exit(1); });
|
|
112
|
+
ids.push(...text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
|
|
113
|
+
}
|
|
114
|
+
ids = [...new Set(ids)];
|
|
115
|
+
const bad = ids.filter((id) => !UUID_RE.test(id));
|
|
116
|
+
if (bad.length) console.error(`⚠ ${bad.length} value(s) are not contact UUIDs and will be ignored (e.g. ${bad[0]})`);
|
|
117
|
+
return ids.filter((id) => UUID_RE.test(id));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `flowiq tag ids <org> --ids a,b | --ids-file f --tag <name> [--commit]`
|
|
121
|
+
export async function ids(orgId, opts = {}) {
|
|
122
|
+
requireOrg(orgId);
|
|
123
|
+
const list = await readIds(opts.ids, opts.idsFile);
|
|
124
|
+
if (!list.length) { console.error("Error: pass --ids a,b,c or --ids-file <path> (contact UUIDs, one per line or comma-separated)."); process.exit(1); }
|
|
125
|
+
const payload = { mode: "ids", organization_id: orgId, contact_ids: list };
|
|
126
|
+
await confirmLargeWrite(orgId, payload, opts, "Id list");
|
|
127
|
+
const commit = !!opts.commit;
|
|
128
|
+
if (commit && !opts.tag) { console.error("Error: --tag <name> is required with --commit."); process.exit(1); }
|
|
129
|
+
let resp;
|
|
130
|
+
try { resp = await http.post("tag", { ...payload, dry_run: !commit, tag: commit ? opts.tag.trim() : undefined }); }
|
|
131
|
+
catch (e) { console.error(`Tag ids failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
132
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
133
|
+
if (!commit) {
|
|
134
|
+
console.log(`Id list: ${resp.matched} of ${list.length} id(s) belong to ${resp.organization_name}${resp.not_found ? ` · ${resp.not_found} not in this org (ignored)` : ""}.`);
|
|
135
|
+
renderSample(resp.sample);
|
|
136
|
+
console.log(`\nAdd --tag <name> --commit to apply. (dry run — nothing written)`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
console.log(`Id list: matched ${resp.matched} · newly tagged ${resp.tagged} · already had "${resp.tag}" ${resp.already_had}${resp.not_found ? ` · not in org ${resp.not_found}` : ""}.`);
|
|
140
|
+
console.log(`\nVerify: flowiq tag holders ${orgId} ${resp.tag} · Undo: flowiq tag remove ${orgId} ${resp.tag} --contacts-file <the same file> --confirm`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// `flowiq tag holders <org> <tag> [--limit] [--all] [--csv]` — who carries a tag.
|
|
144
|
+
export async function holders(orgId, tagName, opts = {}) {
|
|
145
|
+
requireOrg(orgId);
|
|
146
|
+
if (!tagName) { console.error("Error: pass the tag name, e.g. flowiq tag holders <org> vip"); process.exit(1); }
|
|
147
|
+
const pageSize = opts.all ? 1000 : Math.min(Number(opts.limit) || 200, 1000);
|
|
148
|
+
const contacts = [];
|
|
149
|
+
let offset = 0, resp;
|
|
150
|
+
for (;;) {
|
|
151
|
+
try { resp = await http.post("tag", { mode: "holders", organization_id: orgId, tag: tagName, limit: pageSize, offset }); }
|
|
152
|
+
catch (e) { console.error(`Holders failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
153
|
+
contacts.push(...(resp.contacts || []));
|
|
154
|
+
if (!opts.all || !resp.has_more || !(resp.contacts || []).length) break;
|
|
155
|
+
offset += resp.contacts.length;
|
|
156
|
+
}
|
|
157
|
+
if (opts.json) { console.log(JSON.stringify({ ...resp, contacts }, null, 2)); return; }
|
|
158
|
+
if (opts.csv) {
|
|
159
|
+
console.log("id,full_name,whatsapp_id,email,allow_broadcast,archived,blocked");
|
|
160
|
+
for (const c of contacts) console.log([c.id, c.full_name, c.whatsapp_id, c.email, c.allow_broadcast, c.archived, c.blocked].map((v) => { const s = v == null ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }).join(","));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
console.log(`"${tagName}" on ${resp.organization_name}: ${resp.total} contact(s)${contacts.length < resp.total ? ` (showing ${contacts.length}; --all for every page, --csv to export)` : ""}`);
|
|
164
|
+
for (const c of contacts) {
|
|
165
|
+
const flags = [c.allow_broadcast === false && "no-broadcast", c.archived && "archived", c.blocked && "blocked", c.bot_status === false && "AI off"].filter(Boolean).join(", ");
|
|
166
|
+
console.log(` ${(c.full_name || "—").slice(0, 28).padEnd(28)} ${String(c.whatsapp_id || c.email || "").padEnd(16)} ${c.id}${flags ? ` (${flags})` : ""}`);
|
|
167
|
+
}
|
|
100
168
|
}
|
|
101
169
|
|
|
102
170
|
export async function cohort(orgId, opts = {}) {
|
|
@@ -170,12 +238,14 @@ export async function remove(orgId, tagArg, opts = {}) {
|
|
|
170
238
|
requireOrg(orgId);
|
|
171
239
|
const tags = splitList(tagArg);
|
|
172
240
|
if (!tags || !tags.length) { console.error("Error: pass one or more tag names (comma-separated)."); process.exit(1); }
|
|
241
|
+
const contactIds = opts.contacts || opts.contactsFile ? await readIds(opts.contacts, opts.contactsFile) : null;
|
|
242
|
+
if ((opts.contacts || opts.contactsFile) && !contactIds.length) { console.error("Error: --contacts / --contacts-file gave no contact UUIDs."); process.exit(1); }
|
|
173
243
|
if (!opts.confirm) {
|
|
174
|
-
console.log(`DRY RUN — would remove ${tags.length} tag(s) from
|
|
244
|
+
console.log(`DRY RUN — would remove ${tags.length} tag(s) from ${contactIds ? `${contactIds.length} listed contact(s)` : "ALL contacts"}: ${tags.join(", ")}. Add --confirm to remove.`);
|
|
175
245
|
return;
|
|
176
246
|
}
|
|
177
247
|
let resp;
|
|
178
|
-
try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags }); }
|
|
179
|
-
catch (e) { console.error(`Remove failed: ${e.message}`); process.exit(1); }
|
|
180
|
-
console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}.`);
|
|
248
|
+
try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags, ...(contactIds ? { contact_ids: contactIds } : {}) }); }
|
|
249
|
+
catch (e) { console.error(`Remove failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
|
|
250
|
+
console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}${contactIds ? ` (of ${contactIds.length} listed${resp.not_found ? `, ${resp.not_found} not in this org` : ""})` : ""}.`);
|
|
181
251
|
}
|
|
@@ -343,8 +343,37 @@ export async function show(orgId, name, opts = {}) {
|
|
|
343
343
|
|
|
344
344
|
const rows = resp.templates || [];
|
|
345
345
|
if (!rows.length) {
|
|
346
|
-
|
|
347
|
-
|
|
346
|
+
// Not in FlowIQ's table is not "does not exist": a template made in Meta's
|
|
347
|
+
// own tools or on a swapped WABA lives only at Meta (Health Matrix, 22 Sep
|
|
348
|
+
// 2026). Read it from Meta before giving up.
|
|
349
|
+
let live;
|
|
350
|
+
try { live = await http.get("templates", { organization_id: orgId, name }); } catch { live = null; }
|
|
351
|
+
const liveRows = (live?.templates || []).filter((t) => t.name === name) .concat((live?.templates || []).filter((t) => t.name !== name));
|
|
352
|
+
if (!liveRows.length) {
|
|
353
|
+
console.error(`No template on this org matching "${name}", in FlowIQ or at Meta. List them with: flowiq templates status ${orgId} --live`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
const t = liveRows[0];
|
|
357
|
+
if (liveRows.length > 1 && t.name !== name) {
|
|
358
|
+
console.error(`"${name}" matches ${liveRows.length} templates at Meta — name one exactly:`);
|
|
359
|
+
for (const r of liveRows) console.error(` ${r.name} [${r.status}] ${r.category}`);
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
if (opts.json) { console.log(JSON.stringify({ source: "meta", template: t }, null, 2)); return; }
|
|
363
|
+
console.log(`${t.name} [${t.status}] ${t.category}${t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : ""} · ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}`);
|
|
364
|
+
console.log(` org: ${orgId}`);
|
|
365
|
+
console.log(` source: META ONLY — not recorded in FlowIQ's templates table (made in Meta's tools or on another WABA), so it cannot be picked in the send dialog until it is pulled in`);
|
|
366
|
+
if (t.id) console.log(` meta id: ${t.id}`);
|
|
367
|
+
if (t.rejected_reason && t.rejected_reason !== "NONE") console.log(` rejected: ${t.rejected_reason}`);
|
|
368
|
+
for (const c of t.components || []) {
|
|
369
|
+
const label = String(c.type || "").toLowerCase().padEnd(9);
|
|
370
|
+
if (c.type === "BODY" || c.type === "FOOTER") console.log(` ${label} ${String(c.text || "").replace(/\n/g, "\n ")}`);
|
|
371
|
+
else if (c.type === "HEADER") console.log(` ${label} ${c.format}${c.text ? `: ${c.text}` : ""}`);
|
|
372
|
+
else if (c.type === "BUTTONS") for (const b of c.buttons || []) console.log(` button ${b.type}: ${b.text}${b.url ? ` → ${b.url}` : ""}${b.phone_number ? ` → ${b.phone_number}` : ""}`);
|
|
373
|
+
else if (c.type === "CAROUSEL") console.log(` carousel ${(c.cards || []).length} card(s)`);
|
|
374
|
+
}
|
|
375
|
+
console.log(`\n The full Meta record: flowiq templates pull ${orgId}`);
|
|
376
|
+
return;
|
|
348
377
|
}
|
|
349
378
|
// Prefer an exact name match; otherwise if the substring is ambiguous, say so.
|
|
350
379
|
let row = rows.find((r) => r.template_name === name);
|
|
@@ -444,22 +473,54 @@ export async function attempts(orgId, opts = {}) {
|
|
|
444
473
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
445
474
|
|
|
446
475
|
const rows = resp.attempts || [];
|
|
447
|
-
|
|
476
|
+
const logs = resp.status_logs || [];
|
|
477
|
+
if (!rows.length && !logs.length) {
|
|
448
478
|
console.log(opts.failed ? "(no failed submissions on record for this org)" : "(no template submissions on record for this org — the ledger started 21 Sep 2026)");
|
|
449
479
|
return;
|
|
450
480
|
}
|
|
451
|
-
|
|
481
|
+
// Meta's history per template, newest first: what happened AFTER submit.
|
|
482
|
+
// A category flip shows as the same status with a new category.
|
|
483
|
+
const historyOf = (name) => logs.filter((l) => l.template_name === name);
|
|
484
|
+
const describeHistory = (name, submittedCategory) => {
|
|
485
|
+
const h = historyOf(name).slice().reverse(); // oldest first
|
|
486
|
+
if (!h.length) return [];
|
|
487
|
+
const lines = [];
|
|
488
|
+
let lastCat = submittedCategory || null;
|
|
489
|
+
for (const l of h) {
|
|
490
|
+
const flipped = l.category && lastCat && l.category !== lastCat;
|
|
491
|
+
lines.push(`${sast(l.created_at)} ${l.status}${l.category ? ` / ${l.category}` : ""}${flipped ? ` ⚠ CATEGORY MOVED ${lastCat} → ${l.category}` : ""}${l.reason ? ` (${l.reason})` : ""}`);
|
|
492
|
+
if (l.category) lastCat = l.category;
|
|
493
|
+
}
|
|
494
|
+
return lines;
|
|
495
|
+
};
|
|
496
|
+
if (rows.length) console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
|
|
497
|
+
const named = new Set();
|
|
452
498
|
for (const a of rows) {
|
|
453
499
|
const mark = a.outcome === "submitted" ? "✓" : "✗";
|
|
454
500
|
const who = a.source ? ` via ${a.source}` : "";
|
|
455
501
|
console.log(`\n ${mark} ${sast(a.created_at)} ${a.template_name || "(unnamed)"}${a.category ? ` [${a.category}]` : ""}${who}`);
|
|
456
502
|
if (a.outcome === "submitted") {
|
|
457
503
|
console.log(` submitted to Meta — id ${a.meta_template_id || "?"}, status at submit ${a.meta_status || "?"}`);
|
|
504
|
+
const hist = describeHistory(a.template_name, a.category);
|
|
505
|
+
named.add(a.template_name);
|
|
506
|
+
if (hist.length) for (const line of hist) console.log(` then: ${line}`);
|
|
507
|
+
else console.log(` no status webhook from Meta recorded yet (still ${a.meta_status || "PENDING"} as far as FlowIQ knows: flowiq templates status ${orgId} --live --name ${a.template_name})`);
|
|
458
508
|
} else {
|
|
459
509
|
console.log(` FAILED (HTTP ${a.http_status ?? "?"}) ${a.error || ""}`);
|
|
460
510
|
if (a.message && a.message !== a.error) console.log(` ${a.message}`);
|
|
461
511
|
if (a.meta_error?.error_user_msg && a.meta_error.error_user_msg !== a.message) console.log(` Meta: ${a.meta_error.error_user_msg}`);
|
|
462
512
|
}
|
|
463
513
|
}
|
|
464
|
-
|
|
514
|
+
// Templates with Meta history but no submission row (submitted before the
|
|
515
|
+
// ledger existed, or from Meta's own tools): still worth showing when asked
|
|
516
|
+
// for by name, or when nothing else matched.
|
|
517
|
+
const orphan = [...new Set(logs.map((l) => l.template_name))].filter((n) => !named.has(n));
|
|
518
|
+
if (orphan.length && (opts.name || !rows.length)) {
|
|
519
|
+
console.log(`\nMeta status history with no submission row (older than the ledger, or submitted outside FlowIQ):`);
|
|
520
|
+
for (const n of orphan.slice(0, 20)) {
|
|
521
|
+
console.log(`\n ${n}`);
|
|
522
|
+
for (const line of describeHistory(n, null)) console.log(` ${line}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (rows.length) console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
|
|
465
526
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `flowiq tickets list|show|status` — escalation tickets (server: api/cli/tickets.js).
|
|
2
|
+
|
|
3
|
+
import { http } from "../http.js";
|
|
4
|
+
import { fmtSast } from "./audit.js";
|
|
5
|
+
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
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
|
+
|
|
10
|
+
export function parseOlderThan(v) {
|
|
11
|
+
if (v == null || v === "") return undefined;
|
|
12
|
+
const m = /^(\d+)([hdw])$/i.exec(String(v).trim());
|
|
13
|
+
if (!m) { console.error(`Error: --older-than takes 48h / 3d / 1w (got "${v}").`); process.exit(1); }
|
|
14
|
+
return Number(m[1]) * { h: 1, d: 24, w: 168 }[m[2].toLowerCase()];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function list(orgId, opts = {}) {
|
|
18
|
+
if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
19
|
+
let resp;
|
|
20
|
+
try { resp = await http.get("tickets", { organization_id: orgId, status: opts.status, contact_id: opts.contact, older_than_hours: parseOlderThan(opts.olderThan), limit: opts.limit }); }
|
|
21
|
+
catch (e) { fail("List failed", e); }
|
|
22
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
23
|
+
if (!resp.tickets.length) { console.log(`No ${resp.statuses.join("/")} ticket(s) on ${resp.organization_name}${opts.olderThan ? ` older than ${opts.olderThan}` : ""}.`); return; }
|
|
24
|
+
console.log(`${resp.organization_name}: ${resp.capped ? `${resp.tickets.length} of ${resp.total}` : resp.tickets.length} ${resp.statuses.join("/")} ticket(s), newest first`);
|
|
25
|
+
console.log("");
|
|
26
|
+
console.log(` ${pad("OPENED (SAST)", 17)} ${pad("AGE", 6)} ${pad("STATUS", 12)} ${pad("CONTACT", 26)} ${pad("TEAM", 14)} SUMMARY`);
|
|
27
|
+
for (const t of resp.tickets) {
|
|
28
|
+
const who = `${t.contact?.full_name ?? "?"}${t.contact?.whatsapp_id ? ` ${t.contact.whatsapp_id}` : ""}`.slice(0, 26);
|
|
29
|
+
console.log(` ${pad(fmtSast(t.created_at), 17)} ${pad(`${t.age_hours}h`, 6)} ${pad(t.status, 12)} ${pad(who, 26)} ${pad(t.team ?? "", 14)} ${String(t.summary ?? "").replace(/\s+/g, " ").slice(0, 70)}`);
|
|
30
|
+
console.log(` ${" ".repeat(17)} ${t.id}${t.contact?.bot_status === false ? " AI off on contact" : ""}${t.reopened_count ? ` reopened ${t.reopened_count}x` : ""}`);
|
|
31
|
+
}
|
|
32
|
+
if (resp.capped) console.log(`\n ${resp.total - resp.tickets.length} more; narrow with --contact / --older-than or raise --limit (max 500).`);
|
|
33
|
+
console.log(`\nflowiq tickets show <ticket_id> · flowiq tickets status <ticket_id> --set resolved --commit`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function show(ticketId, opts = {}) {
|
|
37
|
+
if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
|
|
38
|
+
let resp;
|
|
39
|
+
try { resp = await http.get("tickets", { ticket_id: ticketId }); } catch (e) { fail("Read failed", e); }
|
|
40
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
41
|
+
const t = resp.ticket;
|
|
42
|
+
console.log(`Ticket ${t.id} on ${resp.organization_name}: ${t.status}${t.type ? ` (${t.type})` : ""}`);
|
|
43
|
+
console.log(` contact: ${t.contact?.full_name ?? "?"} ${t.contact?.whatsapp_id ?? ""} (${t.contact_id})${t.contact?.bot_status === false ? " · AI off" : ""}`);
|
|
44
|
+
console.log(` opened: ${fmtSast(t.created_at)} SAST (${t.age_hours}h ago)${t.team ? ` · team ${t.team}` : ""}`);
|
|
45
|
+
console.log(` summary: ${t.summary ?? "(none)"}`);
|
|
46
|
+
if (t.resolved_at) console.log(` resolved: ${fmtSast(t.resolved_at)} SAST by ${t.resolved_by ?? "?"}`);
|
|
47
|
+
if (t.csat_sent_at) console.log(` csat sent: ${fmtSast(t.csat_sent_at)} SAST`);
|
|
48
|
+
if (t.reopened_count) console.log(` reopened: ${t.reopened_count}x (${t.data?.reopen_reason ?? ""})`);
|
|
49
|
+
if (Array.isArray(t.data?.status_history) && t.data.status_history.length) {
|
|
50
|
+
console.log(" history:");
|
|
51
|
+
for (const h of t.data.status_history.slice(-10)) console.log(` ${fmtSast(h.at ?? h.timestamp ?? h.changed_at)} ${h.from ?? "?"} → ${h.to ?? h.status ?? "?"} by ${h.by ?? "?"}${h.note ? ` (${h.note})` : ""}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function status(ticketId, opts = {}) {
|
|
56
|
+
if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
|
|
57
|
+
if (!opts.set) { console.error("Error: --set open|in_progress|resolved|closed is required."); process.exit(1); }
|
|
58
|
+
let resp;
|
|
59
|
+
try { resp = await http.post("tickets", { action: "status", ticket_id: ticketId, status: opts.set, note: opts.note, dry_run: !opts.commit }); }
|
|
60
|
+
catch (e) { fail("Status change failed", e); }
|
|
61
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
62
|
+
if (resp.unchanged) { console.log(`Ticket is already ${opts.set}.`); return; }
|
|
63
|
+
if (resp.dry_run) {
|
|
64
|
+
console.log(`Ticket ${ticketId} (${resp.ticket.contact?.full_name ?? resp.ticket.contact_id}, ${resp.organization_name}): ${resp.ticket.status} → ${resp.would_set.status}${resp.would_reenable_bot ? "\n the org re-enables AI on resolve: the contact's agent would be switched back on" : ""}`);
|
|
65
|
+
console.log("DRY RUN — add --commit to write.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`✓ ticket ${ticketId}: ${resp.previous_status} → ${resp.ticket.status}${resp.bot_reenabled ? " · contact's AI re-enabled" : ""}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `flowiq widgets list|show|pull|push|embed|activate` — the web chat widgets
|
|
2
|
+
// (server: api/cli/widgets.js). A push is an id-keyed PATCH, never a full
|
|
3
|
+
// replace: these render on live storefronts.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
import { fmtSast } from "./audit.js";
|
|
9
|
+
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const WIDGET_DIR = path.resolve(process.cwd(), ".flowiq", "widgets");
|
|
12
|
+
export const WIDGET_EMBED_HOST = "https://widget.flowapt.com";
|
|
13
|
+
// The bags a pull writes into the file; a push sends back only what differs.
|
|
14
|
+
const PULL_META = ["id", "organization_id", "created_by", "created_at"];
|
|
15
|
+
|
|
16
|
+
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); }
|
|
17
|
+
const requireUuid = (v, what) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid ${what} UUID.`); process.exit(1); } };
|
|
18
|
+
|
|
19
|
+
export function embedSnippet(orgId, widgetId) {
|
|
20
|
+
return `<!-- FlowIQ Chat Widget -->\n<script\n src="${WIDGET_EMBED_HOST}/widget/loader.js"\n data-organization-id="${orgId}"${widgetId ? `\n data-widget-id="${widgetId}"` : ""}\n></script>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function list(orgId, opts = {}) {
|
|
24
|
+
requireUuid(orgId, "organization");
|
|
25
|
+
let resp;
|
|
26
|
+
try { resp = await http.get("widgets", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
|
|
27
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
28
|
+
if (!resp.count) { console.log(`${resp.organization_name} has no web chat widget.`); return; }
|
|
29
|
+
console.log(`${resp.organization_name}: ${resp.count} widget(s)`);
|
|
30
|
+
for (const w of resp.widgets) {
|
|
31
|
+
console.log(` ${w.id} ${w.name ?? "(unnamed)"}${w.is_active ? " [active: the org-only embed resolves to this one]" : ""}`);
|
|
32
|
+
console.log(` agent ${w.agent_name ?? w.agent_id ?? "(org active agent)"}${w.agent_display_name ? ` shown as "${w.agent_display_name}"` : ""} · ${w.widget_position ?? "bottom-right"} · header "${w.header_text ?? ""}"${w.checkout_storefront ? " · checkout storefront" : ""} · created ${fmtSast(w.created_at)}`);
|
|
33
|
+
}
|
|
34
|
+
console.log(`\nflowiq widgets pull <widget_id> · widgets embed ${orgId} [<widget_id>]`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchWidget(widgetId) {
|
|
38
|
+
requireUuid(widgetId, "widget");
|
|
39
|
+
try { return await http.get("widgets", { widget_id: widgetId }); } catch (e) { fail("Widget read failed", e); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function show(widgetId, opts = {}) {
|
|
43
|
+
const resp = await fetchWidget(widgetId);
|
|
44
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
45
|
+
const w = resp.widget;
|
|
46
|
+
console.log(`${w.name ?? "(unnamed)"} ${w.id} on ${resp.organization_name}${w.is_active ? " [active]" : ""}`);
|
|
47
|
+
const scalar = Object.entries(w).filter(([k, v]) => !PULL_META.includes(k) && (v === null || typeof v !== "object"));
|
|
48
|
+
for (const [k, v] of scalar) console.log(` ${k.padEnd(26)} ${v === null ? "(unset)" : String(v).replace(/\s+/g, " ").slice(0, 140)}`);
|
|
49
|
+
const bags = Object.entries(w).filter(([k, v]) => v !== null && typeof v === "object");
|
|
50
|
+
for (const [k, v] of bags) console.log(` ${k.padEnd(26)} ${JSON.stringify(v).slice(0, 200)}${JSON.stringify(v).length > 200 ? "…" : ""}`);
|
|
51
|
+
console.log(`\nFull config: flowiq widgets pull ${w.id}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function pull(widgetId, opts = {}) {
|
|
55
|
+
const resp = await fetchWidget(widgetId);
|
|
56
|
+
const w = resp.widget;
|
|
57
|
+
const slug = String(resp.organization_name || w.organization_id).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
58
|
+
const file = opts.out ? path.resolve(opts.out) : path.join(WIDGET_DIR, `${slug}-${w.id.slice(0, 8)}.json`);
|
|
59
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
60
|
+
await fs.writeFile(file, JSON.stringify({ organization_name: resp.organization_name, widget: w }, null, 2) + "\n", "utf8");
|
|
61
|
+
console.log(`Wrote ${file}\n edit the fields under "widget", then: flowiq widgets push ${file} (dry run) → --commit`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function push(file, opts = {}) {
|
|
65
|
+
let data;
|
|
66
|
+
try { data = JSON.parse(await fs.readFile(path.resolve(file), "utf8")); } catch (e) { console.error(`Cannot read ${file}: ${e.message}`); process.exit(1); }
|
|
67
|
+
const w = data.widget || data;
|
|
68
|
+
requireUuid(w.id, "widget");
|
|
69
|
+
const patch = {};
|
|
70
|
+
for (const [k, v] of Object.entries(w)) if (!PULL_META.includes(k)) patch[k] = v;
|
|
71
|
+
let resp;
|
|
72
|
+
try { resp = await http.post("widgets", { action: "push", widget_id: w.id, patch, dry_run: !opts.commit }); } catch (e) { fail("Push failed", e); }
|
|
73
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
74
|
+
if (resp.unchanged) { console.log("Nothing differs from the live widget."); return; }
|
|
75
|
+
console.log(`${resp.dry_run ? "Would change" : "Changed"} ${resp.changes.length} field(s) on widget ${resp.widget_id} (${resp.organization_name}):`);
|
|
76
|
+
for (const c of resp.changes) console.log(` ${c.field.padEnd(30)} ${JSON.stringify(c.before)?.slice(0, 60) ?? "(unset)"} → ${JSON.stringify(c.after)?.slice(0, 60) ?? "(unset)"}`);
|
|
77
|
+
if (resp.dry_run) console.log("\nDRY RUN — nothing written. Add --commit to apply (the storefront picks it up on the next widget load).");
|
|
78
|
+
else console.log("\n✓ live. Nested keys merge; a key set to null was removed.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function embed(orgId, widgetId, opts = {}) {
|
|
82
|
+
requireUuid(orgId, "organization");
|
|
83
|
+
if (widgetId) requireUuid(widgetId, "widget");
|
|
84
|
+
const snippet = embedSnippet(orgId, widgetId || null);
|
|
85
|
+
if (opts.json) { console.log(JSON.stringify({ organization_id: orgId, widget_id: widgetId || null, snippet })); return; }
|
|
86
|
+
console.log(snippet);
|
|
87
|
+
if (!widgetId) console.error("\n(org-only embed: resolves to the org's single widget, else the one marked active; pass the widget id to pin one)");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function activate(widgetId, opts = {}) {
|
|
91
|
+
requireUuid(widgetId, "widget");
|
|
92
|
+
let resp;
|
|
93
|
+
try { resp = await http.post("widgets", { action: "activate", widget_id: widgetId }); } catch (e) { fail("Activate failed", e); }
|
|
94
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
95
|
+
if (resp.unchanged) { console.log("Already the active widget."); return; }
|
|
96
|
+
console.log(`✓ ${widgetId} is now the active widget${resp.previously_active?.length ? ` (was: ${resp.previously_active.map((p) => p.name ?? p.id).join(", ")})` : ""}.`);
|
|
97
|
+
}
|