@flowapt/flowiq-cli 0.1.12 → 0.2.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.
@@ -0,0 +1,252 @@
1
+ // `flowiq segments` (alias `seg`) — slice a contact cohort into fixed-size
2
+ // batch tags and bulk-apply them (spec 01 / the broadcast-tag-batching
3
+ // runbook). This command TAGS ONLY — it never sends. The send per tag is
4
+ // `flowiq broadcast send --tag <batch-tag>` (or the dashboard broadcaster).
5
+ //
6
+ // plan <org> --tag-prefix X (--from-segment file | --ids-file file) # exclusions → slice → plan file (no DB write)
7
+ // apply <org> <slug> [--commit] [--yes] # append the batch tags (dry-run default)
8
+ // list <org> [--prefix X] # VERIFY: tag → contact count
9
+ // untag <org> <slug> --commit --confirm # ROLLBACK: remove the plan's own tags
10
+ //
11
+ // Golden rules encoded: exclusions (opt-out/archived/blocked) are applied
12
+ // BEFORE slicing; apply is append-only + idempotent (`already_had` reported,
13
+ // never double-added); untag only ever removes the plan's own tags.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import readline from "node:readline";
18
+ import { http } from "../http.js";
19
+
20
+ const SEG_DIR = path.resolve(process.cwd(), ".flowiq", "segments");
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
+ const TAG_PREFIX_RE = /^[a-z0-9][a-z0-9-]*$/;
23
+
24
+ function ask(question) {
25
+ return new Promise((resolve) => {
26
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
28
+ });
29
+ }
30
+
31
+ function slugify(name, fallback) {
32
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
33
+ return s || fallback;
34
+ }
35
+
36
+ async function fileExists(p) {
37
+ try { await fs.access(p); return true; } catch { return false; }
38
+ }
39
+
40
+ const planPath = (slug) => path.join(SEG_DIR, `${slug}.json`);
41
+
42
+ async function resolvePlan(identifier) {
43
+ const candidates = [];
44
+ if (path.isAbsolute(identifier)) candidates.push(identifier);
45
+ else if (identifier.includes("/")) candidates.push(path.resolve(identifier));
46
+ else {
47
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
48
+ candidates.push(path.join(SEG_DIR, base));
49
+ candidates.push(path.resolve(base));
50
+ }
51
+ for (const c of candidates) if (await fileExists(c)) return c;
52
+ throw new Error(`Plan file not found. Tried:\n ${candidates.join("\n ")}`);
53
+ }
54
+
55
+ /** Read the cohort ids from a christiaan/segments snapshot or a plain file. */
56
+ async function readCohort(opts, orgId) {
57
+ if (!!opts.fromSegment === !!opts.idsFile) {
58
+ throw new Error("provide exactly one of --from-segment / --ids-file");
59
+ }
60
+ let ids = [], sourceMeta;
61
+ if (opts.fromSegment) {
62
+ const raw = JSON.parse(await fs.readFile(path.resolve(opts.fromSegment), "utf8"));
63
+ if (raw.organization_id && raw.organization_id !== orgId) {
64
+ throw new Error(`snapshot organization_id (${raw.organization_id}) ≠ the org you passed (${orgId}) — refusing (V-3)`);
65
+ }
66
+ ids = Array.isArray(raw.contact_ids) ? raw.contact_ids : [];
67
+ sourceMeta = { type: "segment_file", path: opts.fromSegment, input_count: ids.length, generated_at: raw.generated_at ?? null, definition: raw.definition ?? null };
68
+ } else {
69
+ const text = await fs.readFile(path.resolve(opts.idsFile), "utf8");
70
+ ids = text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
71
+ sourceMeta = { type: "ids_file", path: opts.idsFile, input_count: ids.length };
72
+ }
73
+ const bad = ids.filter((id) => !UUID_RE.test(id));
74
+ const good = ids.filter((id) => UUID_RE.test(id));
75
+ return { ids: good, badUuids: bad, sourceMeta };
76
+ }
77
+
78
+ export async function plan(orgId, opts = {}) {
79
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
80
+ const prefix = String(opts.tagPrefix || "").trim();
81
+ if (!TAG_PREFIX_RE.test(prefix)) {
82
+ console.error(`Error: --tag-prefix must be slug-safe lowercase ([a-z0-9-], got "${prefix}").`);
83
+ process.exit(1);
84
+ }
85
+ const batchSize = Number(opts.batchSize ?? 75);
86
+ if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
87
+ if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
88
+
89
+ let cohort;
90
+ try { cohort = await readCohort(opts, orgId); }
91
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
92
+ if (cohort.badUuids.length) console.log(`⚠ skipped ${cohort.badUuids.length} non-UUID line(s) in the cohort source.`);
93
+ if (!cohort.ids.length) { console.error("Error: no valid contact ids in the cohort source."); process.exit(1); }
94
+
95
+ let resp;
96
+ try {
97
+ resp = await http.post("segments", {
98
+ mode: "plan", organization_id: orgId,
99
+ contact_ids: cohort.ids, include_unsafe: !!opts.includeUnsafe,
100
+ });
101
+ } catch (e) {
102
+ console.error(`Plan failed: ${e.message}`);
103
+ if (e.body?.error) console.error(` ${e.body.error}`);
104
+ process.exit(1);
105
+ }
106
+ if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
107
+
108
+ // Slice safe_ids into contiguous batches (client-side, per the spec split).
109
+ const batches = [];
110
+ for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
111
+ const nn = String(batches.length + 1).padStart(2, "0");
112
+ batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
113
+ }
114
+
115
+ const slug = slugify(opts.segment || prefix, prefix);
116
+ const planDoc = {
117
+ schema_version: 1,
118
+ segment: slug,
119
+ organization_id: resp.organization_id,
120
+ organization_slug: slugify(resp.organization_name, resp.organization_id.slice(0, 8)),
121
+ tag_prefix: prefix,
122
+ batch_size: batchSize,
123
+ created_at: new Date().toISOString(),
124
+ source: cohort.sourceMeta,
125
+ exclusions: {
126
+ policy: opts.includeUnsafe ? "include_unsafe" : "broadcast_safe",
127
+ excluded_counts: Object.fromEntries(Object.entries(resp.excluded).map(([k, v]) => [k, v.length])),
128
+ excluded_ids: resp.excluded,
129
+ },
130
+ safe_count: resp.safe_count,
131
+ batch_count: batches.length,
132
+ batches,
133
+ applied: null,
134
+ };
135
+ await fs.mkdir(SEG_DIR, { recursive: true });
136
+ await fs.writeFile(planPath(slug), JSON.stringify(planDoc, null, 2) + "\n", "utf8");
137
+
138
+ const ex = planDoc.exclusions.excluded_counts;
139
+ console.log(`Wrote ${planPath(slug)}`);
140
+ console.log(` org: ${resp.organization_name}`);
141
+ 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}`);
142
+ console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
143
+ if (cohort.sourceMeta.generated_at) {
144
+ console.log(` ⚠ tagging point-in-time ids from ${cohort.sourceMeta.generated_at} — re-derive the cohort SQL if freshness matters.`);
145
+ }
146
+ console.log("");
147
+ console.log(`Next: flowiq segments apply ${orgId} ${slug} [--commit]`);
148
+ }
149
+
150
+ export async function apply(orgId, identifier, opts = {}) {
151
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
152
+ let filePath;
153
+ try { filePath = await resolvePlan(identifier); }
154
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
155
+ const planDoc = JSON.parse(await fs.readFile(filePath, "utf8"));
156
+ if (planDoc.organization_id !== orgId) {
157
+ console.error(`Plan is for org ${planDoc.organization_id}, you passed ${orgId} — refusing (V-10).`);
158
+ process.exit(1);
159
+ }
160
+ if (!Array.isArray(planDoc.batches) || !planDoc.batches.length) {
161
+ console.error("Plan has no batches — re-run `segments plan` (V-10).");
162
+ process.exit(1);
163
+ }
164
+
165
+ const total = planDoc.batches.reduce((a, b) => a + b.contact_ids.length, 0);
166
+ console.log(`Plan ${planDoc.segment}: ${planDoc.batches.length} batch tag(s), ${total} contact(s) on ${planDoc.organization_slug}.`);
167
+
168
+ // V-11: warn when the batch tags already carry members (tag reuse merges counts).
169
+ try {
170
+ const before = await http.post("segments", { mode: "list", organization_id: orgId, prefix: planDoc.tag_prefix });
171
+ const existing = (before.tags || []).filter((t) => Number(t.count) > 0);
172
+ if (existing.length) {
173
+ console.log(`⚠ ${existing.length} of these tags already have members (counts will MERGE):`);
174
+ for (const t of existing.slice(0, 5)) console.log(` ${t.tag}: ${t.count}`);
175
+ if (!opts.yes && opts.commit) {
176
+ const a = await ask("Type y to proceed anyway: ");
177
+ if (a.toLowerCase() !== "y") { console.log("Aborted — nothing tagged."); process.exit(0); }
178
+ }
179
+ }
180
+ } catch { /* the pre-check is best-effort */ }
181
+
182
+ if (!opts.commit) {
183
+ console.log(`DRY RUN — would tag ${total} contact(s) across ${planDoc.batches.length} batch tag(s). Add --commit to apply.`);
184
+ return;
185
+ }
186
+ if (!opts.yes) {
187
+ const a = await ask(`Type the segment name ("${planDoc.segment}") to tag ${total} contact(s): `);
188
+ if (a !== planDoc.segment) { console.log("Mismatch — aborted, nothing tagged."); process.exit(0); }
189
+ }
190
+
191
+ let resp;
192
+ try {
193
+ resp = await http.post("segments", {
194
+ mode: "apply", organization_id: orgId,
195
+ batches: planDoc.batches.map((b) => ({ tag: b.tag, contact_ids: b.contact_ids })),
196
+ });
197
+ } catch (e) {
198
+ console.error(`Apply failed: ${e.message}`);
199
+ if (e.body?.error) console.error(` ${e.body.error}`);
200
+ process.exit(1);
201
+ }
202
+
203
+ for (const r of resp.results || []) {
204
+ console.log(` ${r.tag.padEnd(40)} tagged ${r.tagged}${r.already_had ? ` · already_had ${r.already_had}` : ""}${r.not_found ? ` · not_found ${r.not_found}` : ""}`);
205
+ }
206
+ console.log(`Totals: tagged ${resp.totals.tagged} · already_had ${resp.totals.already_had} · not_found ${resp.totals.not_found}`);
207
+
208
+ planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries((resp.results || []).map((r) => [r.tag, r.tagged + r.already_had])) };
209
+ await fs.writeFile(filePath, JSON.stringify(planDoc, null, 2) + "\n", "utf8");
210
+ console.log("");
211
+ console.log(`Send per tag, one batch at a time: flowiq broadcast send ${orgId} --tag ${planDoc.batches[0].tag} --template <name> …`);
212
+ }
213
+
214
+ export async function list(orgId, opts = {}) {
215
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
216
+ let resp;
217
+ try { resp = await http.post("segments", { mode: "list", organization_id: orgId, prefix: opts.prefix }); }
218
+ catch (e) { console.error(`List failed: ${e.message}`); process.exit(1); }
219
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
220
+ if (!resp.tags?.length) { console.log(`(no tags${opts.prefix ? ` with prefix "${opts.prefix}"` : ""} on ${resp.organization_name})`); return; }
221
+ for (const t of resp.tags) console.log(` ${String(t.tag).padEnd(44)} ${t.count}`);
222
+ console.log(` ${"TOTAL".padEnd(44)} ${resp.total_tagged}`);
223
+ }
224
+
225
+ export async function untag(orgId, identifier, opts = {}) {
226
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
227
+ let filePath;
228
+ try { filePath = await resolvePlan(identifier); }
229
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
230
+ const planDoc = JSON.parse(await fs.readFile(filePath, "utf8"));
231
+ if (planDoc.organization_id !== orgId) {
232
+ console.error(`Plan is for org ${planDoc.organization_id}, you passed ${orgId} — refusing.`);
233
+ process.exit(1);
234
+ }
235
+ const tags = (planDoc.batches || []).map((b) => b.tag);
236
+ if (!tags.length) { console.error("Plan has no batch tags."); process.exit(1); }
237
+
238
+ if (!opts.commit) {
239
+ console.log(`DRY RUN — would remove ${tags.length} batch tag(s): ${tags.join(", ")}. Add --commit --confirm to remove.`);
240
+ return;
241
+ }
242
+ if (!opts.confirm) {
243
+ console.error("untag is destructive — add --confirm alongside --commit (V-12).");
244
+ process.exit(1);
245
+ }
246
+
247
+ let resp;
248
+ try { resp = await http.post("segments", { mode: "untag", organization_id: orgId, tags }); }
249
+ catch (e) { console.error(`Untag failed: ${e.message}`); process.exit(1); }
250
+ for (const r of resp.results || []) console.log(` ${r.tag.padEnd(40)} removed ${r.removed}`);
251
+ console.log(`Totals: removed ${resp.totals.removed}`);
252
+ }
package/src/index.js CHANGED
@@ -24,6 +24,8 @@ import * as exportCmd from "./commands/export.js";
24
24
  import * as testCmd from "./commands/agent-test.js";
25
25
  import * as knowledgeCmd from "./commands/knowledge.js";
26
26
  import * as customToolsCmd from "./commands/custom-tools.js";
27
+ import * as broadcastCmd from "./commands/broadcast.js";
28
+ import * as segmentsCmd from "./commands/segments.js";
27
29
 
28
30
  // Read version from package.json so it stays in sync with the published npm
29
31
  // version automatically (single source of truth — bumping package.json on each
@@ -217,10 +219,10 @@ export function run(argv) {
217
219
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
218
220
  .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
219
221
 
220
- // agent-updates (pending client change-requests + chat context; read-only)
222
+ // agent-updates (pending client change-requests + chat context; pull + resolve)
221
223
  const au = program.command("agent-updates")
222
224
  .alias("au")
223
- .description("Pull pending agent_updates + the chat context around each (read-only)");
225
+ .description("Pull pending agent_updates + the chat context around each; resolve a ticket with a client-facing note");
224
226
  au.command("pull <organization_id>")
225
227
  .description("Fetch updates + linked contact + trigger-message window; downloads attachments locally")
226
228
  .option("--status <status>", "pending (default) | resolved | in_progress | all")
@@ -234,6 +236,14 @@ export function run(argv) {
234
236
  au.command("list")
235
237
  .description("List local agent-updates snapshots")
236
238
  .action(() => agentUpdatesCmd.list());
239
+ au.command("resolve <update_id>")
240
+ .description("Flip a client-raised ticket to resolved/declined + write the client-facing note (the client reads \"FlowIQ: <note>\")")
241
+ .option("--note <text>", "client-facing note (required when resolving)")
242
+ .option("--internal <text>", "staff-only note (never shown to the client)")
243
+ .option("--image <url>", "client-facing response image URL")
244
+ .option("--status <status>", "resolved (default) | declined", "resolved")
245
+ .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
246
+ .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
237
247
 
238
248
  // export (full chat history → TXT, byte-identical to the in-app export)
239
249
  const exp = program.command("export").description("Export data to local files");
@@ -335,5 +345,83 @@ export function run(argv) {
335
345
  .description("List local custom-tools snapshots")
336
346
  .action(() => customToolsCmd.list());
337
347
 
348
+ // broadcast (CSV → per-row WhatsApp template broadcast; spec: flowiq-broadcast-send-SPEC)
349
+ const broadcast = program.command("broadcast")
350
+ .alias("bc")
351
+ .description("Send an APPROVED WhatsApp template to every row of a CSV (per-row column→param mapping)");
352
+ broadcast.command("map <organization_id>")
353
+ .description("Introspect the template + CSV and build/refresh the saved mapping interactively (no send)")
354
+ .requiredOption("--template <name>", "approved Meta template name")
355
+ .requiredOption("--csv <file>", "path to the recipients CSV")
356
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename)")
357
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
358
+ .action((orgId, opts) => broadcastCmd.map(orgId, opts));
359
+ broadcast.command("preview <organization_id>")
360
+ .description("Dry-run render of sample rows (exact message text + resolved button URL); never sends")
361
+ .option("--template <name>", "template (default: from the saved campaign)")
362
+ .option("--csv <file>", "CSV (default: from the saved campaign)")
363
+ .option("--campaign <name>", "campaign id / config file slug")
364
+ .option("--rows <n>", "how many sample rows to render", "3")
365
+ .action((orgId, opts) => broadcastCmd.preview(orgId, opts));
366
+ broadcast.command("send <organization_id>")
367
+ .description("Run the full pipeline. DRY-RUN by default; --commit sends (type the campaign name to confirm). Recipients from --csv OR --tag.")
368
+ .requiredOption("--template <name>", "approved Meta template name")
369
+ .option("--csv <file>", "path to the recipients CSV (per-row values)")
370
+ .option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
371
+ .option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
372
+ .option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
373
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
374
+ .option("--commit", "actually send (omit to dry-run)")
375
+ .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
376
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
377
+ .option("--illegal-chars <mode>", "reject | strip — newlines/tabs/4+ spaces in values (Meta #100)", "reject")
378
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
379
+ .option("--upsert-batch <n>", "contacts per upsert call (max 4000)", "4000")
380
+ .option("--skip-upsert", "assume contacts already exist; skip the upsert stage")
381
+ .option("--limit <n>", "only process the first N valid rows (smoke test)")
382
+ .option("--resume", "continue from the existing status log instead of starting fresh")
383
+ .option("--retry-failed", "with --resume: also re-attempt rows previously marked failed")
384
+ .action((orgId, opts) => broadcastCmd.send(orgId, opts));
385
+ broadcast.command("resume <organization_id>")
386
+ .description("Continue an interrupted campaign: only unsent rows from the status log; ambiguous in-flight rows are NEVER auto-resent")
387
+ .requiredOption("--campaign <name>", "campaign id / config file slug")
388
+ .option("--commit", "actually send (omit to dry-run the remaining rows)")
389
+ .option("--yes", "skip the confirm gate")
390
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
391
+ .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
392
+ .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
393
+ broadcast.command("list")
394
+ .description("List local campaigns + sent counts")
395
+ .action(() => broadcastCmd.list());
396
+
397
+ // segments (cohort → batch tags; tags only — the send is `broadcast send --tag`)
398
+ const segments = program.command("segments")
399
+ .alias("seg")
400
+ .description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
401
+ segments.command("plan <organization_id>")
402
+ .description("Ingest a cohort id list, apply broadcast-safety exclusions, slice into N-sized batch tags, write the plan (no DB write)")
403
+ .requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
404
+ .option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
405
+ .option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
406
+ .option("--batch-size <n>", "contacts per batch tag", "75")
407
+ .option("--segment <name>", "plan file slug (default: the tag prefix)")
408
+ .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
409
+ .action((orgId, opts) => segmentsCmd.plan(orgId, opts));
410
+ segments.command("apply <organization_id> <slug-or-path>")
411
+ .description("Append the plan's batch tags to their contacts. DRY-RUN by default; --commit to write (append-only, idempotent)")
412
+ .option("--commit", "actually write the tags")
413
+ .option("--yes", "skip the type-the-segment-name confirm gate")
414
+ .action((orgId, id, opts) => segmentsCmd.apply(orgId, id, opts));
415
+ segments.command("list <organization_id>")
416
+ .description("VERIFY: print tag → contact count (read-only)")
417
+ .option("--prefix <substr>", "only tags starting with this prefix (recommended)")
418
+ .option("--json", "raw JSON")
419
+ .action((orgId, opts) => segmentsCmd.list(orgId, opts));
420
+ segments.command("untag <organization_id> <slug-or-path>")
421
+ .description("ROLLBACK: remove the plan's own batch tags. Destructive — needs --commit AND --confirm")
422
+ .option("--commit", "actually remove (omit to dry-run)")
423
+ .option("--confirm", "required alongside --commit")
424
+ .action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
425
+
338
426
  program.parseAsync(argv);
339
427
  }