@flowapt/flowiq-cli 0.10.0 → 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 CHANGED
@@ -2148,7 +2148,7 @@ flowiq rules disable <rule_id>
2148
2148
  ### Org views — `flowiq org channels|whatsapp|health|automations|abandoned` (0.10.0)
2149
2149
 
2150
2150
  ```bash
2151
- flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads, Zernio
2151
+ flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads (FlowIQ app or Zernio route), Zernio
2152
2152
  flowiq org channels --all # census across every active org
2153
2153
  flowiq org whatsapp <org> # the number's health straight from Meta: status, quality, tier, name status, webhook subscription
2154
2154
  flowiq org whatsapp --all # every Meta org, unhealthy first
@@ -2160,7 +2160,13 @@ flowiq org abandoned <org> --disable 2 --commit
2160
2160
  ```
2161
2161
 
2162
2162
  `health` probes the org's OpenAI key with a one-token embedding, so an
2163
- exhausted or invalid key shows as such rather than "present".
2163
+ exhausted or invalid key shows as such rather than "present". It probes the
2164
+ spare key (`feature_flags.spare_openai_key`) too: the agent, product search and
2165
+ both product syncs fall back to it, so a dead primary with a working spare is a
2166
+ `⚠` warning, not a problem (1 Oct 2026). The webhook count covers both hosts our
2167
+ Shopify webhooks use (`auth.flowiq.live` and the older `supabase.co`); before
2168
+ 1 Oct 2026 it counted `supabase.co` only and read 0 on every org registered by
2169
+ `flowiq webhooks push`.
2164
2170
 
2165
2171
  ### Messages, org-wide — `flowiq messages search|failures|stats` (0.10.0)
2166
2172
 
@@ -2182,6 +2188,73 @@ flowiq messages stats <org> --since 6m
2182
2188
  (`cli_message_volume_summary`): template = broadcast_id, JSON payload, or no
2183
2189
  customer message in the preceding 24 h.
2184
2190
 
2191
+ ### Members · users · steers · notes · products (0.11.0)
2192
+
2193
+ Batch F of the request list. Three new topics, plus a long tail of verbs on
2194
+ existing ones (next section).
2195
+
2196
+ ```bash
2197
+ flowiq members list <org> # role · name · email · last sign-in · pending invitations
2198
+ flowiq members add <org> <email> --role member # an EXISTING account (no account → invite from the app)
2199
+ flowiq members role <org> <email> admin|member|viewer # the last admin cannot be demoted
2200
+ flowiq members rename <email> "Full Name" # profiles.full_name, shown in every org
2201
+ flowiq members remove <org> <email> --confirm # owner + last admin refused
2202
+ flowiq users lookup <email> # sign-in method, confirmation, last sign-in, every org + role
2203
+ flowiq steer list <contact_id> · steer add <contact_id> "Offer the 10% code" [--once] · steer clear <contact_id> [--id <id>] --confirm
2204
+ flowiq notes list <contact_id> · notes add <contact_id> "Vet to CS: …"
2205
+ flowiq products status <org> · products search <org> "whey protein" · products reconcile <org> --confirm
2206
+ ```
2207
+
2208
+ - **`steer`** is the inbox's supervisor instruction: vert injects every
2209
+ unconsumed steer into the agent's history. An ongoing steer applies to EVERY
2210
+ reply until it is deleted, and the inbox has no delete, which is why
2211
+ `steer clear` exists (dry-run; the texts stay in the audit row).
2212
+ - **`products search`** calls the agent's own `fuzzy_match_products` RPC with
2213
+ the agent's threshold (0.15), so "what does the agent find for X?" is answered
2214
+ without a test send. `products status` counts rows with no embedding (the
2215
+ agent cannot find those by meaning) and shows the last syncs; `reconcile`
2216
+ queues the org on `products-reconcile-scheduler` (re-sync + re-embed).
2217
+ - **`members add` takes an existing account only.** Someone who has never
2218
+ signed up goes through the app's invite flow (an invitation row + email);
2219
+ the CLI says so rather than minting a login.
2220
+
2221
+ ### The 0.11.0 long tail (verbs added to existing topics)
2222
+
2223
+ ```bash
2224
+ flowiq links shorten <org> --url <u> --slug spring-sale --commit # readable code; a taken slug falls back and says so
2225
+ flowiq links list <org> --code <code> | --to /collections/x | --all # one code · by destination · every page (500 a call)
2226
+ flowiq links repoint <org> <code> --to <url> --commit # move a live code (clicks + messages keep it)
2227
+ flowiq org list --number 27609751832 # the org behind a WhatsApp number / phone id / WABA
2228
+ flowiq org agent <org> on|off --commit [--yes] # the master switch; ON for everyone needs --yes
2229
+ flowiq org park <org> [--unpark] --confirm # organizations.inactive
2230
+ flowiq org flags set <org> product-tag-notes @notes.json # {tag: sentence}; instagram_comments.* are objects now
2231
+ flowiq agent flags [--tool product_lookup] [--model luna] # every active agent's model + tool gates
2232
+ flowiq tag ids <org> --ids-file ids.txt --tag vip --commit # one tag on an explicit id list
2233
+ flowiq tag holders <org> vip [--all] [--csv] # who carries a tag
2234
+ flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact # whole value, not a substring
2235
+ flowiq tag remove <org> vip --contacts a,b --confirm # only those contacts lose it
2236
+ flowiq prompts push <slug> --dry-run # added / removed / changed sections, nothing written
2237
+ flowiq bc scheduled list <org> --detail # the queued send's body / button values + header
2238
+ flowiq templates show <org> <name> # falls back to Meta when FlowIQ has no row
2239
+ flowiq templates attempts <org> --name x # + Meta's status / category history ("CATEGORY MOVED")
2240
+ flowiq test send <org> "hi" --tools --sender "Sofia" # tool arguments + results; renames a synthetic test contact
2241
+ flowiq messages pull <contact_id> # footer: sent by agent / broadcast / automation: <template>
2242
+ flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --confirm
2243
+ ```
2244
+
2245
+ - **`seg apply` sends one request per 4,000 ids** (the server already split its
2246
+ DB calls; one request still timed out on 18k), and a failure names the chunk;
2247
+ re-running is idempotent. **`seg plan --start-index N`** writes
2248
+ `<slug>-from-NN.json` instead of overwriting the earlier plan (the record of
2249
+ who got the earlier batches); an applied plan file is never overwritten.
2250
+ - **`bc send` audit rows and the `[CLI]` email now carry the audience** (python
2251
+ reports no count on a real fire; the server counts the tag's broadcast-safe
2252
+ contacts itself).
2253
+ - **`org agent`** is `organizations.wati_webhook_status`. Off = no customer gets
2254
+ the AI. On with `vert.active false` + `vert.allowed_whatsapp_ids` = test mode.
2255
+ On with `vert.active` not false = live for every customer, which needs
2256
+ `--yes`; the command prints which of the three it would produce.
2257
+
2185
2258
  ### Guide — `flowiq guide`
