@flowapt/flowiq-cli 0.7.2 → 0.7.4

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
@@ -193,7 +193,7 @@ flowiq ct list
193
193
  - **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
194
194
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
195
195
 
196
- ### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|retry|list` (alias `bc`)
196
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|list-remote|status|route|retry|list` (alias `bc`)
197
197
 
198
198
  Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
199
199
  template's variables **per row** from the CSV's own columns. The
@@ -237,6 +237,16 @@ flowiq bc scheduled list <org_id> # open rows (pending + await
237
237
  flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
238
238
  flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
239
239
  flowiq bc scheduled cancel <org_id> <queue_id> --confirm
240
+
241
+ # REPLY ROUTING (v0.7.4): assign EVERY reply to this broadcast to a team / person.
242
+ flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
243
+ --route-team "Spa" --commit # typed replies AND button taps → the Spa team
244
+ flowiq bc send … --route-team "Spa" --route-member kim@client.com --route-window 48h --no-route-notify
245
+ flowiq bc send … --csv people.csv --campaign 17Sep_Spa --route-team "Spa" --at "2026-09-18 09:00" --commit
246
+
247
+ flowiq bc route <org_id> <broadcastId> # show what a broadcast routes to + how many chats it has routed
248
+ flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set it on a broadcast that already went out
249
+ flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
240
250
  ```
241
251
 
242
252
  - **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
@@ -295,6 +305,37 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
295
305
  template. In **tag mode the campaign slug defaults to the tag**, so to send
296
306
  template A then template B to the SAME tag, pass a distinct `--campaign` for
297
307
  each. `resume` passes no `--template`, so it is never affected.
308
+ - **Reply routing — `--route-team` / `--route-member` on `send`, and `bc route` (v0.7.4)**:
309
+ "every reply to this broadcast goes to team X / person Y". The first message a
310
+ recipient sends inside the window (default **72 h** after the send, `--route-window
311
+ 48h|3d`, max 14 d) assigns the chat — **once per recipient per broadcast** — and
312
+ notifies the target through the normal assignment notification (the Teams-page
313
+ notify settings + the org's two notification flags still decide whether anything is
314
+ actually sent; `--no-route-notify` assigns silently). It is the fix for the gap a
315
+ quick-reply button + exact keyword + `assign_chat` cannot close: that route only
316
+ catches button TAPS, and a broad keyword mutes the AI agent. Routing is a side
317
+ effect — **keywords and the AI agent answer exactly as they would have**, and it
318
+ also applies to a chat whose agent is switched off. Skipped: business auto-replies
319
+ ("thank you for contacting…", out of office) and opt-outs (`stop`). An internal
320
+ note in the chat says why it was assigned. **An existing assignment is
321
+ overwritten**, same as the keyword action.
322
+ - `--route-team` takes a team name (case-insensitive; a unique partial match is
323
+ fine) or id; `--route-member` takes an email, a name or an id and must be an
324
+ ACTIVE member of that org. A typo stops the run in the **dry run** with the org's
325
+ teams listed — nothing is tagged, queued or sent.
326
+ - The routing is stored on the broadcast (`broadcasts.reply_routing`), so it
327
+ **forces the python engine at any size** (the per-row Node engine writes one
328
+ broadcast per recipient). Works in tag mode, CSV mode and with `--at` (the queued
329
+ send carries it and it is applied the moment the send fires).
330
+ - After a commit the CLI prints `Replies → … ✓ saved`. If it prints **REPLY ROUTING
331
+ NOT SAVED / NOT CONFIRMED**, the messages still went out — fix it straight away
332
+ with `bc route <org> <broadcastId> --team … --commit`.
333
+ - `bc route` with no flags shows the current routing and how many chats it has
334
+ routed; with `--team/--member` sets it; `--clear` removes it. Dry-run unless
335
+ `--commit`, audited. Only replies arriving AFTER the change are routed — nothing
336
+ is applied retroactively. `bc status` prints the routing too.
337
+ - Using routing AND a button keyword with `assign_chat` on the same campaign is
338
+ harmless but notifies twice on a tap — drop the keyword's assign when routing is on.
298
339
  - **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
299
340
  the **full broadcastId** per row + template, status, recipient count and SAST
