@flowapt/flowiq-cli 0.3.0 → 0.3.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
@@ -230,6 +230,14 @@ throttle *and* your brake. Tags only; the send is `broadcast send --tag` (one
230
230
  batch at a time) or the dashboard broadcaster.
231
231
 
232
232
  ```bash
233
+ # Broadcast list → N even TAGGED COHORTS in ONE command (v0.3.1 — no id file, no DB access):
234
+ flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3 --seed 42
235
+ # every broadcast-opted-in contact (minus archived/blocked), shuffled into 3
236
+ # BALANCED cohorts → bcast-batch-01/02/03. --from-attribute also takes
237
+ # all_contacts | allow_broadcast_false | no_broadcast_permission (same selector
238
+ # as `tag attributes --filter`). --split N (2..100) replaces --batch-size;
239
+ # --seed makes the shuffle reproducible, --ordered keeps server order (no shuffle).
240
+
233
241
  # Cohort by ORDER-COUNT CRITERIA (v0.2.6 — server-resolved, no id file needed):
234
242
  flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d
235
243
  # "everyone with 2+ orders in the last 60 days" — counted from the captured
@@ -252,11 +260,19 @@ flowiq seg list <org_id> --prefix repeat-60d # VERIFY: tag → count
252
260
  flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own tags only)
253
261
  ```
254
262
 
255
- - **Two cohort sources:** `--min-orders [--window]` resolves the cohort
256
- server-side (the windowed order count the app's Advanced Tagging UI cannot
257
- express — its Min Orders filter is lifetime-only), or pass explicit
258
- **contact UUIDs** (a snapshot JSON's `contact_ids[]` or a plain file) for
259
- SQL-derived / bespoke cohorts.
263
+ - **Cohort sources:** `--from-attribute <filter>` (a broadcast-permission
264
+ audience — `all_contacts` / `allow_broadcast_true` / `allow_broadcast_false`
265
+ / `no_broadcast_permission`, resolved server-side past the 1000-row cap);
266
+ `--min-orders [--window]` (the windowed order count the app's Advanced Tagging
267
+ UI cannot express — its Min Orders filter is lifetime-only); `--bought`
268
+ (product line-items); or explicit **contact UUIDs** (a snapshot JSON's
269
+ `contact_ids[]` or a plain file). Exactly one per plan.
270
+ - **`--split N` = exactly N even cohorts** (2..100), an alternative to
271
+ `--batch-size` fixed chunks. It **shuffles by default** so the cohorts are
272
+ balanced (not skewed by contact age / signup order); `--seed S` makes the
273
+ shuffle reproducible, `--ordered` keeps server order. `--from-attribute
274
+ allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
275
+ tagged cohorts" one-liner — no id file, no database access.
260
276
  - Apply is **append-only** — it never touches a contact's other tags, names,
261
277
  or anything else, and never double-adds.
262
278
  - Point-in-time warning: the plan snapshots the cohort at plan time; re-run
package/TEAM-GUIDE.md CHANGED
@@ -87,6 +87,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
87
87
  | Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
88
88
  | 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` |
89
89
  | List / remove tags | `flowiq tag list <org_id>` · `flowiq tag remove <org_id> <tag> --confirm` |
90
+ | 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) |
90
91
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
91
92
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
92
93
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.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": {
@@ -39,6 +39,27 @@ function slugify(name, fallback) {
39
39
  return s || fallback;
40
40
  }
41
41
 
42
+ /** Deterministic PRNG (mulberry32) so `--seed` gives a reproducible shuffle. */
43
+ function mulberry32(seed) {
44
+ let a = seed >>> 0;
45
+ return function () {
46
+ a |= 0; a = (a + 0x6d2b79f5) | 0;
47
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
48
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
49
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
50
+ };
51
+ }
52
+
53
+ /** Fisher–Yates shuffle in place. Pass a numeric seed for reproducibility, else Math.random. */
54
+ function shuffle(arr, seed) {
55
+ const rng = seed !== undefined && seed !== null && String(seed).length ? mulberry32(Number(seed) >>> 0) : Math.random;
56
+ for (let i = arr.length - 1; i > 0; i--) {
57
+ const j = Math.floor(rng() * (i + 1));
58
+ [arr[i], arr[j]] = [arr[j], arr[i]];
59
+ }
60
+ return arr;
61
+ }
62
+
42
63
  async function fileExists(p) {
43
64
  try { await fs.access(p); return true; } catch { return false; }
44
65
  }