2186
2259
 
2187
2260
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -145,10 +145,22 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
145
145
  | How many messages did an org send last month, free-form vs template? | `flowiq messages stats <org_id> --since 6m` |
146
146
  | Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all` |
147
147
  | Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
148
- | Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed, prompt, catalogue, webhooks, order sync, traffic) |
148
+ | Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed incl. the spare key, prompt, catalogue, webhooks, order sync, traffic; ⚠ lines are warnings, ✗ lines block) |
149
149
  | What automated messages / flows / rules does an org have armed? | `flowiq org automations <org_id>` · `flowiq flows list <org_id>` (+ `flows status` for failures) · `flowiq rules list <org_id>` (+ `rules results <rule_id>`) · `flowiq org abandoned <org_id>` |
150
150
  | See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` · in the app: Changelog → **CLI requests** |
151
151
  | Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --fixed-in 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
152
+ | Who is on a client's account, and give someone access | `flowiq members list <org_id>` · `flowiq members add <org_id> <email> --role member` (they must already have an account; otherwise invite from the app) · `flowiq members role <org_id> <email> admin` |
153
+ | "I can't log in" / which orgs is this person on? | `flowiq users lookup <email>`: sign-in method, email confirmed or not, last sign-in, every org and role |
154
+ | Tell the agent how to handle ONE customer, or stop an ongoing steer | `flowiq steer add <contact_id> "Offer the 10% code once"` (`--once` for the next reply only) · `flowiq steer list <contact_id>` · `flowiq steer clear <contact_id> --confirm` |
155
+ | Leave a note for the team on a contact | `flowiq notes add <contact_id> "Vet to CS: …"` |
156
+ | The agent cannot find a product the store has | `flowiq products search <org_id> "<what the customer typed>"` (the agent's own match) · `flowiq products status <org_id>` (rows with no embedding) · `flowiq products reconcile <org_id> --confirm` |
157
+ | Which org owns this WhatsApp number? | `flowiq org list --number 27609751832` |
158
+ | Switch an org's agent off (or on) for every customer | `flowiq org agent <org_id> off --commit` · back on: `flowiq org agent <org_id> on --commit --yes` (test mode first: `org flags set <org> vert.allowed_whatsapp_ids <numbers>` + `vert.active false`) |
159
+ | A short link points at the wrong page | `flowiq links list <org_id> --code <code>` to see it · `flowiq links repoint <org_id> <code> --to <url> --commit` |
160
+ | Check a prompt edit before it goes live | `flowiq prompts push <slug> --dry-run` — added / removed / changed sections against the live agent |
161
+ | Tag a list of contact ids, or see who carries a tag | `flowiq tag ids <org_id> --ids-file ids.txt --tag vip --commit` · `flowiq tag holders <org_id> vip --csv` |
162
+ | Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
163
+ | Was that message the agent, a broadcast or an automation? | `flowiq messages pull <contact_id>` ends with a "sent by" line per source |
152
164
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
153
165
  > from your working tree — `npm publish` packs whatever is on disk. A
154
166
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,63 @@
1
+ // node --test src/batch-f.test.mjs — pure helpers behind the 0.11.0 topics.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { diffSections } from "./commands/prompts.js";
5
+ import { sentBy } from "./commands/messages.js";
6
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
7
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
8
+ const reg = await import("../../api/_shared/feature-flag-registry.js");
9
+
10
+ test("diffSections reports added / removed / changed by id and ignores untouched sections", () => {
11
+ const live = [
12
+ { id: "a", title: "Main", content: "hello" },
13
+ { id: "b", title: "Hours", content: "9-5", hidden: false },
14
+ { id: "c", title: "Old", content: "gone" },
15
+ ];
16
+ const local = [
17
+ { id: "a", title: "Main", content: "hello" },
18
+ { id: "b", title: "Hours", content: "9-6", hidden: true, channels: ["web"] },
19
+ { id: "d", title: "New", content: "x".repeat(12) },
20
+ ];
21
+ const d = diffSections(live, local);
22
+ assert.deepEqual(d.added, [{ title: "New", chars: 12 }]);
23
+ assert.deepEqual(d.removed, [{ title: "Old", chars: 4 }]);
24
+ assert.equal(d.changed.length, 1);
25
+ assert.equal(d.changed[0].title, "Hours");
26
+ assert.match(d.changed[0].what.join("; "), /content 3 → 3 chars; hidden false → true; channels "all" → \["web"\]/);
27
+ assert.equal(d.unchanged, false);
28
+ assert.equal(diffSections(live, live).unchanged, true);
29
+ });
30
+
31
+ test("diffSections notices a pure reorder", () => {
32
+ const live = [{ id: "a", title: "A", content: "1" }, { id: "b", title: "B", content: "2" }];
33
+ const d = diffSections(live, [live[1], live[0]]);
34
+ assert.equal(d.reordered, true);
35
+ assert.equal(d.unchanged, false);
36
+ });
37
+
38
+ test("sentBy labels broadcast, automation, rule and agent rows", () => {
39
+ assert.equal(sentBy({ broadcast_id: "x" }), "broadcast");
40
+ assert.equal(sentBy({ notification_template: "order_confirm_v1", notification_event: "orders/paid" }), "automation: order_confirm_v1 (orders/paid)");
41
+ assert.equal(sentBy({ notification_rule_id: "38f20704-aaaa-bbbb-cccc-dddddddddddd" }), "rule 38f20704");
42
+ assert.equal(sentBy({ sender_type: "bot-whatsapp" }), "agent");
43
+ });
44
+
45
+ test("product-tag-notes validator takes a {tag: sentence} map and refuses empties", () => {
46
+ const spec = reg.FLAG_REGISTRY["product-tag-notes"];
47
+ assert.deepEqual(reg.validateFlagValue(spec, '{"Free2024":" Ships free. "}'), { value: { Free2024: "Ships free." } });
48
+ assert.match(reg.validateFlagValue(spec, { Free2024: "" }).error, /non-empty sentence/);
49
+ assert.match(reg.validateFlagValue(spec, {}).error, /empty/);
50
+ assert.match(reg.validateFlagValue(spec, "true").error, /must be an object/);
51
+ });
52
+
53
+ test("instagram_comments legs are objects with a bounded message", () => {
54
+ const leg = reg.FLAG_REGISTRY["instagram_comments.private_reply"];
55
+ assert.equal(leg.type, "object");
56
+ assert.match(reg.validateFlagValue(leg, "true").error, /must be an object/);
57
+ assert.deepEqual(reg.validateFlagValue(leg, { enabled: true, message: " hi " }), { value: { enabled: true, message: "hi" } });
58
+ assert.match(reg.validateFlagValue(leg, { enabled: true }).error, /message is empty/);
59
+ assert.match(reg.validateFlagValue(leg, { enabled: true, message: "x".repeat(1001) }).error, /over 1000/);
60
+ const whole = reg.FLAG_REGISTRY["instagram_comments"];
61
+ assert.deepEqual(reg.validateFlagValue(whole, { enabled: true, ads_only: false, public_reply: { enabled: false } }), { value: { enabled: true, ads_only: false, public_reply: { enabled: false } } });
62
+ assert.match(reg.validateFlagValue(whole, { bogus: 1 }).error, /not a known key/);
63
+ });
@@ -72,10 +72,22 @@ function checkExpectations(turn, replyText) {
72
72
  return results;
73
73
  }
74
74
 
75
- function printTurn(sc, t, userText, r, checks) {
75
+ function printTurn(sc, t, userText, r, checks, opts = {}) {
76
76
  if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
77
77
  console.log(`\n 🧑 ${userText}`);
78
78
  if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
79
+ // --tools: what each tool was asked and what it answered (the tool-call rows
80
+ // vert writes). Without it a wrong answer could not be traced to the tool
81
+ // result that produced it.
82
+ if (opts.tools && r.tool_details?.length) {
83
+ for (const d of r.tool_details) {
84
+ console.log(` ⚙ ${d.tool_name}${d.duration_ms != null ? ` (${d.duration_ms} ms)` : ""}${d.error ? ` ERROR ${d.error}` : ""}`);
85
+ if (d.request != null) console.log(` in: ${String(d.request).split("\n").join("\n ")}`);
86
+ if (d.response != null) console.log(` out: ${String(d.response).split("\n").join("\n ")}`);
87
+ }
88
+ } else if (opts.tools && r.tool_calls?.length) {
89
+ console.log(` (tool arguments / results are not stored for this org: switch on feature flag tool_logging, or the API predates 1 Oct 2026)`);
90
+ }
79
91
  if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
80
92
  else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
81
93
  for (const c of checks || []) {
@@ -103,7 +115,7 @@ export async function send(orgId, messageParts, opts = {}) {
103
115
  printWarnings(r);
104
116
  if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
105
117
  console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
106
- printTurn({ title: "send", tq: "" }, 0, message, r, []);
118
+ printTurn({ title: "send", tq: "" }, 0, message, r, [], { tools: !!opts.tools });
107
119
  }
108
120
 
109
121
  async function runPack(orgId, pack, opts) {
@@ -128,7 +140,7 @@ async function runPack(orgId, pack, opts) {
128
140
  }
129
141
  const checks = checkExpectations(turn, r.reply);
130
142
  scResult.turns.push({ ...r, checks });
131
- if (!opts.json) printTurn(sc, t, turn.text, r, checks);
143
+ if (!opts.json) printTurn(sc, t, turn.text, r, checks, { tools: !!opts.tools });
132
144
  }
133
145
  results.push(scResult);
134
146
  }
@@ -32,6 +32,37 @@ export async function list(orgId, opts = {}) {
32
32
  console.log("\n★ = active agent. Pass any id as --agent <id> to prompts/questionnaires/fine-tuning/agent config.");
33
33
  }
34
34
 
35
+ // `flowiq agent flags [--tool product_lookup] [--model x] [--all] [--json]` —
36
+ // every active org's active agent: model, reasoning, prompt switch and the
37
+ // tool gates, one row per org. Filters narrow to the orgs that matter.
38
+ export async function flags(opts = {}) {
39
+ let resp;
40
+ try { resp = await http.get("agents", { census: "flags", all: opts.all ? "1" : undefined }); }
41
+ catch (e) { console.error(`Census failed: ${e.message}`); process.exit(1); }
42
+ let rows = resp.agents || [];
43
+ if (opts.tool) {
44
+ const [k, v] = String(opts.tool).split("=");
45
+ const want = v === undefined ? true : !["false", "0", "off", "no"].includes(v.toLowerCase());
46
+ rows = rows.filter((r) => !r.missing && (r.tools?.[k] === want || (want === false && r.tools?.[k] === undefined)));
47
+ }
48
+ if (opts.model) rows = rows.filter((r) => String(r.model || "").includes(opts.model));
49
+ if (opts.json) { console.log(JSON.stringify({ ...resp, count: rows.length, agents: rows }, null, 2)); return; }
50
+ const pad = (s, n) => String(s ?? "").padEnd(n);
51
+ const short = { product_lookup: "lookup", ticket_tool_status: "tickets", postal_code_tool_status: "postal", restock_tool: "restock", view_cart_tool: "cart", block_tool_status: "block", email_request_tool: "email-req", shopify_products_web_chat: "web-products", woo_order_build: "woo-order", woo_tip_field: "woo-tip", woo_order_note_field: "woo-note", collapse_product_variants: "collapse", silent_option: "silent" };
52
+ console.log(`${rows.length} active agent(s)${opts.tool ? ` with ${opts.tool}` : ""}${opts.model ? ` on model ~${opts.model}` : ""}`);
53
+ console.log("");
54
+ console.log(` ${pad("ORG", 26)} ${pad("AGENT", 18)} ${pad("MODEL", 20)} ${pad("REASON", 7)} ${pad("PROMPT", 9)} ${pad("SW", 3)} TOOLS ON`);
55
+ for (const r of rows) {
56
+ if (r.missing) { console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ⚠ active_whatsapp_agent ${r.agent_id} does not exist`); continue; }
57
+ const on = Object.entries(r.tools || {}).filter(([, v]) => v).map(([k]) => short[k] || k);
58
+ if (r.discount) on.push("discount");
59
+ if (r.custom_tools) on.push(`${r.custom_tools} custom`);
60
+ if (r.disabled_base_tools?.length) on.push(`-${r.disabled_base_tools.join(",-")}`);
61
+ console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ${pad(String(r.agent_name || "").slice(0, 18), 18)} ${pad(r.model || "house", 20)} ${pad(r.reasoning_effort || "—", 7)} ${pad(`${r.prompt_chars.toLocaleString()}${r.use_settings_prompt ? "" : " OFF"}`, 9)} ${pad(r.master_switch ? "on" : "OFF", 3)} ${on.join(" ") || "—"}`);
62
+ }
63
+ console.log(`\n SW = agent master switch · PROMPT = chars (OFF = use_settings_prompt false) · columns: ${(resp.tool_columns || []).map((k) => `${short[k] || k}=${k}`).join(", ")}`);
64
+ }
65
+
35
66
  export async function create(orgId, opts = {}) {
36
67
  if (!UUID_RE.test(orgId)) {
37
68
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -1815,6 +1815,15 @@ export async function scheduledList(orgId, opts = {}) {
1815
1815
  console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1816
1816
  if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1817
1817
  if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
1818
+ if (opts.detail) {
1819
+ const bp = s.body_parameters && Object.keys(s.body_parameters).length ? s.body_parameters : null;
1820
+ const bt = s.button_parameters && Object.keys(s.button_parameters).length ? s.button_parameters : null;
1821
+ console.log(` body: ${bp ? Object.entries(bp).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") : "(no body values)"}`);
1822
+ if (bt) console.log(` buttons: ${Object.entries(bt).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ")}`);
1823
+ if (s.header_media) console.log(` header: ${typeof s.header_media === "string" ? s.header_media : JSON.stringify(s.header_media)}`);
1824
+ if (s.card_overrides != null) console.log(` cards: ${s.card_overrides} card override(s)`);
1825
+ if (!bp && !bt && !s.header_media && s.engine !== "python") console.log(` (a node-engine row carries its values inside the request; --json shows them)`);
1826
+ }
1818
1827
  if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1819
1828
  if (s.error_message) console.log(` error: ${s.error_message}`);
1820
1829
  }
@@ -7,7 +7,9 @@
7
7
  //
8
8
  // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
9
9
  // flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
10
- // flowiq links list <org> [--campaign X] [--limit 25]
10
+ // flowiq links shorten <org> --url <u> --slug spring-sale --commit (readable code)
11
+ // flowiq links list <org> [--campaign X] [--code abc123] [--to /collections/x] [--limit 25] [--all]
12
+ // flowiq links repoint <org> <code> --to <url> [--commit] (move a live code)
11
13
  //
12
14
  // NAMING — you do not have to get the tag right yourself. The house convention
13
15
  // is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
@@ -114,11 +116,13 @@ export async function shorten(orgId, opts = {}) {
114
116
  campaign,
115
117
  content,
116
118
  domain: opts.domain,
119
+ slug: opts.slug,
117
120
  dry_run: dryRun,
118
121
  confirm_new_campaign: !!opts.newCampaign,
119
122
  });
120
123
  } catch (e) {
121
124
  console.error(`Shorten failed: ${e.message}`);
125
+ if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
122
126
  process.exit(1);
123
127
  }
124
128
 
@@ -127,7 +131,7 @@ export async function shorten(orgId, opts = {}) {
127
131
  const utm = resp.utm
128
132
  ? `utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium} · utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content}`
129
133
  : "no UTM tags (plain short link)";
130
- console.log(`${resp.organization_name} · ${resp.domain}`);
134
+ console.log(`${resp.organization_name} · ${resp.domain}${resp.slug ? ` · slug ${resp.slug}` : ""}`);
131
135
  console.log(`${utm}\n`);
132
136
  if (resp.campaign_conflicts?.length) {
133
137
  const c = resp.campaign_conflicts[0];
@@ -169,30 +173,69 @@ export async function shorten(orgId, opts = {}) {
169
173
 
170
174
  export async function list(orgId, opts = {}) {
171
175
  requireOrg(orgId);
172
- let resp;
173
- try {
174
- resp = await http.post("links", {
175
- organization_id: orgId,
176
- action: "list",
177
- campaign: opts.campaign,
178
- limit: opts.limit,
179
- });
180
- } catch (e) {
181
- console.error(`List failed: ${e.message}`);
182
- process.exit(1);
176
+ // --all walks every page (500 a call) so a whole campaign or a whole org can
177
+ // be exported; without it the first page is the answer and has_more says so.
178
+ const pageSize = opts.all ? 500 : (opts.limit ? Number(opts.limit) : 25);
179
+ const links = [];
180
+ let offset = 0, resp, hasMore = false;
181
+ for (;;) {
182
+ try {
183
+ resp = await http.post("links", {
184
+ organization_id: orgId,
185
+ action: "list",
186
+ campaign: opts.campaign,
187
+ code: opts.code,
188
+ to: opts.to,
189
+ limit: pageSize,
190
+ offset,
191
+ });
192
+ } catch (e) {
193
+ console.error(`List failed: ${e.message}`);
194
+ process.exit(1);
195
+ }
196
+ links.push(...(resp.links || []));
197
+ hasMore = !!resp.has_more;
198
+ if (!opts.all || !hasMore || !(resp.links || []).length) break;
199
+ offset += (resp.links || []).length;
200
+ if (offset > 50000) break;
183
201
  }
184
202
 
185
- if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
203
+ if (opts.json) { console.log(JSON.stringify({ ...resp, count: links.length, links, has_more: hasMore }, null, 2)); return; }
186
204
 
187
- if (!resp.links.length) {
188
- console.log(opts.campaign ? `No links on ${resp.organization_name} for campaign "${opts.campaign}".` : `No links on ${resp.organization_name}.`);
205
+ const what = [opts.campaign && `campaign "${opts.campaign}"`, opts.code && `code "${opts.code}"`, opts.to && `destination containing "${opts.to}"`].filter(Boolean).join(", ");
206
+ if (!links.length) {
207
+ console.log(what ? `No links on ${resp.organization_name} for ${what}.` : `No links on ${resp.organization_name}.`);
189
208
  return;
190
209
  }
191
210
  const pad = (s, n) => String(s ?? "").padEnd(n);
192
- console.log(`${pad("CODE", 10)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
193
- for (const l of resp.links) {
211
+ const codeW = Math.min(50, Math.max(10, ...links.map((l) => String(l.code || "").length + 2)));
212
+ console.log(`${pad("CODE", codeW)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
213
+ for (const l of links) {
194
214
  const day = String(l.created_at || "").slice(0, 10);
195
- console.log(`${pad(l.code, 10)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}`);
215
+ console.log(`${pad(l.code, codeW)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}${l.is_active === false ? " (inactive)" : ""}${l.per_message ? " (per-message)" : ""}`);
216
+ }
217
+ console.log(`\n${links.length} link(s)${what ? ` · ${what}` : ""}${hasMore ? ` · more exist (--all, or --limit up to 500)` : ""}`);
218
+ }
219
+
220
+ // `flowiq links repoint <org> <code> --to <url> [--commit]` — move where a
221
+ // live code sends people. The code stays in every message that carries it.
222
+ export async function repoint(orgId, code, opts = {}) {
223
+ requireOrg(orgId);
224
+ if (!code) { console.error("Error: pass the short code, e.g. flowiq links repoint <org> VVSSh2 --to https://…"); process.exit(1); }
225
+ if (!opts.to) { console.error("Error: --to <url> is required (the new destination page)."); process.exit(1); }
226
+ let resp;
227
+ try {
228
+ resp = await http.post("links", { organization_id: orgId, action: "repoint", code, url: opts.to, dry_run: !opts.commit, confirm: !!opts.commit });
229
+ } catch (e) {
230
+ console.error(`Repoint failed: ${e.message}`);
231
+ if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
232
+ process.exit(1);
196
233
  }
197
- console.log(`\n${resp.count} link(s)${opts.campaign ? ` · campaign "${opts.campaign}"` : ""}`);
234
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
235
+ console.log(`${resp.organization_name} · ${resp.code} (${resp.clicks ?? 0} clicks${resp.is_active === false ? ", inactive" : ""})`);
236
+ console.log(` from: ${resp.from}`);
237
+ console.log(` to: ${resp.to}`);
238
+ if (resp.unchanged) { console.log("\nAlready points there — nothing to do."); return; }
239
+ if (resp.dry_run) console.log("\nDRY RUN — nothing changed. Add --commit to repoint.");
240
+ else console.log(`\n✓ repointed. Undo: flowiq links repoint ${orgId} ${resp.code} --to '${resp.from}' --commit`);
198
241
  }
@@ -0,0 +1,95 @@
1
+ // `flowiq members list|add|role|rename|remove` + `flowiq users lookup` — an
2
+ // org's people and one user across orgs (server: api/cli/members.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
+ const who = (v) => (UUID_RE.test(v || "") ? { user_id: v } : { email: v });
12
+
13
+ export async function list(orgId, opts = {}) {
14
+ requireOrg(orgId);
15
+ let resp;
16
+ try { resp = await http.get("members", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
17
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
18
+ console.log(`${resp.organization_name}: ${resp.count} member(s)${resp.pending_invitations.length ? ` · ${resp.pending_invitations.length} pending invitation(s)` : ""}`);
19
+ console.log("");
20
+ console.log(` ${pad("ROLE", 7)} ${pad("NAME", 24)} ${pad("EMAIL", 34)} ${pad("LAST SIGN-IN", 18)} SINCE`);
21
+ const order = { admin: 0, member: 1, viewer: 2 };
22
+ for (const m of [...resp.members].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9) || String(a.full_name || "").localeCompare(String(b.full_name || "")))) {
23
+ const flags = [m.is_owner && "owner", m.status !== "active" && m.status, m.view_only && "view-only", m.account_missing && "NO ACCOUNT", m.profile_deleted && "profile deleted"].filter(Boolean).join(", ");
24
+ console.log(` ${pad(m.role, 7)} ${pad((m.full_name || "—").slice(0, 24), 24)} ${pad((m.email || "?").slice(0, 34), 34)} ${pad(m.last_sign_in_at ? fmtSast(m.last_sign_in_at) : "never", 18)} ${String(m.since || "").slice(0, 10)}${flags ? ` (${flags})` : ""}`);
25
+ }
26
+ for (const i of resp.pending_invitations) console.log(` ${pad("invite", 7)} ${pad("—", 24)} ${pad(i.email, 34)} ${pad("pending", 18)} ${String(i.created_at || "").slice(0, 10)} (${i.role})`);
27
+ console.log(`\nflowiq members add ${orgId} <email> --role member · role ${orgId} <email> admin|member|viewer · remove ${orgId} <email> --confirm`);
28
+ }
29
+
30
+ export async function add(orgId, emailOrId, opts = {}) {
31
+ requireOrg(orgId);
32
+ if (!emailOrId) { console.error("Error: pass the person's email (they must already have an account)."); process.exit(1); }
33
+ let resp;
34
+ try { resp = await http.post("members", { action: "add", organization_id: orgId, ...who(emailOrId), role: opts.role || "member" }); } catch (e) { fail("Add failed", e); }
35
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
36
+ if (resp.unchanged) { console.log(resp.note); return; }
37
+ console.log(`✓ ${resp.email} is now a ${resp.role}${resp.reactivated ? " again" : ""}. Undo: flowiq members remove ${orgId} ${resp.email} --confirm`);
38
+ }
39
+
40
+ export async function role(orgId, emailOrId, newRole, opts = {}) {
41
+ requireOrg(orgId);
42
+ if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
43
+ let resp;
44
+ try { resp = await http.post("members", { action: "role", organization_id: orgId, ...who(emailOrId), role: newRole }); } catch (e) { fail("Role change failed", e); }
45
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
46
+ if (resp.unchanged) { console.log(`Already ${resp.role}.`); return; }
47
+ console.log(`✓ ${resp.email}: ${resp.from} → ${resp.to}. Undo: flowiq members role ${orgId} ${resp.email} ${resp.from}`);
48
+ }
49
+
50
+ export async function rename(emailOrId, name, opts = {}) {
51
+ if (!emailOrId || !name) { console.error("Error: flowiq members rename <email> \"Full Name\""); process.exit(1); }
52
+ let resp;
53
+ try { resp = await http.post("members", { action: "rename", ...who(emailOrId), full_name: name }); } catch (e) { fail("Rename failed", e); }
54
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
55
+ console.log(`✓ ${resp.email}: "${resp.from ?? ""}" → "${resp.to}" (profiles.full_name, shown in every org they belong to)`);
56
+ }
57
+
58
+ export async function remove(orgId, emailOrId, opts = {}) {
59
+ requireOrg(orgId);
60
+ if (!emailOrId) { console.error("Error: flowiq members remove <org> <email> --confirm"); process.exit(1); }
61
+ let resp;
62
+ try { resp = await http.post("members", { action: "remove", organization_id: orgId, ...who(emailOrId), confirm: !!opts.confirm }); } catch (e) { fail("Remove failed", e); }
63
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
64
+ if (resp.dry_run) { console.log(`Would remove ${resp.email} (${resp.role}${resp.is_owner ? ", OWNER" : ""}) from this org. Add --confirm.`); return; }
65
+ console.log(`✓ removed ${resp.email} (${resp.role}). Undo: flowiq members add ${orgId} ${resp.email} --role ${resp.role}`);
66
+ }
67
+
68
+ // `flowiq users lookup <email>` — the account behind an address, across orgs.
69
+ export async function lookup(email, opts = {}) {
70
+ if (!email) { console.error("Error: flowiq users lookup <email>"); process.exit(1); }
71
+ let resp;
72
+ try { resp = await http.get("members", { email }); }
73
+ catch (e) {
74
+ if (e.status === 404 || /No account/.test(e.message)) {
75
+ console.log(`No account for ${email}.`);
76
+ const inv = e.body?.invitations || [];
77
+ if (inv.length) { console.log(` invitations:`); for (const i of inv) console.log(` ${i.status.padEnd(9)} ${i.role.padEnd(7)} org ${i.organization_id} ${String(i.created_at).slice(0, 10)}`); }
78
+ else console.log(" no invitation on record either — they were never invited, or were invited under another address.");
79
+ return;
80
+ }
81
+ fail("Lookup failed", e);
82
+ }
83
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
84
+ const u = resp.user;
85
+ console.log(`${u.email} ${u.id}${resp.super_admin ? " (Flowapt super admin)" : ""}`);
86
+ console.log(` name: ${resp.full_name ?? "(no profile name)"}${resp.profile_deleted ? " (profile deleted)" : ""}`);
87
+ console.log(` signs in with: ${u.providers.join(", ")}${u.email_confirmed_at ? "" : " (email NOT confirmed)"}${u.banned_until ? ` BANNED until ${u.banned_until}` : ""}`);
88
+ console.log(` created: ${fmtSast(u.created_at)} SAST · last sign-in ${u.last_sign_in_at ? `${fmtSast(u.last_sign_in_at)} SAST` : "never"}`);
89
+ if (!resp.organizations.length) console.log(" organizations: none");
90
+ else {
91
+ console.log(` organizations: ${resp.organizations.length}`);
92
+ for (const o of resp.organizations) console.log(` ${pad(o.role, 7)} ${pad((o.organization_name || o.organization_id).slice(0, 30), 30)} ${o.organization_id}${o.status !== "active" ? ` (${o.status})` : ""}${o.inactive ? " (org inactive)" : ""}`);
93
+ }
94
+ console.log(`\n Password-reset emails are sent by Supabase Auth and are not recorded here; a stuck login is usually a wrong provider (Google vs password) or an unconfirmed email.`);
95
+ }
@@ -77,6 +77,45 @@ export async function pull(contactId, opts = {}) {
77
77
  const noStatus = out.filter((m) => m.whatsapp_message_id && m.message_status == null && !m.sent_receipt_received && !m.delivered_receipt_received && !m.read_receipt_received).length;
78
78
  console.log(` delivery: ${out.length} outbound WhatsApp · ${delivered} delivered/read · ${failed} failed · ${noStatus} with NO status at all${noStatus ? " ⚠ accepted by Meta, never confirmed (flowiq messages failures <org>)" : ""}`);
79
79
  }
80
+ // Who sent each bot row: the agent, a broadcast, or an automation (the
81
+ // notification_* columns are in the JSON; this names them so "was 'Hey MRS'
82
+ // the agent or the order confirmation?" is answered here, not in SQL).
83
+ const sources = {};
84
+ for (const m of rows.filter((x) => String(x.sender_type || "").startsWith("bot-"))) sources[sentBy(m)] = (sources[sentBy(m)] || 0) + 1;
85
+ const keys = Object.keys(sources);
86
+ if (keys.length) console.log(` sent by: ${keys.sort().map((k) => `${k} ${sources[k]}`).join(" · ")}`);
87
+ }
88
+
89
+ export function sentBy(m) {
90
+ if (m.broadcast_id) return "broadcast";
91
+ if (m.notification_template) return `automation: ${m.notification_template}${m.notification_event ? ` (${m.notification_event})` : ""}`;
92
+ if (m.notification_rule_id) return `rule ${String(m.notification_rule_id).slice(0, 8)}`;
93
+ if (m.notification_event) return `automation (${m.notification_event})`;
94
+ return "agent";
95
+ }
96
+
97
+ // `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
98
+ export async function remove(contactId, opts = {}) {
99
+ if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
100
+ const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
101
+ let preview;
102
+ try { preview = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: true }); }
103
+ catch (e) { fail("Delete failed", e); }
104
+ if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
105
+ console.log(`${preview.contact_name || "(no name)"} ${preview.contact_whatsapp_id || ""} on ${preview.organization_name}: ${preview.count} message(s)${preview.before ? ` older than ${fmtSast(preview.before)} SAST` : ""}${senderTypes ? ` of type ${senderTypes.join(", ")}` : ""}`);
106
+ for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
107
+ if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
108
+ if (!preview.count) return;
109
+ if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the contact id).`); return; }
110
+ const readline = await import("node:readline");
111
+ const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the contact id (${contactId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
112
+ if (typed !== contactId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
113
+ let resp;
114
+ try { resp = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: false, confirm: true }); }
115
+ catch (e) { fail("Delete failed", e); }
116
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
117
+ console.log(`✓ deleted ${resp.deleted} message(s). The audit row keeps their ids, types and times (flowiq audit --endpoint messages).`);
118
+ if (resp.error) console.error(` ⚠ ${resp.error}`);
80
119
  }
81
120
 
82
121
  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); }