300
341
  created time — the discovery step `status` / `retry` need (previously the id
@@ -1586,6 +1627,38 @@ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
1586
1627
  - An interactive confirm shows exactly what the client will read; `--yes`
1587
1628
  skips the confirm (but never the `--note` requirement).
1588
1629
 
1630
+ ### Broadcast planning — `flowiq plans list|show|status` (alias `planning`) (v0.7.3)
1631
+
1632
+ The Broadcast → Planning board in the app (the campaign plans clients submit
1633
+ and Flowapt reviews), from the terminal.
1634
+
1635
+ ```bash
1636
+ flowiq plans list # open plans across every ACTIVE client
1637
+ flowiq plans list --status pending_review # waiting for Flowapt review
1638
+ flowiq plans list <organization_id> --status all --since 2026-09-01
1639
+ flowiq plans show <plan_id> # copy, second message, buttons, creative, audience, notes
1640
+ flowiq plans show <plan_id> --json --out plan.json
1641
+ flowiq plans status <plan_id> --to approved # dry run
1642
+ flowiq plans status <plan_id> --to rejected --comment "Please send the image as a PNG" --commit
1643
+ flowiq plans status <plan_id> --to sent --commit
1644
+ ```
1645
+
1646
+ - **`list`** defaults to open plans (`draft`, `pending_review`, `approved`,
1647
+ `scheduled`) across all active orgs; pass an org id for one client,
1648
+ `--status all` / a status / a comma list to widen or narrow, `--since` to
1649
+ filter by send date, `--include-inactive` for inactive orgs. The header
1650
+ shows the totals for every status, and each row prints the full plan id.
1651
+ - **`show`** prints the plan the way the dialog does: message copy, the
1652
+ second message sent when a button is tapped, buttons with their URLs, the
1653
+ creative (file name + link), audience, template link, the response to the
1654
+ client, internal notes and the notes thread.
1655
+ - **`status`** is a dry run until `--commit`. `--comment` is the response the
1656
+ **client reads**; `--internal` is staff-only. `approved` and `rejected`
1657
+ record you as the reviewer; `rejected` requires `--comment`. Every commit is
1658
+ in `flowiq audit --endpoint plans`.
1659
+ - Editing a plan's copy, buttons or links is not in the CLI yet; do that in
1660
+ the app.
1661
+
1589
1662
  ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
1590
1663
 
1591
1664
  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. |
@@ -169,6 +172,8 @@ several.
169
172
  | Check how a broadcast is landing (accepted → delivered → read, plus failed/pending) | `flowiq bc status <org_id> <broadcastId>` (from the send output, or `bc list-remote`) |
170
173
  | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
171
174
  | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
175
+ | Make EVERY reply to a broadcast land with a team or a person (client asks "all replies to the Spa team") | Add `--route-team "Spa"` (and/or `--route-member kim@client.com`) to the `bc send`. The first reply from each recipient within 72 hours (`--route-window 48h` to change) assigns the chat and notifies them, typed replies and button taps alike. The AI agent and keywords still answer as normal, so nothing is muted. The dry run shows `Replies → team Spa …`; a wrong team name stops the run and lists the org's teams. Works with `--csv` and `--at` too. |
176
+ | Add, check or remove that routing on a broadcast that already went out | `flowiq bc route <org_id> <broadcastId>` shows it and how many chats it has routed · `… --team "Spa" --commit` sets it · `… --clear --commit` removes it. Only replies from now on are affected. |
172
177
  | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
173
178
  | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
174
179
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
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.4",
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": {
@@ -944,6 +944,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
944
944
  }
945
945
  }
946
946
 
947
+ // Reply routing: resolved before the dry-run exit so the preview shows it, and
948
+ // before the upsert so a bad team name never tags a single contact.
949
+ const replyRouting = isResume ? null : await checkRouting(orgId, opts);
950
+
947
951
  if (!commitStage) {
948
952
  console.log("");
949
953
  console.log(`DRY RUN — nothing was ${scheduledAt ? "scheduled" : "sent"}, no contacts written or tagged. Add --commit to ${scheduledAt ? "queue it" : "send"}.`);
@@ -968,6 +972,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
968
972
  body_parameters: tokens.body,
969
973
  ...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
970
974
  ...(headerMedia ? { header_media: headerMedia } : {}),
975
+ ...(replyRouting ? { reply_routing: replyRouting } : {}),
971
976
  dry_run: dryRun,
972
977
  });
