@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 +74 -1
- package/TEAM-GUIDE.md +5 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +157 -5
- package/src/commands/plans.js +258 -0
- package/src/index.js +40 -0
- package/src/reply-routing.test.mjs +58 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
+
});
|