@@ -98,26 +119,45 @@ export async function plan(orgId, opts = {}) {
98
119
  console.error(`Error: --tag-prefix must be slug-safe lowercase ([a-z0-9-], got "${prefix}").`);
99
120
  process.exit(1);
100
121
  }
122
+ const splitMode = opts.split !== undefined;
101
123
  const batchSize = Number(opts.batchSize ?? 75);
102
- if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
103
- if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
124
+ if (!splitMode) {
125
+ if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
126
+ if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
127
+ }
128
+ let splitN = 0;
129
+ if (splitMode) {
130
+ splitN = Number(opts.split);
131
+ if (!Number.isInteger(splitN) || splitN < 2 || splitN > 100) { console.error("Error: --split must be an integer 2..100."); process.exit(1); }
132
+ if (opts.seed !== undefined && !Number.isFinite(Number(opts.seed))) { console.error("Error: --seed must be a number."); process.exit(1); }
133
+ }
104
134
 
105
- // Cohort source: a server-resolved criterion (--min-orders OR --bought,
106
- // both with an optional --window) OR an explicit id list (--from-segment /
107
- // --ids-file).
135
+ // Cohort source: a server-resolved criterion (--from-attribute / --min-orders
136
+ // / --bought) OR an explicit id list (--from-segment / --ids-file). Exactly one.
108
137
  const orderMode = opts.minOrders !== undefined;
109
138
  const productMode = opts.bought !== undefined;