973
978
  let pyTotal = null;
@@ -1013,6 +1018,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
1013
1018
  body_parameters: tokens.body,
1014
1019
  ...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
1015
1020
  ...(headerMedia ? { header_media: headerMedia } : {}),
1021
+ ...(replyRouting ? { reply_routing: replyRouting } : {}),
1016
1022
  });
1017
1023
  } catch (e) {
1018
1024
  console.error(`Schedule failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
@@ -1035,6 +1041,10 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
1035
1041
  console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1036
1042
  }
1037
1043
  console.log(` Also visible/manageable on the dashboard's Scheduled sends page.`);
1044
+ if (replyRouting) {
1045
+ if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
1046
+ else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
1047
+ }
1038
1048
  return;
1039
1049
  }
1040
1050
 
@@ -1052,6 +1062,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
1052
1062
  console.log(` Python is sending in the background to tag "${tag}" (${pyTotal ?? workSet.length} contact(s)) and tracking it under that broadcast id.`);
1053
1063
  console.log(` Delivery: flowiq bc status ${orgId} ${out.broadcastId || "<broadcast_id>"} [--failures]`);
1054
1064
  console.log(` Failures: flowiq bc retry ${orgId} ${out.broadcastId || "<broadcast_id>"} --commit`);
1065
+ if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
1055
1066
  }
1056
1067
 
1057
1068
  /** The paced, write-ahead-logged per-row send loop + final report. */
