@flowapt/flowiq-cli 0.4.3 → 0.4.5

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
@@ -206,8 +206,34 @@ flowiq bc send <org_id> --template … --csv … --campaign july-referrals --com
206
206
 
207
207
  # 4) Interrupted? Continue only unsent rows:
208
208
  flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
209
+
210
+ # SCHEDULE a tag send for later instead of sending now (--at is SAST):
211
+ flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
212
+ --at "2026-08-05 09:00" --commit
213
+ flowiq bc send … --at "2026-08-05 09:00" --needs-approval --commit # park it for approval
214
+
215
+ flowiq bc scheduled list <org_id> # open rows (pending + awaiting-approval)
216
+ flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
217
+ flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
218
+ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
209
219
  ```
210
220
 
221
+ - **Scheduling (v0.4.4)** — `--at "YYYY-MM-DD HH:MM"` on a `--tag` send queues it
222
+ instead of sending. It writes the same `api_request_queue` row the dashboard
223
+ writes, replayed by the `process-api-queue` cron (every 3 min, so it fires
224
+ 0–3 min after the stated minute). Runs **every** pre-send check first
225
+ (APPROVED, positional, param arithmetic, header-media type) — a scheduled
226
+ send can't skip a guard an immediate one enforces.
227
+ - `--at` is always read as **SAST**, never the machine's local timezone, so
228
+ the same command schedules the same instant from anywhere.
229
+ - The **audience resolves when it FIRES**, not when you schedule — someone
230
+ tagged tomorrow morning still receives a send scheduled today.
231
+ - Defaults to fire-by-itself. `--needs-approval` parks it until
232
+ `bc scheduled approve`. ⚠ **A parked row NEVER fires until approved** and
233
+ never errors — `scheduled list` flags those `AWAITING APPROVAL — will NOT
234
+ fire` (two dashboard-scheduled broadcasts silently missed their sends this
235
+ way). Cancel is dry-run unless you pass `--confirm`.
236
+
211
237
  - **Per-row values**: each template slot maps to a CSV **column**, a
212
238
  **literal**, or a **concat transform** (e.g. `Country Code` + `Phone`,
213
239
  leading-zero handled). The mapper auto-suggests from the template's
@@ -552,6 +578,18 @@ flowiq audit show <audit_id> --out entry.json # dump the whole entry
552
578
  Filters: `--endpoint --action --status --agent --target --user --since --until
