@flowapt/flowiq-cli 0.6.3 → 0.6.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
@@ -485,6 +485,14 @@ flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own ta
485
485
  shuffle reproducible, `--ordered` keeps server order. `--from-attribute
486
486
  allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
487
487
  tagged cohorts" one-liner — no id file, no database access.
488
+ - **`--start-index N` continues an existing batch series** (v0.6.4, 3 Sep 2026).
489
+ Batch tags are always numbered from `01`, so a campaign already holding
490
+ `f500-batch-01…12` could not get its 13th batch from the CLI — the plan would
491
+ have produced `f500-batch-01` again and, because apply is append-only, quietly
492
+ MERGED a new cohort into the oldest one. `--start-index 13` names the batch
493
+ `f500-batch-13`. `plan` now also **refuses outright** when any batch tag it
494
+ would create already holds contacts, printing the correct `--start-index` to
495
+ use; `--allow-existing-tag` overrides when the merge is deliberate.
488
496
  - Apply is **append-only** — it never touches a contact's other tags, names,
489
497
  or anything else, and never double-adds.
490
498
  - **Large cohorts are chunked server-side** (fixed 3 Aug 2026): the tagging RPC
@@ -943,6 +951,37 @@ flowiq hours summary # every org with logged work this
943
951
  - Corrections (delete/edit) live in the Changelog → Client hours page, not the
944
952
  CLI. Every `log` is audited.
945
953
 
954
+ ### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
955
+
956
+ The monthly 10-slide client deck (`/reporting/deck` in the app), driven from the
957
+ terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
958
+ function: the metrics are built from the FROZEN month snapshot, the copy is
959
+ written by the model under the report rules and a numeric guard (every figure
960
+ in the copy must exist in the data), approval needs the required inputs, and
961
+ `send` renders the approved slides with headless Chrome and emails the PDF
962
+ from flowiq@flowapt.com.
963
+
964
+ ```bash
965
+ flowiq report deck status <org_id> 2026-08 # what exists, workflow status, what blocks approval
966
+ flowiq report deck generate <org_id> 2026-08 # build the metrics (if missing) + write the copy
967
+ flowiq report deck generate <org_id> 2026-08 --refresh --force # rebuild everything
968
+ flowiq report deck build <org_id> 2026-08 --refresh # metrics only
969
+ flowiq report deck narrate <org_id> 2026-08 # copy only (hand edits are kept)
970
+ flowiq report deck inputs <org_id> 2026-08 --file inputs.json # the super-admin input form
971
+ flowiq report deck approve <org_id> 2026-08 # refused until every required input is present
972
+ flowiq report deck send <org_id> 2026-08 --test-to me@flowapt.com # one test copy, status untouched
973
+ flowiq report deck send <org_id> 2026-08 --client # approved deck → configured client recipients
974
+ flowiq report deck print <org_id> 2026-08 # 15-minute print URL (what Chrome renders)
975
+ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/reports/<slug>-2026-08.json
976
+ ```
977
+
978
+ - Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
979
+ Comparisons need the prior month's deck, which `build` creates on the fly.
980
+ - `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
981
+ Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
982
+ - `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
983
+ - Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
984
+
946
985
  ### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
947
986
 
948
987
  Read an org's live templates straight from Meta (read-only), and submit new
package/TEAM-GUIDE.md CHANGED
@@ -114,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
114
114
  | Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
115
115
  | 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/…`) |
116
116
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
117
+ | Send the NEXT batch of a campaign that already has batches 01-12 | `flowiq seg plan <org_id> --tag-prefix f500 --ids-file next100.txt --batch-size 100 --start-index 13` → `flowiq seg apply <org_id> f500 --commit` (makes `f500-batch-13`; without `--start-index` the plan is REFUSED, because it would re-use `f500-batch-01` and merge the two cohorts) |
117
118
  | 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) |
118
119
  | 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. |
