@flowapt/flowiq-cli 0.2.5 → 0.2.7
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 +70 -8
- package/TEAM-GUIDE.md +5 -1
- package/package.json +1 -1
- package/src/commands/segments.js +74 -7
- package/src/commands/tag.js +180 -0
- package/src/index.js +76 -1
package/README.md
CHANGED
|
@@ -230,20 +230,79 @@ 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
|
+
# Cohort by ORDER-COUNT CRITERIA (v0.2.6 — server-resolved, no id file needed):
|
|
234
|
+
flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d
|
|
235
|
+
# "everyone with 2+ orders in the last 60 days" — counted from the captured
|
|
236
|
+
# order stores (shopify_orders/woo_orders), NOT the lifetime orders_count column.
|
|
237
|
+
# --window takes 60d / 8w / 3m; omit it for all captured history. --platform auto|shopify|woo.
|
|
238
|
+
|
|
239
|
+
# Cohort by PRODUCT PURCHASE (v0.2.7 — from real order line-items):
|
|
240
|
+
flowiq seg plan <org_id> --tag-prefix whey --bought "Protein Water,Whey" --window 90d
|
|
241
|
+
# contacts who bought any of these products (name substrings) in the window;
|
|
242
|
+
# --match all (bought every listed product), --min-purchases 2 (bought 2+ times).
|
|
243
|
+
# More accurate than the app's Field Search on order_history text (which drifts).
|
|
244
|
+
|
|
245
|
+
# Cohort by explicit id list (the original mode):
|
|
233
246
|
flowiq seg plan <org_id> --tag-prefix july-promo --ids-file ./cohort.txt # or --from-segment snapshot.json
|
|
247
|
+
|
|
234
248
|
# → exclusions (opt-out/archived/blocked) applied BEFORE slicing → .flowiq/segments/<slug>.json
|
|
235
|
-
flowiq seg apply <org_id>
|
|
236
|
-
flowiq seg apply <org_id>
|
|
237
|
-
flowiq seg list <org_id> --prefix
|
|
238
|
-
flowiq seg untag <org_id>
|
|
249
|
+
flowiq seg apply <org_id> repeat-60d # DRY-RUN (default — nothing writes)
|
|
250
|
+
flowiq seg apply <org_id> repeat-60d --commit # append the tags (idempotent — re-runs report already_had)
|
|
251
|
+
flowiq seg list <org_id> --prefix repeat-60d # VERIFY: tag → count
|
|
252
|
+
flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own tags only)
|
|
239
253
|
```
|
|
240
254
|
|
|
241
|
-
-
|
|
242
|
-
|
|
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.
|
|
243
260
|
- Apply is **append-only** — it never touches a contact's other tags, names,
|
|
244
261
|
or anything else, and never double-adds.
|
|
245
|
-
- Point-in-time warning: the plan
|
|
246
|
-
|
|
262
|
+
- Point-in-time warning: the plan snapshots the cohort at plan time; re-run
|
|
263
|
+
`plan` to refresh it (someone who ordered since won't auto-drop, criteria
|
|
264
|
+
mode included).
|
|
265
|
+
|
|
266
|
+
### Advanced Tagging — `flowiq tag field|cohort|segment|attributes|messages|list|remove`
|
|
267
|
+
|
|
268
|
+
The **Contacts → Advanced Tagging** dialog in the terminal — applies **one
|
|
269
|
+
named tag** to a matched set of contacts (not batch tags; that's `segments`).
|
|
270
|
+
Every match mode is **dry-run by default** (prints the count + a sample); add
|
|
271
|
+
`--tag <name> --commit` to write. Appends only — it never touches a contact's
|
|
272
|
+
other tags. Runs through the staff-gated **service role**, so it's immune to
|
|
273
|
+
the anon exposure the in-app RPCs carry.
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
# Field search — keyword(s) in a field (order_history/contact_summary/shopify_tags/full_name/email/…)
|
|
277
|
+
flowiq tag field <org_id> --field order_history --any "Protein Water,Whey" # dry-run
|
|
278
|
+
flowiq tag field <org_id> --field shopify_tags --any "VIP" --tag vip --commit
|
|
279
|
+
|
|
280
|
+
# Cohort — top-N ranking (top_spenders / frequent_buyers / recent_buyers / high_value_low_frequency)
|
|
281
|
+
flowiq tag cohort <org_id> --type top_spenders --limit 500 --tag top-500 --commit
|
|
282
|
+
|
|
283
|
+
# Custom segment — order-count / spend / last-order-date / region / keyword filters
|
|
284
|
+
flowiq tag segment <org_id> --min-orders 2 --min-spent 1000 --region ZA-GP --tag gp-repeat --commit
|
|
285
|
+
|
|
286
|
+
# Attributes — broadcast permission (all_contacts / allow_broadcast_true|false / no_broadcast_permission)
|
|
287
|
+
flowiq tag attributes <org_id> --filter allow_broadcast_true --tag broadcastable --commit
|
|
288
|
+
|
|
289
|
+
# Message activity — ≥ N messages of a sender type, optional date range
|
|
290
|
+
flowiq tag messages <org_id> --min-count 3 --sender user-whatsapp --tag engaged --commit
|
|
291
|
+
|
|
292
|
+
flowiq tag list <org_id> # tags + counts (read-only)
|
|
293
|
+
flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL contacts (destructive)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- **Dry-run first, always.** Without `--commit` you get the match count + a
|
|
297
|
+
10-row sample and nothing writes. A commit over 2000 contacts asks you to
|
|
298
|
+
type the tag name (skip with `--yes`).
|
|
299
|
+
- **`field` reads the `order_history` TEXT** (which can drift from the real
|
|
300
|
+
orders). For accurate "who bought product X", prefer
|
|
301
|
+
`flowiq seg plan --bought "X"` (real order line-items, windowable).
|
|
302
|
+
- **`cohort` is top-N, not a threshold** — `top_spenders --limit 500` tags the
|
|
303
|
+
top 500 by spend, not "everyone above £X". Use `tag segment --min-spent` for
|
|
304
|
+
a threshold.
|
|
305
|
+
- **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
|
|
247
306
|
|
|
248
307
|
### Keywords — `flowiq keywords pull|push|list` (alias `kw`)
|
|
249
308
|
|
|
@@ -269,6 +328,9 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
269
328
|
collapses to 1 at runtime — rejected).
|
|
270
329
|
- `field:"attributes"` actions are warned (full jsonb replace; constant
|
|
271
330
|
values only) but applied — this CLI is their only safe editing surface.
|
|
331
|
+
- `action_config.link_preview: false` disables WhatsApp's link-preview card
|
|
332
|
+
on that action's text send (absent/`true` = preview on, the default).
|
|
333
|
+
Passed through verbatim; also toggleable per action in the dashboard.
|
|
272
334
|
- Scheduled keywords: the keyword-scheduler cron reconciles `active` from
|
|
273
335
|
`start_date`/`end_date` within ~30 min of your push.
|
|
274
336
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -76,7 +76,11 @@ you have installed.
|
|
|
76
76
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
77
77
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
78
78
|
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
|
|
79
|
-
|
|
|
79
|
+
| 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` |
|
|
80
|
+
| Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
|
|
81
|
+
| 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` |
|
|
82
|
+
| List / remove tags | `flowiq tag list <org_id>` · `flowiq tag remove <org_id> <tag> --confirm` |
|
|
83
|
+
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
80
84
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
81
85
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
82
86
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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,11 +3,17 @@
|
|
|
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-segment file | --ids-file file
|
|
6
|
+
// plan <org> --tag-prefix X (--from-segment file | --ids-file file
|
|
7
|
+
// | --min-orders N [--window 60d]) # exclusions → slice → plan file (no DB write)
|
|
7
8
|
// apply <org> <slug> [--commit] [--yes] # append the batch tags (dry-run default)
|
|
8
9
|
// list <org> [--prefix X] # VERIFY: tag → contact count
|
|
9
10
|
// untag <org> <slug> --commit --confirm # ROLLBACK: remove the plan's own tags
|
|
10
11
|
//
|
|
12
|
+
// --min-orders resolves the cohort SERVER-SIDE (segment_order_cohort RPC):
|
|
13
|
+
// contacts with >= N captured orders (shopify_orders/woo_orders) inside the
|
|
14
|
+
// --window (e.g. 60d / 8w / 3m; omit for all captured history) — the windowed
|
|
15
|
+
// count the Advanced Tagging UI cannot express (its Min Orders is lifetime).
|
|
16
|
+
//
|
|
11
17
|
// Golden rules encoded: exclusions (opt-out/archived/blocked) are applied
|
|
12
18
|
// BEFORE slicing; apply is append-only + idempotent (`already_had` reported,
|
|
13
19
|
// never double-added); untag only ever removes the plan's own tags.
|
|
@@ -52,10 +58,20 @@ async function resolvePlan(identifier) {
|
|
|
52
58
|
throw new Error(`Plan file not found. Tried:\n ${candidates.join("\n ")}`);
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/** Parse --window "60d" / "8w" / "3m" / "45" → days. */
|
|
62
|
+
function parseWindowDays(spec) {
|
|
63
|
+
const m = String(spec).trim().match(/^(\d+)([dwm])?$/i);
|
|
64
|
+
if (!m) throw new Error(`--window must look like 60d / 8w / 3m (got "${spec}")`);
|
|
65
|
+
const n = Number(m[1]);
|
|
66
|
+
const days = n * ({ d: 1, w: 7, m: 30 }[(m[2] || "d").toLowerCase()]);
|
|
67
|
+
if (!Number.isInteger(days) || days < 1 || days > 3650) throw new Error("--window must resolve to 1..3650 days");
|
|
68
|
+
return days;
|
|
69
|
+
}
|
|
70
|
+
|
|
55
71
|
/** Read the cohort ids from a christiaan/segments snapshot or a plain file. */
|
|
56
72
|
async function readCohort(opts, orgId) {
|
|
57
73
|
if (!!opts.fromSegment === !!opts.idsFile) {
|
|
58
|
-
throw new Error("provide exactly one of --from-segment / --ids-file");
|
|
74
|
+
throw new Error("provide exactly one of --from-segment / --ids-file / --min-orders");
|
|
59
75
|
}
|
|
60
76
|
let ids = [], sourceMeta;
|
|
61
77
|
if (opts.fromSegment) {
|
|
@@ -86,23 +102,74 @@ export async function plan(orgId, opts = {}) {
|
|
|
86
102
|
if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
|
|
87
103
|
if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
|
|
88
104
|
|
|
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).
|
|
108
|
+
const orderMode = opts.minOrders !== undefined;
|
|
109
|
+
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);
|
|
113
|
+
}
|
|
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);
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
let cohort;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
let cohortSpec = null;
|
|
120
|
+
if (criteriaMode) {
|
|
121
|
+
let windowDays = null;
|
|
122
|
+
if (opts.window) {
|
|
123
|
+
try { windowDays = parseWindowDays(opts.window); }
|
|
124
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
125
|
+
}
|
|
126
|
+
if (productMode) {
|
|
127
|
+
const terms = String(opts.bought).split(",").map((s) => s.trim()).filter(Boolean);
|
|
128
|
+
if (!terms.length) { console.error("Error: --bought needs at least one product name (comma-separate several)."); process.exit(1); }
|
|
129
|
+
const match = opts.match === "all" ? "all" : "any";
|
|
130
|
+
let minPurchases = 1;
|
|
131
|
+
if (opts.minPurchases !== undefined) {
|
|
132
|
+
minPurchases = Number(opts.minPurchases);
|
|
133
|
+
if (!Number.isInteger(minPurchases) || minPurchases < 1) { console.error("Error: --min-purchases must be an integer ≥ 1."); process.exit(1); }
|
|
134
|
+
}
|
|
135
|
+
cohortSpec = { product_terms: terms, match, min_purchases: minPurchases, window_days: windowDays, platform: opts.platform || "auto" };
|
|
136
|
+
cohort = { ids: [], badUuids: [], sourceMeta: { type: "product_cohort", ...cohortSpec } };
|
|
137
|
+
} else {
|
|
138
|
+
const minOrders = Number(opts.minOrders);
|
|
139
|
+
if (!Number.isInteger(minOrders) || minOrders < 1) { console.error("Error: --min-orders must be an integer ≥ 1."); process.exit(1); }
|
|
140
|
+
cohortSpec = { min_orders: minOrders, window_days: windowDays, platform: opts.platform || "auto" };
|
|
141
|
+
cohort = { ids: [], badUuids: [], sourceMeta: { type: "order_cohort", ...cohortSpec } };
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
try { cohort = await readCohort(opts, orgId); }
|
|
145
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
146
|
+
if (cohort.badUuids.length) console.log(`⚠ skipped ${cohort.badUuids.length} non-UUID line(s) in the cohort source.`);
|
|
147
|
+
if (!cohort.ids.length) { console.error("Error: no valid contact ids in the cohort source."); process.exit(1); }
|
|
148
|
+
}
|
|
94
149
|
|
|
95
150
|
let resp;
|
|
96
151
|
try {
|
|
97
152
|
resp = await http.post("segments", {
|
|
98
153
|
mode: "plan", organization_id: orgId,
|
|
99
|
-
contact_ids: cohort.ids
|
|
154
|
+
...(criteriaMode ? { cohort: cohortSpec } : { contact_ids: cohort.ids }),
|
|
155
|
+
include_unsafe: !!opts.includeUnsafe,
|
|
100
156
|
});
|
|
101
157
|
} catch (e) {
|
|
102
158
|
console.error(`Plan failed: ${e.message}`);
|
|
103
159
|
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
104
160
|
process.exit(1);
|
|
105
161
|
}
|
|
162
|
+
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).`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`Cohort: ${resp.cohort.min_orders}+ orders ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from captured orders).`);
|
|
170
|
+
}
|
|
171
|
+
cohort.sourceMeta.resolved_count = resp.cohort.resolved_count;
|
|
172
|
+
}
|
|
106
173
|
if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
|
|
107
174
|
|
|
108
175
|
// Slice safe_ids into contiguous batches (client-side, per the spec split).
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `flowiq tag` — the Advanced Tagging surface (the Contacts → "Advanced
|
|
2
|
+
// Tagging" dialog) in the terminal. Applies ONE named tag to a matched set,
|
|
3
|
+
// mirroring the app's five tabs, DRY-RUN by default (add --commit to write).
|
|
4
|
+
// The batch-tag slicing for controlled sends is `flowiq segments` instead.
|
|
5
|
+
//
|
|
6
|
+
// flowiq tag field <org> --field order_history --any "Protein Water,Whey" --tag whey-buyers
|
|
7
|
+
// flowiq tag cohort <org> --type top_spenders --limit 500 --tag vip
|
|
8
|
+
// flowiq tag segment <org> --min-orders 2 --min-spent 1000 --region ZA-GP --tag gp-repeat
|
|
9
|
+
// flowiq tag attributes <org> --filter allow_broadcast_true --tag broadcastable
|
|
10
|
+
// flowiq tag messages <org> --min-count 3 --sender user-whatsapp --tag engaged
|
|
11
|
+
// flowiq tag list <org>
|
|
12
|
+
// flowiq tag remove <org> <tag>[,<tag>...] --confirm
|
|
13
|
+
//
|
|
14
|
+
// Every match mode prints the dry-run count + a sample; nothing writes until
|
|
15
|
+
// --commit AND (for large writes) a typed confirmation. Appends only — it
|
|
16
|
+
// never removes or overwrites a contact's other tags.
|
|
17
|
+
|
|
18
|
+
import readline from "node:readline";
|
|
19
|
+
import { http } from "../http.js";
|
|
20
|
+
|
|
21
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
|
|
23
|
+
function ask(q) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireOrg(orgId) {
|
|
31
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function splitList(v) {
|
|
35
|
+
return v ? String(v).split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderSample(sample) {
|
|
39
|
+
for (const s of (sample || []).slice(0, 10)) {
|
|
40
|
+
console.log(` ${(s.name || "—").slice(0, 32).padEnd(32)} ${s.whatsapp_id || ""}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Shared: POST a match-mode payload, render dry-run or commit result. */
|
|
45
|
+
async function runMatch(label, payload, opts) {
|
|
46
|
+
const commit = !!opts.commit;
|
|
47
|
+
const body = { ...payload, dry_run: !commit };
|
|
48
|
+
if (commit) {
|
|
49
|
+
if (!opts.tag || !opts.tag.trim()) { console.error("Error: --tag <name> is required with --commit."); process.exit(1); }
|
|
50
|
+
body.tag = opts.tag.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let resp;
|
|
54
|
+
try { resp = await http.post("tag", body); }
|
|
55
|
+
catch (e) {
|
|
56
|
+
console.error(`${label} failed: ${e.message}`);
|
|
57
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!commit) {
|
|
62
|
+
let note = "";
|
|
63
|
+
if (resp.capped) note = " (⚠ capped at the RPC limit — narrow the query)";
|
|
64
|
+
else if (resp.top_n && resp.matched >= resp.top_n) note = ` (top ${resp.top_n} by rank — raise --limit for more)`;
|
|
65
|
+
console.log(`${label}: ${resp.matched} contact(s) match${note}.`);
|
|
66
|
+
renderSample(resp.sample);
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(`Add --tag <name> --commit to apply. (dry run — nothing written)`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`${label}: matched ${resp.matched} · newly tagged ${resp.tagged} · already had "${resp.tag}" ${resp.already_had}.`);
|
|
73
|
+
console.log("");
|
|
74
|
+
console.log(`Verify: flowiq tag list ${payload.organization_id} · Undo: flowiq tag remove ${payload.organization_id} ${resp.tag} --confirm`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pre-commit confirmation for large writes — mirror the segments gate.
|
|
78
|
+
async function confirmLargeWrite(orgId, payload, opts, previewLabel) {
|
|
79
|
+
if (!opts.commit || opts.yes) return;
|
|
80
|
+
// Peek at the dry-run count first so the operator sees scale before committing.
|
|
81
|
+
let peek;
|
|
82
|
+
try { peek = await http.post("tag", { ...payload, dry_run: true }); } catch { return; }
|
|
83
|
+
if ((peek?.matched ?? 0) > 2000) {
|
|
84
|
+
console.log(`${previewLabel}: ${peek.matched} contact(s) would be tagged "${opts.tag}".`);
|
|
85
|
+
const a = await ask(`That's a large write. Type the tag name ("${opts.tag}") to proceed: `);
|
|
86
|
+
if (a !== opts.tag) { console.log("Mismatch — aborted, nothing tagged."); process.exit(0); }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function field(orgId, opts = {}) {
|
|
91
|
+
requireOrg(orgId);
|
|
92
|
+
const keywords = splitList(opts.any || opts.all);
|
|
93
|
+
if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
|
|
94
|
+
const payload = {
|
|
95
|
+
mode: "field", organization_id: orgId, field_name: opts.field || "order_history",
|
|
96
|
+
keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive,
|
|
97
|
+
};
|
|
98
|
+
await confirmLargeWrite(orgId, payload, opts, "Field search");
|
|
99
|
+
await runMatch("Field search", payload, opts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function cohort(orgId, opts = {}) {
|
|
103
|
+
requireOrg(orgId);
|
|
104
|
+
const payload = {
|
|
105
|
+
mode: "cohort", organization_id: orgId, cohort_type: opts.type || "top_spenders",
|
|
106
|
+
limit: opts.limit ? Number(opts.limit) : undefined, offset: opts.offset ? Number(opts.offset) : 0,
|
|
107
|
+
exclude_tags: splitList(opts.exclude),
|
|
108
|
+
};
|
|
109
|
+
await confirmLargeWrite(orgId, payload, opts, `Cohort ${payload.cohort_type}`);
|
|
110
|
+
await runMatch(`Cohort ${payload.cohort_type}`, payload, opts);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function segment(orgId, opts = {}) {
|
|
114
|
+
requireOrg(orgId);
|
|
115
|
+
const payload = {
|
|
116
|
+
mode: "segment", organization_id: orgId,
|
|
117
|
+
min_orders: opts.minOrders !== undefined ? Number(opts.minOrders) : null,
|
|
118
|
+
max_orders: opts.maxOrders !== undefined ? Number(opts.maxOrders) : null,
|
|
119
|
+
min_spent: opts.minSpent !== undefined ? Number(opts.minSpent) : null,
|
|
120
|
+
max_spent: opts.maxSpent !== undefined ? Number(opts.maxSpent) : null,
|
|
121
|
+
last_order_after: opts.after || null, last_order_before: opts.before || null,
|
|
122
|
+
regions: splitList(opts.region), keywords: splitList(opts.any || opts.all),
|
|
123
|
+
keyword_field: opts.field || "order_history", logic: opts.all ? "AND" : "OR",
|
|
124
|
+
case_sensitive: !!opts.caseSensitive, exclude_tags: splitList(opts.exclude),
|
|
125
|
+
};
|
|
126
|
+
await confirmLargeWrite(orgId, payload, opts, "Custom segment");
|
|
127
|
+
await runMatch("Custom segment", payload, opts);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function attributes(orgId, opts = {}) {
|
|
131
|
+
requireOrg(orgId);
|
|
132
|
+
const payload = {
|
|
133
|
+
mode: "attributes", organization_id: orgId, filter_type: opts.filter || "all_contacts",
|
|
134
|
+
include_tags: splitList(opts.include), exclude_tags: splitList(opts.exclude),
|
|
135
|
+
};
|
|
136
|
+
await confirmLargeWrite(orgId, payload, opts, `Attributes ${payload.filter_type}`);
|
|
137
|
+
await runMatch(`Attributes ${payload.filter_type}`, payload, opts);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function messages(orgId, opts = {}) {
|
|
141
|
+
requireOrg(orgId);
|
|
142
|
+
const payload = {
|
|
143
|
+
mode: "messages", organization_id: orgId, min_count: Number(opts.minCount ?? 3),
|
|
144
|
+
max_count: opts.maxCount !== undefined ? Number(opts.maxCount) : null,
|
|
145
|
+
sender_types: splitList(opts.sender) || ["user-whatsapp"],
|
|
146
|
+
excluded_messages: opts.excludeMessages ? String(opts.excludeMessages).split("|").map((s) => s.trim()).filter(Boolean) : ["View More", "VIEW MORE"],
|
|
147
|
+
date_after: opts.after || null, date_before: opts.before || null, exclude_tags: splitList(opts.exclude),
|
|
148
|
+
};
|
|
149
|
+
await confirmLargeWrite(orgId, payload, opts, "Message activity");
|
|
150
|
+
await runMatch("Message activity", payload, opts);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function list(orgId, opts = {}) {
|
|
154
|
+
requireOrg(orgId);
|
|
155
|
+
let resp;
|
|
156
|
+
try { resp = await http.post("tag", { mode: "list", organization_id: orgId }); }
|
|
157
|
+
catch (e) { console.error(`List failed: ${e.message}`); process.exit(1); }
|
|
158
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
159
|
+
if (!resp.tags?.length) { console.log(`(no tags on ${resp.organization_name})`); return; }
|
|
160
|
+
const shown = opts.prefix ? resp.tags.filter((t) => String(t.tag).startsWith(opts.prefix)) : resp.tags;
|
|
161
|
+
for (const t of shown.slice(0, opts.limit ? Number(opts.limit) : 60)) {
|
|
162
|
+
console.log(` ${String(t.tag).slice(0, 44).padEnd(44)} ${t.count}`);
|
|
163
|
+
}
|
|
164
|
+
console.log(` ${"—".padEnd(44)}`);
|
|
165
|
+
console.log(` ${shown.length} tag(s) on ${resp.organization_name}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function remove(orgId, tagArg, opts = {}) {
|
|
169
|
+
requireOrg(orgId);
|
|
170
|
+
const tags = splitList(tagArg);
|
|
171
|
+
if (!tags || !tags.length) { console.error("Error: pass one or more tag names (comma-separated)."); process.exit(1); }
|
|
172
|
+
if (!opts.confirm) {
|
|
173
|
+
console.log(`DRY RUN — would remove ${tags.length} tag(s) from all contacts: ${tags.join(", ")}. Add --confirm to remove.`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let resp;
|
|
177
|
+
try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags }); }
|
|
178
|
+
catch (e) { console.error(`Remove failed: ${e.message}`); process.exit(1); }
|
|
179
|
+
console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}.`);
|
|
180
|
+
}
|
package/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as knowledgeCmd from "./commands/knowledge.js";
|
|
|
26
26
|
import * as customToolsCmd from "./commands/custom-tools.js";
|
|
27
27
|
import * as broadcastCmd from "./commands/broadcast.js";
|
|
28
28
|
import * as segmentsCmd from "./commands/segments.js";
|
|
29
|
+
import * as tagCmd from "./commands/tag.js";
|
|
29
30
|
import * as keywordsCmd from "./commands/keywords.js";
|
|
30
31
|
import * as guideCmd from "./commands/guide.js";
|
|
31
32
|
|
|
@@ -409,10 +410,16 @@ export function run(argv) {
|
|
|
409
410
|
.alias("seg")
|
|
410
411
|
.description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
|
|
411
412
|
segments.command("plan <organization_id>")
|
|
412
|
-
.description("
|
|
413
|
+
.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)")
|
|
413
414
|
.requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
|
|
414
415
|
.option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
|
|
415
416
|
.option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
|
|
417
|
+
.option("--min-orders <n>", "SERVER-RESOLVED cohort: contacts with ≥ n captured orders (instead of an id file)")
|
|
418
|
+
.option("--bought <terms>", "SERVER-RESOLVED cohort: contacts who bought these product(s) (comma-separated name substrings) from real order line-items")
|
|
419
|
+
.option("--match <mode>", "with --bought: any (bought any listed product) | all (bought every one)", "any")
|
|
420
|
+
.option("--min-purchases <n>", "with --bought: require ≥ n distinct orders containing the product")
|
|
421
|
+
.option("--window <span>", "with --min-orders/--bought: only count orders in this window, e.g. 60d / 8w / 3m (omit = all captured history)")
|
|
422
|
+
.option("--platform <p>", "with --min-orders/--bought: auto | shopify | woo", "auto")
|
|
416
423
|
.option("--batch-size <n>", "contacts per batch tag", "75")
|
|
417
424
|
.option("--segment <name>", "plan file slug (default: the tag prefix)")
|
|
418
425
|
.option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
|
|
@@ -433,6 +440,74 @@ export function run(argv) {
|
|
|
433
440
|
.option("--confirm", "required alongside --commit")
|
|
434
441
|
.action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
|
|
435
442
|
|
|
443
|
+
// tag (the Advanced Tagging dialog in the terminal — ONE named tag on a matched set; dry-run default)
|
|
444
|
+
const tag = program.command("tag")
|
|
445
|
+
.description("Advanced Tagging: apply one named tag to a matched set of contacts (dry-run by default; --commit to write)");
|
|
446
|
+
tag.command("field <organization_id>")
|
|
447
|
+
.description("Tag contacts whose FIELD contains keyword(s). --field order_history|contact_context|contact_summary|shopify_tags|full_name|email|whatsapp_id|phone_number")
|
|
448
|
+
.option("--field <name>", "field to search", "order_history")
|
|
449
|
+
.option("--any <csv>", "match ANY of these comma-separated keywords (OR)")
|
|
450
|
+
.option("--all <csv>", "match ALL of these comma-separated keywords (AND)")
|
|
451
|
+
.option("--case-sensitive", "case-sensitive match")
|
|
452
|
+
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
453
|
+
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
454
|
+
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|
|
455
|
+
.action((orgId, opts) => tagCmd.field(orgId, opts));
|
|
456
|
+
tag.command("cohort <organization_id>")
|
|
457
|
+
.description("Tag a ranked cohort (top-N). --type top_spenders|frequent_buyers|recent_buyers|high_value_low_frequency")
|
|
458
|
+
.option("--type <t>", "cohort type", "top_spenders")
|
|
459
|
+
.option("--limit <n>", "how many top contacts to tag (commit)", "1000")
|
|
460
|
+
.option("--offset <n>", "skip the first N", "0")
|
|
461
|
+
.option("--exclude <csv>", "exclude contacts already carrying any of these tags")
|
|
462
|
+
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
463
|
+
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
464
|
+
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|
|
465
|
+
.action((orgId, opts) => tagCmd.cohort(orgId, opts));
|
|
466
|
+
tag.command("segment <organization_id>")
|
|
467
|
+
.description("Tag a custom segment: order-count / spend / last-order-date / region / keyword filters (reads the denormalized columns)")
|
|
468
|
+
.option("--min-orders <n>").option("--max-orders <n>")
|
|
469
|
+
.option("--min-spent <n>").option("--max-spent <n>")
|
|
470
|
+
.option("--after <date>", "last order on/after YYYY-MM-DD").option("--before <date>", "last order on/before YYYY-MM-DD")
|
|
471
|
+
.option("--region <csv>", "customer_regions contains any of these (e.g. ZA-GP,ZA-WC)")
|
|
472
|
+
.option("--any <csv>", "keyword match ANY (OR)").option("--all <csv>", "keyword match ALL (AND)")
|
|
473
|
+
.option("--field <name>", "keyword search field", "order_history")
|
|
474
|
+
.option("--case-sensitive", "case-sensitive keyword match")
|
|
475
|
+
.option("--exclude <csv>", "exclude contacts already carrying any of these tags")
|
|
476
|
+
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
477
|
+
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
478
|
+
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|
|
479
|
+
.action((orgId, opts) => tagCmd.segment(orgId, opts));
|
|
480
|
+
tag.command("attributes <organization_id>")
|
|
481
|
+
.description("Tag by broadcast permission. --filter all_contacts|allow_broadcast_true|allow_broadcast_false|no_broadcast_permission")
|
|
482
|
+
.option("--filter <f>", "attribute filter", "all_contacts")
|
|
483
|
+
.option("--include <csv>", "only contacts carrying any of these tags")
|
|
484
|
+
.option("--exclude <csv>", "exclude contacts carrying any of these tags")
|
|
485
|
+
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
486
|
+
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
487
|
+
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|
|
488
|
+
.action((orgId, opts) => tagCmd.attributes(orgId, opts));
|
|
489
|
+
tag.command("messages <organization_id>")
|
|
490
|
+
.description("Tag by message activity: contacts with ≥ N messages of the given sender type(s) in an optional date range")
|
|
491
|
+
.option("--min-count <n>", "minimum message count", "3").option("--max-count <n>")
|
|
492
|
+
.option("--sender <csv>", "sender types (e.g. user-whatsapp,user-web)", "user-whatsapp")
|
|
493
|
+
.option("--exclude-messages <pipe>", "pipe-separated messages to ignore (default: View More|VIEW MORE)")
|
|
494
|
+
.option("--after <date>", "messages on/after YYYY-MM-DD").option("--before <date>", "messages on/before YYYY-MM-DD")
|
|
495
|
+
.option("--exclude <csv>", "exclude contacts already carrying any of these tags")
|
|
496
|
+
.option("--tag <name>", "tag to apply (required with --commit)")
|
|
497
|
+
.option("--commit", "write the tag (omit = dry-run count + sample)")
|
|
498
|
+
.option("--yes", "skip the type-the-tag confirm on large (>2000) writes")
|
|
499
|
+
.action((orgId, opts) => tagCmd.messages(orgId, opts));
|
|
500
|
+
tag.command("list <organization_id>")
|
|
501
|
+
.description("List the org's tags with contact counts (read-only)")
|
|
502
|
+
.option("--prefix <substr>", "only tags starting with this")
|
|
503
|
+
.option("--limit <n>", "cap rows printed", "60")
|
|
504
|
+
.option("--json", "raw JSON")
|
|
505
|
+
.action((orgId, opts) => tagCmd.list(orgId, opts));
|
|
506
|
+
tag.command("remove <organization_id> <tags>")
|
|
507
|
+
.description("Remove tag(s) (comma-separated) from ALL contacts in the org. Destructive — needs --confirm")
|
|
508
|
+
.option("--confirm", "actually remove (omit = dry-run)")
|
|
509
|
+
.action((orgId, tags, opts) => tagCmd.remove(orgId, tags, opts));
|
|
510
|
+
|
|
436
511
|
// keywords (org auto-reply engine: keywords + keyword_actions; id-keyed diff push)
|
|
437
512
|
const keywords = program.command("keywords")
|
|
438
513
|
.alias("kw")
|