@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,879 @@
1
+ // `flowiq broadcast` — CSV → WhatsApp template broadcast (per the
2
+ // flowiq-broadcast-send-SPEC).
3
+ //
4
+ // map <org> --template T --csv F [--campaign C] build/refresh the saved mapping (no send)
5
+ // preview <org> [--campaign C] [--rows 3] dry-run render of sample rows
6
+ // send <org> --template T --csv F [--commit] full pipeline; DRY-RUN by default
7
+ // resume <org> --campaign C [--commit] continue from the status log
8
+ //
9
+ // Pipeline: introspect template (live Meta + DB labels, via /cli/broadcast) →
10
+ // parse CSV → build/load column→param MAPPING (each row fills the template
11
+ // with its OWN values) → validate (V-1..V-13) → broadcast-safety classify
12
+ // (opted-out/archived/blocked are skipped) → preview → [--commit] upsert
13
+ // contacts (/cli/contacts-upsert, ≤4000/batch, 3× retry then ABORT) → send
14
+ // per row via /api/send-template (org-UUID branch, NO auth header), paced
15
+ // ≤10 msg/s, with a WRITE-AHEAD per-row status log:
16
+ // {status:"sending"} appended BEFORE the POST, the terminal sent/failed
17
+ // line after — so a crash mid-row leaves an ambiguous row that is NEVER
18
+ // auto-resent (surfaced for manual review). Resume dedups on
19
+ // normalized-number-within-campaign, never on CSV fingerprint.
20
+ //
21
+ // #132000 structural guard: the payload builder whitelists /^param\d+$/ keys
22
+ // only — a stray "name" key (which would add a body param on a POSITIONAL
23
+ // template) is impossible by construction.
24
+
25
+ import fs from "node:fs/promises";
26
+ import path from "node:path";
27
+ import readline from "node:readline";
28
+ import { createHash } from "node:crypto";
29
+ import { http } from "../http.js";
30
+ import { loadConfig } from "../config.js";
31
+
32
+ const CAMPAIGN_DIR = path.resolve(process.cwd(), ".flowiq", "campaigns");
33
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
+ const HARD_RATE_CAP = 10;
35
+ const CONTACT_BATCH_CAP = 4000;
36
+ const CLASSIFY_CHUNK = 2000;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // small utils
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
+
44
+ function slugify(name, fallback) {
45
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
46
+ return s || fallback;
47
+ }
48
+
49
+ async function fileExists(p) {
50
+ try { await fs.access(p); return true; } catch { return false; }
51
+ }
52
+
53
+ function ask(question) {
54
+ return new Promise((resolve) => {
55
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
56
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
57
+ });
58
+ }
59
+
60
+ function sha256(s) {
61
+ return "sha256:" + createHash("sha256").update(s).digest("hex");
62
+ }
63
+
64
+ // Mirrors the server's normalizePhoneNumber (send-template.js) exactly.
65
+ function normalizePhone(phone) {
66
+ if (!phone) return "";
67
+ let n = String(phone).replace(/\D/g, "");
68
+ if (n.startsWith("0")) n = "27" + n.substring(1);
69
+ if (n.length === 9 && !n.startsWith("0")) n = "27" + n;
70
+ return n;
71
+ }
72
+
73
+ function isPlausiblePhone(n) {
74
+ return /^\d{10,15}$/.test(n);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // CSV (RFC-4180-ish, dependency-free)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function sniffDelimiter(headerLine) {
82
+ const counts = [",", ";", "\t"].map((d) => [d, headerLine.split(d).length - 1]);
83
+ counts.sort((a, b) => b[1] - a[1]);
84
+ return counts[0][1] > 0 ? counts[0][0] : ",";
85
+ }
86
+
87
+ function parseCsv(text, delimiter) {
88
+ const rows = [];
89
+ let field = "", row = [], inQuotes = false;
90
+ for (let i = 0; i < text.length; i++) {
91
+ const ch = text[i];
92
+ if (inQuotes) {
93
+ if (ch === '"') {
94
+ if (text[i + 1] === '"') { field += '"'; i++; }
95
+ else inQuotes = false;
96
+ } else field += ch;
97
+ } else if (ch === '"') inQuotes = true;
98
+ else if (ch === delimiter) { row.push(field); field = ""; }
99
+ else if (ch === "\n") { row.push(field); rows.push(row); field = ""; row = []; }
100
+ else if (ch === "\r") { /* handled by \n */ }
101
+ else field += ch;
102
+ }
103
+ if (field.length || row.length) { row.push(field); rows.push(row); }
104
+ return rows.filter((r) => r.some((c) => c.trim() !== ""));
105
+ }
106
+
107
+ async function loadCsv(csvPath) {
108
+ let text = await fs.readFile(path.resolve(csvPath), "utf8");
109
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip BOM
110
+ const firstLine = text.split("\n", 1)[0];
111
+ const delimiter = sniffDelimiter(firstLine);
112
+ const table = parseCsv(text, delimiter);
113
+ if (table.length < 2) throw new Error("CSV needs a header row + at least one data row");
114
+ const headers = table[0].map((h) => h.trim());
115
+ const seen = new Set();
116
+ for (const h of headers) {
117
+ const k = h.toLowerCase();
118
+ if (seen.has(k)) throw new Error(`Duplicate CSV header "${h}" — mapping would be ambiguous`);
119
+ seen.add(k);
120
+ }
121
+ const rows = table.slice(1).map((r, i) => {
122
+ const obj = { __rownum: i + 1 };
123
+ headers.forEach((h, j) => { obj[h] = (r[j] ?? "").trim(); });
124
+ return obj;
125
+ });
126
+ return { headers, rows, delimiter, header_fingerprint: sha256(JSON.stringify(headers)) };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // mapping engine
131
+ // ---------------------------------------------------------------------------
132
+
133
+ const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
134
+ const tokens = (s) => new Set(String(s || "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
135
+
136
+ function similarity(label, header) {
137
+ const nl = norm(label), nh = norm(header);
138
+ if (!nl || !nh) return 0;
139
+ if (nl === nh) return 1.0;
140
+ const tl = tokens(label), th = tokens(header);
141
+ const inter = [...tl].filter((t) => th.has(t)).length;
142
+ if (inter && inter === tl.size && inter === th.size) return 0.9;
143
+ if (nl.includes(nh) || nh.includes(nl)) return 0.7;
144
+ const union = new Set([...tl, ...th]).size;
145
+ return union ? 0.3 + 0.3 * (inter / union) : 0;
146
+ }
147
+
148
+ const CC_HEADERS = ["countrycode", "cc", "dialcode", "dialingcode"];
149
+ const PHONE_HEADERS = ["phone", "phonenumber", "whatsapp", "whatsappid", "whatsappnumber", "number", "mobile", "cell", "msisdn"];
150
+
151
+ function findHeader(headers, candidates) {
152
+ return headers.find((h) => candidates.includes(norm(h))) ?? null;
153
+ }
154
+
155
+ /** Resolve one mapped value for a row. concat strips a leading 0 from parts
156
+ * AFTER the first (the country-code + local-part rule: 27 + 0831112222 must
157
+ * become 27831112222, not 270831112222). */
158
+ function resolveValue(mapping, row) {
159
+ if (!mapping || !mapping.source) return "";
160
+ if (mapping.source === "literal") return String(mapping.value ?? "");
161
+ if (mapping.source === "column") return String(row[mapping.name] ?? "");
162
+ if (mapping.source === "transform" && mapping.transform === "concat") {
163
+ let out = "";
164
+ for (const part of mapping.parts || []) {
165
+ let v = part.type === "literal" ? String(part.value ?? "") : String(row[part.name] ?? "");
166
+ if (mapping.normalizePhone && out.length > 0) v = v.replace(/^\s*0/, "");
167
+ out += v;
168
+ }
169
+ return mapping.normalizePhone ? normalizePhone(out) : out;
170
+ }
171
+ return "";
172
+ }
173
+
174
+ function describeMapping(m) {
175
+ if (!m || !m.source) return "(unmapped)";
176
+ if (m.source === "column") return `column "${m.name}"`;
177
+ if (m.source === "literal") return `literal "${m.value}"`;
178
+ if (m.source === "transform") {
179
+ const parts = (m.parts || []).map((p) => (p.type === "column" ? p.name : `"${p.value}"`)).join(" + ");
180
+ return `concat(${parts})${m.normalizePhone ? " [normalizePhone]" : ""}`;
181
+ }
182
+ return "(unknown)";
183
+ }
184
+
185
+ function contextSnippet(bodyText, position) {
186
+ const idx = bodyText.indexOf(`{{${position}}}`);
187
+ if (idx === -1) return "";
188
+ const start = Math.max(0, idx - 40);
189
+ const end = Math.min(bodyText.length, idx + 40);
190
+ return `${start > 0 ? "…" : ""}${bodyText.slice(start, end).replace(/\n/g, " ")}${end < bodyText.length ? "…" : ""}`;
191
+ }
192
+
193
+ function autoSuggest(template, headers) {
194
+ const ccHeader = findHeader(headers, CC_HEADERS);
195
+ const phoneHeader = headers.find((h) => PHONE_HEADERS.includes(norm(h))) ?? null;
196
+ const phoneMapping = ccHeader && phoneHeader
197
+ ? { source: "transform", transform: "concat", normalizePhone: true, parts: [{ type: "column", name: ccHeader }, { type: "column", name: phoneHeader }] }
198
+ : phoneHeader
199
+ ? { source: "transform", transform: "concat", normalizePhone: true, parts: [{ type: "column", name: phoneHeader }] }
200
+ : null;
201
+
202
+ const bodyParams = {};
203
+ for (const pos of template.body_positions) {
204
+ const label = template.body_labels?.[String(pos)];
205
+ const useLabel = label && norm(label) !== String(pos) ? label : null;
206
+ let best = null, bestScore = 0;
207
+ if (useLabel) {
208
+ for (const h of headers) {
209
+ const s = similarity(useLabel, h);
210
+ if (s > bestScore) { best = h; bestScore = s; }
211
+ }
212
+ }
213
+ const phoneish = useLabel && /phone|number|whatsapp/i.test(useLabel);
214
+ let suggestion = null;
215
+ if (phoneish && phoneMapping) suggestion = { ...phoneMapping };
216
+ else if (best && bestScore >= 0.7) suggestion = { source: "column", name: best };
217
+ bodyParams[`param${pos}`] = {
218
+ mapping: suggestion,
219
+ confidence: phoneish && phoneMapping ? 0.78 : bestScore,
220
+ label: useLabel ?? String(pos),
221
+ context: contextSnippet(template.body_text, pos),
222
+ };
223
+ }
224
+ return { bodyParams, recipient: phoneMapping, ccHeader, phoneHeader };
225
+ }
226
+
227
+ async function interactiveMapping(template, headers, campaign) {
228
+ const auto = autoSuggest(template, headers);
229
+ const pickColumn = async () => {
230
+ headers.forEach((h, i) => console.log(` ${i + 1}. ${h}`));
231
+ const n = Number(await ask(" column #: "));
232
+ if (!Number.isInteger(n) || n < 1 || n > headers.length) throw new Error("invalid column number");
233
+ return headers[n - 1];
234
+ };
235
+ const buildConcat = async () => {
236
+ console.log(' build a concat — enter parts one per line ("col:<header>" or "lit:<text>"), empty line to finish:');
237
+ const parts = [];
238
+ for (;;) {
239
+ const line = await ask(" part: ");
240
+ if (!line) break;
241
+ if (line.startsWith("col:")) parts.push({ type: "column", name: line.slice(4).trim() });
242
+ else if (line.startsWith("lit:")) parts.push({ type: "literal", value: line.slice(4) });
243
+ else console.log(' (use "col:<header>" or "lit:<text>")');
244
+ }
245
+ const np = (await ask(" normalizePhone? [y/N]: ")).toLowerCase() === "y";
246
+ return { source: "transform", transform: "concat", normalizePhone: np, parts };
247
+ };
248
+ const confirmSlot = async (name, meta) => {
249
+ console.log("");
250
+ console.log(` ${name} label: ${meta.label ?? "-"}`);
251
+ if (meta.context) console.log(` context: "${meta.context}"`);
252
+ console.log(` suggestion: ${describeMapping(meta.mapping)}${meta.confidence ? ` [confidence ${meta.confidence.toFixed(2)}]` : ""}`);
253
+ for (;;) {
254
+ const a = (await ask(" [Enter]=accept c=column l=literal t=transform: ")).toLowerCase();
255
+ if (a === "" && meta.mapping) return meta.mapping;
256
+ if (a === "" && !meta.mapping) { console.log(" (no suggestion — pick c/l/t)"); continue; }
257
+ if (a === "c") return { source: "column", name: await pickColumn() };
258
+ if (a === "l") return { source: "literal", value: await ask(" literal value: ") };
259
+ if (a === "t") return await buildConcat();
260
+ }
261
+ };
262
+
263
+ console.log(`Template ${template.name} — ${template.parameter_format}, ${template.body_var_count} body var(s)${template.url_button?.present ? " + 1 URL button" : ""}.`);
264
+ const body_params = {};
265
+ for (const pos of template.body_positions) {
266
+ const key = `param${pos}`;
267
+ body_params[key] = { ...(await confirmSlot(key, auto.bodyParams[key])), label: auto.bodyParams[key].label };
268
+ }
269
+ let button_params = {};
270
+ if (template.url_button?.present) {
271
+ console.log("");
272
+ console.log(` BUTTON param1 (URL base: ${template.url_button.url_base})`);
273
+ const code = await ask(" short code / value for this campaign: ");
274
+ button_params = { param1: { source: "literal", value: code, url_base: template.url_button.url_base } };
275
+ }
276
+ console.log("");
277
+ const recipient = await confirmSlot("RECIPIENT whatsappNumber", { mapping: auto.recipient, label: "recipient", confidence: auto.recipient ? 0.78 : 0 });
278
+
279
+ console.log("");
280
+ console.log("Mapping:");
281
+ for (const [k, v] of Object.entries(body_params)) console.log(` ${k.padEnd(18)} ${describeMapping(v)}`);
282
+ if (button_params.param1) console.log(` BUTTON param1 ${describeMapping(button_params.param1)}`);
283
+ console.log(` RECIPIENT ${describeMapping(recipient)}`);
284
+ const save = (await ask(`Save this mapping to .flowiq/campaigns/${campaign}.json? [Y/n]: `)).toLowerCase();
285
+ if (save === "n") throw new Error("mapping not saved — aborted");
286
+ return { body_params, button_params, recipient };
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // campaign config + status log
291
+ // ---------------------------------------------------------------------------
292
+
293
+ const cfgPath = (campaign) => path.join(CAMPAIGN_DIR, `${campaign}.json`);
294
+ const logPath = (campaign) => path.join(CAMPAIGN_DIR, `${campaign}.status.jsonl`);
295
+
296
+ async function loadCampaign(campaign) {
297
+ try { return JSON.parse(await fs.readFile(cfgPath(campaign), "utf8")); }
298
+ catch { return null; }
299
+ }
300
+
301
+ async function saveCampaign(cfg) {
302
+ await fs.mkdir(CAMPAIGN_DIR, { recursive: true });
303
+ await fs.writeFile(cfgPath(cfg.campaign), JSON.stringify(cfg, null, 2) + "\n", "utf8");
304
+ }
305
+
306
+ /** Fold the JSONL into number → latest status. `sending` with no terminal
307
+ * line = ambiguous (crash mid-row): NEVER auto-resent. */
308
+ async function loadStatusLog(campaign) {
309
+ const map = new Map();
310
+ let header = null;
311
+ try {
312
+ const text = await fs.readFile(logPath(campaign), "utf8");
313
+ for (const line of text.split("\n")) {
314
+ if (!line.trim()) continue;
315
+ let obj; try { obj = JSON.parse(line); } catch { continue; }
316
+ if (obj.type === "meta") { header = obj; continue; }
317
+ if (obj.type === "row" && obj.number) map.set(obj.number, obj);
318
+ }
319
+ } catch { /* no log yet */ }
320
+ return { header, map };
321
+ }
322
+
323
+ async function appendLog(campaign, obj) {
324
+ await fs.mkdir(CAMPAIGN_DIR, { recursive: true });
325
+ await fs.appendFile(logPath(campaign), JSON.stringify(obj) + "\n", "utf8");
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // pipeline stages
330
+ // ---------------------------------------------------------------------------
331
+
332
+ async function introspect(orgId, templateName) {
333
+ const resp = await http.post("broadcast", { action: "introspect", organization_id: orgId, template_name: templateName });
334
+ return resp;
335
+ }
336
+
337
+ /** Validation V-1..V-13. Returns {valid, skipped, aborts}. */
338
+ function validateRows(template, mapping, headers, rows, illegalChars) {
339
+ const aborts = [];
340
+ if (template.status !== "APPROVED") aborts.push(`V-1: template status is ${template.status} — only APPROVED templates send`);
341
+ if (template.parameter_format !== "POSITIONAL") aborts.push("V-2: NAMED templates are not supported in v1");
342
+ if (template.is_carousel) aborts.push("V-3: carousel templates are not supported in v1");
343
+ if (!["text", "none"].includes(template.header_type)) aborts.push(`V-3: media-header templates (${template.header_type}) are not supported in v1`);
344
+ for (const pos of template.body_positions) {
345
+ if (!mapping.body_params?.[`param${pos}`]?.source) aborts.push(`V-4: body param${pos} is unmapped`);
346
+ }
347
+ // V-2b: positions must be contiguous 1..N (the #132000 arithmetic guard).
348
+ const want = Array.from({ length: template.body_var_count }, (_, i) => i + 1);
349
+ if (JSON.stringify(template.body_positions) !== JSON.stringify(want)) {
350
+ aborts.push(`V-2: template positions ${JSON.stringify(template.body_positions)} are not contiguous 1..N — refusing`);
351
+ }
352
+ if (template.url_button?.present && !mapping.button_params?.param1?.source) {
353
+ aborts.push("V-5: the template has a dynamic URL button but button param1 is unmapped");
354
+ }
355
+ const referenced = new Set();
356
+ const collect = (m) => {
357
+ if (!m) return;
358
+ if (m.source === "column") referenced.add(m.name);
359
+ if (m.source === "transform") for (const p of m.parts || []) if (p.type === "column") referenced.add(p.name);
360
+ };
361
+ Object.values(mapping.body_params || {}).forEach(collect);
362
+ collect(mapping.recipient);
363
+ for (const col of referenced) {
364
+ if (!headers.includes(col)) aborts.push(`V-6: mapped column "${col}" is not in the CSV headers`);
365
+ }
366
+ if (aborts.length) return { aborts, valid: [], skipped: [] };
367
+
368
+ const valid = [], skipped = [];
369
+ const seenNumbers = new Set();
370
+ const illegal = (v) => /[\n\r\t]| {4,}/.test(v);
371
+ for (const row of rows) {
372
+ const recipient = resolveValue(mapping.recipient, row);
373
+ if (!recipient) { skipped.push({ row: row.__rownum, reason: "empty_recipient" }); continue; }
374
+ const number = normalizePhone(recipient);
375
+ if (!isPlausiblePhone(number)) { skipped.push({ row: row.__rownum, reason: `bad_phone:${recipient}` }); continue; }
376
+ if (seenNumbers.has(number)) { skipped.push({ row: row.__rownum, reason: "duplicate_number", number }); continue; }
377
+
378
+ const values = {};
379
+ let bad = null, sanitized = false;
380
+ for (const pos of template.body_positions) {
381
+ let v = resolveValue(mapping.body_params[`param${pos}`], row);
382
+ if (!v) { bad = `empty_value:param${pos}`; break; }
383
+ if (illegal(v)) {
384
+ if (illegalChars === "strip") { v = v.replace(/[\n\r\t]+| {4,}/g, " ").replace(/ +/g, " ").trim(); sanitized = true; }
385
+ else { bad = `illegal_chars:param${pos}`; break; }
386
+ }
387
+ values[`param${pos}`] = v;
388
+ }
389
+ if (bad) { skipped.push({ row: row.__rownum, reason: bad, number }); continue; }
390
+
391
+ let buttonValue = null;
392
+ if (template.url_button?.present) {
393
+ buttonValue = resolveValue(mapping.button_params.param1, row);
394
+ if (!buttonValue) { skipped.push({ row: row.__rownum, reason: "empty_value:button", number }); continue; }
395
+ }
396
+ seenNumbers.add(number);
397
+ valid.push({ rownum: row.__rownum, number, values, buttonValue, sanitized, contactName: firstColumnName(mapping, row) });
398
+ }
399
+ if (!valid.length) aborts.push("V-13: 0 valid rows after filtering — nothing to send");
400
+ return { aborts, valid, skipped };
401
+ }
402
+
403
+ /** Best human name for the contact upsert — the value mapped to param1 when
404
+ * it's a column (usually the customer-name column), else null. */
405
+ function firstColumnName(mapping, row) {
406
+ const m = mapping.body_params?.param1;
407
+ if (m?.source === "column") return String(row[m.name] ?? "").trim() || null;
408
+ return null;
409
+ }
410
+
411
+ /** Broadcast-safety classification via /cli/broadcast — excluded numbers are
412
+ * moved to skipped (send-template itself does NOT check allow_broadcast). */
413
+ async function applySafetyExclusions(orgId, valid, skipped) {
414
+ const excludedNumbers = new Map();
415
+ for (let i = 0; i < valid.length; i += CLASSIFY_CHUNK) {
416
+ const chunk = valid.slice(i, i + CLASSIFY_CHUNK).map((r) => r.number);
417
+ const resp = await http.post("broadcast", { action: "classify", organization_id: orgId, phones: chunk });
418
+ for (const r of resp.results || []) {
419
+ if (r.classification === "excluded") excludedNumbers.set(r.phone, r.reason);
420
+ }
421
+ }
422
+ const keep = [];
423
+ for (const r of valid) {
424
+ const reason = excludedNumbers.get(r.number);
425
+ if (reason) skipped.push({ row: r.rownum, reason: `opted_out:${reason}`, number: r.number });
426
+ else keep.push(r);
427
+ }
428
+ return keep;
429
+ }
430
+
431
+ function renderPreview(template, sample) {
432
+ let bodyText = template.body_text;
433
+ for (const [k, v] of Object.entries(sample.values)) {
434
+ bodyText = bodyText.replaceAll(`{{${k.replace("param", "")}}}`, v);
435
+ }
436
+ console.log(`Row ${sample.rownum} → ${sample.number} [VALID]${sample.sanitized ? " (sanitized)" : ""}`);
437
+ console.log(` Body:\n ${bodyText.replace(/\n/g, "\n ")}`);
438
+ if (template.url_button?.present && sample.buttonValue != null) {
439
+ const url = template.url_button.url_base.replace(/\{\{[^}]+\}\}/, sample.buttonValue);
440
+ console.log(` Button "${template.url_button.text ?? "URL"}" → ${url}`);
441
+ }
442
+ }
443
+
444
+ async function upsertContacts(orgId, rows, batchCap) {
445
+ let created = 0, updated = 0, skippedN = 0, dropped = 0;
446
+ const uniq = rows;
447
+ for (let i = 0; i < uniq.length; i += batchCap) {
448
+ const chunk = uniq.slice(i, i + batchCap).map((r) => ({ whatsapp_id: r.number, full_name: r.contactName }));
449
+ let resp = null, lastErr = null;
450
+ for (let attempt = 1; attempt <= 3; attempt++) {
451
+ try { resp = await http.post("contacts-upsert", { organization_id: orgId, contacts: chunk }); break; }
452
+ catch (e) { lastErr = e; await sleep(1000 * 2 ** (attempt - 1)); }
453
+ }
454
+ if (!resp) {
455
+ // MUST-FIX: never send against a partially-populated contact set.
456
+ throw new Error(`contact upsert failed after 3 attempts (${lastErr?.message}) — ABORTING before the send stage`);
457
+ }
458
+ created += resp.created ?? 0; updated += resp.updated ?? 0; skippedN += resp.skipped ?? 0; dropped += resp.dropped_invalid ?? 0;
459
+ process.stdout.write(` upserted ${Math.min(i + batchCap, uniq.length)}/${uniq.length}\r`);
460
+ }
461
+ console.log(`\n contacts: +${created} created, ${updated} updated, ${skippedN} skipped${dropped ? `, ⚠ ${dropped} dropped as invalid server-side` : ""}`);
462
+ return { created, updated, skipped: skippedN, dropped };
463
+ }
464
+
465
+ function classifySendError(errText) {
466
+ const t = String(errText || "");
467
+ if (t.includes("#132000") || /parameters .*does not match/i.test(t)) return "config_fatal";
468
+ if (/messaging limit|daily.*cap|tier/i.test(t)) return "soft_stop";
469
+ if (/rate|too many|throughput|429/i.test(t)) return "retryable";
470
+ if (t.includes("#100")) return "invalid_param";
471
+ if (/contact not found/i.test(t)) return "contact_missing";
472
+ return "row_fatal";
473
+ }
474
+
475
+ async function sendOne(apiUrl, orgId, templateName, row) {
476
+ // #132000 structural guard: whitelist param\d+ keys only.
477
+ const bodyParameters = {};
478
+ for (const [k, v] of Object.entries(row.values)) {
479
+ if (!/^param\d+$/.test(k)) throw new Error(`internal: stray bodyParameters key "${k}" — refusing (V-12)`);
480
+ bodyParameters[k] = v;
481
+ }
482
+ const payload = {
483
+ templateName,
484
+ whatsappNumber: row.number,
485
+ bodyParameters,
486
+ ...(row.buttonValue != null ? { buttonParameters: { param1: row.buttonValue } } : {}),
487
+ };
488
+ // Org-UUID branch: NO auth header (a staff key here would 401 on the fiq_ branch).
489
+ const resp = await fetch(`${apiUrl.replace(/\/$/, "")}/send-template?organizationId=${orgId}`, {
490
+ method: "POST",
491
+ headers: { "Content-Type": "application/json" },
492
+ body: JSON.stringify(payload),
493
+ });
494
+ const json = await resp.json().catch(() => ({}));
495
+ const result = json?.results?.[0] ?? {};
496
+ const ok = json?.success === true && (result.success !== false) && (result.messageId || json?.messageId);
497
+ return { ok: !!ok, messageId: result.messageId ?? json?.messageId ?? null, error: result.error ?? json?.error ?? (resp.ok ? null : `HTTP ${resp.status}`) };
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // shared pipeline runner
502
+ // ---------------------------------------------------------------------------
503
+
504
+ async function runPipeline(orgId, opts, { commitStage, isResume }) {
505
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
506
+ const campaign = opts.campaign || (opts.csv ? slugify(path.basename(opts.csv, path.extname(opts.csv)), "campaign") : null);
507
+ if (!campaign) { console.error("Error: pass --campaign (no CSV filename to derive it from)."); process.exit(1); }
508
+
509
+ let cfg = await loadCampaign(campaign);
510
+ const templateName = opts.template || cfg?.template_name;
511
+ if (!templateName) { console.error("Error: --template required (or an existing campaign config)."); process.exit(1); }
512
+ const csvPath = opts.csv || cfg?.csv?.path;
513
+ if (!csvPath) { console.error("Error: --csv required (or an existing campaign config)."); process.exit(1); }
514
+
515
+ // 1-3. introspect (doubles as org check)
516
+ let intro;
517
+ try { intro = await introspect(orgId, templateName); }
518
+ catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
519
+ const template = intro.template;
520
+ console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${template.url_button?.present ? ", dynamic URL button" : ""}`);
521
+
522
+ // 4. CSV
523
+ let csv;
524
+ try { csv = await loadCsv(csvPath); }
525
+ catch (e) { console.error(`CSV error: ${e.message}`); process.exit(1); }
526
+
527
+ // 5. mapping (load + revalidate, else interactive)
528
+ let mapping = cfg && !opts.forceRemap ? cfg : null;
529
+ if (mapping) {
530
+ const problems = [];
531
+ if (mapping.template_name !== templateName) problems.push(`saved mapping is for template ${mapping.template_name}`);
532
+ if (mapping.csv?.header_fingerprint !== csv.header_fingerprint) problems.push("CSV headers changed since the mapping was saved");
533
+ if (Object.keys(mapping.body_params || {}).length !== template.body_var_count) problems.push("template variable count changed");
534
+ if (problems.length) {
535
+ console.log(`Saved mapping can't be reused: ${problems.join("; ")} — rebuilding interactively.`);
536
+ mapping = null;
537
+ }
538
+ }
539
+ if (!mapping) {
540
+ const built = await interactiveMapping(template, csv.headers, campaign);
541
+ cfg = {
542
+ schema_version: 1,
543
+ campaign,
544
+ organization_id: orgId,
545
+ organization_slug: slugify(intro.organization_name, orgId.slice(0, 8)),
546
+ template_name: templateName,
547
+ parameter_format: template.parameter_format,
548
+ created_at: new Date().toISOString(),
549
+ csv: { path: csvPath, delimiter: csv.delimiter, headers: csv.headers, header_fingerprint: csv.header_fingerprint },
550
+ recipient: built.recipient,
551
+ body_params: built.body_params,
552
+ button_params: built.button_params,
553
+ options: { illegal_chars: opts.illegalChars || "reject" },
554
+ };
555
+ await saveCampaign(cfg);
556
+ console.log(`Saved mapping: ${cfgPath(campaign)}`);
557
+ mapping = cfg;
558
+ }
559
+
560
+ // 6. validate
561
+ const { aborts, valid, skipped } = validateRows(template, mapping, csv.headers, csv.rows, opts.illegalChars || mapping.options?.illegal_chars || "reject");
562
+ if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
563
+
564
+ // 6b. broadcast-safety exclusions (live)
565
+ let sendable = await applySafetyExclusions(orgId, valid, skipped);
566
+ if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
567
+
568
+ // resume folding
569
+ const { header: logHeader, map: statusMap } = await loadStatusLog(campaign);
570
+ if (isResume) {
571
+ if (!logHeader) { console.error(`No status log for campaign "${campaign}" — nothing to resume.`); process.exit(1); }
572
+ if (logHeader.csv_fingerprint && logHeader.csv_fingerprint !== csv.header_fingerprint) {
573
+ console.error("CSV headers changed since this campaign started — start a NEW campaign instead of resuming.");
574
+ process.exit(1);
575
+ }
576
+ }
577
+ const ambiguous = [];
578
+ const workSet = sendable.filter((r) => {
579
+ const st = statusMap.get(r.number);
580
+ if (!st) return true;
581
+ if (st.status === "sent") return false;
582
+ if (st.status === "sending") { ambiguous.push(r.number); return false; } // never auto-resend
583
+ if (st.status === "failed") return isResume ? !!opts.retryFailed : true;
584
+ return true;
585
+ });
586
+
587
+ // 7. preview
588
+ const skippedByReason = {};
589
+ for (const s of skipped) { const k = s.reason.split(":")[0]; skippedByReason[k] = (skippedByReason[k] ?? 0) + 1; }
590
+ console.log("");
591
+ const nPreview = Math.max(1, Number(opts.rows ?? 3));
592
+ const samples = [sendable[0], ...sendable.slice(1).filter((r, i, a) => a.findIndex((x) => x.values.param1 === r.values.param1) === i)].filter(Boolean).slice(0, nPreview);
593
+ for (const s of samples) { renderPreview(template, s); console.log(""); }
594
+ console.log(`Summary: ${sendable.length} sendable · skipped ${skipped.length} (${Object.entries(skippedByReason).map(([k, v]) => `${k}: ${v}`).join(", ") || "none"})`);
595
+ if (statusMap.size) console.log(`Status log: ${[...statusMap.values()].filter((s) => s.status === "sent").length} already sent · work set ${workSet.length}`);
596
+ if (ambiguous.length) console.log(`⚠ ${ambiguous.length} number(s) have an AMBIGUOUS in-flight status (crash mid-send?) — never auto-resent: ${ambiguous.slice(0, 5).join(", ")}${ambiguous.length > 5 ? "…" : ""}. Check the chat, then edit the status log if they must be retried.`);
597
+ console.log(`Upsert plan: ${workSet.length} contacts in ${Math.ceil(workSet.length / CONTACT_BATCH_CAP)} batch(es)`);
598
+ const rate = Math.min(Number(opts.rate ?? 8) || 8, HARD_RATE_CAP);
599
+ console.log(`Send plan: ${workSet.length} messages @ ≤${rate}/s ≈ ${Math.ceil(workSet.length / rate)}s`);
600
+
601
+ if (!commitStage) {
602
+ console.log("");
603
+ console.log("DRY RUN — nothing was sent, no contacts written. Add --commit to send.");
604
+ return;
605
+ }
606
+ if (!workSet.length) { console.log("Nothing to send — all rows are already sent/skipped."); return; }
607
+
608
+ // 8. gate
609
+ if (!opts.yes) {
610
+ console.log("");
611
+ const answer = await ask(`Type the campaign name ("${campaign}") to send ${workSet.length} message(s): `);
612
+ if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
613
+ }
614
+
615
+ // 9. upsert
616
+ if (!opts.skipUpsert && !(isResume && logHeader)) {
617
+ try { await upsertContacts(orgId, workSet, Math.min(Number(opts.upsertBatch ?? CONTACT_BATCH_CAP) || CONTACT_BATCH_CAP, CONTACT_BATCH_CAP)); }
618
+ catch (e) { console.error(e.message); process.exit(1); }
619
+ }
620
+
621
+ // 10-11. send loop + report (shared with the --tag pipeline)
622
+ await executeSendLoop({
623
+ orgId, campaign, templateName, workSet, statusMap, rate,
624
+ skippedCount: skipped.length,
625
+ writeMetaHeader: !logHeader,
626
+ metaExtras: { csv_fingerprint: csv.header_fingerprint, total_rows: csv.rows.length, valid_rows: sendable.length },
627
+ });
628
+ }
629
+
630
+ /** The paced, write-ahead-logged per-row send loop + final report. */
631
+ async function executeSendLoop({ orgId, campaign, templateName, workSet, statusMap, rate, skippedCount, writeMetaHeader, metaExtras }) {
632
+ const { api_url } = await loadConfig();
633
+ if (writeMetaHeader) {
634
+ await appendLog(campaign, {
635
+ type: "meta", campaign, organization_id: orgId, template_name: templateName,
636
+ ...(metaExtras || {}),
637
+ started_at: new Date().toISOString(),
638
+ });
639
+ }
640
+ let sent = 0, failed = 0, effRate = rate;
641
+ const perMsgMs = () => Math.ceil(1000 / Math.max(1, effRate));
642
+ console.log(`Sending ${workSet.length} messages @ ≤${effRate}/s …`);
643
+
644
+ outer: for (const [i, row] of workSet.entries()) {
645
+ const rowStart = Date.now();
646
+ const attemptBase = (statusMap.get(row.number)?.attempt ?? 0) + 1;
647
+ for (let attempt = attemptBase; ; attempt++) {
648
+ // WRITE-AHEAD: sending line BEFORE the POST.
649
+ await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "sending", attempt, at: new Date().toISOString() });
650
+ let out;
651
+ try { out = await sendOne(api_url, orgId, templateName, row); }
652
+ catch (e) {
653
+ // network/timeout with no response = UNKNOWN outcome → ambiguous, never auto-retried
654
+ await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "unknown", error: e.message, attempt, at: new Date().toISOString() });
655
+ console.error(`\n ${row.number}: network error mid-send (${e.message}) — outcome UNKNOWN, left for manual review.`);
656
+ failed++;
657
+ break;
658
+ }
659
+ if (out.ok) {
660
+ await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "sent", message_id: out.messageId, attempt, at: new Date().toISOString() });
661
+ sent++;
662
+ break;
663
+ }
664
+ const cls = classifySendError(out.error);
665
+ if (cls === "retryable" && attempt < attemptBase + 5) {
666
+ await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "retrying", error: String(out.error), attempt, at: new Date().toISOString() });
667
+ await sleep(Math.min(500 * 2 ** (attempt - attemptBase), 30000));
668
+ continue;
669
+ }
670
+ await appendLog(campaign, { type: "row", number: row.number, rownum: row.rownum, status: "failed", error: String(out.error), attempt, at: new Date().toISOString() });
671
+ failed++;
672
+ if (cls === "config_fatal") {
673
+ console.error(`\nABORT — Meta #132000 on ${row.number}: ${out.error}`);
674
+ console.error("The parameters produce the wrong count; every row would fail. Fix the mapping/params and resume.");
675
+ break outer;
676
+ }
677
+ if (cls === "soft_stop") {
678
+ console.error(`\nSOFT STOP — Meta messaging limit reached (${out.error}). Remaining rows stay pending; resume tomorrow:`);
679
+ console.error(` flowiq broadcast resume ${orgId} --campaign ${campaign} --commit`);
680
+ break outer;
681
+ }
682
+ if (cls === "retryable") effRate = Math.max(1, Math.floor(effRate / 2));
683
+ break;
684
+ }
685
+ const spent = Date.now() - rowStart;
686
+ await sleep(Math.max(0, perMsgMs() - spent));
687
+ if ((i + 1) % 25 === 0 || i === workSet.length - 1) {
688
+ process.stdout.write(` ${sent} sent · ${failed} failed · ${workSet.length - sent - failed} pending (${Math.round(((i + 1) / workSet.length) * 100)}%)\r`);
689
+ }
690
+ }
691
+
692
+ console.log("\n");
693
+ console.log(`Broadcast — ${campaign}`);
694
+ console.log(` sent: ${sent}`);
695
+ console.log(` failed: ${failed}`);
696
+ console.log(` skipped: ${skippedCount} [pre-send validation/safety]`);
697
+ console.log(` status log: ${logPath(campaign)}`);
698
+ if (failed || workSet.length > sent + failed) {
699
+ console.log(` Re-run failed/pending: flowiq broadcast resume ${orgId} --campaign ${campaign} --commit --retry-failed`);
700
+ }
701
+ await fs.writeFile(path.join(CAMPAIGN_DIR, `${campaign}.report.json`),
702
+ JSON.stringify({ campaign, sent, failed, skipped: skippedCount, finished_at: new Date().toISOString() }, null, 2) + "\n", "utf8");
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // tag pipeline (`send --tag` — the segments↔broadcast bridge)
707
+ // ---------------------------------------------------------------------------
708
+
709
+ /** Commander collector for repeatable k=v flags (`--body param1="Hi {{first_name}}"`). */
710
+ export function collectKV(pair, mapAcc) {
711
+ const idx = pair.indexOf("=");
712
+ if (idx < 1) throw new Error(`Expected k=v (got "${pair}")`);
713
+ mapAcc[pair.slice(0, idx)] = pair.slice(idx + 1);
714
+ return mapAcc;
715
+ }
716
+
717
+ async function runTagPipeline(orgId, opts, { commitStage }) {
718
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
719
+ const campaign = opts.campaign || slugify(opts.tag, "tag-campaign");
720
+ let cfg = await loadCampaign(campaign);
721
+ if (cfg && cfg.mode !== "tag") { console.error(`Campaign "${campaign}" is a CSV campaign — pick a different --campaign for the tag send.`); process.exit(1); }
722
+ const tag = opts.tag || cfg?.tag;
723
+ const templateName = opts.template || cfg?.template_name;
724
+ if (!tag || !templateName) { console.error("Error: --tag and --template required (or an existing tag campaign config)."); process.exit(1); }
725
+ const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
726
+ const buttonLiteral = (opts.button && opts.button.param1) ?? cfg?.button_param_literal ?? null;
727
+
728
+ // introspect + validate params arithmetically (the #132000 guard, pre-send)
729
+ let intro;
730
+ try { intro = await introspect(orgId, templateName); }
731
+ catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
732
+ const template = intro.template;
733
+ console.log(`Template ${template.name} [${template.status}] — ${template.parameter_format}, ${template.body_var_count} body var(s), header ${template.header_type}${template.url_button?.present ? ", dynamic URL button" : ""}`);
734
+ const aborts = [];
735
+ if (template.status !== "APPROVED") aborts.push(`template status is ${template.status} — only APPROVED templates send`);
736
+ if (template.parameter_format !== "POSITIONAL") aborts.push("NAMED templates are not supported in v1");
737
+ if (template.is_carousel) aborts.push("carousel templates are not supported in v1");
738
+ if (!["text", "none"].includes(template.header_type)) aborts.push(`media-header templates (${template.header_type}) are not supported in v1`);
739
+ const keys = Object.keys(bodyLiterals);
740
+ if (keys.some((k) => !/^param\d+$/.test(k))) aborts.push(`--body keys must be param1..N (got ${keys.join(", ")})`);
741
+ if (keys.length !== template.body_var_count) aborts.push(`template expects ${template.body_var_count} body param(s), you provided ${keys.length} (--body paramN=…). Values may use {{first_name}}/{{full_name}}/{{email}}/{{phone_number}} — resolved per contact.`);
742
+ if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
743
+ if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
744
+
745
+ // resolve the tag server-side (broadcast-safe recipients only)
746
+ let resolved;
747
+ try { resolved = await http.post("broadcast", { action: "resolve-tag", organization_id: orgId, tag }); }
748
+ catch (e) { console.error(`Tag resolution failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
749
+ console.log(`Tag "${tag}" on ${resolved.organization_name}: ${resolved.tagged_total} contact(s), ${resolved.recipients.length} sendable, ${resolved.excluded.length} excluded (opt-out/archived/blocked).`);
750
+ if (!resolved.recipients.length) { console.log("Nothing sendable under this tag."); return; }
751
+
752
+ const values = {};
753
+ for (const [k, v] of Object.entries(bodyLiterals)) values[k] = String(v);
754
+ let sendable = resolved.recipients.map((r, i) => ({
755
+ rownum: i + 1, number: r.number, values, buttonValue: buttonLiteral, contactName: r.full_name,
756
+ }));
757
+ if (opts.limit) sendable = sendable.slice(0, Number(opts.limit));
758
+
759
+ // resume folding (same semantics as the CSV path)
760
+ const { header: logHeader, map: statusMap } = await loadStatusLog(campaign);
761
+ const ambiguous = [];
762
+ const workSet = sendable.filter((r) => {
763
+ const st = statusMap.get(r.number);
764
+ if (!st) return true;
765
+ if (st.status === "sent") return false;
766
+ if (st.status === "sending") { ambiguous.push(r.number); return false; }
767
+ if (st.status === "failed") return !!opts.retryFailed || !logHeader;
768
+ return true;
769
+ });
770
+
771
+ console.log("");
772
+ renderPreview(template, sendable[0]);
773
+ if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
774
+ console.log(" ({{first_name}}-style tokens above are resolved PER CONTACT server-side)");
775
+ }
776
+ console.log("");
777
+ console.log(`Summary: ${sendable.length} sendable · ${resolved.excluded.length} excluded`);
778
+ if (statusMap.size) console.log(`Status log: ${[...statusMap.values()].filter((s) => s.status === "sent").length} already sent · work set ${workSet.length}`);
779
+ if (ambiguous.length) console.log(`⚠ ${ambiguous.length} AMBIGUOUS in-flight number(s) — never auto-resent: ${ambiguous.slice(0, 5).join(", ")}${ambiguous.length > 5 ? "…" : ""}`);
780
+ const rate = Math.min(Number(opts.rate ?? 8) || 8, HARD_RATE_CAP);
781
+ console.log(`Send plan: ${workSet.length} messages @ ≤${rate}/s ≈ ${Math.ceil(Math.max(1, workSet.length) / rate)}s`);
782
+
783
+ if (!commitStage) {
784
+ console.log("");
785
+ console.log("DRY RUN — nothing was sent. Add --commit to send.");
786
+ return;
787
+ }
788
+ if (!workSet.length) { console.log("Nothing to send — all rows are already sent."); return; }
789
+
790
+ if (!opts.yes) {
791
+ console.log("");
792
+ const answer = await ask(`Type the campaign name ("${campaign}") to send ${workSet.length} message(s): `);
793
+ if (answer !== campaign) { console.log("Mismatch — aborted, nothing sent."); process.exit(0); }
794
+ }
795
+
796
+ // Persist the tag campaign config so `resume` can reconstruct it.
797
+ await saveCampaign({
798
+ schema_version: 1, campaign, mode: "tag",
799
+ organization_id: orgId, organization_slug: slugify(resolved.organization_name, orgId.slice(0, 8)),
800
+ template_name: templateName, tag,
801
+ body_params_literal: bodyLiterals, button_param_literal: buttonLiteral,
802
+ created_at: cfg?.created_at ?? new Date().toISOString(),
803
+ });
804
+
805
+ await executeSendLoop({
806
+ orgId, campaign, templateName, workSet, statusMap, rate,
807
+ skippedCount: resolved.excluded.length,
808
+ writeMetaHeader: !logHeader,
809
+ metaExtras: { tag, tagged_total: resolved.tagged_total, valid_rows: sendable.length },
810
+ });
811
+ }
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // verbs
815
+ // ---------------------------------------------------------------------------
816
+
817
+ export async function map(orgId, opts = {}) {
818
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
819
+ const campaign = opts.campaign || slugify(path.basename(opts.csv, path.extname(opts.csv)), "campaign");
820
+ let intro;
821
+ try { intro = await introspect(orgId, opts.template); }
822
+ catch (e) { console.error(`Template introspection failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
823
+ let csv;
824
+ try { csv = await loadCsv(opts.csv); }
825
+ catch (e) { console.error(`CSV error: ${e.message}`); process.exit(1); }
826
+ const existing = await loadCampaign(campaign);
827
+ if (existing && !opts.forceRemap) {
828
+ console.log(`Campaign "${campaign}" already has a saved mapping (${cfgPath(campaign)}) — use --force-remap to rebuild.`);
829
+ return;
830
+ }
831
+ const built = await interactiveMapping(intro.template, csv.headers, campaign);
832
+ await saveCampaign({
833
+ schema_version: 1, campaign, organization_id: orgId,
834
+ organization_slug: slugify(intro.organization_name, orgId.slice(0, 8)),
835
+ template_name: opts.template, parameter_format: intro.template.parameter_format,
836
+ created_at: new Date().toISOString(),
837
+ csv: { path: opts.csv, delimiter: csv.delimiter, headers: csv.headers, header_fingerprint: csv.header_fingerprint },
838
+ recipient: built.recipient, body_params: built.body_params, button_params: built.button_params,
839
+ options: { illegal_chars: "reject" },
840
+ });
841
+ console.log(`Saved mapping: ${cfgPath(campaign)}`);
842
+ }
843
+
844
+ export async function preview(orgId, opts = {}) {
845
+ await runPipeline(orgId, opts, { commitStage: false, isResume: false });
846
+ }
847
+
848
+ export async function send(orgId, opts = {}) {
849
+ if (opts.tag && opts.csv) { console.error("Error: --tag and --csv are mutually exclusive."); process.exit(1); }
850
+ if (opts.tag) return runTagPipeline(orgId, opts, { commitStage: !!opts.commit });
851
+ await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: !!opts.resume });
852
+ }
853
+
854
+ export async function resume(orgId, opts = {}) {
855
+ const cfg = opts.campaign ? await loadCampaign(opts.campaign) : null;
856
+ if (cfg?.mode === "tag") return runTagPipeline(orgId, { ...opts, tag: cfg.tag }, { commitStage: !!opts.commit });
857
+ await runPipeline(orgId, opts, { commitStage: !!opts.commit, isResume: true });
858
+ }
859
+
860
+ export async function list() {
861
+ let files;
862
+ try { files = (await fs.readdir(CAMPAIGN_DIR)).filter((f) => f.endsWith(".json") && !f.endsWith(".report.json")); }
863
+ catch { console.log("(no local campaigns; run `broadcast map <org_id> --template … --csv …`)"); return; }
864
+ if (!files.length) { console.log("(no local campaigns)"); return; }
865
+ files.sort();
866
+ for (const f of files) {
867
+ try {
868
+ const d = JSON.parse(await fs.readFile(path.join(CAMPAIGN_DIR, f), "utf8"));
869
+ const { map: statusMap } = await loadStatusLog(d.campaign);
870
+ const sent = [...statusMap.values()].filter((s) => s.status === "sent").length;
871
+ console.log(f);
872
+ console.log(` org: ${d.organization_slug ?? d.organization_id} · template ${d.template_name}`);
873
+ console.log(` csv: ${d.csv?.path} (${(d.csv?.headers || []).join(", ")})`);
874
+ console.log(` sent: ${sent} (status log)`);
875
+ } catch (e) {
876
+ console.log(`${f} [invalid JSON: ${e.message}]`);
877
+ }
878
+ }
879
+ }