@flowapt/flowiq-cli 0.4.9 → 0.6.1

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
@@ -199,6 +199,16 @@ template's variables **per row** from the CSV's own columns. The
199
199
  highest-stakes command in the CLI — built as a map → preview → commit
200
200
  pipeline with several layers of deliberate friction.
201
201
 
202
+ **v0.6.0 — the CSV send is a server-side fan-out.** A commit imports every
203
+ sendable row as a contact carrying the campaign tag `bc-<campaign>` plus that
204
+ row's values as campaign-scoped attributes (`bc_<campaign>_param1…` /
205
+ `_button`), then fires **ONE** python `/meta-broadcast` call whose params are
206
+ `{{attributes.bc_<campaign>_paramN}}` tokens — python resolves them **per
207
+ contact** (body AND URL-button) and paces the send server-side. The CLI
208
+ returns in seconds; closing the laptop no longer matters. A 1,846-row CSV
209
+ that took ~77 min on the old per-row loop is now python-paced like any big
210
+ tag send.
211
+
202
212
  ```bash
203
213
  # 1) Build the column→param mapping interactively (saved per campaign, no send)
204
214
  flowiq bc map <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
@@ -206,11 +216,15 @@ flowiq bc map <org_id> --template rewards_referral_v1 --csv ./people.csv --campa
206
216
  # 2) Dry-run: validation + exact rendered messages for sample rows (default)
207
217
  flowiq bc send <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
208
218
 
209
- # 3) Commit: upsert contacts, then send per row @ ≤8/s
219
+ # 3) Commit: import+tag the rows, fire ONE python broadcast (returns a broadcastId)
210
220
  flowiq bc send <org_id> --template … --csv … --campaign july-referrals --commit
211
221
  # → prompts: type the campaign name to confirm
222
+ # → then audit with: flowiq bc status <org_id> <broadcastId> [--failures]
223
+
224
+ # 4) Re-fire ONLY the failed recipients (python campaigns have no per-row resume):
225
+ flowiq bc retry <org_id> <broadcastId> --commit
212
226
 
213
- # 4) Interrupted? Continue only unsent rows:
227
+ # (pre-v0.6.0 campaigns with a per-row status log still finish on the old loop:)
214
228
  flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
215
229
 
216
230
  # SCHEDULE a tag send for later instead of sending now (--at is SAST):
@@ -224,12 +238,20 @@ flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (-
224
238
  flowiq bc scheduled cancel <org_id> <queue_id> --confirm
225
239
  ```
226
240
 
227
- - **Scheduling (v0.4.4)** — `--at "YYYY-MM-DD HH:MM"` on a `--tag` send queues it
228
- instead of sending. It writes the same `api_request_queue` row the dashboard
229
- writes, replayed by the `process-api-queue` cron (every 3 min, so it fires
230
- 0–3 min after the stated minute). Runs **every** pre-send check first
241
+ - **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
242
+ send instead of firing it. It writes the same `api_request_queue` row the
243
+ dashboard writes, replayed by the `process-api-queue` cron (every 3 min, so it
244
+ fires 0–3 min after the stated minute). Runs **every** pre-send check first
231
245
  (APPROVED, positional, param arithmetic, header-media type) — a scheduled
232
246
  send can't skip a guard an immediate one enforces.
247
+ - **CSV mode (v0.6.1):** the rows are imported + tagged **NOW** (same as an
248
+ immediate send); only the python call is queued. Scheduled CSV campaigns
249
+ therefore show on the dashboard's Scheduled sends page and can be
250
+ approved/cancelled from there or via `bc scheduled` — nothing depends on
251
+ anyone's laptop being awake at send time. Python re-resolves the tag when
252
+ it fires, so anyone tagged `bc-<campaign>` in the meantime is included.
253
+ A scheduled campaign counts as fired for the re-run guard — cancel the
254
+ queued row first, then re-run with `--resend`.
233
255
  - `--at` is always read as **SAST**, never the machine's local timezone, so
234
256
  the same command schedules the same instant from anywhere.
235
257
  - The **audience resolves when it FIRES**, not when you schedule — someone
@@ -307,10 +329,31 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
307
329
  DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
308
330
  Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
309
331
  retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
332
+ - **CSV fan-out semantics (v0.6.0)** — the things the engine change makes true:
333
+ - **Per-row values persist on the contact** as `bc_<campaign>_paramN` /
334
+ `_button` attributes (and the `bc-<campaign>` tag) — deliberate: they're the
335
+ audit trail of exactly what each person was sent. Re-running a campaign
336
+ overwrites them with the current CSV's values. Names are safe: a blank/missing
337
+ CSV name can never null a real contact's name (the upsert RPC preserves it).
338
+ - **One campaign = one audience.** A campaign that already fanned out refuses
339
+ a re-run (`--resend` overrides, and resends to EVERY tagged contact — for
340
+ failures use `bc retry`, which re-fires only the failed ones). Smoke-test
341
+ with `--limit` under a THROWAWAY `--campaign`: a limited fan-off still
342
+ records the campaign as fired, and the full run would need `--resend`.
343
+ - **The audience is the TAG, not the file.** If an earlier run of the same
344
+ campaign tagged rows since removed from the CSV, python still resolves them —
345
+ the CLI cross-checks python's count against the CSV after tagging and makes
346
+ you type `YES` when the tag carries MORE people than the file.
347
+ - No per-row write-ahead log or `resume` on fresh sends — the campaign log
348
+ records ONE `handoff` line (broadcastId); delivery/failure detail lives in
349
+ `bc status <org> <broadcastId> [--failures]` and `bc retry`.
350
+ - `--rate` / `--skip-upsert` only apply to pre-v0.6.0 `resume`; fresh sends
351
+ ignore them (python paces itself; the upsert IS the mechanism).
310
352
  - **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
311
353
  recipients **always uses the python engine** (a `>2000` tag routes there too).
312
- Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
313
- python at any size. (CSV per-row sends stay on Node — python takes uniform params.)
354
+ Only a ≤10 tag send stays on the per-row Node engine. `--python` forces
355
+ python at any size. **CSV sends are ALWAYS python from v0.6.0** (per-row values
356
+ ride as contact attributes — see above).
314
357
  - **`--python` engine (v0.3.4, `send --tag` only)**: hand the whole send to the
315
358
  **python `/meta-broadcast` endpoint** — the *same* sender the dashboard's "Python
316
359
  endpoint" toggle uses — via a staff-gated proxy (the master key stays server-side
@@ -350,17 +393,19 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
350
393
  blocked contacts are skipped, always.