553
579
  --limit` (default 50, max 200) `--json`.
554
580
 
581
+ **What gets logged.** Every command that CHANGES something writes a row —
582
+ including two that were missed until 4 Aug 2026 and are now covered:
583
+ `flowiq test` (it creates the test contact, can re-enable a contact's bot, and
584
+ clears the conversation) and `flowiq auth refresh` (it mints a new key and
585
+ revokes the old one). Reads and dry-runs deliberately write nothing.
586
+
587
+ **What is never logged:** the content itself. `messages` / `export chats` /
588
+ `shopify` / `woo` record *who read what*, never a copy of the conversation or
589
+ the store data. `flowiq test` records the prompt you sent, the tools the agent
590
+ called and how long its reply was — not the reply. `auth refresh` records key
591
+ ids and prefixes — never a key.
592
+
555
593
  **What's captured, and how much:**
556
594
 
557
595
  | Surface | Action | Content stored |
@@ -729,6 +767,109 @@ server-side (`auto_synthesized: true`; the response says
729
767
  broadcast mapping suggestions — pass your own only when you want real slot
730
768
  labels (e.g. `"body_params": {"1": "first_name", "2": "order_number"}`).
731
769
 
770
+ ### Store API (READ-ONLY) — `flowiq shopify get|gql` / `flowiq woo get`
771
+
772
+ Write your own request against a client's **own** store API. You supply the
773
+ endpoint (or the GraphQL query); the server resolves that org's credentials and
774
+ makes the call. Credentials never reach your machine and are never printed.
775
+
776
+ **Writes are refused, structurally:**
777
+
778
+ * **REST** — the HTTP method is hardcoded `GET` and is never read from your
779
+ request. Every write in both APIs is POST/PUT/PATCH/DELETE, so "GET only" *is*
780
+ "read only". There is no endpoint allowlist to fall out of date, so any
781
+ endpoint — including ones Shopify or Woo ship tomorrow — works immediately.
782
+ * **GraphQL** — the document is lexed (strings and `#` comments stripped) and
783
+ any `mutation` / `subscription` operation is rejected before the call is made.
784
+ Shopify exposes every Admin write as a mutation, so this rejects the whole
785
+ write surface. The word "mutation" inside a *string* or a *comment* is fine.
786
+
787
+ ```bash
788
+ # Shopify REST — any Admin endpoint. The ".json" suffix is optional.
789
+ flowiq shopify get <org_id> orders.json --q status=any --q limit=250
790
+ flowiq shopify get <org_id> products --q limit=5 --q fields=id,title,status
791
+ flowiq shopify get <org_id> inventory_levels.json --q location_ids=123 --all
792
+ flowiq shopify get <org_id> orders/450789469.json # a single record
793
+
794
+ # Shopify GraphQL — you write the query. Inline, from a file, or on stdin.
795
+ flowiq shopify gql <org_id> -q 'query { shop { name currencyCode } }'
796
+ flowiq shopify gql <org_id> --query-file ./q.graphql --var n=50
797
+ cat q.graphql | flowiq shopify gql <org_id>
798
+
799
+ # WooCommerce REST — default namespace wc/v3.
800
+ flowiq woo get <org_id> orders --q status=completed --q per_page=100 --all
801
+ flowiq woo get <org_id> products/1234
802
+ flowiq woo get <org_id> reports/sales --namespace wc/v3 --q period=month
803
+ flowiq woo get <org_id> settings/general --namespace wc/v3
804
+ ```
805
+
806
+ **Output contract — stdout is the payload and nothing else**, so it pipes
807
+ straight into `jq`. The metadata banner (record count, pages, rate-limit
808
+ headroom, timing) goes to **stderr**, so you still see it on your terminal:
809
+
810
+ ```bash
811
+ flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
812
+ flowiq woo get <org_id> orders --all --out ./orders.json # or write to a file
813
+ flowiq shopify get <org_id> shop --json # full envelope incl. metadata
814
+ ```
815
+
816
+ Flags:
817
+
818
+ | Flag | Meaning |
819
+ |---|---|
820
+ | `--q <k=v>` | Query parameter, repeatable. A repeated key becomes a repeated parameter (`--q ids=1 --q ids=2`). Params go here, never in the path. |
821
+ | `--all` | Follow pagination (Shopify's `Link: rel=next`; Woo's `page`) and return every record. |
822
+ | `--max-pages <n>` | Page limit for `--all` (default 10, hard cap 50). |
823
+ | `--namespace <ns>` | Woo REST namespace — default `wc/v3`; also `wc-analytics`, `wp/v2`. |
824
+ | `--api-version <v>` | Shopify Admin API version (default `2024-01`). |
825
+ | `-q, --query` / `--query-file` | The GraphQL document, inline or from a file (stdin also works). |
826
+ | `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
827
+ | `--json` | Print the full envelope (metadata + data) instead of just the payload. |
828
+ | `--compact` | Single-line JSON. |
829
+ | `--table` | Render the returned list as a table instead of JSON. A rendering only — same data, nothing reshaped or filtered. |
830
+ | `--out <file>` | Write the JSON to a file instead of stdout. |
831
+
832
+ #### Two recipes — composed of the SAME calls, and they print them
833
+
834
+ Some questions take 3-4 chained calls and a manual id-join (stock needs
835
+ product → `inventory_item_id` → `inventory_levels` → `locations`). These two do
836
+ that chaining for you — and **echo every raw call they make as a runnable
837
+ command**, so the API stays the interface and you can take those lines further
838
+ than the recipe goes. Nothing here is expressible only through the recipe.
839
+
840
+ ```bash
841
+ flowiq shopify stock <org> "olive oil" # stock per LOCATION NAME, per variant
842
+ flowiq shopify order <org> '#14728' # order + fulfilments + tracking url
843
+ flowiq shopify stock <org> "olive oil" --raw # the underlying JSON instead
844
+ ```
845
+
846
+ ```
847
+ Looking up stock for "1 litre Oil Pourer" — the calls being made:
848
+ $ flowiq shopify get <org> products.json --q title="1 litre Oil Pourer" --q limit=50
849
+ $ flowiq shopify get <org> inventory_levels.json --q inventory_item_ids=45186940764314
850
+ $ flowiq shopify get <org> locations.json --q fields=id,name
851
+ ```
852
+
853
+ `stock` prints **"not tracked"** where Shopify returns `available: null` — that
854
+ means no inventory record exists at that location, which is not the same as zero.
855
+
856
+ Notes:
857
+
858
+ * **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
859
+ response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
860
+ on stderr. A partial result is never presented as a complete one.
861
+ * **Rate limits are surfaced, not hidden.** REST prints Shopify's
862
+ `api calls 3/40`; GraphQL prints the query cost and remaining points.
863
+ * **GraphQL errors are surfaced.** Shopify returns field errors as HTTP 200 with
864
+ an `errors` array; the CLI prints them rather than letting you read
865
+ `data: null` as "no results".
866
+ * **Orgs behind an IP whitelist / WAF** (`feature_flags.ip_whitelist`, e.g.
867
+ Hydroponic on Wordfence) reject calls from our servers. You get an explicit
868
+ message saying so, not an opaque 403.
869
+ * Every call is written to the audit log (`flowiq audit <org>`) — who, which
870
+ org, which endpoint or query, how many records. **The response body is never
871
+ stored**; the log records what was asked, not the customer data that came back.
872
+
732
873
  ### Org — `flowiq org create` / `flowiq org info <organization_id>`
733
874
 
734
875
  Create a brand-new organization, or look one up (read-only; raw store/Meta
@@ -740,6 +881,10 @@ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.
740
881
  # person to already have an account (otherwise invite them later in the app).
741
882
  # Provider defaults to meta. Prints the new org id + the next-step commands.
742
883
 
884
+ flowiq org list # every org + its UUID (the lookup everything else needs)
885
+ flowiq org list african # filter by name or slug
886
+ flowiq org list --all # include inactive orgs
887
+
743
888
  flowiq org info <organization_id> # platform, storefront url, active agent
744
889
  ```
745
890
 
@@ -802,10 +947,22 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
802
947
  Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