@@ -1146,13 +1157,98 @@ export function collectKV(pair, mapAcc) {
1146
1157
  * (allow_broadcast=true, not blocked) and sends in the background, returning a
1147
1158
  * broadcastId. No CLI write-ahead log / resume for this engine (python owns the
1148
1159
  * broadcast record). Dry-run (no --commit) asks python for the eligible count. */
1149
- async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
1160
+ // ── Reply routing (v0.7.4) ───────────────────────────────────────────────────
1161
+ // `--route-team` / `--route-member` = "assign every reply to this broadcast to …".
1162
+ // The server resolves names → ids, stores it on the broadcast, and the agent runtime
1163
+ // applies it once per recipient inside the window WITHOUT muting keywords or the AI.
1164
+
1165
+ /** "72" | "72h" | "3d" → whole hours, or { error }. */
1166
+ export function parseRouteWindow(input) {
1167
+ if (input === undefined || input === null || input === "") return { hours: null };
1168
+ const m = String(input).trim().toLowerCase().match(/^(\d{1,4})\s*(h|hr|hrs|hours?|d|days?)?$/);
1169
+ if (!m) return { error: `"${input}" is not a window — use hours or days, e.g. 72, 72h or 3d` };
1170
+ const n = Number(m[1]);
1171
+ const hours = m[2] && m[2].startsWith("d") ? n * 24 : n;
1172
+ if (hours < 1 || hours > 336) return { error: `the window must be between 1 hour and 14 days (got ${hours} h)` };
1173
+ return { hours };
1174
+ }
1175
+
1176
+ /** The send flags → the request's reply_routing object (null when no routing asked for). */
1177
+ export function routingFromOpts(opts = {}, keys = { team: "routeTeam", member: "routeMember", window: "routeWindow", notify: "routeNotify" }) {
1178
+ const team = typeof opts[keys.team] === "string" ? opts[keys.team].trim() : "";
1179
+ const member = typeof opts[keys.member] === "string" ? opts[keys.member].trim() : "";
1180
+ const hasWindow = opts[keys.window] !== undefined && opts[keys.window] !== null && opts[keys.window] !== "";
1181
+ const noNotify = opts[keys.notify] === false;
1182
+ if (!team && !member) {
1183
+ if (hasWindow || noNotify) return { error: "the routing window / no-notify flags need a team or a member to route to" };
1184
+ return { routing: null };
1185
+ }
1186
+ const win = parseRouteWindow(opts[keys.window]);
1187
+ if (win.error) return { error: win.error };
1188
+ return {
1189
+ routing: {
1190
+ ...(team ? { team } : {}),
1191
+ ...(member ? { member } : {}),
1192
+ ...(win.hours ? { window_hours: win.hours } : {}),
1193
+ ...(noNotify ? { notify: false } : {}),
1194
+ },
1195
+ };
1196
+ }
1197
+
1198
+ export function describeRouting(d) {
1199
+ if (!d) return "none";
1200
+ const who = [d.team_name ? `team ${d.team_name}` : null, d.member_name || d.member_email || null].filter(Boolean).join(" + ") || "(unnamed target)";
1201
+ const notes = [];
1202
+ if (d.team_name) notes.push(d.notify_team === false ? "team not notified" : "team notified");
1203
+ if (d.member_name || d.member_email) notes.push(d.notify_member === false ? "person not notified" : "person notified");
1204
+ return `${who} · first reply within ${d.window_hours ?? 72} h · ${notes.join(", ")}`;
1205
+ }
1206
+
1207
+ /** Resolve the routing on the server BEFORE anything is tagged, queued or sent. Exits on a bad team/member. */
1208
+ async function checkRouting(orgId, opts) {
1209
+ const parsed = routingFromOpts(opts);
1210
+ if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
1211
+ if (!parsed.routing) return null;
1212
+ let resp;
1213
+ try { resp = await http.post("broadcast", { action: "resolve-routing", organization_id: orgId, reply_routing: parsed.routing }); }
1214
+ catch (e) {
1215
+ console.error(`Reply routing refused: ${e.body?.error || e.message}`);
1216
+ if (/unknown action|Unknown action|not supported/i.test(String(e.body?.error || "")) || e.status === 404) {
1217
+ console.error(" (the server does not know reply routing yet — run `flowiq doctor`)");
1218
+ }
1219
+ process.exit(1);
1220
+ }
1221
+ if (!resp?.reply_routing) {
1222
+ console.error("Reply routing refused: the server did not confirm it (it may predate v0.7.4 — run `flowiq doctor`). Nothing sent.");
1223
+ process.exit(1);
1224
+ }
1225
+ console.log(`Replies → ${describeRouting(resp.reply_routing)}`);
1226
+ console.log(` (assigned once per recipient; keywords and the AI agent still answer as normal)`);
1227
+ return parsed.routing;
1228
+ }
1229
+
1230
+ /** After a commit: say plainly whether the routing landed. */
1231
+ function reportRoutingResult(orgId, rr, broadcastId) {
1232
+ if (!rr) {
1233
+ console.log(" ⚠ REPLY ROUTING NOT CONFIRMED by the server — replies will NOT be assigned.");
1234
+ if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
1235
+ return;
1236
+ }
1237
+ if (rr.saved) console.log(` Replies → ${describeRouting(rr.requested)} ✓ saved`);
1238
+ else {
1239
+ console.log(` ⚠ REPLY ROUTING NOT SAVED: ${rr.error || "unknown reason"}`);
1240
+ if (broadcastId) console.log(` Fix now: flowiq bc route ${orgId} ${broadcastId} --team … --commit`);
1241
+ }
1242
+ }
1243
+
1244
+ async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
1150
1245
  const reqBody = (dryRun) => ({
1151
1246
  action: "send-python", organization_id: orgId, tag, template_name: templateName,
1152
1247
  body_parameters: bodyLiterals,
1153
1248
  ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1154
1249
  ...(headerMedia ? { header_media: headerMedia } : {}),
1155
1250
  ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
1251
+ ...(replyRouting ? { reply_routing: replyRouting } : {}),
1156
1252
  dry_run: dryRun,
1157
1253
  });
1158
1254
 
@@ -1189,6 +1285,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
1189
1285
  console.log("");
1190
1286
  console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
1191
1287
  console.log(` Python is sending in the background${out.mode ? ` (${out.mode})` : ""} and tracking it under that broadcast id — no CLI resume for this engine.`);
1288
+ if (replyRouting) reportRoutingResult(orgId, out.reply_routing, out.broadcastId ?? out.broadcast_id ?? null);
1192
1289
  }
1193
1290
 