119
120
  | 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` |
@@ -141,6 +142,10 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
141
142
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
142
143
  | 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 |
143
144
  | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
145
+ | Generate a client's monthly deck (metrics + copy) after the month has frozen | `flowiq report deck generate <org_id> 2026-08` — then review it at /reporting/deck |
146
+ | See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
147
+ | Send yourself a test copy of a client deck (PDF from flowiq@flowapt.com) | `flowiq report deck send <org_id> 2026-08 --test-to you@flowapt.com` — status untouched |
148
+ | Approve a client deck / send it to the client | `flowiq report deck approve <org_id> 2026-08` then `… send … --client` (or let the 09:00 SAST schedule send it on the 5th) |
144
149
  | Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
145
150
 
146
151
  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.6.3",
3
+ "version": "0.6.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": {
@@ -0,0 +1,163 @@
1
+ // `flowiq report deck <verb> <org_id> <YYYY-MM>` — the monthly client deck.
2
+ // status what exists for the month + workflow status + what blocks approval
3
+ // build rebuild the deck metrics from the frozen month (--refresh)
4
+ // narrate rewrite the copy (always forces a fresh write)
5
+ // generate build (if missing or --refresh) + write the copy
6
+ // approve / unapprove
7
+ // send --test-to me@x → one test copy, status untouched; without it the
8
+ // deck must be approved and goes to the org's configured recipients
9
+ // inputs push a JSON file of inputs (the §3.2 form) — see README
10
+ // print mint a 15-minute print URL (the page headless Chrome renders)
11
+ // pull write ./.flowiq/reports/<slug>-<month>.json (doc + deck + copy + inputs)
12
+ // All the work happens server-side (/cli/report → the flowiq-reporting-deck
13
+ // edge fn); every mutating verb is audited.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { http } from "../http.js";
18
+
19
+ const REPORT_DIR = path.resolve(process.cwd(), ".flowiq", "reports");
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
+ const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
22
+
23
+ function slugify(name, fallback) {
24
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
25
+ return s || fallback;
26
+ }
27
+
28
+ function requireArgs(orgId, month) {
29
+ if (!UUID_RE.test(orgId || "")) {
30
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
31
+ process.exit(1);
32
+ }
33
+ if (!MONTH_RE.test(month || "")) {
34
+ console.error(`Error: month must be YYYY-MM (got "${month}").`);
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ function printStatus(r) {
40
+ const s = r.status || (r.deck_available ? "draft" : "no deck");
41
+ console.log(`${r.organization?.name ?? r.organization?.id} — ${r.month}`);
42
+ console.log(` snapshot: ${r.snapshot ? (r.frozen ? "frozen" : "not frozen") : "MISSING (months freeze on the 1st)"}`);
43
+ console.log(` metrics: ${r.deck_available ? `built ${r.deck_computed_at}` : "not built"}${r.deck_available && !r.prior_available ? " (no prior month deck, comparisons limited)" : ""}`);
44
+ console.log(` copy: ${r.narrative_available ? `written ${r.narrative_generated_at}` : "not written"}`);
45
+ console.log(` status: ${s}${r.approved_at ? ` (approved ${r.approved_at}${r.approved_by ? ` by ${r.approved_by}` : ""})` : ""}${r.sent_at ? ` · sent ${r.sent_at}` : ""}`);
46
+ if (Array.isArray(r.missing) && r.missing.length && s !== "sent") console.log(` needed: ${r.missing.join(" · ")}`);
47
+ console.log(` client recipients: ${Array.isArray(r.recipients) && r.recipients.length ? r.recipients.join(", ") : "none configured (Control center → Report delivery)"}`);
48
+ console.log(` from flowiq@flowapt.com · reply-to ${r.reply_to ?? "matt@flowapt.com"} · signed ${r.account_owner ?? "Matt Cronson"} · schedule ${r.deck_enabled ? "ON" : "off"}`);
49
+ }
50
+
51
+ export async function status(orgId, month, opts) {
52
+ requireArgs(orgId, month);
53
+ let r;
54
+ try { r = await http.get("report", { organization_id: orgId, month }); }
55
+ catch (e) { console.error(`Status failed: ${e.message}`); process.exit(1); }
56
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
57
+ printStatus(r);
58
+ }
59
+
60
+ async function post(action, orgId, month, extra, label) {
61
+ let r;
62
+ try { r = await http.post("report", { organization_id: orgId, month, action, ...extra }); }
63
+ catch (e) { console.error(`${label} failed: ${e.message}`); process.exit(1); }
64
+ return r;
65
+ }
66
+
67
+ export async function build(orgId, month, opts) {
68
+ requireArgs(orgId, month);
69
+ const r = await post("build", orgId, month, { refresh: !!opts.refresh }, "Build");
70
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
71
+ console.log(r.built ? `Built the ${month} deck metrics for ${r.organization.name} (${r.broadcasts} broadcast${r.broadcasts === 1 ? "" : "s"} read${r.timings ? `, ${Object.values(r.timings).reduce((a, b) => a + b, 0)} ms` : ""})` : `Metrics already built for ${month}; pass --refresh to rebuild.`);
72
+ }
73
+
74
+ export async function narrate(orgId, month, opts) {
75
+ requireArgs(orgId, month);
76
+ const r = await post("narrate", orgId, month, {}, "Narrate");
77
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
78
+ reportCopy(r, month);
79
+ }
80
+
81
+ export async function generate(orgId, month, opts) {
82
+ requireArgs(orgId, month);
83
+ const r = await post("generate", orgId, month, { refresh: !!opts.refresh, force: !!opts.force }, "Generate");
84
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
85
+ console.log(`${r.organization.name} — ${month}: metrics ${r.built ? "built" : "kept"}, copy ${r.generated ? `written by ${r.model}` : "kept"}`);
86
+ reportCopy(r, month);
87
+ if (r.status) console.log(` status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}`);
88
+ }
89
+
90
+ function reportCopy(r, month) {
91
+ if (r.guard && r.guard.passed === false) {
92
+ const n = Object.keys(r.guard.offenders || {}).length;
93
+ console.log(` ⚠ numeric guard: ${n} field${n === 1 ? "" : "s"} carry figures that are not in the data — flagged on the slides: ${Object.keys(r.guard.offenders).join(", ")}`);
94
+ } else if (r.guard) console.log(" numeric guard: passed (every figure in the copy exists in the data)");
95
+ if (Array.isArray(r.length_issues) && r.length_issues.length) console.log(` trimmed to limit: ${r.length_issues.map((i) => `${i.path} (${i.length}→${i.limit})`).join(", ")}`);
96
+ }
97
+
98
+ export async function approve(orgId, month, opts) {
99
+ requireArgs(orgId, month);
100
+ const r = await post("approve", orgId, month, {}, "Approve");
101
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
102
+ console.log(`Approved the ${month} deck for ${r.organization.name}. It goes to the client recipients at 09:00 SAST on the 5th (or the next morning if the 5th has passed).`);
103
+ }
104
+
105
+ export async function unapprove(orgId, month, opts) {
106
+ requireArgs(orgId, month);
107
+ const r = await post("unapprove", orgId, month, {}, "Unapprove");
108
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
109
+ console.log(`Unapproved. Status is now ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}.`);
110
+ }
111
+
112
+ export async function send(orgId, month, opts) {
113
+ requireArgs(orgId, month);
114
+ if (!opts.testTo && !opts.client) {
115
+ console.error("Error: pass --test-to <email> for a test copy, or --client to send to the org's configured recipients (needs an approved deck).");
116
+ process.exit(1);
117
+ }
118
+ if (opts.testTo && opts.client) {
119
+ console.error("Error: --test-to and --client are alternatives.");
120
+ process.exit(1);
121
+ }
122
+ const r = await post("send", orgId, month, opts.testTo ? { test_to: opts.testTo } : {}, "Send");
123
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
124
+ const ok = (r.results || []).filter((x) => x.status === "sent").map((x) => x.to);
125
+ const failed = (r.results || []).filter((x) => x.status !== "sent");
126
+ console.log(`${r.test ? "Test copy" : "Deck"} ${ok.length ? `sent to ${ok.join(", ")}` : "not sent"} — ${r.filename} (${Math.round((r.pdf_bytes || 0) / 1024)} KB, rendered in ${((r.pdf_ms || 0) / 1000).toFixed(1)} s)${r.test ? " · status unchanged" : " · status: sent"}`);
127
+ for (const f of failed) console.log(` ✗ ${f.to}: ${f.error}`);
128
+ if (failed.length) process.exit(1);
129
+ }
130
+
131
+ export async function inputs(orgId, month, opts) {
132
+ requireArgs(orgId, month);
133
+ if (!opts.file) { console.error("Error: --file <inputs.json> is required."); process.exit(1); }
134
+ let parsed;
135
+ try { parsed = JSON.parse(await fs.readFile(path.resolve(process.cwd(), opts.file), "utf8")); }
136
+ catch (e) { console.error(`Error: could not read ${opts.file}: ${e.message}`); process.exit(1); }
137
+ const r = await post("save_inputs", orgId, month, { inputs: parsed }, "Save inputs");
138
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
139
+ console.log(`Inputs saved. Status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : " · ready to approve"}`);
140
+ }
141
+
142
+ export async function print(orgId, month, opts) {
143
+ requireArgs(orgId, month);
144
+ const r = await post("print_url", orgId, month, {}, "Print URL");
145
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
146
+ console.log(r.url);
147
+ console.error(`(valid ${Math.round((r.expires_in || 900) / 60)} min; this is the page headless Chrome prints, one 1600×900 page per slide)`);
148
+ }
149
+
150
+ export async function pull(orgId, month, opts) {
151
+ requireArgs(orgId, month);
152
+ let r;
153
+ try { r = await http.get("report", { organization_id: orgId, month, pull: 1 }); }
154
+ catch (e) { console.error(`Pull failed: ${e.message}`); process.exit(1); }
155
+ const slug = slugify(r.organization?.name, orgId.slice(0, 8));
156
+ const file = path.resolve(opts.out || path.join(REPORT_DIR, `${slug}-${month}.json`));
157
+ await fs.mkdir(path.dirname(file), { recursive: true });
158
+ await fs.writeFile(file, JSON.stringify(r, null, 2));
159
+ const d = r.doc || {};
160
+ console.log(`Wrote ${path.relative(process.cwd(), file)}`);
161
+ console.log(` ${r.organization?.name} — ${month} · status ${r.workflow?.status ?? "draft"} · deck ${d.deck ? "built" : "not built"} · copy ${r.narrative?.fields ? `written by ${r.narrative.model}` : "not written"}`);
162
+ if (d.revenue?.influenced) console.log(` influenced ${d.revenue.influenced.revenue} · conversations ${d.conversations?.conversations} · sends ${d.deck?.broadcasts?.totals?.sends ?? "—"}`);
163
+ }
@@ -122,6 +122,13 @@ export async function plan(orgId, opts = {}) {
122
122
  }
123
123
  const splitMode = opts.split !== undefined;
124
124
  const batchSize = Number(opts.batchSize ?? 75);
125
+ // --start-index continues an EXISTING batch series (f500-batch-13 after 12
126
+ // manual batches) instead of always restarting at 01 and colliding with it.
127
+ const startIndex = Number(opts.startIndex ?? 1);
128
+ if (!Number.isInteger(startIndex) || startIndex < 1 || startIndex > 999) {
129
+ console.error("Error: --start-index must be an integer 1..999.");
130
+ process.exit(1);
131
+ }
125
132
  if (!splitMode) {
126
133
  if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
127
134
  if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
@@ -252,18 +259,35 @@ export async function plan(orgId, opts = {}) {
252
259
  let idx = 0;
253
260
  for (let g = 0; g < splitN; g++) {
254
261
  const size = base + (g < rem ? 1 : 0);
255
- const nn = String(g + 1).padStart(2, "0");
262
+ const nn = String(startIndex + g).padStart(2, "0");
256
263
  batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
257
264
  idx += size;
258
265
  }
259
266
  if (batches[0].count > 500) console.log(`⚠ each cohort is ~${batches[0].count} contacts — a single-cohort send is large (Meta tier risk); pace the sends.`);
260
267
  } else {
261
268
  for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
262
- const nn = String(batches.length + 1).padStart(2, "0");
269
+ const nn = String(startIndex + batches.length).padStart(2, "0");
263
270
  batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
264
271
  }
265
272
  }
266
273
 
274
+ // A batch tag that ALREADY holds contacts means this plan would merge a new
275
+ // cohort into a past one (append-only apply = silent corruption of that
276
+ // batch's record). Refuse unless the caller says it is deliberate.
277
+ try {
278
+ const existing = await http.post("segments", { mode: "list", organization_id: orgId, prefix });
279
+ const held = new Map((existing?.tags || []).map((t) => [String(t.tag), Number(t.count) || 0]));
280
+ const clashes = batches.map((b) => b.tag).filter((t) => (held.get(t) || 0) > 0);
281
+ if (clashes.length && !opts.allowExistingTag) {
282
+ console.error(`Error: ${clashes.length} batch tag(s) already hold contacts on this org: ${clashes.slice(0, 5).join(", ")}${clashes.length > 5 ? ` …+${clashes.length - 5}` : ""}.`);
283
+ console.error(" apply is append-only, so this plan would MERGE a new cohort into an existing batch.");
284
+ console.error(` Use --start-index ${Math.max(...[...held.keys()].map((t) => Number(String(t).split("-batch-").pop())).filter(Number.isFinite), 0) + 1} to continue the series, or --allow-existing-tag if the merge is deliberate.`);
285
+ process.exit(1);
286
+ }
287
+ } catch (e) {
288
+ console.log(`⚠ could not check existing batch tags (${e.message}) — verify with \`seg list --prefix ${prefix}\` before apply.`);
289
+ }
290
+
267
291
  const slug = slugify(opts.segment || prefix, prefix);