803
948
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
804
949
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
805
- `shopify_products_web_chat`, `ticket_tool_status`), `discount.enabled`, and the `flowiq test` contact
950
+ `shopify_products_web_chat`, `ticket_tool_status`, `collapse_product_variants`),
951
+ `discount.enabled`, and the `flowiq test` contact
806
952
  (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
807
953
  rejected; every change is reported before → after.
808
954
 
955
+ **`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
956
+ it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
957
+ **variant rows**, so on a catalogue with several packaging/size variants per product
958
+ the agent sees only a handful of distinct products and will tell a customer an
959
+ in-stock item "isn't showing". ON, the tool fetches `max*6` rows (cap 300) and
960
+ collapses them to ≤`max` **distinct products**, listing each product's variants
961
+ inline. Measured on African Oils: a 40-row response went from **15–24 distinct
962
+ products to 40**, out of a 334-product catalogue. Costs ~50% more tokens per call.
963
+ Requires ≤12 variants per product for the inline rendering branch — check with
964
+ `SELECT max(c) FROM (SELECT count(*) c FROM products_all WHERE organization_id=… GROUP BY product_id) t`.
965
+
809
966
  **Test contact (`--test-contact-number` / `--test-contact-name`, v0.2.9).**
810
967
  This is the contact `flowiq test` drives the live agent through (read by
811
968
  `api/cli/agent-test.js` as `settings.test_contact_number`). ⚠ **Use a clearly
package/TEAM-GUIDE.md CHANGED
@@ -80,8 +80,17 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
80
80
  | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
81
81
  | Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
82
82
  | 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 |
83
+ | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
84
+ | 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 |
85
+ | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
86
+ | Ask a client's Shopify store anything (read-only) | `flowiq shopify get <org_id> orders.json --q status=any --q limit=250` — any Admin REST endpoint; writes are refused |
87
+ | Ask a client's Shopify store something REST can't express | `flowiq shopify gql <org_id> -q 'query { shop { name } }'` — you write the GraphQL; any `mutation` is refused |
88
+ | Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
89
+ | Pull every matching record, not just the first page | add `--all` (bounded — it prints `⚠ TRUNCATED` if it stops early, so a partial answer never looks complete) |
90
+ | Feed store data into jq / a file | stdout is the payload only, banner goes to stderr: `flowiq shopify get <org> orders.json \| jq -r '.orders[].name'` · or `--out ./orders.json` |
83
91
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
84
92
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
93
+ | Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
85
94
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
86
95
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
87
96
  | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
@@ -93,6 +102,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
93
102
  | Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
94
103
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
95
104
  | Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
105
+ | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time) |
106
+ | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
96
107
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`) |
97
108
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
98
109
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
@@ -103,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
103
114
  | **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` |
104
115
  | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
105
116
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
117
+ | Know what the log does and doesn't keep | Anything that CHANGES something is logged — including `flowiq test` (it creates the test contact and clears conversations) and `flowiq auth refresh`. Reads and dry-runs are not. The log never stores the content itself: chats, store data and agent replies are recorded as "who read what", never copied. |
106
118
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
107
119
  | Export an org's full chat history | `flowiq export chats <org_id>` |
108
120
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
@@ -192,6 +204,75 @@ flowiq tag attributes <org_id> --filter allow_broadcast_true \
192
204
  `--exclude` names the same tag you're adding, so every run skips the people who
193
205
  already have it. Safe to re-run as many times as you like.
194
206
 
207
+ ### Scheduling a broadcast
208
+
209
+ Add `--at` to a tag send and it queues instead of sending. The time is **South
210
+ African time**, always — it does not matter where your laptop thinks it is.
211
+
212
+ ```bash
213
+ flowiq bc send <org_id> --tag 5-aug-batch-01 --template my_template \
214
+ --body param1="Hi {{first_name}}" --at "2026-08-05 09:00" --commit
215
+ ```
216
+
217
+ It fires on its own, so there is nothing more to do. Two things worth knowing:
218
+
219
+ - **The audience is worked out when it fires, not now.** Anyone who picks up
220
+ that tag between now and then is included.
221
+ - It goes out **within about 3 minutes** of the time you set, not to the second.
222
+
223
+ Check on it, change your mind, or hand it to someone else to sign off:
224
+
225
+ ```bash
226
+ flowiq bc scheduled list <org_id> # what's queued
227
+ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
228
+ ```
229
+
230
+ If you'd rather someone approved it before it goes out, add `--needs-approval`
231
+ when scheduling. **It then will not send until somebody runs
232
+ `flowiq bc scheduled approve <org_id> <queue_id>`** — `scheduled list` marks it
233
+ `AWAITING APPROVAL — will NOT fire` so it can't be forgotten. (Two broadcasts
234
+ scheduled in the dashboard were missed exactly this way.)
235
+
236
+ ### Asking a client's store a question directly
237
+
238
+ When you need something the dashboard doesn't show — "how many orders are still
239
+ unfulfilled?", "what does this product's inventory actually look like per
240
+ location?", "which orders used that discount code?" — you can ask the client's
241
+ own store, in their own API, without a Shopify or Woo login.
242
+
243
+ ```bash
244
+ # Shopify — any Admin REST endpoint. ".json" is optional.
245
+ flowiq shopify get <org_id> orders.json --q status=any --q financial_status=paid --q limit=250
246
+
247
+ # WooCommerce — same idea.
248
+ flowiq woo get <org_id> orders --q status=processing --all
249
+ ```
250
+
251
+ **You cannot break anything with this.** Reads are the only thing it can do:
252
+ REST calls are locked to `GET`, and a GraphQL document containing a `mutation`
253
+ is refused before it is sent. There is no "are you sure?" because there is
254
+ nothing to be sure about.
255
+
256
+ The answer comes back as JSON on stdout and a summary line on stderr, so it
257
+ pipes into other tools cleanly:
258
+
259
+ ```bash
260
+ flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
261
+ flowiq woo get <org_id> products --all --out ./products.json
262
+ ```
263
+
264
+ Three things worth knowing:
265
+
266
+ - **`--all` pages through everything**, but it stops at a sane limit and tells
267
+ you when it did (`⚠ TRUNCATED — stopped at --max-pages 10`). If you see that,
268
+ narrow the query rather than assuming you have the full set.
269
+ - **If REST can't express your question, write GraphQL** —
270
+ `flowiq shopify gql <org_id> -q 'query { … }'` lets you pick exactly the
271
+ fields and nesting you want. Put a long query in a file and use
272
+ `--query-file`.
273
+ - **Some stores sit behind a firewall** (Hydroponic's, for one) and will refuse
274
+ our call. You'll get a message saying exactly that.
275
+
195
276
  ## Keys, rotation, logging out
196
277
 
197
278
  - **`flowiq auth refresh`** — rotates this device's key in place (a fresh key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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": {
@@ -864,6 +864,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
864
864
  if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
865
865
  if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
866
866
 
867
+ // SCHEDULE (--at): validation above has already run, so a scheduled send is
868
+ // gated by exactly the same checks as an immediate one (APPROVED, positional,
869
+ // param arithmetic, header-media type). Instead of sending we queue the send
870
+ // for later; python resolves the tag at FIRE time, so the audience is fresh.
871
+ if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
872
+
867
873
  // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
868
874
  // python /meta-broadcast engine (the proven bulk sender). --python forces it at
869
875
  // any size; only a ≤10 send stays on the resumable per-row Node engine.
@@ -1002,6 +1008,145 @@ export async function map(orgId, opts = {}) {
1002
1008
  console.log(`Saved mapping: ${cfgPath(campaign)}`);
1003
1009
  }
1004
1010
 
1011
+ /** Parse a `--at` value as SOUTH AFRICAN time (SAST, UTC+2) and return an ISO string.
1012
+ * Accepts "YYYY-MM-DD HH:MM" / "YYYY-MM-DDTHH:MM" (SAST), or a value carrying its
1013
+ * own offset/Z (honoured as given). A bare local string is deliberately NOT handed
1014
+ * to `new Date()`: that interprets it in the RUNNER's timezone, so the same command
1015
+ * would schedule a different moment on a laptop in another country. */
1016
+ export function parseSastAt(input) {
1017
+ const raw = String(input || "").trim();
1018
+ if (!raw) return { error: "empty --at value" };
1019
+ if (/(Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
1020
+ const d = new Date(raw);
1021
+ return Number.isNaN(d.getTime()) ? { error: `not a valid timestamp: ${raw}` } : { iso: d.toISOString(), explicitOffset: true };
1022
+ }
1023
+ const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/);
1024
+ if (!m) return { error: `use "YYYY-MM-DD HH:MM" (SAST), e.g. --at "2026-08-05 09:00" — got "${raw}"` };
1025
+ const [, y, mo, d, h, mi, s] = m;
1026
+ // Build the instant explicitly at +02:00 so the runner's TZ is irrelevant.
1027
+ const iso = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s || "00"}+02:00`);
1028
+ if (Number.isNaN(iso.getTime())) return { error: `not a valid date/time: ${raw}` };
1029
+ return { iso: iso.toISOString(), explicitOffset: false };
1030
+ }
1031
+
1032
+ const fmtSast = (iso) =>
1033
+ new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
1034
+
1035
+ /** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
1036
+ async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
1037
+ const when = parseSastAt(opts.at);
1038
+ if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
1039
+ if (new Date(when.iso).getTime() <= Date.now()) {
1040
+ console.error(`ABORT — --at is in the past (${fmtSast(when.iso)} SAST). Nothing would ever fire.`);
1041
+ process.exit(1);
1042
+ }
1043
+ const needsApproval = !!opts.needsApproval;
1044
+
1045
+ console.log("");
1046
+ renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
1047
+ if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
1048
+ console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
1049
+ }
1050
+ console.log("");
1051
+ console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
1052
+ console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
1053
+ console.log(`Engine: python /meta-broadcast`);
1054
+ console.log(needsApproval
1055
+ ? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
1056
+ : `Approval: none — fires automatically at the scheduled time`);
1057
+
1058
+ if (!commitStage) {
1059
+ console.log("");
1060
+ console.log("DRY RUN — nothing was scheduled. Add --commit to queue it.");
1061
+ return;
1062
+ }
1063
+
1064
+ let resp;
1065
+ try {
1066
+ resp = await http.post("broadcast", {
1067
+ action: "schedule",
1068
+ organization_id: orgId,
1069
+ tag,
1070
+ template_name: templateName,
1071
+ scheduled_for: when.iso,
1072
+ needs_approval: needsApproval,
1073
+ body_parameters: bodyLiterals,
1074
+ ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1075
+ ...(headerMedia ? { header_media: headerMedia } : {}),
1076
+ });
1077
+ } catch (e) {
1078
+ console.error(`Schedule failed: ${e.message}`);
1079
+ if (e.body?.error) console.error(` ${e.body.error}`);
1080
+ process.exit(1);
1081
+ }
1082
+
1083
+ console.log("");
1084
+ console.log(`✓ Scheduled for ${fmtSast(resp.scheduled_for)} SAST — queue id ${resp.queue_id}`);
1085
+ console.log(` ~${resp.audience_at_schedule ?? "?"} contacts carry "${tag}" right now (re-counted at send time).`);
1086
+ if (resp.fires_automatically) {
1087
+ console.log(` It will fire on its own. Cancel with: flowiq bc scheduled cancel ${orgId} ${resp.queue_id}`);
1088
+ } else {
1089
+ console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
1090
+ console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1091
+ }
1092
+ }
1093
+
1094
+ /** List scheduled broadcasts (queue rows). Read-only. */
1095
+ export async function scheduledList(orgId, opts = {}) {
1096
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1097
+ let resp;
1098
+ try {
1099
+ resp = await http.post("broadcast", {
1100
+ action: "scheduled-list",
1101
+ organization_id: orgId,
1102
+ status_filter: opts.all ? "all" : (opts.status || "open"),
1103
+ limit: opts.limit,
1104
+ });
1105
+ } catch (e) { console.error(`Scheduled list failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1106
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1107
+ if (!resp.scheduled?.length) { console.log(`No ${opts.all ? "" : "open "}scheduled broadcasts on ${resp.organization_name}.`); return; }
1108
+ console.log(`Scheduled broadcasts on ${resp.organization_name}:`);
1109
+ for (const s of resp.scheduled) {
1110
+ const flag = s.stalled ? " ⚠ AWAITING APPROVAL — will NOT fire" : "";
1111
+ console.log("");
1112
+ console.log(` ${s.id}`);
1113
+ console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
1114
+ console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1115
+ if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1116
+ if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1117
+ if (s.error_message) console.log(` error: ${s.error_message}`);
1118
+ }
1119
+ console.log("");
1120
+ console.log(` ${resp.count} row(s). Approve: flowiq bc scheduled approve <org> <id> · Cancel: … cancel <org> <id>`);
1121
+ }
1122
+
1123
+ /** Approve (request → pending) or cancel a scheduled broadcast. */
1124
+ export async function scheduledUpdate(orgId, queueId, op, opts = {}) {
1125
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1126
+ if (!UUID_RE.test(queueId || "")) { console.error(`Error: "${queueId}" is not a valid queue id (get it from: flowiq bc scheduled list <org>).`); process.exit(1); }
1127
+ let at = null;
1128
+ if (opts.at) {
1129
+ const parsed = parseSastAt(opts.at);
1130
+ if (parsed.error) { console.error(`Error: --at ${parsed.error}`); process.exit(1); }
1131
+ at = parsed.iso;
1132
+ }
1133
+ if (op === "cancel" && !opts.confirm) {
1134
+ console.log(`DRY RUN — would cancel scheduled broadcast ${queueId}. Add --confirm to cancel.`);
1135
+ return;
1136
+ }
1137
+ let resp;
1138
+ try {
1139
+ resp = await http.post("broadcast", {
1140
+ action: "scheduled-update", organization_id: orgId, queue_id: queueId, op, ...(at ? { scheduled_for: at } : {}),
1141
+ });
1142
+ } catch (e) { console.error(`${op} failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1143
+ if (op === "approve") {
1144
+ console.log(`✓ Approved — ${queueId} is now '${resp.status}' and WILL fire at ${fmtSast(resp.scheduled_for)} SAST.`);
1145
+ } else {
1146
+ console.log(`✓ Cancelled — ${queueId} is now '${resp.status}' and will not fire.`);
1147
+ }
1148
+ }
1149
+
1005
1150
  export async function preview(orgId, opts = {}) {
1006
1151
  await runPipeline(orgId, opts, { commitStage: false, isResume: false });
1007
1152
  }
@@ -6,6 +6,37 @@ import { http } from "../http.js";
6
6
 
7
7
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
8
 
9
+ // `flowiq org list [search]` — the discovery step every other command needed.
10
+ // Until this existed the only way to turn "African Oils" into a UUID was the
11
+ // dashboard or raw SQL, which made every org-scoped command awkward to start.
12
+ export async function list(search, opts = {}) {
13
+ let resp;
14
+ try {
15
+ resp = await http.get("org", { list: "1", search: search || undefined });
16
+ } catch (e) {
17
+ console.error(`Lookup failed: ${e.message}`);
18
+ process.exit(1);
19
+ }
20
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
21
+
22
+ const orgs = (resp.orgs || []).filter((o) => (opts.all ? true : !o.inactive));
23
+ if (!orgs.length) {
24
+ console.log(search ? `No organizations match "${search}".` : "No organizations found.");
25
+ return;
26
+ }
27
+ const pad = (s, n) => String(s ?? "").padEnd(n);
28
+ const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
29
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${pad("PLATFORM", 9)}STORE`);
30
+ for (const o of orgs) {
31
+ console.log(
32
+ `${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
33
+ `${pad(o.platform || "—", 9)}${o.store || ""}${o.inactive ? " (inactive)" : ""}`
34
+ );
35
+ }
36
+ const hidden = (resp.orgs || []).length - orgs.length;
37
+ console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
38
+ }
39
+
9
40
  export async function info(orgId, opts = {}) {
10
41
  if (!UUID_RE.test(orgId)) {
11
42
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -0,0 +1,374 @@
1
+ // `flowiq shopify` / `flowiq woo` — READ-ONLY passthrough to a client org's own
2
+ // store API. You write the request; the server resolves the credentials and
3
+ // refuses anything that isn't a read.
4
+ //
5
+ // flowiq shopify get <org> orders.json --q status=any --q limit=250 --all
6
+ // flowiq shopify gql <org> -q 'query { shop { name currencyCode } }'
7
+ // flowiq woo get <org> orders --q status=completed --all
8
+ //
9
+ // OUTPUT CONTRACT (why this is jq-friendly by default):
10
+ // stdout = the API payload, and nothing else.
11
+ // stderr = the one-line metadata banner (pages, count, rate limit, timing).
12
+ // So `flowiq shopify get <org> orders.json | jq '.orders[].name'` just works,
13
+ // and the banner still shows up on your terminal. `--json` puts the FULL
14
+ // envelope (metadata + data) on stdout instead, for when you want to keep it.
15
+
16
+ import { writeFileSync } from "node:fs";
17
+ import { readFileSync } from "node:fs";
18
+ import { http } from "../http.js";
19
+
20
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+
22
+ /**
23
+ * Repeatable `--q key=value`. A repeated key becomes an array, because several
24
+ * store filters are legitimately multi-valued (`--q ids=1 --q ids=2`).
25
+ */
26
+ export function collectQuery(pair, acc) {
27
+ const idx = pair.indexOf("=");
28
+ if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
29
+ const key = pair.slice(0, idx);
30
+ const value = pair.slice(idx + 1);
31
+ if (key in acc) {
32
+ acc[key] = Array.isArray(acc[key]) ? acc[key].concat(value) : [acc[key], value];
33
+ } else {
34
+ acc[key] = value;
35
+ }
36
+ return acc;
37
+ }
38
+
39
+ /** Repeatable `--var key=value` for GraphQL variables (JSON value if it parses). */
40
+ export function collectVar(pair, acc) {
41
+ const idx = pair.indexOf("=");
42
+ if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
43
+ const key = pair.slice(0, idx);
44
+ const raw = pair.slice(idx + 1);
45
+ try { acc[key] = JSON.parse(raw); } catch { acc[key] = raw; }
46
+ return acc;
47
+ }
48
+
49
+ function requireOrg(orgId) {
50
+ if (!UUID_RE.test(orgId || "")) {
51
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
52
+ console.error("Find it with: flowiq org info <organization_id>");
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ /** Read a query from --query-file, an inline flag, or piped stdin. */
58
+ function resolveQueryText(opts) {
59
+ if (opts.queryFile) {
60
+ try {
61
+ return readFileSync(opts.queryFile, "utf8");
62
+ } catch (e) {
63
+ console.error(`Could not read --query-file ${opts.queryFile}: ${e.message}`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ if (opts.query) return opts.query;
68
+ if (!process.stdin.isTTY) {
69
+ try {
70
+ const piped = readFileSync(0, "utf8");
71
+ if (piped.trim()) return piped;
72
+ } catch { /* nothing piped */ }
73
+ }
74
+ console.error("Error: pass a query with -q/--query, --query-file <path>, or on stdin.");
75
+ console.error("Example: flowiq shopify gql <org> -q 'query { shop { name } }'");
76
+ process.exit(1);
77
+ }
78
+
79
+ function banner(resp) {
80
+ const bits = [];
81
+ bits.push(`${resp.organization?.name ?? "?"} · ${resp.platform}${resp.mode === "graphql" ? " graphql" : ""}`);
82
+ if (resp.count != null) bits.push(`${resp.count} record${resp.count === 1 ? "" : "s"}`);
83
+ if (resp.pages > 1) bits.push(`${resp.pages} pages`);
84
+ if (resp.total_pages) bits.push(`of ${resp.total_pages} available`);
85
+ if (resp.rate_limit) bits.push(`api calls ${resp.rate_limit}`);
86
+ if (resp.cost?.throttleStatus) {
87
+ const t = resp.cost.throttleStatus;
88
+ bits.push(`cost ${resp.cost.actualQueryCost ?? "?"} · ${Math.round(t.currentlyAvailable)}/${t.maximumAvailable} left`);
89
+ }
90
+ if (resp.auth_mode === "query") bits.push("auth via query string (host stripped the header)");
91
+ bits.push(`${resp.duration_ms}ms`);
92
+ console.error(`→ ${bits.join(" · ")}`);
93
+
94
+ if (resp.truncated) {
95
+ console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
96
+ console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
97
+ }
98
+ if (resp.graphql_errors?.length) {
99
+ console.error(`⚠ GraphQL returned ${resp.graphql_errors.length} error(s):`);
100
+ for (const err of resp.graphql_errors.slice(0, 5)) {
101
+ console.error(` ${err.message}${err.path ? ` (at ${Array.isArray(err.path) ? err.path.join(".") : err.path})` : ""}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Print a flat array of objects as a table. Only ever a RENDERING of whatever
108
+ * the API returned — it never changes, filters or reshapes the data, so
109
+ * `--table` and the default JSON are the same answer in two skins.
110
+ */
111
+ function renderTable(rows) {
112
+ if (!Array.isArray(rows) || rows.length === 0) return null;
113
+ const flat = rows.map((r) => {
114
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return { value: r };
115
+ const o = {};
116
+ for (const [k, v] of Object.entries(r)) {
117
+ o[k] = v === null || v === undefined ? ""
118
+ : typeof v === "object" ? (Array.isArray(v) ? `[${v.length}]` : "{…}")
119
+ : String(v);
120
+ }
121
+ return o;
122
+ });
123
+ const cols = [...new Set(flat.flatMap((r) => Object.keys(r)))];
124
+ if (!cols.length) return null;
125
+ const w = {};
126
+ for (const c of cols) w[c] = Math.min(48, Math.max(c.length, ...flat.map((r) => (r[c] ?? "").length)));
127
+ const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
128
+ const lines = [cols.map((c) => cut(c.toUpperCase(), w[c])).join(" ")];
129
+ lines.push(cols.map((c) => "─".repeat(w[c])).join(" "));
130
+ for (const r of flat) lines.push(cols.map((c) => cut(r[c] ?? "", w[c])).join(" "));
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /** The single array in a Shopify-style `{orders: [...]}` wrapper, or the array itself. */
135
+ function primaryArray(data) {
136
+ if (Array.isArray(data)) return data;
137
+ if (data && typeof data === "object") {
138
+ const keys = Object.keys(data).filter((k) => Array.isArray(data[k]));
139
+ if (keys.length === 1) return data[keys[0]];
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function emit(resp, opts) {
145
+ banner(resp);
146
+ const payload = opts.json ? resp : resp.data;
147
+
148
+ if (opts.table && !opts.json) {
149
+ const rows = primaryArray(resp.data);
150
+ const table = rows ? renderTable(rows) : renderTable([resp.data]);
151
+ if (table) {
152
+ if (opts.out) { writeFileSync(opts.out, table + "\n"); console.error(`✓ wrote ${opts.out}`); return; }
153
+ console.log(table);
154
+ return;
155
+ }
156
+ console.error("(--table: response is not a flat list; printing JSON)");
157
+ }
158
+
159
+ const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
160
+ if (opts.out) {
161
+ writeFileSync(opts.out, text);
162
+ console.error(`✓ wrote ${opts.out} (${text.length.toLocaleString()} chars)`);
163
+ return;
164
+ }
165
+ console.log(text);
166
+ }
167
+
168
+ async function send(payload, opts) {
169
+ let resp;
170
+ try {
171
+ resp = await http.post("store-api", payload);
172
+ } catch (e) {
173
+ console.error(`Request failed: ${e.message}`);
174
+ if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
175
+ process.exit(1);
176
+ }
177
+ emit(resp, opts);
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // shopify
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export async function shopifyGet(orgId, path, opts = {}) {
185
+ requireOrg(orgId);
186
+ await send({
187
+ platform: "shopify",
188
+ organization_id: orgId,
189
+ mode: "rest",
190
+ path,
191
+ query: opts.q || {},
192
+ all: !!opts.all,
193
+ max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
194
+ api_version: opts.apiVersion,
195
+ }, opts);
196
+ }
197
+
198
+ export async function shopifyGql(orgId, opts = {}) {
199
+ requireOrg(orgId);
200
+ const query = resolveQueryText(opts);
201
+ await send({
202
+ platform: "shopify",
203
+ organization_id: orgId,
204
+ mode: "graphql",
205
+ graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
206
+ api_version: opts.apiVersion,
207
+ }, opts);
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Recipes — NOT wrappers.
212
+ //
213
+ // These answer the two questions people actually ask ("what's the stock on X",
214
+ // "did this order ship") which otherwise take 3-4 chained calls and a manual
215
+ // id-join. They are composed of THE SAME `shopify get` passthrough calls you
216
+ // could type yourself, and they PRINT EVERY CALL THEY MAKE as a runnable
217
+ // command — so the raw API stays the interface, and anyone (or any agent)
218
+ // reading the output can copy those lines, change them, and go further than
219
+ // the recipe does. Nothing here is expressible only through the recipe.
220
+ // `--raw` dumps the underlying JSON instead of the summary.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** Run a passthrough GET, echoing the equivalent command so it can be reused. */
224
+ async function rawGet(orgId, path, query, { label } = {}) {
225
+ const qs = Object.entries(query || {})
226
+ .flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]]))
227
+ .map(([k, v]) => `--q ${k}=${/[ "']/.test(String(v)) ? JSON.stringify(String(v)) : v}`)
228
+ .join(" ");
229
+ console.error(` $ flowiq shopify get <org> ${path}${qs ? " " + qs : ""}${label ? ` # ${label}` : ""}`);
230
+ const resp = await http.post("store-api", {
231
+ platform: "shopify", organization_id: orgId, mode: "rest", path, query: query || {},
232
+ });
233
+ return resp.data;
234
+ }
235
+
236
+ export async function shopifyStock(orgId, search, opts = {}) {
237
+ requireOrg(orgId);
238
+ if (!search || !String(search).trim()) {
239
+ console.error('Error: give me something to look for, e.g. flowiq shopify stock <org> "olive oil"');
240
+ process.exit(1);
241
+ }
242
+ console.error(`Looking up stock for "${search}" — the calls being made:`);
243
+ let products, levels, locations;
244
+ try {
245
+ // 1. name → products + variants (variants carry inventory_item_id)
246
+ const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
247
+ products = (p?.products || []).filter((x) =>
248
+ String(x.title).toLowerCase().includes(String(search).toLowerCase()));
249
+ if (!products.length) {
250
+ const all = await rawGet(orgId, "products.json", { limit: 250, fields: "id,title,variants,status" },
251
+ { label: "no title match — scanning the catalogue" });
252
+ products = (all?.products || []).filter((x) =>
253
+ String(x.title).toLowerCase().includes(String(search).toLowerCase()));
254
+ }
255
+ if (!products.length) {
256
+ console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
257
+ `the passthrough can query anything else Shopify exposes.`);
258
+ process.exit(1);
259
+ }
260
+ const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
261
+ // 2. inventory_item_id → per-location levels 3. location_id → names
262
+ levels = itemIds.length
263
+ ? (await rawGet(orgId, "inventory_levels.json",
264
+ { inventory_item_ids: itemIds.slice(0, 50).join(","), limit: 250 },
265
+ { label: "per-location levels" }))?.inventory_levels || []
266
+ : [];
267
+ locations = (await rawGet(orgId, "locations.json", { fields: "id,name" },
268
+ { label: "turn location ids into names" }))?.locations || [];
269
+ } catch (e) {
270
+ console.error(`\nRequest failed: ${e.message}`);
271
+ process.exit(1);
272
+ }
273
+
274
+ if (opts.raw) { console.log(JSON.stringify({ products, inventory_levels: levels, locations }, null, 2)); return; }
275
+
276
+ const locName = new Map(locations.map((l) => [l.id, l.name]));
277
+ const byItem = new Map();
278
+ for (const l of levels) {
279
+ if (!byItem.has(l.inventory_item_id)) byItem.set(l.inventory_item_id, []);
280
+ byItem.get(l.inventory_item_id).push(l);
281
+ }
282
+ const rows = [];
283
+ for (const p of products) {
284
+ for (const v of p.variants || []) {
285
+ const ls = byItem.get(v.inventory_item_id) || [];
286
+ const per = ls.map((l) => {
287
+ // `available: null` is Shopify saying "this item is not TRACKED at this
288
+ // location" — not zero, and not an error. Say so, rather than printing
289
+ // null and letting the reader guess.
290
+ const qty = l.available === null || l.available === undefined ? "not tracked" : String(l.available);
291
+ return `${locName.get(l.location_id) || l.location_id}: ${qty}`;
292
+ });
293
+ rows.push({
294
+ product: p.title,
295
+ variant: v.title === "Default Title" ? "—" : v.title,
296
+ sku: v.sku || "",
297
+ price: v.price ?? "",
298
+ policy: v.inventory_policy === "continue" ? "oversell ok" : "stop at 0",
299
+ total: v.inventory_quantity ?? "",
300
+ per_location: per.length ? per.join(" · ") : "(no level rows — untracked)",
301
+ });
302
+ }
303
+ }
304
+ console.error("");
305
+ console.log(renderTable(rows) ?? "(no variants)");
306
+ console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
307
+ `"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
308
+ }
309
+
310
+ export async function shopifyOrder(orgId, ref, opts = {}) {
311
+ requireOrg(orgId);
312
+ const name = String(ref).trim();
313
+ console.error(`Looking up order ${name} — the calls being made:`);
314
+ let order;
315
+ try {
316
+ if (/^\d{10,}$/.test(name)) {
317
+ order = (await rawGet(orgId, `orders/${name}.json`, {}, { label: "by Shopify order id" }))?.order;
318
+ } else {
319
+ const r = await rawGet(orgId, "orders.json",
320
+ { name, status: "any", limit: 5 }, { label: "by order name" });
321
+ order = (r?.orders || [])[0];
322
+ }
323
+ } catch (e) {
324
+ console.error(`\nRequest failed: ${e.message}`);
325
+ process.exit(1);
326
+ }
327
+ if (!order) {
328
+ console.error(`\nNo order matching "${name}". Note Shopify matches the ORDER NAME (#14728), not the ` +
329
+ `confirmation number customers are shown on the thank-you page.`);
330
+ process.exit(1);
331
+ }
332
+ if (opts.raw) { console.log(JSON.stringify(order, null, 2)); return; }
333
+
334
+ const f = order.fulfillments || [];
335
+ console.error("");
336
+ console.log(`${order.name} ${order.financial_status || "?"} / ${order.fulfillment_status || "unfulfilled"}`);
337
+ console.log(` placed ${order.created_at}`);
338
+ console.log(` total ${order.currency} ${order.total_price}`);
339
+ console.log(` customer ${[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}` +
340
+ `${order.email ? ` · ${order.email}` : ""}`);
341
+ console.log(` items ${(order.line_items || []).map((li) => `${li.quantity}× ${li.title}`).join(", ") || "—"}`);
342
+ if (!f.length) {
343
+ console.log(` shipping nothing fulfilled yet`);
344
+ } else {
345
+ for (const ff of f) {
346
+ console.log(` shipment ${ff.status}` +
347
+ ` · ${ff.tracking_company || "no carrier recorded"}` +
348
+ ` · ${ff.tracking_number || "no tracking number"}` +
349
+ ` · courier status: ${ff.shipment_status || "none reported"}` +
350
+ ` · ${(ff.line_items || []).length} item(s)`);
351
+ if (ff.tracking_url) console.log(` ${ff.tracking_url}`);
352
+ }
353
+ }
354
+ console.error(`\nFull payload: add --raw (or use the passthrough directly — ` +
355
+ `flowiq shopify get <org> orders/${order.id}.json)`);
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // woo
360
+ // ---------------------------------------------------------------------------
361
+
362
+ export async function wooGet(orgId, path, opts = {}) {
363
+ requireOrg(orgId);
364
+ await send({
365
+ platform: "woo",
366
+ organization_id: orgId,
367
+ mode: "rest",
368
+ path,
369
+ namespace: opts.namespace,
370
+ query: opts.q || {},
371
+ all: !!opts.all,
372
+ max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
373
+ }, opts);
374
+ }
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ import * as tagCmd from "./commands/tag.js";
30
30
  import * as keywordsCmd from "./commands/keywords.js";
31
31
  import * as guideCmd from "./commands/guide.js";
32
32
  import * as auditCmd from "./commands/audit.js";
33
+ import * as storeApiCmd from "./commands/store-api.js";
33
34
  import { maybeNotifyUpdate } from "./update-check.js";
34
35
 
35
36
  // Read version from package.json so it stays in sync with the published npm
@@ -210,6 +211,11 @@ export function run(argv) {
210
211
 
211
212
  // org (read-only org summary for the prompt-builder skill, creds stripped)
212
213
  const org = program.command("org").description("Org info (read) + create a new organization");
214
+ org.command("list [search]")
215
+ .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
216
+ .option("--all", "include inactive organizations")
217
+ .option("--json", "raw JSON")
218
+ .action((search, opts) => orgCmd.list(search, opts));
213
219
  org.command("create")
214
220
  .description("Create a NEW organization (org row + admin membership; owner defaults to you)")
215
221
  .requiredOption("--name <name>", "organization name")
@@ -241,7 +247,7 @@ export function run(argv) {
241
247
  .option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
242
248
  .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
243
249
  .option("--rename <name>", "rename the agent")
244
- .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat", agentConfigCmd.collectTool, [])
250
+ .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
245
251
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
246
252
  .option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
247
253
  .option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
@@ -426,6 +432,8 @@ export function run(argv) {
426
432
  .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
427
433
  .option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
428
434
  .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")
435
+ .option("--at <when>", "with --tag: SCHEDULE instead of sending now — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). Fires automatically; audience is resolved at send time")
436
+ .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
429
437
  .option("--commit", "actually send (omit to dry-run)")
430
438
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
431
439
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
@@ -445,6 +453,24 @@ export function run(argv) {
445
453
  .option("--rate <n>", "max messages per second (hard cap 10)", "8")
446
454
  .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
447
455
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
456
+ const scheduled = broadcast.command("scheduled")
457
+ .description("Scheduled broadcasts (queue rows): list / approve / cancel");
458
+ scheduled.command("list <organization_id>")
459
+ .description("List scheduled broadcasts. Default: only OPEN rows (pending + awaiting-approval)")
460
+ .option("--all", "include fired / cancelled / failed rows too")
461
+ .option("--status <s>", "filter to one status (pending|request|completed|cancelled|failed)")
462
+ .option("--limit <n>", "max rows (default 25, max 200)")
463
+ .option("--json", "raw JSON")
464
+ .action((orgId, opts) => broadcastCmd.scheduledList(orgId, opts));
465
+ scheduled.command("approve <organization_id> <queue_id>")
466
+ .description("Approve a parked ('request') scheduled broadcast so it will fire")
467
+ .option("--at <when>", "also re-time it — \"YYYY-MM-DD HH:MM\" SAST")
468
+ .action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "approve", opts));
469
+ scheduled.command("cancel <organization_id> <queue_id>")
470
+ .description("Cancel a scheduled broadcast so it never fires (dry-run unless --confirm)")
471
+ .option("--confirm", "actually cancel")
472
+ .action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "cancel", opts));
473
+
448
474
  broadcast.command("list-remote <organization_id>")
449
475
  .description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
450
476
  .option("--limit <n>", "how many to show (default 25, max 200)")
@@ -594,6 +620,58 @@ export function run(argv) {
594
620
  .description("List local keyword snapshots")
595
621
  .action(() => keywordsCmd.list());
596
622
 
623
+ // shopify / woo — READ-ONLY passthrough to the client's own store API.
624
+ // You write the request; creds are resolved server-side and never displayed.
625
+ // stdout is the payload (pipe it to jq); the metadata banner goes to stderr.
626
+ const shopify = program.command("shopify")
627
+ .description("Read-only passthrough to an org's Shopify Admin API (REST + GraphQL). Writes are refused.");
628
+ shopify.command("get <organization_id> <path>")
629
+ .description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
630
+ .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
631
+ .option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
632
+ .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
633
+ .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
634
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
635
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
636
+ .option("--compact", "single-line JSON")
637
+ .option("--out <file>", "write the JSON to a file instead of stdout")
638
+ .action((orgId, path, opts) => storeApiCmd.shopifyGet(orgId, path, opts));
639
+ // Recipes: the same passthrough calls, composed — and every call is PRINTED
640
+ // as a runnable command so the raw API stays the interface.
641
+ shopify.command("stock <organization_id> <search>")
642
+ .description("Stock per location for products matching a title — chains products → inventory_levels → locations and PRINTS each raw call it makes")
643
+ .option("--raw", "print the underlying JSON from every call instead of the summary table")
644
+ .action((orgId, search, opts) => storeApiCmd.shopifyStock(orgId, search, opts));
645
+ shopify.command("order <organization_id> <name-or-id>")
646
+ .description("One order with its fulfilments + tracking, e.g. `order <org> #14728` — PRINTS the raw call it makes")
647
+ .option("--raw", "print the full order JSON instead of the summary")
648
+ .action((orgId, ref, opts) => storeApiCmd.shopifyOrder(orgId, ref, opts));
649
+ shopify.command("gql <organization_id>")
650
+ .description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
651
+ .option("-q, --query <graphql>", "the query document, inline")
652
+ .option("--query-file <path>", "read the query document from a file")
653
+ .option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
654
+ .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
655
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
656
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
657
+ .option("--compact", "single-line JSON")
658
+ .option("--out <file>", "write the JSON to a file instead of stdout")
659
+ .action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
660
+
661
+ const woo = program.command("woo")
662
+ .description("Read-only passthrough to an org's WooCommerce REST API. Writes are refused.");
663
+ woo.command("get <organization_id> <path>")
664
+ .description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
665
+ .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
666
+ .option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
667
+ .option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
668
+ .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
669
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
670
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
671
+ .option("--compact", "single-line JSON")
672
+ .option("--out <file>", "write the JSON to a file instead of stdout")
673
+ .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
674
+
597
675
  // guide (bundled docs — always match the installed version)
598
676
  program.command("guide")
599
677
  .description("Read the team guide (how we use this CLI); --reference for the full command reference")