351
394
  - **Dry-run is the default**; `--commit` + typing the campaign name is the
352
395
  only way to send (`--yes` for CI skips the typing, never the dry-run).
353
- - **Write-ahead status log** (`.flowiq/campaigns/<campaign>.status.jsonl`):
354
- every row logs `sending` *before* the POST and `sent`/`failed` after. A
355
- crash mid-row leaves an *ambiguous* row that is **never auto-resent** —
356
- it's surfaced for manual review. `resume` sends only never-attempted rows
357
- (`--retry-failed` adds confirmed failures).
358
- - **Error handling**: `#132000` aborts the run (config bug — every row would
359
- fail); rate errors back off exponentially and halve the send rate; a Meta
360
- daily-cap soft-stops cleanly with a resume hint.
361
- - Contacts are **pre-upserted** (`/cli/contacts-upsert`, ≤4000/batch, retries
362
- then aborts) so the send payload never needs a `name` key — the classic
363
- `#132000` footgun.
396
+ - **Campaign status log** (`.flowiq/campaigns/<campaign>.status.jsonl`): a
397
+ fresh (v0.6.0) send records a `meta` header + ONE `handoff` line carrying the
398
+ `broadcastId` — the re-run guard and your local pointer to `bc status`.
399
+ Pre-v0.6.0 campaigns keep their per-row `sending`/`sent`/`failed` lines and
400
+ finish on `resume` (ambiguous mid-crash rows are still never auto-resent;
401
+ `--retry-failed` adds confirmed failures).
402
+ - **Error handling**: all pre-send validation is unchanged and client-side;
403
+ per-recipient send errors are python-side — audit them with
404
+ `bc status <org> <broadcastId> --failures`, re-fire with `bc retry`.
405
+ - Contacts are **imported/updated** via `/cli/contacts-upsert` (≤4000/batch,
406
+ retries then aborts) carrying the campaign tag + attributes; the python
407
+ params are pure `{{attributes.*}}` tokens, so the payload never needs a
408
+ `name` key — the classic `#132000` footgun stays structurally impossible.
364
409
 
365
410
  **Tag mode** — send to everyone carrying a tag (e.g. a `segments` batch tag)
366
411
  instead of a CSV. Same guards, same status log, same resume:
@@ -759,6 +804,40 @@ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
759
804
  allowed enum, valid http(s) URL, no duplicate (platform, type, url)
760
805
  tuples.
761
806
 
807
+ **Auth fields (19 Aug 2026).** Each row also carries `auth_type`
808
+ (`none` | `bearer` | `basic` | `api_key` | `oauth2_client_credentials`),
809
+ `auth_config`, `headers` and `secret` — how FlowIQ authenticates when it POSTs
810
+ the event, plus an optional HMAC signing key. Pull returns all four; push
811
+ writes all four.
812
+
813
+ **Because push is FULL-REPLACE, a body that omits these fields STRIPS the
814
+ credentials off every webhook.** Always pull-edit-push the same file; never
815
+ hand-write a push body. The dry-run diff includes an auth fingerprint, so
816
+ changing only a credential correctly shows as `to insert 1 / to delete 1`
817
+ rather than "unchanged".
818
+
819
+ ```jsonc
820
+ {
821
+ "url": "https://middleware.example.com/notification/log",
822
+ "type": "sent_message",
823
+ "platform": "whatsapp",
824
+ "auth_type": "oauth2_client_credentials",
825
+ "auth_config": {
826
+ "token_url": "https://middleware.example.com/oauth2/token",
827
+ "client_id": "acme_flowapt",
828
+ "client_secret": "…",
829
+ "scope": "notify:write", // optional
830
+ "client_auth": "basic" // optional: "basic" (default) | "body"
831
+ },
832
+ "headers": { "X-Tenant": "acme" }, // optional static headers
833
+ "secret": "…" // optional → X-Flowiq-Signature: sha256=<hmac>
834
+ }
835
+ ```
836
+
837
+ Push refuses an `auth_type` whose credentials are incomplete (e.g. `bearer`
838
+ with no `auth_config.token`), because that would make every delivery fail
839
+ closed — FlowIQ never falls back to an unauthenticated send.
840
+
762
841
  ### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
763
842
 
764
843
  Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
@@ -834,6 +913,33 @@ Editable fields: `name`, `description`, `status` (open / in_progress / done),
834
913
  null), `organizations[]`, `media[]`, `messages[]`, `created_by`. Array columns
835
914
  are full-replace within the row.
836
915
 
916
+ ### Client hours — `flowiq hours log|list|summary` (v0.5.0)
917
+
918
+ The client-hours ledger. Every org's package includes **10 hours of Flowapt
919
+ work per calendar month**; every piece of client work gets logged against it —
920
+ manually here, or automatically by Claude sessions via the FlowIQ MCP's
921
+ `log_client_hours` tool. Clients see their own usage on the Client Console;
922
+ the cross-org view lives in the super-admin Changelog dialog → Client hours.
923
+
924
+ ```bash
925
+ flowiq hours log <org_id> --hours 1.5 --desc "Rebuilt the abandoned-cart copy" # fractions fine
926
+ flowiq hours log <org_id> --minutes 45 --desc "Fixed template header" --date 2026-08-18
927
+ flowiq hours list <org_id> # this month's entries + package standing
928
+ flowiq hours list <org_id> --month 2026-07 # a past month
929
+ flowiq hours summary # every org with logged work this month
930
+ ```
931
+
932
+ - `log` takes exactly ONE of `--minutes <n>` (whole minutes) or `--hours <h>`
933
+ (fractions fine — `--hours 1.5` = 90 min). `--desc` is required and should be
934
+ plain language the client could read (it shows on their Client Console).
935
+ - `--by` overrides who did the work (default: your staff login email);
936
+ `--date YYYY-MM-DD` backdates an entry (it counts toward THAT month);
937
+ `--session` tags the session/task name.
938
+ - `list` / `summary` default to the current month and print each org's usage
939
+ against the 10h package, flagging any org that has gone over.
940
+ - Corrections (delete/edit) live in the Changelog → Client hours page, not the
941
+ CLI. Every `log` is audited.
942
+
837
943
  ### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
838
944
 
839
945
  Read an org's live templates straight from Meta (read-only), and submit new
package/TEAM-GUIDE.md CHANGED
@@ -60,7 +60,11 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
60
60
  would change before anything writes.
61
61
  3. **Pushes are full-replace.** What's in your file becomes the whole surface
62
62
  (all prompt sections, all tools, all webhooks). Deleting an entry from the