268
292
  const planDoc = {
269
293
  schema_version: 1,
@@ -326,7 +350,10 @@ export async function apply(orgId, identifier, opts = {}) {
326
350
  // V-11: warn when the batch tags already carry members (tag reuse merges counts).
327
351
  try {
328
352
  const before = await http.post("segments", { mode: "list", organization_id: orgId, prefix: planDoc.tag_prefix });
329
- const existing = (before.tags || []).filter((t) => Number(t.count) > 0);
353
+ // Only THIS plan's batch tags matter — filtering on the prefix alone warned
354
+ // about every past batch of the campaign and prompted on a clean plan.
355
+ const planTags = new Set(planDoc.batches.map((b) => String(b.tag)));
356
+ const existing = (before.tags || []).filter((t) => Number(t.count) > 0 && planTags.has(String(t.tag)));
330
357
  if (existing.length) {
331
358
  console.log(`⚠ ${existing.length} of these tags already have members (counts will MERGE):`);
332
359
  for (const t of existing.slice(0, 5)) console.log(` ${t.tag}: ${t.count}`);
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ 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
18
  import * as hoursCmd from "./commands/hours.js";
19
+ import * as reportCmd from "./commands/report.js";
19
20
  import * as templatesCmd from "./commands/templates.js";
20
21
  import * as orgCmd from "./commands/org.js";
21
22
  import * as agentConfigCmd from "./commands/agent-config.js";
@@ -215,6 +216,56 @@ export function run(argv) {
215
216
  .option("--json", "raw JSON output")
216
217
  .action((opts) => hoursCmd.summary(opts));
217
218
 
219
+ // report deck — the monthly client deck (build / copy / approve / send)
220
+ const report = program.command("report").description("Client reporting: the monthly 10-slide client deck");
221
+ const deck = report.command("deck").description("The monthly client deck for one org and month (/reporting/deck)");
222
+ deck.command("status <organization_id> <month>")
223
+ .description("What exists for the month, the workflow status and what still blocks approval")
224
+ .option("--json", "raw JSON output")
225
+ .action((orgId, month, opts) => reportCmd.status(orgId, month, opts));
226
+ deck.command("build <organization_id> <month>")
227
+ .description("Build the deck metrics from the frozen month (leaderboard, timing, popup, carts, records, changes)")
228
+ .option("--refresh", "rebuild even if the metrics already exist")
229
+ .option("--json", "raw JSON output")
230
+ .action((orgId, month, opts) => reportCmd.build(orgId, month, opts));
231
+ deck.command("narrate <organization_id> <month>")
232
+ .description("Rewrite the ten slides' copy (LLM, under the report rules + numeric guard); hand edits are kept")
233
+ .option("--json", "raw JSON output")
234
+ .action((orgId, month, opts) => reportCmd.narrate(orgId, month, opts));
235
+ deck.command("generate <organization_id> <month>")
236
+ .description("Build the metrics (if missing, or --refresh) and write the copy")
237
+ .option("--refresh", "rebuild the metrics first")
238
+ .option("--force", "rewrite the copy even if it exists")
239
+ .option("--json", "raw JSON output")
240
+ .action((orgId, month, opts) => reportCmd.generate(orgId, month, opts));
241
+ deck.command("inputs <organization_id> <month>")
242
+ .description("Save the super-admin inputs from a JSON file (changes reviewed, action points, waiting-on-you, recipients confirmed)")
243
+ .option("--file <path>", "inputs JSON file")
244
+ .option("--json", "raw JSON output")
245
+ .action((orgId, month, opts) => reportCmd.inputs(orgId, month, opts));
246
+ deck.command("approve <organization_id> <month>")
247
+ .description("Approve the deck (refused until every required input is present)")
248
+ .option("--json", "raw JSON output")
249
+ .action((orgId, month, opts) => reportCmd.approve(orgId, month, opts));
250
+ deck.command("unapprove <organization_id> <month>")
251
+ .description("Take an approved deck back to needs input / ready")
252
+ .option("--json", "raw JSON output")
253
+ .action((orgId, month, opts) => reportCmd.unapprove(orgId, month, opts));
254
+ deck.command("send <organization_id> <month>")
255
+ .description("Email the PDF from flowiq@flowapt.com: --test-to for one test copy (status untouched), --client for the configured recipients (needs approval)")
256
+ .option("--test-to <email>", "send a test copy to this address only")
257
+ .option("--client", "send to the org's configured client recipients")
258
+ .option("--json", "raw JSON output")
259
+ .action((orgId, month, opts) => reportCmd.send(orgId, month, opts));
260
+ deck.command("print <organization_id> <month>")
261
+ .description("Mint a 15-minute print URL (the page headless Chrome renders to PDF)")
262
+ .option("--json", "raw JSON output")
263
+ .action((orgId, month, opts) => reportCmd.print(orgId, month, opts));
264
+ deck.command("pull <organization_id> <month>")
265
+ .description("Write the month's doc + deck metrics + copy + inputs to ./.flowiq/reports/<slug>-<month>.json")
266
+ .option("--out <path>", "write to this file instead")
267
+ .action((orgId, month, opts) => reportCmd.pull(orgId, month, opts));
268
+
218
269
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
219
270
  const templates = program.command("templates")
220
271
  .alias("tpl")
@@ -544,6 +595,8 @@ export function run(argv) {
544
595
  .option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
545
596
  .option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
546
597
  .option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
598
+ .option("--start-index <n>", "number the first batch tag from N instead of 01 — continues an existing series (e.g. --start-index 13 → <prefix>-batch-13)", "1")
599
+ .option("--allow-existing-tag", "allow a batch tag that already holds contacts (apply is append-only, so this MERGES cohorts)")
547
600
  .option("--segment <name>", "plan file slug (default: the tag prefix)")
548
601
  .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
549
602
  .action((orgId, opts) => segmentsCmd.plan(orgId, opts));