@flowapt/flowiq-cli 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1586,6 +1586,38 @@ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
1586
1586
  - An interactive confirm shows exactly what the client will read; `--yes`
1587
1587
  skips the confirm (but never the `--note` requirement).
1588
1588
 
1589
+ ### Broadcast planning — `flowiq plans list|show|status` (alias `planning`) (v0.7.3)
1590
+
1591
+ The Broadcast → Planning board in the app (the campaign plans clients submit
1592
+ and Flowapt reviews), from the terminal.
1593
+
1594
+ ```bash
1595
+ flowiq plans list # open plans across every ACTIVE client
1596
+ flowiq plans list --status pending_review # waiting for Flowapt review
1597
+ flowiq plans list <organization_id> --status all --since 2026-09-01
1598
+ flowiq plans show <plan_id> # copy, second message, buttons, creative, audience, notes
1599
+ flowiq plans show <plan_id> --json --out plan.json
1600
+ flowiq plans status <plan_id> --to approved # dry run
1601
+ flowiq plans status <plan_id> --to rejected --comment "Please send the image as a PNG" --commit
1602
+ flowiq plans status <plan_id> --to sent --commit
1603
+ ```
1604
+
1605
+ - **`list`** defaults to open plans (`draft`, `pending_review`, `approved`,
1606
+ `scheduled`) across all active orgs; pass an org id for one client,
1607
+ `--status all` / a status / a comma list to widen or narrow, `--since` to
1608
+ filter by send date, `--include-inactive` for inactive orgs. The header
1609
+ shows the totals for every status, and each row prints the full plan id.
1610
+ - **`show`** prints the plan the way the dialog does: message copy, the
1611
+ second message sent when a button is tapped, buttons with their URLs, the
1612
+ creative (file name + link), audience, template link, the response to the
1613
+ client, internal notes and the notes thread.
1614
+ - **`status`** is a dry run until `--commit`. `--comment` is the response the
1615
+ **client reads**; `--internal` is staff-only. `approved` and `rejected`
1616
+ record you as the reviewer; `rejected` requires `--comment`. Every commit is
1617
+ in `flowiq audit --endpoint plans`.
1618
+ - Editing a plan's copy, buttons or links is not in the CLI yet; do that in
1619
+ the app.
1620
+
1589
1621
  ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
1590
1622
 
1591
1623
  Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
package/TEAM-GUIDE.md CHANGED
@@ -87,6 +87,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
87
87
  | Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
88
88
  | Make a keyword/button **hand the chat to a team or person** (assign in the inbox + email/WhatsApp them) | keyword action `{"type":"assign_chat","team_id":"…","assignee_user_id":"…","notify_member":true}` — see *Keywords* in `flowiq guide --reference`. Also in the dashboard (action type "Assign Chat") |
89
89
  | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
90
+ | See broadcast plans waiting for Flowapt review, across every client | `flowiq plans list --status pending_review`, then `flowiq plans show <plan_id>` for the copy, second message, buttons, creative and audience |
91
+ | See one client's broadcast plans | `flowiq plans list <org_id>` (open plans) or `flowiq plans list <org_id> --status all --since 2026-09-01` |
92
+ | Mark a broadcast plan approved, scheduled or sent | `flowiq plans status <plan_id> --to sent` (a dry run), then add `--commit`. Rejecting needs `--comment "..."`, which the client reads |
90
93
  | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
91
94
  | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
92
95
  | **Size a customer cohort** | Use `customers(first:250, query:…)` and paginate — **NOT `customersCount(query:…)`, which Shopify ignores the filter on** and answers 10,000 every time. Any count showing `precision: AT_LEAST` is a cap, not a total; the CLI warns you. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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,258 @@