110
- const criteriaMode = orderMode || productMode;
111
- if (orderMode && productMode) {
112
- console.error("Error: use either --min-orders or --bought, not both."); process.exit(1);
139
+ const attributeMode = opts.fromAttribute !== undefined;
140
+ const criteriaMode = orderMode || productMode || attributeMode;
141
+ const sourceCount = [orderMode, productMode, attributeMode, !!opts.fromSegment, !!opts.idsFile].filter(Boolean).length;
142
+ if (sourceCount === 0) {
143
+ console.error("Error: a cohort source is required — one of --from-attribute / --min-orders / --bought / --from-segment / --ids-file."); process.exit(1);
113
144
  }
114
- if (criteriaMode && (opts.fromSegment || opts.idsFile)) {
115
- console.error("Error: --min-orders / --bought cannot be combined with --from-segment / --ids-file."); process.exit(1);
145
+ if (sourceCount > 1) {
146
+ console.error("Error: pick exactly ONE cohort source (--from-attribute / --min-orders / --bought / --from-segment / --ids-file)."); process.exit(1);
116
147
  }
117
148
 
118
149
  let cohort;
119
150
  let cohortSpec = null;
120
151
  if (criteriaMode) {
152
+ if (attributeMode) {
153
+ const ATTR = ["all_contacts", "allow_broadcast_true", "allow_broadcast_false", "no_broadcast_permission"];
154
+ const attribute = String(opts.fromAttribute).trim();
155
+ if (!ATTR.includes(attribute)) {
156
+ console.error(`Error: --from-attribute must be one of: ${ATTR.join(", ")}`); process.exit(1);
157
+ }
158
+ cohortSpec = { attribute };
159
+ cohort = { ids: [], badUuids: [], sourceMeta: { type: "attribute_cohort", attribute } };
160
+ } else {
121
161
  let windowDays = null;
122
162
  if (opts.window) {
123
163
  try { windowDays = parseWindowDays(opts.window); }
@@ -140,6 +180,7 @@ export async function plan(orgId, opts = {}) {
140
180
  cohortSpec = { min_orders: minOrders, window_days: windowDays, platform: opts.platform || "auto" };
141
181
  cohort = { ids: [], badUuids: [], sourceMeta: { type: "order_cohort", ...cohortSpec } };
142
182
  }
183
+ }
143
184
  } else {
144
185
  try { cohort = await readCohort(opts, orgId); }
145
186
  catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
@@ -160,23 +201,54 @@ export async function plan(orgId, opts = {}) {
160
201
  process.exit(1);
161
202
  }
162
203
  if (resp.cohort) {
163
- const w = resp.cohort.window_days ? `in the last ${resp.cohort.window_days} days` : "across all captured history";
164
- if (resp.cohort.kind === "product") {
165
- const verb = resp.cohort.min_purchases > 1 ? `bought ${resp.cohort.min_purchases}+ times` : "bought";
166
- const join = resp.cohort.match === "all" ? "ALL of" : "any of";
167
- console.log(`Cohort: ${verb} ${join} [${resp.cohort.product_terms.join(", ")}] ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from order line-items).`);
204
+ if (resp.cohort.kind === "attribute") {
205
+ const label = {
206
+ all_contacts: "all contacts",
207
+ allow_broadcast_true: "broadcast-opted-in contacts",
208
+ allow_broadcast_false: "broadcast-opted-OUT contacts",
209
+ no_broadcast_permission: "contacts with no broadcast permission set",
210
+ }[resp.cohort.attribute] || resp.cohort.attribute;
211
+ console.log(`Cohort: ${label} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
168
212
  } else {
169
- console.log(`Cohort: ${resp.cohort.min_orders}+ orders ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from captured orders).`);
213
+ const w = resp.cohort.window_days ? `in the last ${resp.cohort.window_days} days` : "across all captured history";
214
+ if (resp.cohort.kind === "product") {
215
+ const verb = resp.cohort.min_purchases > 1 ? `bought ${resp.cohort.min_purchases}+ times` : "bought";
216
+ const join = resp.cohort.match === "all" ? "ALL of" : "any of";
217
+ console.log(`Cohort: ${verb} ${join} [${resp.cohort.product_terms.join(", ")}] ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from order line-items).`);
218
+ } else {
219
+ console.log(`Cohort: ${resp.cohort.min_orders}+ orders ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from captured orders).`);
220
+ }
170
221
  }
171
222
  cohort.sourceMeta.resolved_count = resp.cohort.resolved_count;
172
223
  }
173
224
  if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
225
+ if (splitMode && splitN > resp.safe_count) {
226
+ console.error(`Error: --split ${splitN} but only ${resp.safe_count} safe contact(s) — can't make that many cohorts.`); process.exit(1);
227
+ }
174
228
 
175
- // Slice safe_ids into contiguous batches (client-side, per the spec split).
229
+ // --split makes exactly N EVEN cohorts and shuffles by default so the groups
230
+ // are balanced (not skewed by contact age / id order); --ordered keeps server
231
+ // order; --seed makes the shuffle reproducible. Without --split, slice into
232
+ // contiguous --batch-size chunks (the original behaviour).
176
233
  const batches = [];
177
- for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
178
- const nn = String(batches.length + 1).padStart(2, "0");
179
- batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
234
+ if (splitMode) {
235
+ const ids = opts.ordered ? resp.safe_ids.slice() : shuffle(resp.safe_ids.slice(), opts.seed);
236
+ const total = ids.length;
237
+ const base = Math.floor(total / splitN);
238
+ const rem = total % splitN;
239
+ let idx = 0;
240
+ for (let g = 0; g < splitN; g++) {
241
+ const size = base + (g < rem ? 1 : 0);
242
+ const nn = String(g + 1).padStart(2, "0");
243
+ batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
244
+ idx += size;
245
+ }
246
+ 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.`);
247
+ } else {
248
+ for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
249
+ const nn = String(batches.length + 1).padStart(2, "0");
250
+ batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
251
+ }
180
252
  }
181
253
 
182
254
  const slug = slugify(opts.segment || prefix, prefix);
@@ -186,7 +258,8 @@ export async function plan(orgId, opts = {}) {
186
258
  organization_id: resp.organization_id,
187
259
  organization_slug: slugify(resp.organization_name, resp.organization_id.slice(0, 8)),
188
260
  tag_prefix: prefix,
189
- batch_size: batchSize,
261
+ batch_size: splitMode ? null : batchSize,
262
+ split: splitMode ? { cohorts: splitN, shuffled: !opts.ordered, seed: opts.seed ?? null } : null,
190
263
  created_at: new Date().toISOString(),
191
264
  source: cohort.sourceMeta,
192
265
  exclusions: {
@@ -206,7 +279,12 @@ export async function plan(orgId, opts = {}) {
206
279
  console.log(`Wrote ${planPath(slug)}`);
207
280
  console.log(` org: ${resp.organization_name}`);
208
281
  console.log(` input: ${resp.input_count} · safe: ${resp.safe_count} · excluded: ${ex.not_allow_broadcast + ex.archived + ex.blocked} (allow_broadcast ${ex.not_allow_broadcast}, archived ${ex.archived}, blocked ${ex.blocked}) · not_found ${ex.not_found_in_org} · dupes ${ex.duplicate_in_input}`);
209
- console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
282
+ if (splitMode) {
283
+ const how = opts.ordered ? "ordered" : (opts.seed != null ? `shuffled, seed ${opts.seed}` : "shuffled");
284
+ console.log(` cohorts: ${batches.length} even (${how}) — sizes ${batches.map((b) => b.count).join(" / ")} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
285
+ } else {
286
+ console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
287
+ }
210
288
  if (cohort.sourceMeta.generated_at) {
211
289
  console.log(` ⚠ tagging point-in-time ids from ${cohort.sourceMeta.generated_at} — re-derive the cohort SQL if freshness matters.`);
212
290
  }
package/src/index.js CHANGED
@@ -416,10 +416,11 @@ export function run(argv) {
416
416
  .alias("seg")
417
417
  .description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
418
418
  segments.command("plan <organization_id>")
419
- .description("Resolve a cohort (id list, order-count, or product-purchase criteria), apply broadcast-safety exclusions, slice into N-sized batch tags, write the plan (no DB write)")
419
+ .description("Resolve a cohort (id list, order-count, product-purchase, or broadcast-attribute), apply broadcast-safety exclusions, slice into fixed-size OR N even batch tags, write the plan (no DB write)")
420
420
  .requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
421
421
  .option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
422
422
  .option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
423
+ .option("--from-attribute <filter>", "SERVER-RESOLVED cohort: every contact matching a broadcast-permission filter (all_contacts | allow_broadcast_true | allow_broadcast_false | no_broadcast_permission)")
423
424
  .option("--min-orders <n>", "SERVER-RESOLVED cohort: contacts with ≥ n captured orders (instead of an id file)")
424
425
  .option("--bought <terms>", "SERVER-RESOLVED cohort: contacts who bought these product(s) (comma-separated name substrings) from real order line-items")
425
426
  .option("--match <mode>", "with --bought: any (bought any listed product) | all (bought every one)", "any")
@@ -427,6 +428,9 @@ export function run(argv) {
427
428
  .option("--window <span>", "with --min-orders/--bought: only count orders in this window, e.g. 60d / 8w / 3m (omit = all captured history)")
428
429
  .option("--platform <p>", "with --min-orders/--bought: auto | shopify | woo", "auto")
429
430
  .option("--batch-size <n>", "contacts per batch tag", "75")
431
+ .option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
432
+ .option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
433
+ .option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
430
434
  .option("--segment <name>", "plan file slug (default: the tag prefix)")
431
435
  .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
432
436
  .action((orgId, opts) => segmentsCmd.plan(orgId, opts));