@flowapt/flowiq-cli 0.3.0 → 0.3.2
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 +31 -5
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/commands/segments.js +115 -24
- package/src/index.js +7 -1
package/README.md
CHANGED
|
@@ -230,6 +230,22 @@ 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
|
+
|
|
241
|
+
# Cohort from EXISTING TAGS → batched (v0.3.2 — combine tags, then split, no id file):
|
|
242
|
+
flowiq seg plan <org_id> --tag-prefix 18-july-clearance-bc \
|
|
243
|
+
--from-tag "9-jul-loyalty-list" --exclude "recent-campaign,opt-out" --batch-size 1000
|
|
244
|
+
# contacts carrying ANY of the --from-tag tags, MINUS anyone carrying any --exclude
|
|
245
|
+
# tag, minus archived/blocked/opted-out → sliced into 1000-sized batch tags
|
|
246
|
+
# (18-july-clearance-bc-batch-01, -02, …). --from-tag pairs with --split too.
|
|
247
|
+
# This is the "existing tags → batched send list" flow, entirely in the CLI.
|
|
248
|
+
|
|
233
249
|
# Cohort by ORDER-COUNT CRITERIA (v0.2.6 — server-resolved, no id file needed):
|
|
234
250
|
flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d
|
|
235
251
|
# "everyone with 2+ orders in the last 60 days" — counted from the captured
|
|
@@ -252,11 +268,21 @@ flowiq seg list <org_id> --prefix repeat-60d # VERIFY: tag → count
|
|
|
252
268
|
flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own tags only)
|
|
253
269
|
```
|
|
254
270
|
|
|
255
|
-
- **
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
271
|
+
- **Cohort sources:** `--from-attribute <filter>` (a broadcast-permission
|
|
272
|
+
audience — `all_contacts` / `allow_broadcast_true` / `allow_broadcast_false`
|
|
273
|
+
/ `no_broadcast_permission`, resolved server-side in one RPC call);
|
|
274
|
+
**`--from-tag "a,b" [--exclude "c,d"]`** (contacts carrying ANY of the include
|
|
275
|
+
tags minus anyone carrying an exclude tag — the "existing tags → batched send
|
|
276
|
+
list" source, v0.3.2); `--min-orders [--window]` (the windowed order count the
|
|
277
|
+
app's Advanced Tagging UI cannot express — its Min Orders filter is
|
|
278
|
+
lifetime-only); `--bought` (product line-items); or explicit **contact UUIDs**
|
|
279
|
+
(a snapshot JSON's `contact_ids[]` or a plain file). Exactly one per plan.
|
|
280
|
+
- **`--split N` = exactly N even cohorts** (2..100), an alternative to
|
|
281
|
+
`--batch-size` fixed chunks. It **shuffles by default** so the cohorts are
|
|
282
|
+
balanced (not skewed by contact age / signup order); `--seed S` makes the
|
|
283
|
+
shuffle reproducible, `--ordered` keeps server order. `--from-attribute
|
|
284
|
+
allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
|
|
285
|
+
tagged cohorts" one-liner — no id file, no database access.
|
|
260
286
|
- Apply is **append-only** — it never touches a contact's other tags, names,
|
|
261
287
|
or anything else, and never double-adds.
|
|
262
288
|
- Point-in-time warning: the plan snapshots the cohort at plan time; re-run
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -87,6 +87,8 @@ 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) |
|
|
91
|
+
| 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/…`) |
|
|
90
92
|
| 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
93
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
92
94
|
| 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.
|
|
3
|
+
"version": "0.3.2",
|
|
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": {
|
package/src/commands/segments.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// runbook). This command TAGS ONLY — it never sends. The send per tag is
|
|
4
4
|
// `flowiq broadcast send --tag <batch-tag>` (or the dashboard broadcaster).
|
|
5
5
|
//
|
|
6
|
-
// plan <org> --tag-prefix X (--from-
|
|
7
|
-
// | --min-orders N [--window 60d]
|
|
6
|
+
// plan <org> --tag-prefix X (--from-attribute F | --from-tag "a,b" [--exclude "c,d"]
|
|
7
|
+
// | --min-orders N [--window 60d] | --bought "P"
|
|
8
|
+
// | --from-segment file | --ids-file file) # exclusions → slice → plan file (no DB write)
|
|
8
9
|
// apply <org> <slug> [--commit] [--yes] # append the batch tags (dry-run default)
|
|
9
10
|
// list <org> [--prefix X] # VERIFY: tag → contact count
|
|
10
11
|
// untag <org> <slug> --commit --confirm # ROLLBACK: remove the plan's own tags
|
|
@@ -39,6 +40,27 @@ function slugify(name, fallback) {
|
|
|
39
40
|
return s || fallback;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Deterministic PRNG (mulberry32) so `--seed` gives a reproducible shuffle. */
|
|
44
|
+
function mulberry32(seed) {
|
|
45
|
+
let a = seed >>> 0;
|
|
46
|
+
return function () {
|
|
47
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
48
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
49
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
50
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fisher–Yates shuffle in place. Pass a numeric seed for reproducibility, else Math.random. */
|
|
55
|
+
function shuffle(arr, seed) {
|
|
56
|
+
const rng = seed !== undefined && seed !== null && String(seed).length ? mulberry32(Number(seed) >>> 0) : Math.random;
|
|
57
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
58
|
+
const j = Math.floor(rng() * (i + 1));
|
|
59
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
60
|
+
}
|
|
61
|
+
return arr;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
async function fileExists(p) {
|
|
43
65
|
try { await fs.access(p); return true; } catch { return false; }
|
|
44
66
|
}
|
|
@@ -98,26 +120,53 @@ export async function plan(orgId, opts = {}) {
|
|
|
98
120
|
console.error(`Error: --tag-prefix must be slug-safe lowercase ([a-z0-9-], got "${prefix}").`);
|
|
99
121
|
process.exit(1);
|
|
100
122
|
}
|
|
123
|
+
const splitMode = opts.split !== undefined;
|
|
101
124
|
const batchSize = Number(opts.batchSize ?? 75);
|
|
102
|
-
if (!
|
|
103
|
-
|
|
125
|
+
if (!splitMode) {
|
|
126
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
|
|
127
|
+
if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
|
|
128
|
+
}
|
|
129
|
+
let splitN = 0;
|
|
130
|
+
if (splitMode) {
|
|
131
|
+
splitN = Number(opts.split);
|
|
132
|
+
if (!Number.isInteger(splitN) || splitN < 2 || splitN > 100) { console.error("Error: --split must be an integer 2..100."); process.exit(1); }
|
|
133
|
+
if (opts.seed !== undefined && !Number.isFinite(Number(opts.seed))) { console.error("Error: --seed must be a number."); process.exit(1); }
|
|
134
|
+
}
|
|
104
135
|
|
|
105
|
-
// Cohort source: a server-resolved criterion (--
|
|
106
|
-
//
|
|
107
|
-
//
|
|
136
|
+
// Cohort source: a server-resolved criterion (--from-attribute / --from-tag /
|
|
137
|
+
// --min-orders / --bought) OR an explicit id list (--from-segment / --ids-file).
|
|
138
|
+
// Exactly one.
|
|
108
139
|
const orderMode = opts.minOrders !== undefined;
|
|
109
140
|
const productMode = opts.bought !== undefined;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
141
|
+
const attributeMode = opts.fromAttribute !== undefined;
|
|
142
|
+
const tagMode = opts.fromTag !== undefined;
|
|
143
|
+
const criteriaMode = orderMode || productMode || attributeMode || tagMode;
|
|
144
|
+
const sourceCount = [orderMode, productMode, attributeMode, tagMode, !!opts.fromSegment, !!opts.idsFile].filter(Boolean).length;
|
|
145
|
+
if (sourceCount === 0) {
|
|
146
|
+
console.error("Error: a cohort source is required — one of --from-attribute / --from-tag / --min-orders / --bought / --from-segment / --ids-file."); process.exit(1);
|
|
113
147
|
}
|
|
114
|
-
if (
|
|
115
|
-
console.error("Error: --min-orders / --bought
|
|
148
|
+
if (sourceCount > 1) {
|
|
149
|
+
console.error("Error: pick exactly ONE cohort source (--from-attribute / --from-tag / --min-orders / --bought / --from-segment / --ids-file)."); process.exit(1);
|
|
116
150
|
}
|
|
117
151
|
|
|
118
152
|
let cohort;
|
|
119
153
|
let cohortSpec = null;
|
|
120
154
|
if (criteriaMode) {
|
|
155
|
+
if (attributeMode) {
|
|
156
|
+
const ATTR = ["all_contacts", "allow_broadcast_true", "allow_broadcast_false", "no_broadcast_permission"];
|
|
157
|
+
const attribute = String(opts.fromAttribute).trim();
|
|
158
|
+
if (!ATTR.includes(attribute)) {
|
|
159
|
+
console.error(`Error: --from-attribute must be one of: ${ATTR.join(", ")}`); process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
cohortSpec = { attribute };
|
|
162
|
+
cohort = { ids: [], badUuids: [], sourceMeta: { type: "attribute_cohort", attribute } };
|
|
163
|
+
} else if (tagMode) {
|
|
164
|
+
const includeTags = String(opts.fromTag).split(",").map((s) => s.trim()).filter(Boolean);
|
|
165
|
+
if (!includeTags.length) { console.error("Error: --from-tag needs at least one tag (comma-separate several)."); process.exit(1); }
|
|
166
|
+
const excludeTags = opts.exclude ? String(opts.exclude).split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
167
|
+
cohortSpec = { include_tags: includeTags, exclude_tags: excludeTags };
|
|
168
|
+
cohort = { ids: [], badUuids: [], sourceMeta: { type: "tag_cohort", include_tags: includeTags, exclude_tags: excludeTags } };
|
|
169
|
+
} else {
|
|
121
170
|
let windowDays = null;
|
|
122
171
|
if (opts.window) {
|
|
123
172
|
try { windowDays = parseWindowDays(opts.window); }
|
|
@@ -140,6 +189,7 @@ export async function plan(orgId, opts = {}) {
|
|
|
140
189
|
cohortSpec = { min_orders: minOrders, window_days: windowDays, platform: opts.platform || "auto" };
|
|
141
190
|
cohort = { ids: [], badUuids: [], sourceMeta: { type: "order_cohort", ...cohortSpec } };
|
|
142
191
|
}
|
|
192
|
+
}
|
|
143
193
|
} else {
|
|
144
194
|
try { cohort = await readCohort(opts, orgId); }
|
|
145
195
|
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
@@ -160,23 +210,58 @@ export async function plan(orgId, opts = {}) {
|
|
|
160
210
|
process.exit(1);
|
|
161
211
|
}
|
|
162
212
|
if (resp.cohort) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
213
|
+
if (resp.cohort.kind === "attribute") {
|
|
214
|
+
const label = {
|
|
215
|
+
all_contacts: "all contacts",
|
|
216
|
+
allow_broadcast_true: "broadcast-opted-in contacts",
|
|
217
|
+
allow_broadcast_false: "broadcast-opted-OUT contacts",
|
|
218
|
+
no_broadcast_permission: "contacts with no broadcast permission set",
|
|
219
|
+
}[resp.cohort.attribute] || resp.cohort.attribute;
|
|
220
|
+
console.log(`Cohort: ${label} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
|
|
221
|
+
} else if (resp.cohort.kind === "tag") {
|
|
222
|
+
const inc = resp.cohort.include_tags.join(", ");
|
|
223
|
+
const exc = resp.cohort.exclude_tags && resp.cohort.exclude_tags.length ? `, excluding [${resp.cohort.exclude_tags.join(", ")}]` : "";
|
|
224
|
+
console.log(`Cohort: contacts tagged any of [${inc}]${exc} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
|
|
168
225
|
} else {
|
|
169
|
-
|
|
226
|
+
const w = resp.cohort.window_days ? `in the last ${resp.cohort.window_days} days` : "across all captured history";
|
|
227
|
+
if (resp.cohort.kind === "product") {
|
|
228
|
+
const verb = resp.cohort.min_purchases > 1 ? `bought ${resp.cohort.min_purchases}+ times` : "bought";
|
|
229
|
+
const join = resp.cohort.match === "all" ? "ALL of" : "any of";
|
|
230
|
+
console.log(`Cohort: ${verb} ${join} [${resp.cohort.product_terms.join(", ")}] ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from order line-items).`);
|
|
231
|
+
} else {
|
|
232
|
+
console.log(`Cohort: ${resp.cohort.min_orders}+ orders ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from captured orders).`);
|
|
233
|
+
}
|
|
170
234
|
}
|
|
171
235
|
cohort.sourceMeta.resolved_count = resp.cohort.resolved_count;
|
|
172
236
|
}
|
|
173
237
|
if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
|
|
238
|
+
if (splitMode && splitN > resp.safe_count) {
|
|
239
|
+
console.error(`Error: --split ${splitN} but only ${resp.safe_count} safe contact(s) — can't make that many cohorts.`); process.exit(1);
|
|
240
|
+
}
|
|
174
241
|
|
|
175
|
-
//
|
|
242
|
+
// --split makes exactly N EVEN cohorts and shuffles by default so the groups
|
|
243
|
+
// are balanced (not skewed by contact age / id order); --ordered keeps server
|
|
244
|
+
// order; --seed makes the shuffle reproducible. Without --split, slice into
|
|
245
|
+
// contiguous --batch-size chunks (the original behaviour).
|
|
176
246
|
const batches = [];
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
247
|
+
if (splitMode) {
|
|
248
|
+
const ids = opts.ordered ? resp.safe_ids.slice() : shuffle(resp.safe_ids.slice(), opts.seed);
|
|
249
|
+
const total = ids.length;
|
|
250
|
+
const base = Math.floor(total / splitN);
|
|
251
|
+
const rem = total % splitN;
|
|
252
|
+
let idx = 0;
|
|
253
|
+
for (let g = 0; g < splitN; g++) {
|
|
254
|
+
const size = base + (g < rem ? 1 : 0);
|
|
255
|
+
const nn = String(g + 1).padStart(2, "0");
|
|
256
|
+
batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
|
|
257
|
+
idx += size;
|
|
258
|
+
}
|
|
259
|
+
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
|
+
} else {
|
|
261
|
+
for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
|
|
262
|
+
const nn = String(batches.length + 1).padStart(2, "0");
|
|
263
|
+
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
|
+
}
|
|
180
265
|
}
|
|
181
266
|
|
|
182
267
|
const slug = slugify(opts.segment || prefix, prefix);
|
|
@@ -186,7 +271,8 @@ export async function plan(orgId, opts = {}) {
|
|
|
186
271
|
organization_id: resp.organization_id,
|
|
187
272
|
organization_slug: slugify(resp.organization_name, resp.organization_id.slice(0, 8)),
|
|
188
273
|
tag_prefix: prefix,
|
|
189
|
-
batch_size: batchSize,
|
|
274
|
+
batch_size: splitMode ? null : batchSize,
|
|
275
|
+
split: splitMode ? { cohorts: splitN, shuffled: !opts.ordered, seed: opts.seed ?? null } : null,
|
|
190
276
|
created_at: new Date().toISOString(),
|
|
191
277
|
source: cohort.sourceMeta,
|
|
192
278
|
exclusions: {
|
|
@@ -206,7 +292,12 @@ export async function plan(orgId, opts = {}) {
|
|
|
206
292
|
console.log(`Wrote ${planPath(slug)}`);
|
|
207
293
|
console.log(` org: ${resp.organization_name}`);
|
|
208
294
|
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
|
-
|
|
295
|
+
if (splitMode) {
|
|
296
|
+
const how = opts.ordered ? "ordered" : (opts.seed != null ? `shuffled, seed ${opts.seed}` : "shuffled");
|
|
297
|
+
console.log(` cohorts: ${batches.length} even (${how}) — sizes ${batches.map((b) => b.count).join(" / ")} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
|
|
298
|
+
} else {
|
|
299
|
+
console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
|
|
300
|
+
}
|
|
210
301
|
if (cohort.sourceMeta.generated_at) {
|
|
211
302
|
console.log(` ⚠ tagging point-in-time ids from ${cohort.sourceMeta.generated_at} — re-derive the cohort SQL if freshness matters.`);
|
|
212
303
|
}
|
package/src/index.js
CHANGED
|
@@ -416,10 +416,13 @@ 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,
|
|
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)")
|
|
424
|
+
.option("--from-tag <csv>", "SERVER-RESOLVED cohort: contacts carrying ANY of these tag(s) (comma-separated)")
|
|
425
|
+
.option("--exclude <csv>", "with --from-tag: drop contacts carrying ANY of these tag(s)")
|
|
423
426
|
.option("--min-orders <n>", "SERVER-RESOLVED cohort: contacts with ≥ n captured orders (instead of an id file)")
|
|
424
427
|
.option("--bought <terms>", "SERVER-RESOLVED cohort: contacts who bought these product(s) (comma-separated name substrings) from real order line-items")
|
|
425
428
|
.option("--match <mode>", "with --bought: any (bought any listed product) | all (bought every one)", "any")
|
|
@@ -427,6 +430,9 @@ export function run(argv) {
|
|
|
427
430
|
.option("--window <span>", "with --min-orders/--bought: only count orders in this window, e.g. 60d / 8w / 3m (omit = all captured history)")
|
|
428
431
|
.option("--platform <p>", "with --min-orders/--bought: auto | shopify | woo", "auto")
|
|
429
432
|
.option("--batch-size <n>", "contacts per batch tag", "75")
|
|
433
|
+
.option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
|
|
434
|
+
.option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
|
|
435
|
+
.option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
|
|
430
436
|
.option("--segment <name>", "plan file slug (default: the tag prefix)")
|
|
431
437
|
.option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
|
|
432
438
|
.action((orgId, opts) => segmentsCmd.plan(orgId, opts));
|