1194
1291
  async function runTagPipeline(orgId, opts, { commitStage }) {
@@ -1259,7 +1356,11 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1259
1356
  const cardOverrides = carouselPlan?.overrides ?? null;
1260
1357
  const cardLines = carouselPlan?.lines ?? [];
1261
1358
 
1262
- if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
1359
+ // Reply routing is resolved HERE — after the template checks, before any engine is
1360
+ // picked — so a mistyped team stops the run in the dry run, not after a send.
1361
+ const replyRouting = await checkRouting(orgId, opts);
1362
+
1363
+ if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
1263
1364
 
1264
1365
  // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
1265
1366
  // python /meta-broadcast engine (the proven bulk sender). --python forces it at
@@ -1270,8 +1371,13 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1270
1371
  if (template.is_carousel && !opts.python) {
1271
1372
  console.log("Carousel template → PYTHON engine at any size (the per-row Node engine cannot send carousels).");
1272
1373
  }
1273
- if (opts.python || template.is_carousel) {
1274
- return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines });
1374
+ // Reply routing lives on ONE broadcasts row, which only the python engine creates per
1375
+ // send (the per-row Node engine writes a row per recipient) → python at any size.
1376
+ if (replyRouting && !opts.python && !template.is_carousel) {
1377
+ console.log("Reply routing → PYTHON engine at any size (the routing is stored on the single broadcast python creates).");
1378
+ }
1379
+ if (opts.python || template.is_carousel || replyRouting) {
1380
+ return runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides, cardLines, replyRouting });
1275
1381
  }
1276
1382
 
1277
1383
  // resolve the tag server-side (broadcast-safe recipients only). A >2000 tag
@@ -1433,7 +1539,7 @@ const fmtSast = (iso) =>
1433
1539
  new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
1434
1540
 
1435
1541
  /** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
1436
- async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [] }) {
1542
+ async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
1437
1543
  const when = parseSastAt(opts.at);
1438
1544
  if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
1439
1545
  if (new Date(when.iso).getTime() <= Date.now()) {
@@ -1475,6 +1581,7 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1475
1581
  ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1476
1582
  ...(headerMedia ? { header_media: headerMedia } : {}),
1477
1583
  ...(cardOverrides ? { card_overrides: cardOverrides } : {}),
1584
+ ...(replyRouting ? { reply_routing: replyRouting } : {}),
1478
1585
  });
1479
1586
  } catch (e) {
1480
1587
  console.error(`Schedule failed: ${e.message}`);
@@ -1491,6 +1598,10 @@ async function scheduleTagSend(orgId, { opts, tag, templateName, template, heade
1491
1598
  console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
1492
1599
  console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1493
1600
  }
1601
+ if (replyRouting) {
1602
+ if (resp.reply_routing?.saved) console.log(` Replies → ${describeRouting(resp.reply_routing.requested)} ✓ queued with the send (applied the moment it fires)`);
1603
+ else console.log(` ⚠ REPLY ROUTING NOT CONFIRMED by the server — cancel and re-schedule, or set it after it fires with: flowiq bc route ${orgId} <broadcast_id> --team … --commit`);
1604
+ }
1494
1605
  }
1495
1606
 
1496
1607
  /** List scheduled broadcasts (queue rows). Read-only. */
@@ -1515,6 +1626,7 @@ export async function scheduledList(orgId, opts = {}) {
1515
1626
  console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
1516
1627
  console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1517
1628
  if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1629
+ if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
1518
1630
  if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1519
1631
  if (s.error_message) console.log(` error: ${s.error_message}`);
1520
1632
  }