63
- file deletes it from the platform.
63
+ file deletes it from the platform. This is also why you must never
64
+ hand-write a push body: messaging webhooks carry their own credentials
65
+ (`auth_type` / `auth_config` / `headers` / `secret`), and a body that leaves
66
+ those fields out strips the auth off every webhook for that org. Pull, edit
67
+ that same file, push it.
64
68
  4. **`--agent <id>` targets a specific agent**; without it you get the org's
65
69
  *active* agent. Files remember which agent they came from, so a push always
66
70
  goes back to the agent you pulled — never "whatever is active now".
@@ -101,7 +105,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
101
105
  | Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign X --content X --commit` |
102
106
  | Check which links a campaign has, and their clicks | `flowiq links list <org_id> --campaign 13Aug_Seeds` |
103
107
  | A link already looks short (`linklnk.io/abc123`) — can I re-tag it? | No: paste the DESTINATION url instead. Re-shortening keeps the old campaign tag and splits the click count, so the CLI refuses it |
104
- | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
108
+ | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit`. **v0.6.0: the commit imports every row as a contact (tag `bc-<campaign>` + the row's values as attributes) and fires ONE python broadcast** — it returns in seconds with a `broadcastId`; python sends in the background. Check delivery with `bc status`, re-fire failures with `bc retry`. A campaign that already fired refuses a re-run (`--resend` deliberately resends to everyone tagged). |
105
109
  | Tag every repeat buyer (e.g. 2+ orders in the last 60 days) | `flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d` → `flowiq seg apply <org_id> repeat-60d` (dry run) → `… --commit` |
106
110
  | Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
107
111
  | Advanced Tagging in the terminal (one named tag on a matched set) | `flowiq tag field\|cohort\|segment\|attributes\|messages <org_id> …` (dry-run) → add `--tag <name> --commit` |
@@ -110,7 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
110
114
  | 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/…`) |
111
115
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
112
116
  | 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) |
113
- | 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) |
117
+ | 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). **Works with --csv too (v0.6.1):** rows import+tag immediately, the send queues server-side, and it shows on the dashboard's Scheduled sends page. Nothing depends on your laptop being on at send time. |
114
118
  | 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` |
115
119
  | 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>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
116
120
  | 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. |
@@ -128,12 +132,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
128
132
  | Export an org's full chat history | `flowiq export chats <org_id>` |
129
133
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
130
134
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
131
- | Manage outbound messaging webhooks | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
135
+ | Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
132
136
  | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
133
137
  | See a client's pending change requests | `flowiq au pull <org_id>` |
134
138
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
135
139
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
136
140
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
141
+ | Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
142
+ | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
137
143
  | Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
138
144
 
139
145
  The flow is the same everywhere: **pull → edit the JSON → push**. Slugs are the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.9",
3
+ "version": "0.6.1",
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": {
@@ -6,17 +6,23 @@
6
6
  // send <org> --template T --csv F [--commit] full pipeline; DRY-RUN by default
7
7
  // resume <org> --campaign C [--commit] continue from the status log
8
8
  //
9
- // Pipeline: introspect template (live Meta + DB labels, via /cli/broadcast) →
10
- // parse CSV → build/load column→param MAPPING (each row fills the template
11
- // with its OWN values) → validate (V-1..V-13) → broadcast-safety classify
12
- // (opted-out/archived/blocked are skipped) → preview → [--commit] upsert
13
- // contacts (/cli/contacts-upsert, ≤4000/batch, 3× retry then ABORT) → send
14
- // per row via /api/send-template (org-UUID branch, NO auth header), paced
15
- // ≤10 msg/s, with a WRITE-AHEAD per-row status log:
16
- // {status:"sending"} appended BEFORE the POST, the terminal sent/failed
17
- // line after — so a crash mid-row leaves an ambiguous row that is NEVER
18
- // auto-resent (surfaced for manual review). Resume dedups on
19
- // normalized-number-within-campaign, never on CSV fingerprint.
9
+ // Pipeline (v0.6.0 — python server-side fan-out): introspect template (live
10
+ // Meta + DB labels, via /cli/broadcast) → parse CSV → build/load column→param
11
+ // MAPPING (each row fills the template with its OWN values) → validate
12
+ // (V-1..V-13) → broadcast-safety classify (opted-out/archived/blocked are
13
+ // skipped) → preview → [--commit] upsert contacts (/cli/contacts-upsert,
14
+ // ≤4000/batch, 3× retry then ABORT) carrying the campaign TAG bc-<campaign>
15
+ // plus each row's values as campaign-scoped ATTRIBUTES
16
+ // (bc_<campaign>_paramN / _button) → ONE python /meta-broadcast call with
17
+ // {{attributes.bc_<campaign>_paramN}} tokens, which python resolves PER
18
+ // CONTACT (broadcast.py substitute_contact_fields — body AND URL-button
19
+ // params). The send is fire-and-forget: python owns pacing + the broadcast
20
+ // record; audit via `bc status <org> <broadcastId>` / `bc retry`.
21
+ //
22
+ // The OLD per-row Node loop (write-ahead status log, `resume`) survives ONLY
23
+ // to finish campaigns started before v0.6.0 — a fresh CSV send never uses it.
24
+ // A python campaign records ONE {type:"handoff"} line instead; re-running it
25
+ // needs --resend (the tag fan-out cannot skip already-reached rows).
20
26
  //
21
27
  // #132000 structural guard: the payload builder whitelists /^param\d+$/ keys
22
28
  // only — a stray "name" key (which would add a body param on a POSITIONAL
@@ -308,16 +314,18 @@ async function saveCampaign(cfg) {
308
314
  async function loadStatusLog(campaign) {
309
315
  const map = new Map();
310
316
  let header = null;
317
+ let handoff = null; // last {type:"handoff"} line — a completed python fan-off
311
318
  try {
312
319
  const text = await fs.readFile(logPath(campaign), "utf8");
313
320
  for (const line of text.split("\n")) {
314
321
  if (!line.trim()) continue;
315
322
  let obj; try { obj = JSON.parse(line); } catch { continue; }
316
323
  if (obj.type === "meta") { header = obj; continue; }
324
+ if (obj.type === "handoff") { handoff = obj; continue; }
317
325
  if (obj.type === "row" && obj.number) map.set(obj.number, obj);
318
326
  }
319
327
  } catch { /* no log yet */ }
320
- return { header, map };
328
+ return { header, map, handoff };
321
329
  }
322
330
 
323
331
  async function appendLog(campaign, obj) {
@@ -602,11 +610,54 @@ function renderPreview(template, sample, headerMedia) {
602
610
  }
603
611
  }
604
612
 
605
- async function upsertContacts(orgId, rows, batchCap) {
613
+ // ---------------------------------------------------------------------------
614
+ // CSV → python fan-out builders (v0.6.0). Exported for the test harness.
615
+ // ---------------------------------------------------------------------------
616
+
617
+ /** The campaign's contact tag — what python resolves the audience from. */
618
+ export const csvCampaignTag = (campaign) => `bc-${campaign}`;
619
+
620
+ /** Campaign-scoped attribute key for one template slot ("param1".."paramN" | "button"). */
621
+ export const csvAttrKey = (campaign, slot) => `bc_${campaign}_${slot}`;
622
+
623
+ /** workSet rows → /cli/contacts-upsert contact rows: tag + per-row values as
624
+ * attributes. full_name only when the CSV actually carries a non-empty name
625
+ * (the RPC COALESCEs, so an omitted name can never null a real one). */
626
+ export function buildCsvContactRows(workSet, campaign) {
627
+ const tag = csvCampaignTag(campaign);
628
+ return workSet.map((r) => {
629
+ const attributes = {};
630
+ for (const [k, v] of Object.entries(r.values)) {
631
+ // Same structural guard as the old per-row payload builder (V-12).
632
+ if (!/^param\d+$/.test(k)) throw new Error(`internal: stray value key "${k}" — refusing (V-12)`);
633
+ attributes[csvAttrKey(campaign, k)] = v;
634
+ }
635
+ if (r.buttonValue != null) attributes[csvAttrKey(campaign, "button")] = r.buttonValue;
636
+ return {
637
+ whatsapp_id: r.number,
638
+ ...(typeof r.contactName === "string" && r.contactName.trim() ? { full_name: r.contactName.trim() } : {}),
639
+ tags: [tag],
640
+ attributes,
641
+ };
642
+ });
643
+ }
644
+
645
+ /** The ONE shared param set handed to python — every value is an
646
+ * {{attributes.bc_<campaign>_slot}} token python resolves per contact. */
647
+ export function buildCsvTokenParams(template, campaign) {
648
+ const body = {};
649
+ for (const pos of template.body_positions) {
650
+ body[`param${pos}`] = `{{attributes.${csvAttrKey(campaign, `param${pos}`)}}}`;
651
+ }
652
+ const button = template.url_button?.present ? `{{attributes.${csvAttrKey(campaign, "button")}}}` : null;
653
+ return { body, button };
654
+ }
655
+
656
+ async function upsertContacts(orgId, contactRows, batchCap) {
606
657
  let created = 0, updated = 0, skippedN = 0, dropped = 0;
607
- const uniq = rows;
658
+ const uniq = contactRows;
608
659
  for (let i = 0; i < uniq.length; i += batchCap) {
609
- const chunk = uniq.slice(i, i + batchCap).map((r) => ({ whatsapp_id: r.number, full_name: r.contactName }));
660
+ const chunk = uniq.slice(i, i + batchCap);
610
661
  let resp = null, lastErr = null;
611
662
  for (let attempt = 1; attempt <= 3; attempt++) {
612
663
  try { resp = await http.post("contacts-upsert", { organization_id: orgId, contacts: chunk }); break; }
@@ -749,9 +800,23 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
749
800
  let sendable = await applySafetyExclusions(orgId, valid, skipped);
750
801
  if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
751
802
 
752
- // resume folding
753
- const { header: logHeader, map: statusMap } = await loadStatusLog(campaign);
803
+ // resume folding (pre-v0.6.0 per-row campaigns only — python campaigns have no per-row log)
804
+ const { header: logHeader, map: statusMap, handoff } = await loadStatusLog(campaign);
754
805
  if (isResume) {
806
+ if (handoff) {
807
+ if (handoff.queue_id) {
808
+ console.error(`Campaign "${campaign}" is QUEUED via python (queue id ${handoff.queue_id}, fires ${handoff.scheduled_for || "?"}).`);
809
+ console.error("There is nothing to resume — manage the queued send instead:");
810
+ console.error(` flowiq bc scheduled list ${orgId}`);
811
+ console.error(` flowiq bc scheduled cancel ${orgId} ${handoff.queue_id} --confirm`);
812
+ } else {
813
+ console.error(`Campaign "${campaign}" already fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
814
+ console.error("There is no per-row resume on the python engine — python owns the send. Audit / re-fire failures with:");
815
+ console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
816
+ console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
817
+ }
818
+ process.exit(1);
819
+ }
755
820
  if (!logHeader) { console.error(`No status log for campaign "${campaign}" — nothing to resume.`); process.exit(1); }
756
821
  if (logHeader.csv_fingerprint && logHeader.csv_fingerprint !== csv.header_fingerprint) {
757
822
  console.error("CSV headers changed since this campaign started — start a NEW campaign instead of resuming.");
@@ -778,37 +843,215 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
778
843
  console.log(`Summary: ${sendable.length} sendable · skipped ${skipped.length} (${Object.entries(skippedByReason).map(([k, v]) => `${k}: ${v}`).join(", ") || "none"})`);
779
844
  if (statusMap.size) console.log(`Status log: ${[...statusMap.values()].filter((s) => s.status === "sent").length} already sent · work set ${workSet.length}`);
780
845
  if (ambiguous.length) console.log(`⚠ ${ambiguous.length} number(s) have an AMBIGUOUS in-flight status (crash mid-send?) — never auto-resent: ${ambiguous.slice(0, 5).join(", ")}${ambiguous.length > 5 ? "…" : ""}. Check the chat, then edit the status log if they must be retried.`);
781
- console.log(`Upsert plan: ${workSet.length} contacts in ${Math.ceil(workSet.length / CONTACT_BATCH_CAP)} batch(es)`);
782
- const rate = Math.min(Number(opts.rate ?? 8) || 8, HARD_RATE_CAP);
783
- console.log(`Send plan: ${workSet.length} messages @ ≤${rate}/s ≈ ${Math.ceil(workSet.length / rate)}s`);
846
+ // ── OLD-STYLE RESUME (pre-v0.6.0 per-row campaign): finish it on the Node loop ──
847
+ if (isResume) {
848
+ const rate = Math.min(Number(opts.rate ?? 8) || 8, HARD_RATE_CAP);
849
+ console.log(`Send plan: ${workSet.length} messages @ ≤${rate}/s (per-row Node loop — pre-v0.6.0 campaign)`);
850
+ if (!commitStage) {
851
+ console.log("");
852
+ console.log("DRY RUN — nothing was sent, no contacts written. Add --commit to send.");
853
+ return;
854
+ }
855
+ if (!workSet.length) { console.log("Nothing to send — all rows are already sent/skipped."); return; }
856
+ if (!opts.yes) {
857
+ console.log("");
858
+ const answer = await ask(`Type the campaign name ("${campaign}") to send ${workSet.length} message(s): `);
859
+ if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
860
+ }
861
+ await executeSendLoop({
862
+ orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
863
+ skippedCount: skipped.length,
864
+ writeMetaHeader: !logHeader,
865
+ metaExtras: { csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length },
866
+ });
867
+ return;
868
+ }
869
+
870
+ // ── FRESH SEND: python server-side fan-out (v0.6.0) ────────────────────────
871
+ // Per-row values ride as campaign-scoped contact attributes; ONE python
872
+ // /meta-broadcast call resolves {{attributes.bc_<campaign>_paramN}} per
873
+ // contact and paces the send server-side. No laptop in the loop.
874
+ const tag = csvCampaignTag(campaign);
875
+ const tokens = buildCsvTokenParams(template, campaign);
876
+ const batchCap = Math.min(Number(opts.upsertBatch ?? CONTACT_BATCH_CAP) || CONTACT_BATCH_CAP, CONTACT_BATCH_CAP);
877
+ if (opts.skipUpsert) console.log("⚠ --skip-upsert is ignored on the python CSV path — the upsert IS how per-row values reach python.");
878
+ // --at (v0.6.1): upsert+tag NOW, then QUEUE the python call instead of firing
879
+ // it — the same api_request_queue row tag sends and the dashboard write,
880
+ // replayed by the process-api-queue cron. Parsed EARLY so a garbage/past
881
+ // value aborts before anything is written.
882
+ let scheduledAt = null;
883
+ if (opts.at) {
884
+ scheduledAt = parseSastAt(opts.at);
885
+ if (scheduledAt.error) { console.error(`Error: --at ${scheduledAt.error}`); process.exit(1); }
886
+ if (new Date(scheduledAt.iso).getTime() <= Date.now()) {
887
+ console.error(`ABORT — --at is in the past (${fmtSast(scheduledAt.iso)} SAST). Nothing would ever fire.`);
888
+ process.exit(1);
889
+ }
890
+ }
891
+ console.log(`Engine: PYTHON server-side fan-out (yapi.store/meta-broadcast) — fire-and-forget, python-paced`);
892
+ console.log(`Tag: "${tag}" · per-row values → attributes bc_${campaign}_param1..${template.body_var_count}${tokens.button ? " + _button" : ""}`);
893
+ console.log(`Plan: upsert+tag ${workSet.length} contact(s) in ${Math.ceil(Math.max(1, workSet.length) / batchCap)} batch(es) → ${scheduledAt ? "QUEUE one python broadcast" : "one python broadcast"}`);
894
+ if (scheduledAt) {
895
+ console.log(`Schedule: fires ${fmtSast(scheduledAt.iso)} SAST${scheduledAt.explicitOffset ? "" : " (--at read as SAST)"} — server-side queue row, replayed by the process-api-queue cron (0–3 min after the minute); shows on the dashboard's Scheduled sends page`);
896
+ console.log(opts.needsApproval
897
+ ? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
898
+ : `Approval: none — fires automatically at the scheduled time`);
899
+ }
900
+
901
+ // A prior fan-off means the tag already reached its audience — re-running
902
+ // resends to EVERY tagged contact (python cannot skip per row).
903
+ if (handoff && !opts.resend) {
904
+ console.log("");
905
+ if (handoff.queue_id) {
906
+ console.log(`⚠ Campaign "${campaign}" is already QUEUED via python (queue id ${handoff.queue_id}, fires ${handoff.scheduled_for || "?"}).`);
907
+ if (!commitStage) {
908
+ console.log(" A --commit re-run would be refused — manage the queued send with bc scheduled list/cancel; pass --resend to run this campaign again (e.g. after cancelling the queued row).");
909
+ } else {
910
+ console.error(" Refusing to double-book the tag. Manage the queued send instead:");
911
+ console.error(` flowiq bc scheduled list ${orgId}`);
912
+ console.error(` flowiq bc scheduled cancel ${orgId} ${handoff.queue_id} --confirm`);
913
+ console.error(" To run this campaign again anyway (e.g. after cancelling), pass --resend.");
914
+ process.exit(1);
915
+ }
916
+ } else {
917
+ console.log(`⚠ Campaign "${campaign}" ALREADY fanned out via python on ${handoff.at}${handoff.broadcast_id ? ` (broadcastId ${handoff.broadcast_id})` : ""}.`);
918
+ if (!commitStage) {
919
+ console.log(" A --commit re-run would resend to EVERY tagged contact (no per-row skip). Audit with bc status; re-fire failures with bc retry; pass --resend to deliberately resend to all.");
920
+ } else {
921
+ console.error(" Refusing to resend to the whole tag. Audit / re-fire failures with:");
922
+ console.error(` flowiq bc status ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} [--failures]`);
923
+ console.error(` flowiq bc retry ${orgId} ${handoff.broadcast_id || "<broadcast_id>"} --commit`);
924
+ console.error(" To deliberately resend to EVERYONE tagged, pass --resend.");
925
+ process.exit(1);
926
+ }
927
+ }
928
+ }
929
+ // A pre-v0.6.0 per-row log means part of the audience was already reached
930
+ // row-by-row — the tag fan-out cannot skip them, so this campaign must be
931
+ // finished on the old engine (bc resume) or restarted under a fresh slug.
932
+ if (statusMap.size > 0) {
933
+ const sentAlready = [...statusMap.values()].filter((s) => s.status === "sent").length;
934
+ if (!commitStage) {
935
+ console.log("");
936
+ console.log(`⚠ This campaign has a pre-v0.6.0 per-row status log (${sentAlready} already sent). A --commit here would be refused — finish it with \`flowiq bc resume\`, or start a fresh --campaign.`);
937
+ } else {
938
+ console.error("");
939
+ console.error(`Refusing: campaign "${campaign}" has a pre-v0.6.0 per-row status log (${sentAlready} row(s) already sent).`);
940
+ console.error("The python fan-out sends to the WHOLE tag and cannot skip already-sent rows. Either:");
941
+ console.error(` flowiq bc resume ${orgId} --campaign ${campaign} --commit # finish on the old per-row engine`);
942
+ console.error(" …or re-run under a NEW --campaign slug (fresh tag, everyone in the CSV gets it).");
943
+ process.exit(1);
944
+ }
945
+ }
784
946
 
785
947
  if (!commitStage) {
786
948
  console.log("");
787
- console.log("DRY RUN — nothing was sent, no contacts written. Add --commit to send.");
949
+ console.log(`DRY RUN — nothing was ${scheduledAt ? "scheduled" : "sent"}, no contacts written or tagged. Add --commit to ${scheduledAt ? "queue it" : "send"}.`);
788
950
  return;
789
951
  }
790
- if (!workSet.length) { console.log("Nothing to send — all rows are already sent/skipped."); return; }
952
+ if (!workSet.length) { console.log("Nothing to send — all rows were skipped."); return; }
791
953
 
792
- // 8. gate
954
+ // 8. gate (BEFORE any write — the upsert itself tags real contacts)
793
955
  if (!opts.yes) {
794
956
  console.log("");
795
- const answer = await ask(`Type the campaign name ("${campaign}") to send ${workSet.length} message(s): `);
796
- if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
957
+ const answer = await ask(`Type the campaign name ("${campaign}") to tag ${workSet.length} contact(s) and ${scheduledAt ? `QUEUE one python broadcast for ${fmtSast(scheduledAt.iso)} SAST` : "fire ONE python broadcast"}: `);
958
+ if (answer !== campaign) { console.log("Mismatch — aborted, nothing written, nothing sent."); process.exit(0); }
797
959
  }
798
960
 
799
- // 9. upsert
800
- if (!opts.skipUpsert && !(isResume && logHeader)) {
801
- try { await upsertContacts(orgId, workSet, Math.min(Number(opts.upsertBatch ?? CONTACT_BATCH_CAP) || CONTACT_BATCH_CAP, CONTACT_BATCH_CAP)); }
802
- catch (e) { console.error(e.message); process.exit(1); }
961
+ // 9. upsert: tag + per-row attributes (the fan-out's data plane)
962
+ try { await upsertContacts(orgId, buildCsvContactRows(workSet, campaign), batchCap); }
963
+ catch (e) { console.error(e.message); process.exit(1); }
964
+
965
+ // 10. python dry-run cross-check: what does the tag ACTUALLY resolve to?
966
+ const reqBody = (dryRun) => ({
967
+ action: "send-python", organization_id: orgId, tag, template_name: templateName,
968
+ body_parameters: tokens.body,
969
+ ...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
970
+ ...(headerMedia ? { header_media: headerMedia } : {}),
971
+ dry_run: dryRun,
972
+ });
973
+ let pyTotal = null;
974
+ try {
975
+ const dry = await http.post("broadcast", reqBody(true));
976
+ pyTotal = dry.total_contacts_found ?? null;
977
+ } catch (e) {
978
+ console.error(`Python dry-run failed after tagging: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
979
+ console.error(`Nothing sent. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the fan-off.`);
980
+ process.exit(1);
981
+ }
982
+ if (pyTotal != null && pyTotal !== workSet.length) {
983
+ if (pyTotal > workSet.length) {
984
+ console.log(`⚠ Tag "${tag}" resolves to ${pyTotal} contact(s) — ${pyTotal - workSet.length} MORE than this CSV (tagged by an earlier run of this campaign?). They will ALSO receive the send${scheduledAt ? " when it fires" : ""}.`);
985
+ if (!opts.yes) {
986
+ const extra = await ask(`Type YES to ${scheduledAt ? "schedule for" : "send to"} all ${pyTotal}: `);
987
+ if (extra !== "YES") { console.log(`Aborted — nothing ${scheduledAt ? "scheduled" : "sent"}. The tag still carries ${pyTotal} contact(s); use a fresh --campaign for a clean audience.`); process.exit(0); }
988
+ }
989
+ } else {
990
+ console.log(`Note: python resolves ${pyTotal} of ${workSet.length} tagged (the rest are opted-out/blocked server-side).`);
991
+ }
803
992
  }
804
993
 
805
- // 10-11. send loop + report (shared with the --tag pipeline)
806
- await executeSendLoop({
807
- orgId, campaign, templateName, workSet, statusMap, rate, headerMedia,
808
- skippedCount: skipped.length,
809
- writeMetaHeader: !logHeader,
810
- metaExtras: { csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length },
994
+ const writeMetaLine = async () => {
995
+ if (logHeader) return;
996
+ await appendLog(campaign, {
997
+ type: "meta", campaign, organization_id: orgId, template_name: templateName, engine: "python",
998
+ csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length,
999
+ started_at: new Date().toISOString(),
1000
+ });
1001
+ };
1002
+
1003
+ // 11a. --at: QUEUE instead of firing — the same api_request_queue row a tag
1004
+ // send writes (type broadcast_send_python), so the dashboard's Scheduled
1005
+ // sends page + bc scheduled list/approve/cancel all see it. Python
1006
+ // re-resolves the tag when it FIRES, so late tag joiners are included.
1007
+ if (scheduledAt) {
1008
+ let resp;
1009
+ try {
1010
+ resp = await http.post("broadcast", {
1011
+ action: "schedule", organization_id: orgId, tag, template_name: templateName,
1012
+ scheduled_for: scheduledAt.iso, needs_approval: !!opts.needsApproval,
1013
+ body_parameters: tokens.body,
1014
+ ...(tokens.button ? { button_parameters: { param1: tokens.button } } : {}),
1015
+ ...(headerMedia ? { header_media: headerMedia } : {}),
1016
+ });
1017
+ } catch (e) {
1018
+ console.error(`Schedule failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
1019
+ console.error(`Nothing queued. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the schedule.`);
1020
+ process.exit(1);
1021
+ }
1022
+ await writeMetaLine();
1023
+ await appendLog(campaign, {
1024
+ type: "handoff", engine: "python-scheduled", tag, queue_id: resp.queue_id ?? null,
1025
+ scheduled_for: resp.scheduled_for ?? scheduledAt.iso, tagged: workSet.length, python_total: pyTotal,
1026
+ at: new Date().toISOString(),
1027
+ });
1028
+ console.log("");
1029
+ console.log(`✓ Scheduled for ${fmtSast(resp.scheduled_for ?? scheduledAt.iso)} SAST — queue id ${resp.queue_id}`);
1030
+ console.log(` ${workSet.length} contact(s) tagged "${tag}" now; python re-resolves the tag when it fires.`);
1031
+ if (resp.fires_automatically ?? !opts.needsApproval) {
1032
+ console.log(` It will fire on its own. Cancel with: flowiq bc scheduled cancel ${orgId} ${resp.queue_id}`);
1033
+ } else {
1034
+ console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
1035
+ console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1036
+ }
1037
+ console.log(` Also visible/manageable on the dashboard's Scheduled sends page.`);
1038
+ return;
1039
+ }
1040
+
1041
+ // 11b. fire — ONE call; python paces + tracks the rest
1042
+ let out;
1043
+ try { out = await http.post("broadcast", reqBody(false)); }
1044
+ catch (e) { console.error(`Python broadcast failed: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`); process.exit(1); }
1045
+ await writeMetaLine();
1046
+ await appendLog(campaign, {
1047
+ type: "handoff", engine: "python", tag, broadcast_id: out.broadcastId ?? out.broadcast_id ?? null,
1048
+ tagged: workSet.length, python_total: pyTotal, at: new Date().toISOString(),
811
1049
  });
1050
+ console.log("");
1051
+ console.log(`✅ ${out.message || "Broadcast started"}${out.broadcastId ? ` · broadcastId ${out.broadcastId}` : ""}`);
1052
+ console.log(` Python is sending in the background to tag "${tag}" (${pyTotal ?? workSet.length} contact(s)) and tracking it under that broadcast id.`);
1053
+ console.log(` Delivery: flowiq bc status ${orgId} ${out.broadcastId || "<broadcast_id>"} [--failures]`);
1054
+ console.log(` Failures: flowiq bc retry ${orgId} ${out.broadcastId || "<broadcast_id>"} --commit`);
812
1055
  }
813
1056
 
814
1057
  /** The paced, write-ahead-logged per-row send loop + final report. */
@@ -0,0 +1,150 @@
1
+ // `flowiq hours log <org> --minutes N|--hours H --desc "…"` / `list <org>` /
2
+ // `summary`. The client-hours ledger: every org's package includes 10 hours of
3
+ // Flowapt work per calendar month, and every piece of client work gets logged
4
+ // against it (manually here, or automatically by Claude via the FlowIQ MCP).
5
+ // All writes happen server-side (/cli/hours) with the staff token.
6
+
7
+ import { http } from "../http.js";
8
+
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
11
+
12
+ function fmtHours(minutes) {
13
+ const h = minutes / 60;
14
+ return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
15
+ }
16
+
17
+ function requireOrg(orgId) {
18
+ if (!UUID_RE.test(orgId || "")) {
19
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ function requireMonth(month) {
25
+ if (month && !MONTH_RE.test(month)) {
26
+ console.error(`Error: --month must be YYYY-MM (got "${month}").`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ /** --minutes and --hours are alternatives; exactly one must yield an integer minute count. */
32
+ function resolveMinutes(opts) {
33
+ const hasMinutes = opts.minutes !== undefined;
34
+ const hasHours = opts.hours !== undefined;
35
+ if (hasMinutes === hasHours) {
36
+ console.error("Error: pass exactly one of --minutes <n> or --hours <h> (e.g. --hours 1.5).");
37
+ process.exit(1);
38
+ }
39
+ let minutes;
40
+ if (hasMinutes) {
41
+ minutes = Number(opts.minutes);
42
+ if (!Number.isInteger(minutes)) {
43
+ console.error(`Error: --minutes must be a whole number (got "${opts.minutes}"). Use --hours for fractions.`);
44
+ process.exit(1);
45
+ }
46
+ } else {
47
+ const h = Number(opts.hours);
48
+ if (!Number.isFinite(h)) {
49
+ console.error(`Error: --hours must be a number (got "${opts.hours}").`);
50
+ process.exit(1);
51
+ }
52
+ minutes = Math.round(h * 60);
53
+ }
54
+ if (minutes < 1 || minutes > 6000) {
55
+ console.error(`Error: the entry must be between 1 minute and 100 hours (got ${minutes} min).`);
56
+ process.exit(1);
57
+ }
58
+ return minutes;
59
+ }
60
+
61
+ export async function log(orgId, opts) {
62
+ requireOrg(orgId);
63
+ const minutes = resolveMinutes(opts);
64
+ const description = (opts.desc || "").trim();
65
+ if (!description) {
66
+ console.error('Error: --desc "what was done" is required.');
67
+ process.exit(1);
68
+ }
69
+ if (opts.date && !/^\d{4}-\d{2}-\d{2}$/.test(opts.date)) {
70
+ console.error(`Error: --date must be YYYY-MM-DD (got "${opts.date}").`);
71
+ process.exit(1);
72
+ }
73
+
74
+ let resp;
75
+ try {
76
+ resp = await http.post("hours", {
77
+ organization_id: orgId,
78
+ description,
79
+ minutes,
80
+ performed_by: opts.by || undefined,
81
+ session: opts.session || undefined,
82
+ // A bare date logs at noon UTC so it can never slip into a neighbouring
83
+ // day (or month) in any sane timezone rendering.
84
+ performed_at: opts.date ? `${opts.date}T12:00:00Z` : undefined,
85
+ });
86
+ } catch (e) {
87
+ console.error(`Log failed: ${e.message}`);
88
+ process.exit(1);
89
+ }
90
+
91
+ const used = resp.month_total_minutes;
92
+ const allowance = resp.allowance_hours * 60;
93
+ console.log(`Logged ${fmtHours(minutes)} for ${resp.organization.name}`);
94
+ console.log(` entry: ${resp.id}`);
95
+ console.log(` month: ${resp.month} — ${fmtHours(used)} of ${resp.allowance_hours}h package used${used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`}`);
96
+ }
97
+
98
+ export async function list(orgId, opts) {
99
+ requireOrg(orgId);
100
+ requireMonth(opts.month);
101
+ let resp;
102
+ try {
103
+ resp = await http.get("hours", { organization_id: orgId, month: opts.month });
104
+ } catch (e) {
105
+ console.error(`List failed: ${e.message}`);
106
+ process.exit(1);
107
+ }
108
+ if (opts.json) {
109
+ console.log(JSON.stringify(resp, null, 2));
110
+ return;
111
+ }
112
+ const over = resp.remaining_minutes < 0;
113
+ console.log(`${resp.organization.name} — ${resp.month}`);
114
+ console.log(` package: ${resp.allowance_hours}h/month · used ${fmtHours(resp.total_minutes)} · ${over ? `⚠ ${fmtHours(-resp.remaining_minutes)} OVER` : `${fmtHours(resp.remaining_minutes)} left`}`);
115
+ if (!resp.entries.length) {
116
+ console.log(" (no entries this month)");
117
+ return;
118
+ }
119
+ console.log("");
120
+ for (const e of resp.entries) {
121
+ const when = String(e.performed_at).slice(0, 10);
122
+ const src = e.source === "manual" ? "ui" : e.source;
123
+ console.log(` ${when} ${fmtHours(e.minutes).padStart(6)} [${src}] ${e.performed_by} — ${e.description}`);
124
+ }
125
+ }
126
+
127
+ export async function summary(opts) {
128
+ requireMonth(opts.month);
129
+ let resp;
130
+ try {
131
+ resp = await http.get("hours", { summary: "1", month: opts.month });
132
+ } catch (e) {
133
+ console.error(`Summary failed: ${e.message}`);
134
+ process.exit(1);
135
+ }
136
+ if (opts.json) {
137
+ console.log(JSON.stringify(resp, null, 2));
138
+ return;
139
+ }
140
+ if (!resp.organizations.length) {
141
+ console.log(`(no hours logged for any org in ${resp.month})`);
142
+ return;
143
+ }
144
+ const allowance = resp.allowance_hours * 60;
145
+ console.log(`Client hours — ${resp.month} (package: ${resp.allowance_hours}h/month per org)\n`);
146
+ for (const o of resp.organizations) {
147
+ const flag = o.total_minutes > allowance ? ` ⚠ ${fmtHours(o.total_minutes - allowance)} OVER` : "";
148
+ console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(2)} entr${o.entries === 1 ? "y" : "ies"}) ${o.name || o.id}${flag}`);
149
+ }
150
+ }
@@ -97,8 +97,15 @@ export async function push(identifier, opts = {}) {
97
97
  try {
98
98
  resp = await http.post("messaging-webhooks", {
99
99
  organization_id: data.organization_id,
100
+ // auth_type / auth_config / headers / secret ride along deliberately:
101
+ // push is FULL-REPLACE (delete-all then insert), so dropping them here
102
+ // would silently strip every webhook's credentials on the next push.
100
103
  messaging_webhooks: data.messaging_webhooks.map((w) => ({
101
104
  url: w.url, type: w.type, platform: w.platform,
105
+ auth_type: w.auth_type ?? "none",
106
+ auth_config: w.auth_config ?? null,
107
+ headers: w.headers ?? null,
108
+ secret: w.secret ?? null,
102
109
  })),
103
110
  dry_run: !!opts.dryRun,
104
111
  });
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ import * as webhooksCmd from "./commands/webhooks.js";
15
15
  import * as flowmodCmd from "./commands/flowmod.js";
16
16
  import * as groupsCmd from "./commands/groups.js";
17
17
  import * as pinboardCmd from "./commands/pinboard.js";
18
+ import * as hoursCmd from "./commands/hours.js";
18
19
  import * as templatesCmd from "./commands/templates.js";
19
20
  import * as orgCmd from "./commands/org.js";
20
21
  import * as agentConfigCmd from "./commands/agent-config.js";
@@ -191,6 +192,29 @@ export function run(argv) {
191
192
  .description("List every task in the DB (optionally filter: open | in_progress | done) to find an id")
192
193
  .action((status) => pinboardCmd.listRemote(status));
193
194
 
195
+ // hours (client-hours ledger — every org's package = 10h Flowapt work/month)
196
+ const hours = program.command("hours")
197
+ .description("Client-hours ledger: log + report Flowapt work against each org's 10h/month package");
198
+ hours.command("log <organization_id>")
199
+ .description("Log work done for a client org (exactly one of --minutes / --hours)")
200
+ .option("--minutes <n>", "duration in whole minutes")
201
+ .option("--hours <h>", "duration in hours (fractions fine, e.g. 1.5)")
202
+ .option("--desc <text>", "what was done, in plain language the client could read")
203
+ .option("--by <name>", "who did the work (default: your staff login email)")
204
+ .option("--session <name>", "session/task name the work happened in")
205
+ .option("--date <YYYY-MM-DD>", "when the work was done (default: now)")
206
+ .action((orgId, opts) => hoursCmd.log(orgId, opts));
207
+ hours.command("list <organization_id>")
208
+ .description("One org's entries + package standing for a month (default: current)")
209
+ .option("--month <YYYY-MM>", "which month to report")
210
+ .option("--json", "raw JSON output")
211
+ .action((orgId, opts) => hoursCmd.list(orgId, opts));
212
+ hours.command("summary")
213
+ .description("Cross-org month totals — every org with logged work, vs the 10h package")
214
+ .option("--month <YYYY-MM>", "which month to report")
215
+ .option("--json", "raw JSON output")
216
+ .action((opts) => hoursCmd.summary(opts));
217
+
194
218
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
195
219
  const templates = program.command("templates")
196
220
  .alias("tpl")
@@ -435,17 +459,18 @@ export function run(argv) {
435
459
  .option("--card-media <url>", "with --tag, carousel templates: card image/video URL in card order (repeatable — one per card; default: each card's stored template image)", (v, acc) => (acc || []).concat([v]), [])
436
460
  .option("--cards-file <path>", "with --tag, carousel templates: JSON file of per-card overrides [{header_media?, body_params?, button_payloads?, url_vars?}] — required when cards carry {{n}} body variables or URL-button variables")
437
461
  .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")
438
- .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")
462
+ .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")
439
463
  .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
440
464
  .option("--commit", "actually send (omit to dry-run)")
441
465
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
442
466
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
443
467
  .option("--illegal-chars <mode>", "reject | strip — newlines/tabs/4+ spaces in values (Meta #100)", "reject")
444
- .option("--rate <n>", "max messages per second (hard cap 10)", "8")
468
+ .option("--rate <n>", "max messages per second — ONLY the pre-v0.6.0 per-row resume path; python paces fresh sends itself", "8")
445
469
  .option("--upsert-batch <n>", "contacts per upsert call (max 4000)", "4000")
446
- .option("--skip-upsert", "assume contacts already exist; skip the upsert stage")
447
- .option("--limit <n>", "only process the first N valid rows (smoke test)")
448
- .option("--resume", "continue from the existing status log instead of starting fresh")
470
+ .option("--skip-upsert", "pre-v0.6.0 resume only — ignored on fresh CSV sends (the upsert IS how per-row values reach python)")
471
+ .option("--limit <n>", "only process the first N valid rows (smoke-test under a THROWAWAY --campaign — a limited python fan-off still records the campaign as fired)")
472
+ .option("--resend", "re-fire a CSV campaign that already fanned out via python — resends to EVERY tagged contact, incl. those already reached (bc retry re-fires failures only)")
473
+ .option("--resume", "continue a pre-v0.6.0 per-row campaign from its status log (python campaigns have no per-row resume — use bc status / bc retry)")
449
474
  .option("--retry-failed", "with --resume: also re-attempt rows previously marked failed")
450
475
  .action((orgId, opts) => broadcastCmd.send(orgId, opts));
451
476
  broadcast.command("resume <organization_id>")