1
+ // `flowiq plans list|show|status` — Broadcast Planning (the Broadcast → Planning
2
+ // board in the app, table broadcast_planning), via /cli/plans.
3
+ // list read: open plans across all active orgs, or one org
4
+ // show read: one plan in full (copy, second message, buttons, creative, notes)
5
+ // status write: dry run unless --commit; audited server-side
6
+
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { http } from "../http.js";
10
+
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const STATUSES = ["draft", "pending_review", "approved", "rejected", "scheduled", "sent", "cancelled"];
13
+ const STATUS_ORDER = ["pending_review", "approved", "scheduled", "draft", "rejected", "sent", "cancelled"];
14
+ const STATUS_LABEL = {
15
+ pending_review: "PENDING REVIEW",
16
+ approved: "APPROVED",
17
+ scheduled: "SCHEDULED",
18
+ draft: "DRAFT",
19
+ rejected: "REJECTED",
20
+ sent: "SENT",
21
+ cancelled: "CANCELLED",
22
+ };
23
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
24
+
25
+ function sastParts(ts) {
26
+ const parts = new Intl.DateTimeFormat("en-CA", {
27
+ timeZone: "Africa/Johannesburg",
28
+ year: "numeric", month: "2-digit", day: "2-digit",
29
+ hour: "2-digit", minute: "2-digit", hour12: false,
30
+ }).formatToParts(new Date(ts));
31
+ return Object.fromEntries(parts.map((p) => [p.type, p.value]));
32
+ }
33
+
34
+ function fmtTs(ts) {
35
+ if (!ts) return "-";
36
+ const p = sastParts(ts);
37
+ const hour = p.hour === "24" ? "00" : p.hour;
38
+ return `${Number(p.day)} ${MONTHS[Number(p.month) - 1]} ${p.year} ${hour}:${p.minute}`;
39
+ }
40
+
41
+ function fmtDate(dateStr, timeStr) {
42
+ if (!dateStr) return "no date";
43
+ const [y, m, d] = String(dateStr).split("-");
44
+ return `${Number(d)} ${MONTHS[Number(m) - 1]} ${y} ${timeStr ? String(timeStr).slice(0, 5) : "--:--"}`;
45
+ }
46
+
47
+ function indent(text, pad = " ") {
48
+ const t = String(text ?? "").trim();
49
+ if (!t) return `${pad}(empty)`;
50
+ return t.split("\n").map((line) => `${pad}${line}`).join("\n");
51
+ }
52
+
53
+ function fail(prefix, e) {
54
+ console.error(`${prefix}: ${e.message}`);
55
+ if (e.body?.error && !String(e.message).includes(e.body.error)) console.error(` ${e.body.error}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ export async function list(orgId, opts = {}) {
60
+ if (orgId && !UUID_RE.test(orgId)) {
61
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list\`).`);
62
+ process.exit(1);
63
+ }
64
+ const query = {};
65
+ if (orgId) query.organization_id = orgId;
66
+ if (opts.status) query.status = opts.status;
67
+ if (opts.since) query.since = opts.since;
68
+ if (opts.includeInactive) query.include_inactive = "1";
69
+ if (opts.limit != null) query.limit = String(opts.limit);
70
+
71
+ let resp;
72
+ try {
73
+ resp = await http.get("plans", query);
74
+ } catch (e) {
75
+ fail("List failed", e);
76
+ }
77
+ if (opts.json) {
78
+ console.log(JSON.stringify(resp, null, 2));
79
+ return;
80
+ }
81
+
82
+ const scope = resp.organization_name
83
+ ? resp.organization_name
84
+ : `all ${resp.include_inactive ? "" : "active "}orgs`;
85
+ const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
86
+ console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${resp.since ? ` · from ${resp.since}` : ""}`);
87
+ console.log(` every status in this scope: ${totals || "none"}`);
88
+
89
+ const plans = resp.plans || [];
90
+ if (!plans.length) {
91
+ console.log(" (no matching plans)");
92
+ return;
93
+ }
94
+
95
+ for (const s of STATUS_ORDER) {
96
+ const group = plans.filter((p) => p.status === s);
97
+ if (!group.length) continue;
98
+ console.log(`\n${STATUS_LABEL[s]} (${group.length})`);
99
+ for (const p of group) {
100
+ const flag = p.date_passed ? " (date passed)" : p.scheduled_date === resp.today ? " (today)" : "";
101
+ console.log(` ${fmtDate(p.scheduled_date, p.scheduled_time)}${flag} ${p.organization_name ?? p.organization_id} · ${p.topic}`);
102
+ const bits = [
103
+ p.type || "type not set",
104
+ p.audience,
105
+ p.origin === "flowapt" ? `Flowapt suggestion (client: ${p.client_status ?? "-"})` : null,
106
+ p.creative_file ? `creative ${p.creative_file}` : null,
107
+ p.template_id ? "template linked" : null,
108
+ p.has_client_comment ? "has response to client" : null,
109
+ p.notes_count ? `${p.notes_count} note(s)` : null,
110
+ ].filter(Boolean);
111
+ console.log(` ${bits.join(" · ")}`);
112
+ const reviewed = p.reviewed_at
113
+ ? ` · reviewed ${fmtTs(p.reviewed_at)}${p.reviewed_by_name ? ` by ${p.reviewed_by_name}` : ""}`
114
+ : "";
115
+ console.log(` submitted ${fmtTs(p.created_at)}${p.created_by_name ? ` by ${p.created_by_name}` : ""}${reviewed}`);
116
+ console.log(` id ${p.id}`);
117
+ }
118
+ }
119
+ if (resp.truncated) {
120
+ console.log(`\n⚠ ${plans.length} shown and the limit (${resp.limit}) was reached: pass --limit, or narrow with --status / --since.`);
121
+ }
122
+ console.log("\nFull plan: flowiq plans show <plan_id>");
123
+ }
124
+
125
+ export async function show(planId, opts = {}) {
126
+ if (!UUID_RE.test(planId)) {
127
+ console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
128
+ process.exit(1);
129
+ }
130
+ let resp;
131
+ try {
132
+ resp = await http.get("plans", { plan_id: planId });
133
+ } catch (e) {
134
+ fail("Show failed", e);
135
+ }
136
+ if (opts.out) {
137
+ const out = path.resolve(process.cwd(), opts.out);
138
+ await fs.mkdir(path.dirname(out), { recursive: true });
139
+ await fs.writeFile(out, JSON.stringify(resp, null, 2) + "\n", "utf8");
140
+ console.error(`Wrote ${out}`);
141
+ }
142
+ if (opts.json) {
143
+ console.log(JSON.stringify(resp, null, 2));
144
+ return;
145
+ }
146
+
147
+ const p = resp.plan;
148
+ const more = p.more && typeof p.more === "object" ? p.more : {};
149
+ const creative = p.creative && typeof p.creative === "object" ? p.creative : {};
150
+ const flag = p.scheduled_date && p.scheduled_date < resp.today
151
+ ? " (date passed)"
152
+ : p.scheduled_date === resp.today ? " (today)" : "";
153
+
154
+ console.log(p.topic);
155
+ console.log(` org: ${p.organization_name ?? "?"} (${p.organization_id})${p.organization_inactive ? " [inactive org]" : ""}`);
156
+ console.log(` status: ${p.status}${p.origin === "flowapt" ? ` · Flowapt suggestion, client: ${p.client_status ?? "-"}` : ""}`);
157
+ console.log(` send: ${fmtDate(p.scheduled_date, p.scheduled_time)}${flag}`);
158
+ console.log(` type: ${more.type ?? "-"}`);
159
+ console.log(` audience: ${p.has_segment ? (p.segment_details || "segment (no details given)") : "whole list"}`);
160
+ if (p.shopify_segment_formula) console.log(` formula: ${p.shopify_segment_formula}`);
161
+ if (p.product_focus) console.log(` product: ${p.product_focus}`);
162
+ if (p.landing_page_url) console.log(` landing: ${p.landing_page_url}`);
163
+ console.log(` submitted: ${fmtTs(p.created_at)}${p.created_by_name ? ` by ${p.created_by_name}` : ""}`);
164
+ if (p.reviewed_at) console.log(` reviewed: ${fmtTs(p.reviewed_at)}${p.reviewed_by_name ? ` by ${p.reviewed_by_name}` : ""}`);
165
+ if (p.client_approved_at) console.log(` client OK: ${fmtTs(p.client_approved_at)}${p.client_approved_by_name ? ` by ${p.client_approved_by_name}` : ""}`);
166
+ console.log(` id: ${p.id}`);
167
+
168
+ console.log("\nMessage copy");
169
+ console.log(indent(p.copy_text));
170
+
171
+ if (more.slide2?.message) {
172
+ console.log("\nSecond message (sent when a button is tapped)");
173
+ console.log(indent(more.slide2.message));
174
+ }
175
+ if (more.slides && typeof more.slides === "object") {
176
+ for (const [key, slide] of Object.entries(more.slides)) {
177
+ if (!slide || typeof slide !== "object") continue;
178
+ console.log(`\n${key}${slide.triggered_by ? ` (after ${slide.triggered_by})` : ""}`);
179
+ if (slide.message) console.log(indent(slide.message));
180
+ if (slide.media_url) console.log(` media: ${slide.media_url}`);
181
+ }
182
+ }
183
+
184
+ const buttons = Array.isArray(more.buttons) ? more.buttons : [];
185
+ if (buttons.length) {
186
+ console.log("\nButtons");
187
+ for (const b of buttons) console.log(` [${b?.type ?? "?"}] ${b?.text ?? ""}${b?.url ? ` → ${b.url}` : ""}`);
188
+ }
189
+
190
+ console.log("\nCreative");
191
+ if (creative.original_filename || creative.file_url) {
192
+ const size = creative.file_size ? ` · ${(creative.file_size / 1024 / 1024).toFixed(1)} MB` : "";
193
+ console.log(` ${creative.original_filename ?? "(unnamed)"} · ${creative.file_type ?? "type ?"}${size} · supplied by ${creative.supplier ?? "?"}`);
194
+ if (creative.file_url) console.log(` ${creative.file_url}`);
195
+ } else {
196
+ console.log(` none attached${creative.supplier ? ` (supplied by ${creative.supplier})` : ""}`);
197
+ }
198
+
199
+ if (p.template) console.log(`\nTemplate: ${p.template.template_name} (${p.template.status ?? "status ?"}, ${p.template.category ?? "category ?"})`);
200
+ else if (p.template_id) console.log(`\nTemplate id: ${p.template_id}`);
201
+ if (p.send_draft_id) console.log(`Send draft id: ${p.send_draft_id}`);
202
+
203
+ if (p.superadmin_comment) {
204
+ console.log("\nResponse to client");
205
+ console.log(indent(p.superadmin_comment));
206
+ }
207
+ if (p.internal_notes) {
208
+ console.log("\nInternal notes (staff only)");
209
+ console.log(indent(p.internal_notes));
210
+ }
211
+ const notes = resp.notes || [];
212
+ if (notes.length) {
213
+ console.log(`\nNotes (${notes.length})`);
214
+ for (const n of notes) console.log(` ${fmtTs(n.created_at)} ${n.author_name ?? "?"} [${n.kind ?? "note"}]: ${String(n.body ?? "").replace(/\s+/g, " ").trim()}`);
215
+ }
216
+
217
+ console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
218
+ }
219
+
220
+ export async function status(planId, opts = {}) {
221
+ if (!UUID_RE.test(planId)) {
222
+ console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
223
+ process.exit(1);
224
+ }
225
+ const to = String(opts.to || "").toLowerCase();
226
+ if (!STATUSES.includes(to)) {
227
+ console.error(`Error: --to must be one of ${STATUSES.join(" | ")} (got "${opts.to}").`);
228
+ process.exit(1);
229
+ }
230
+ if (to === "rejected" && !(opts.comment && opts.comment.trim())) {
231
+ console.error("Refusing to reject without --comment: the client reads it as the response to their plan.");
232
+ process.exit(1);
233
+ }
234
+
235
+ let resp;
236
+ try {
237
+ resp = await http.post("plans", {
238
+ action: "status",
239
+ plan_id: planId,
240
+ status: to,
241
+ superadmin_comment: opts.comment ?? null,
242
+ internal_notes: opts.internal ?? null,
243
+ dry_run: !opts.commit,
244
+ });
245
+ } catch (e) {
246
+ fail("Status change failed", e);
247
+ }
248
+
249
+ const prev = resp.previous || {};
250
+ const next = resp.next || {};
251
+ console.log(`${resp.dry_run ? "DRY RUN" : "Updated"} · ${resp.topic}`);
252
+ console.log(` org: ${resp.organization_name ?? resp.organization_id}`);
253
+ console.log(` status: ${prev.status} → ${next.status}`);
254
+ if (next.superadmin_comment !== prev.superadmin_comment) console.log(` client reads: ${next.superadmin_comment}`);
255
+ if (next.internal_notes !== prev.internal_notes) console.log(` internal: ${next.internal_notes}`);
256
+ if (resp.reviewer_email) console.log(` reviewer: ${resp.reviewer_email} (reviewed_at ${fmtTs(next.reviewed_at)})`);
257
+ if (resp.dry_run) console.log("\nNothing written. Re-run with --commit to apply.");
258
+ }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ import * as teamUpdatesCmd from "./commands/team-updates.js";
25
25
  import * as agentConfigCmd from "./commands/agent-config.js";
26
26
  import * as agentsCmd from "./commands/agents.js";
27
27
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
28
+ import * as plansCmd from "./commands/plans.js";
28
29
  import * as exportCmd from "./commands/export.js";
29
30
  import * as testCmd from "./commands/agent-test.js";
30
31
  import * as knowledgeCmd from "./commands/knowledge.js";
@@ -445,6 +446,31 @@ export function run(argv) {
445
446
  .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
446
447
  .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
447
448
 
449
+ // plans (Broadcast Planning board: list across orgs, show one plan, change status)
450
+ const plans = program.command("plans")
451
+ .alias("planning")
452
+ .description("Broadcast Planning (Broadcast → Planning): list plans across orgs, show one in full, change a plan's status");
453
+ plans.command("list [organization_id]")
454
+ .description("List plans, default open (draft, pending_review, approved, scheduled), across all active orgs or one org")
455
+ .option("--status <status>", "open (default) | all | pending_review | draft | approved | rejected | scheduled | sent | cancelled (comma-separated allowed)")
456
+ .option("--since <date>", "only plans scheduled on or after YYYY-MM-DD")
457
+ .option("--include-inactive", "include plans from inactive orgs (cross-org listing)")
458
+ .option("--limit <n>", "max plans returned (default 100, max 500)")
459
+ .option("--json", "print the raw response")
460
+ .action((orgId, opts) => plansCmd.list(orgId, opts));
461
+ plans.command("show <plan_id>")
462
+ .description("Show one plan in full: copy, second message, buttons, creative, audience, notes")
463
+ .option("--json", "print the raw response")
464
+ .option("--out <file>", "also write the full plan JSON to a file")
465
+ .action((planId, opts) => plansCmd.show(planId, opts));
466
+ plans.command("status <plan_id>")
467
+ .description("Change a plan's status (dry run unless --commit); approved/rejected also record you as the reviewer")
468
+ .requiredOption("--to <status>", "draft | pending_review | approved | rejected | scheduled | sent | cancelled")
469
+ .option("--comment <text>", "response to the client, shown on their plan (required for rejected)")
470
+ .option("--internal <text>", "staff-only internal notes (replaces the current internal notes)")
471
+ .option("--commit", "apply the change (default is a dry run)")
472
+ .action((planId, opts) => plansCmd.status(planId, opts));
473
+
448
474
  // audit (who did what, when — with the full before/after content)
449
475
  const audit = program.command("audit")
450
476
  .description("Staff-CLI audit trail: who changed what, when — with full before/after content")