@@ -1628,6 +1740,11 @@ export async function status(orgId, broadcastId, opts = {}) {
1628
1740
  if (b.total_recipients) {
1629
1741
  console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
1630
1742
  }
1743
+ if (resp.reply_routing) {
1744
+ const rr = resp.reply_routing;
1745
+ console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
1746
+ console.log(` ${rr.routed} chat(s) routed so far${rr.set_by ? ` · set by ${rr.set_by}` : ""}`);
1747
+ }
1631
1748
  if (opts.failures && resp.failures) {
1632
1749
  console.log("");
1633
1750
  console.log(` Failures by reason:`);
@@ -1641,6 +1758,41 @@ export async function status(orgId, broadcastId, opts = {}) {
1641
1758
  }
1642
1759
  }
1643
1760
 
1761
+ /** Show / set / clear reply routing on a broadcast that already exists. Dry run unless --commit. */
1762
+ export async function route(orgId, broadcastId, opts = {}) {
1763
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1764
+ if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (see `flowiq bc list-remote <org>`)."); process.exit(1); }
1765
+ const parsed = routingFromOpts(opts, { team: "team", member: "member", window: "window", notify: "notify" });
1766
+ if (parsed.error) { console.error(`Error: ${parsed.error}`); process.exit(1); }
1767
+ if (opts.clear && parsed.routing) { console.error("Error: pass --clear OR a --team/--member, not both."); process.exit(1); }
1768
+ let resp;
1769
+ try {
1770
+ resp = await http.post("broadcast", {
1771
+ action: "route", organization_id: orgId, broadcast_id: broadcastId,
1772
+ ...(parsed.routing ? { reply_routing: parsed.routing } : {}),
1773
+ ...(opts.clear ? { clear: true } : {}),
1774
+ commit: !!opts.commit,
1775
+ });
1776
+ } catch (e) { console.error(`Route failed: ${e.body?.error || e.message}`); process.exit(1); }
1777
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1778
+ const b = resp.broadcast;
1779
+ console.log(`Broadcast ${b.id} — ${resp.organization_name}`);
1780
+ console.log(` template: ${b.template_name} · sent ${fmtSast(b.created_at)} SAST · ${b.total_recipients ?? "?"} recipient(s)`);
1781
+ console.log(` replies → ${resp.current ? describeRouting(resp.current) : "no routing set"}`);
1782
+ console.log(` ${resp.routed} chat(s) routed so far`);
1783
+ if (resp.mode === "show") {
1784
+ console.log("");
1785
+ console.log(`Set it: flowiq bc route ${orgId} ${b.id} --team "<team>" [--member <email>] [--window 72h] [--no-notify] --commit`);
1786
+ console.log(`Clear it: flowiq bc route ${orgId} ${b.id} --clear --commit`);
1787
+ return;
1788
+ }
1789
+ console.log("");
1790
+ console.log(` ${resp.mode === "clear" ? "CLEAR" : "SET"} → ${resp.mode === "clear" ? "no routing (replies are no longer assigned)" : describeRouting(resp.next)}`);
1791
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
1792
+ if (resp.dry_run) { console.log(""); console.log("DRY RUN — nothing changed. Add --commit to apply."); return; }
1793
+ console.log(` ✓ saved. Only replies that arrive from now on are routed; chats already routed are left as they are.`);
1794
+ }
1795
+
1644
1796
  /** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
1645
1797
  * Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
1646
1798
  * shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
@@ -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")
@@ -602,6 +628,10 @@ export function run(argv) {
602
628
  .option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
603
629
  .option("--at <when>", "SCHEDULE instead of sending now (tag AND CSV mode, v0.6.1) — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). CSV: rows are imported+tagged NOW, the python call is queued server-side and shows on the dashboard's Scheduled sends page. Fires automatically; the tag is re-resolved at send time")
604
630
  .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
631
+ .option("--route-team <team>", "REPLY ROUTING (v0.7.4): assign every reply to this broadcast to this team (name or id). Once per recipient, typed replies AND button taps, without muting keywords or the AI agent. Forces the python engine")
632
+ .option("--route-member <who>", "REPLY ROUTING: also/instead assign to this person (email, name or id) — must be an active member of the org")
633
+ .option("--route-window <dur>", "REPLY ROUTING: how long after the send a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
634
+ .option("--no-route-notify", "REPLY ROUTING: assign silently — do not send the team/person their assignment notification")
605
635
  .option("--commit", "actually send (omit to dry-run)")
606
636
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
607
637
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
@@ -653,6 +683,16 @@ export function run(argv) {
653
683
  .option("--limit <n>", "with --failures: how many failed rows to print", "40")
654
684
  .option("--json", "raw JSON")
655
685
  .action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
686
+ broadcast.command("route <organization_id> <broadcast_id>")
687
+ .description("Reply routing on an EXISTING broadcast: no flags = show it; --team/--member = set it; --clear = switch it off. DRY-RUN unless --commit. Only replies arriving after the change are routed")
688
+ .option("--team <team>", "assign replies to this team (name or id)")
689
+ .option("--member <who>", "assign replies to this person (email, name or id)")
690
+ .option("--window <dur>", "how long after the SEND a reply still counts, e.g. 72h or 3d (default 72h, max 14d)")
691
+ .option("--no-notify", "assign silently (no assignment notification)")
692
+ .option("--clear", "remove the routing from this broadcast")
693
+ .option("--commit", "apply (omit to dry-run)")
694
+ .option("--json", "raw JSON")
695
+ .action((orgId, broadcastId, opts) => broadcastCmd.route(orgId, broadcastId, opts));
656
696
  broadcast.command("retry <organization_id> <broadcast_id>")
657
697
  .description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
658
698
  .option("--commit", "actually re-send (omit to see the failed count + reasons)")
@@ -0,0 +1,58 @@
1
+ // Reply routing flag parsing (v0.7.4) — pure functions only, no network.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { parseRouteWindow, routingFromOpts, describeRouting } from "./commands/broadcast.js";
5
+
6
+ test("window: hours, h suffix, days", () => {
7
+ assert.deepEqual(parseRouteWindow("72"), { hours: 72 });
8
+ assert.deepEqual(parseRouteWindow("72h"), { hours: 72 });
9
+ assert.deepEqual(parseRouteWindow(" 3D "), { hours: 72 });
10
+ assert.deepEqual(parseRouteWindow("14 days"), { hours: 336 });
11
+ assert.deepEqual(parseRouteWindow(undefined), { hours: null });
12
+ });
13
+
14
+ test("window: rejects garbage, zero and anything past 14 days", () => {
15
+ assert.ok(parseRouteWindow("soon").error);
16
+ assert.ok(parseRouteWindow("0").error);
17
+ assert.ok(parseRouteWindow("15d").error);
18
+ assert.ok(parseRouteWindow("-5").error);
19
+ assert.ok(parseRouteWindow("1.5h").error);
20
+ });
21
+
22
+ test("no routing flags = no routing, and the send request stays untouched", () => {
23
+ assert.deepEqual(routingFromOpts({ routeNotify: true }), { routing: null });
24
+ assert.deepEqual(routingFromOpts({}), { routing: null });
25
+ });
26
+
27
+ test("a window or --no-route-notify without a target is an error, never a silent no-op", () => {
28
+ assert.ok(routingFromOpts({ routeWindow: "48h" }).error);
29
+ assert.ok(routingFromOpts({ routeNotify: false }).error);
30
+ });
31
+
32
+ test("team + member + window + silent", () => {
33
+ assert.deepEqual(
34
+ routingFromOpts({ routeTeam: " Spa ", routeMember: "kim@example.com", routeWindow: "2d", routeNotify: false }),
35
+ { routing: { team: "Spa", member: "kim@example.com", window_hours: 48, notify: false } },
36
+ );
37
+ // Defaults are left to the server (72 h, notify on) — the client sends only what was typed.
38
+ assert.deepEqual(routingFromOpts({ routeTeam: "Spa", routeNotify: true }), { routing: { team: "Spa" } });
39
+ });
40
+
41
+ test("a bad window stops the run", () => {
42
+ assert.ok(routingFromOpts({ routeTeam: "Spa", routeWindow: "whenever" }).error);
43
+ });
44
+
45
+ test("`bc route` reads its own flag names", () => {
46
+ const keys = { team: "team", member: "member", window: "window", notify: "notify" };
47
+ assert.deepEqual(routingFromOpts({ team: "Spa", window: "24", notify: true }, keys), { routing: { team: "Spa", window_hours: 24 } });
48
+ assert.deepEqual(routingFromOpts({ notify: true }, keys), { routing: null });
49
+ });
50
+
51
+ test("describeRouting reads like a sentence", () => {
52
+ assert.equal(describeRouting(null), "none");
53
+ assert.equal(
54
+ describeRouting({ team_name: "Spa", member_name: null, window_hours: 72, notify_team: true, notify_member: null }),
55
+ "team Spa · first reply within 72 h · team notified",
56
+ );
57
+ assert.match(describeRouting({ team_name: "Spa", member_name: "Kim", window_hours: 24, notify_team: false, notify_member: false }), /team Spa \+ Kim .* 24 h .* team not notified, person not notified/);
58
+ });