@flowapt/flowiq-cli 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,7 +82,7 @@ flowiq prompts push <slug>
82
82
  Notes:
83
83
  - `system_prompt` is regenerated server-side from `prompt_sections` on
84
84
  push using the `## SECTION: <title>` format.
85
- - Sections support optional `hidden: boolean` + `channels: string[]`
85
+ - Sections support optional `hidden: boolean` + `channels: string[]` (`web`, `whatsapp`, `messenger`, `instagram`, `email`; empty = every channel)
86
86
  (`web` / `whatsapp` / `messenger` / `instagram`) — validated on push.
87
87
  - **Time-boxed sections:** optional `active_from` / `active_until`
88
88
  (`"YYYY-MM-DD"` or `"YYYY-MM-DDTHH:mm"`) + `active_tz` (IANA, default
@@ -462,6 +462,27 @@ flowiq bc send <org_id> --tag new-arrivals-batch-01 --template new_arrivals_v3 \
462
462
  --button param1=<cycle-harmony-code> --button param2=<neuroshyft-code> --commit
463
463
  ```
464
464
 
465
+ **Pasted links (v0.8.0, first on npm as 0.8.1):** you can paste the REAL link instead of a code:
466
+
467
+ ```bash
468
+ flowiq bc send <org_id> --tag spring-batch-01 --template updates_v8 \
469
+ --body param1="Hi {{first_name}}" \
470
+ --button param1="https://shop.co.za/collections/spring?ref=ig&utm_source=klaviyo"
471
+ ```
472
+
473
+ The dry run shows what will go out: `→ linklnk.io/<code-on-commit>`, the tags
474
+ (`utm_campaign=18Sep_UpdatesV8 · utm_content=ClickHere · utm_source=whatsapp`),
475
+ anything it replaced (`⚠ replaced utm_source=klaviyo with whatsapp`) and where
476
+ the link really lands (a store redirect that drops the tags is pointed past;
477
+ a `/discount/` link is kept). Your other params (`ref`, a discount code, a
478
+ variant) are kept. `--commit` mints the code, then sends. `--link-campaign
479
+ "Spring Promotion"` names the campaign tag (default: the template name; `--at`
480
+ supplies the date); `--keep-link` sends the link exactly as pasted with no
481
+ FlowIQ tags (clicks are counted, the sale will not show as WhatsApp). Only a
482
+ button whose base is one of our short domains (`linklnk.io/{{1}}`) takes a
483
+ link; a button that already points at the store takes the part after the
484
+ slash. A whole short link pasted as the value is reduced to its code.
485
+
465
486
  **Link buttons (v0.7.5):** every button whose URL carries a `{{1}}` needs its own
466
487
  `--button paramN=` value (`param1` = the first such button, `param2` = the
467
488
  second). The dry run prints each button's final URL and stops if one is
@@ -1241,25 +1262,41 @@ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send,
1241
1262
  (`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
1242
1263
  (`flowiq audit --endpoint team-updates`).
1243
1264
 
1244
- ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
1265
+ ### WhatsApp templates — `flowiq templates pull|list|show|create|status|attempts` (alias `tpl`)
1245
1266
 
1246
1267
  Read an org's live templates straight from Meta (read-only), render any single
1247
- row **including an unsubmitted DRAFT**, and submit new ones through the
1248
- `create-meta-template` edge function.
1268
+ row **including an unsubmitted DRAFT**, submit new ones through the
1269
+ `create-meta-template` edge function, and read the **submission ledger** — every
1270
+ create call, including the ones that failed and why.
1249
1271
 
1250
1272
  ```bash
1251
1273
  flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
1252
1274
  flowiq templates status <organization_id> --name booking # poll approval
1253
1275
  flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
1254
1276
  flowiq templates create <organization_id> --request-file req.json
1277
+ flowiq templates attempts <organization_id> --failed # why a submit was refused (v0.8.1)
1255
1278
  ```
1256
1279
 
1280
+ **`attempts` is the only record of a FAILED submission (v0.8.1).** A refused
1281
+ submission writes no template row (so `status` cannot show it), never reaches
1282
+ Meta (so `pull` cannot either), and the server logs that used to be the only
1283
+ evidence expire after 24 hours. Every create call since 21 Sep 2026 — from the
1284
+ dashboard or this CLI — now writes a `template_submission_attempts` row, and
1285
+ `attempts` prints them newest first in SAST: `✓` submitted (Meta id + status at
1286
+ submit) or `✗` FAILED with the HTTP status, the short label and the sentence
1287
+ that names the cause, e.g. `HEADER format is DOCUMENT but the media at file_url
1288
+ is video/mp4 — point file_url at a document (pdf)`. That sentence is what to
1289
+ tell the client. `--failed` filters to refusals, `--name <substr>` to one
1290
+ template, `--limit <n>` (default 20, max 100), `--json` for the rows.
1291
+
1257
1292
  **`show` is the only way to read a DRAFT from the terminal (v0.6.7).** A draft
1258
1293
  never reaches Meta, so `templates pull` cannot see it and neither can the
1259
1294
  broadcast introspector — before this, reviewing one meant opening the dialog.
1260
1295
  `show` prints the body with its examples substituted (as the customer will read
1261
- it), then every carousel card: media URL, source filename, whether the Meta
1262
- asset handle is present, card body, and each button's resolved URL. `--raw`
1296
+ it), then — for a carousel draft only (v0.8.1; a standard draft used to print
1297
+ the dialog's two default cards as a phantom carousel) — every carousel card:
1298
+ media URL, source filename, whether the Meta asset handle is present, card
1299
+ body, and each button's resolved URL. `--raw`
1263
1300
  also prints the escaped body so invisible whitespace is visible; `--json` gives
1264
1301
  the row.
1265
1302
 
package/TEAM-GUIDE.md CHANGED
@@ -167,6 +167,7 @@ several.
167
167
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
168
168
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
169
169
  | Send a **CAROUSEL** template (v0.4.9) | Tag mode only: `flowiq bc send <org_id> --tag <batch-tag> --template <carousel_name> --body param1="Hi {{first_name}}" --commit`. Card images are automatic (each card's stored template image, type-verified per card); override with repeatable `--card-media <url>` (one per card, in order). If the cards carry `{{n}}` body variables or URL-button variables, the CLI tells you exactly what to put in a `--cards-file <path>` JSON (one entry per card: `header_media` / `body_params` / `button_payloads` / `url_vars`). Always sends via the python engine (any size); no CSV mode for carousels. |
170
+ | Send a broadcast whose link button should open a page on the store, tracked | Paste the real link: `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1="https://shop.co.za/collections/spring?ref=ig"` (dry run shows `→ linklnk.io/<code>`, the campaign tag and anything replaced) → `… --commit` mints the code and sends. `--link-campaign "Spring Promotion"` names the tag (default: the template name); `--keep-link` sends your link untouched with no FlowIQ tags (clicks counted, the sale will not show as WhatsApp) |
170
171
  | Send a template with TWO link buttons (e.g. one per product) and track clicks per batch | Mint two short links per batch with the same campaign and a different `--content` (e.g. `--content CycleHarmony_b01` and `--content NeuroShyft_b01`), then `flowiq bc send <org_id> --tag <batch-tag> --template <name> --button param1=<first-code> --button param2=<second-code>`. `param1` is the first link button on the template, `param2` the second. The dry run prints both final URLs and stops if either is missing. Tag mode only (v0.7.5). |
171
172
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
172
173
  | Find a broadcast's id (don't have the `broadcastId`?) | `flowiq bc list-remote <org_id>` — the org's broadcasts newest-first with full ids (`--template <substr>` / `--since <date>` / `--limit <n>` to narrow) |
@@ -184,6 +185,7 @@ several.
184
185
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
185
186
  | **Read a template you have not submitted yet (a DRAFT)** | `flowiq tpl show <org_id> <template_name>` — the ONLY way to see a draft from the terminal (`pull` reads Meta, and a draft never gets there). Renders the message as the customer will read it, every carousel card, and a **Checks** list of the things Meta would bounce it for |
186
187
  | Check a carousel before submitting it | `flowiq tpl show <org_id> <name>` and read **Checks**. It catches a card link using the wrong `{{n}}`, a missing greeting example, a bold/italic marker against a space (WhatsApp shows the literal `_`), wrong card counts and over-length text. It cannot know a card names a product you do not stock — read the card text against the shop yourself |
188
+ | **A template would not submit and you want to know why** | `flowiq tpl attempts <org_id> --failed` — the submission ledger. Every create call since 21 Sep 2026, from the dashboard or the CLI, with the reason a refused one was refused (e.g. `HEADER format is DOCUMENT but the media at file_url is video/mp4`). A failed submit writes no template row and never reaches Meta, so this is the ONLY place it shows |
187
189
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
188
190
  | Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
189
191
  | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,11 @@
21
21
  // • `flowiq bc --campaign`, which names the LOCAL .flowiq/campaigns/<x>.json
22
22
  // file and the `bc-<x>` contact tag, not a UTM.
23
23
  // `--raw-campaign` bypasses normalisation entirely when you genuinely need it.
24
+ //
25
+ // MIRROR: api/_shared/campaign-naming.js is a byte-identical copy of this file
26
+ // (the server normalises the tag for the dashboard's tracked links and for
27
+ // `bc send --button <url>`; the npm tarball cannot reach api/). Edit BOTH, or
28
+ // `npm test` fails on the parity check in campaign-naming.test.mjs.
24
29
 
25
30
  export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
26
31
 
@@ -162,3 +167,11 @@ export function resolveContent(input) {
162
167
  notes: value && value !== original ? [`content "${original}" → "${value}"`] : [],
163
168
  };
164
169
  }
170
+
171
+ /** A button label → utm_content: "CLICK HERE" → ClickHere, "Shop now" → ShopNow, "Plano" → Plano. */
172
+ export function contentFromLabel(label) {
173
+ const words = String(label ?? "").replace(/[^A-Za-z0-9]+/g, " ").trim().split(/\s+/).filter(Boolean);
174
+ return words
175
+ .map((w) => (/^[A-Z0-9]+$/.test(w) && w.length > 1 ? w[0] + w.slice(1).toLowerCase() : w[0].toUpperCase() + w.slice(1)))
176
+ .join("");
177
+ }
@@ -74,3 +74,16 @@ test("dateToken / isConforming", () => {
74
74
  assert.ok(!isConforming("SpringPromotion"));
75
75
  assert.ok(!isConforming("9Sep_Spring_Promotion"));
76
76
  });
77
+
78
+ // The server carries a byte-identical copy (api/_shared/campaign-naming.js) so the
79
+ // dashboard's tracked links and `bc send --button <url>` name campaigns exactly the
80
+ // way `links shorten` does. Skipped inside the npm tarball, where api/ does not exist.
81
+ test("api/_shared/campaign-naming.js is a byte-identical mirror", async (t) => {
82
+ const { readFileSync, existsSync } = await import("node:fs");
83
+ const { fileURLToPath } = await import("node:url");
84
+ const path = await import("node:path");
85
+ const here = path.dirname(fileURLToPath(import.meta.url));
86
+ const mirror = path.join(here, "..", "..", "api", "_shared", "campaign-naming.js");
87
+ if (!existsSync(mirror)) { t.skip("api/ not present (npm tarball)"); return; }
88
+ assert.equal(readFileSync(mirror, "utf8"), readFileSync(path.join(here, "campaign-naming.js"), "utf8"));
89
+ });
@@ -663,6 +663,101 @@ export function checkButtonParams(template, buttonParams) {
663
663
  return { aborts, warnings };
664
664
  }
665
665
 
666
+ // ---------------------------------------------------------------------------
667
+ // Tracked links from a PASTED url (v0.8.0, 18 Sep 2026)
668
+ // ---------------------------------------------------------------------------
669
+ // `--button param1=https://store.co.za/collections/new?ref=ig` — a real link
670
+ // instead of a pre-minted code. The server (api/cli/links.js `tracked_link`, the
671
+ // same engine the dashboard's send dialog uses) keeps their params, puts our
672
+ // utm_source / utm_medium on, names utm_campaign `Date_Campaign` (default: the
673
+ // template name, --link-campaign to override) and utm_content after the button,
674
+ // follows the link to where it lands, and mints the code on --commit. The dry
675
+ // run prints all of it first. `--keep-link` sends the link exactly as pasted
676
+ // (a plain forward, no tags). Only a button whose base is one of OUR short
677
+ // domains (`linklnk.io/{{1}}`) can take a full link; a client-domain button
678
+ // takes the part after the slash.
679
+ const OUR_SHORT_HOST_RE = /(^|\.)(linklnk\.io|chatcart\.io|yapi\.store)$/i;
680
+ const SHORT_LINK_RE = /^https?:\/\/(?:[a-z0-9-]+\.)*(?:linklnk\.io|chatcart\.io|yapi\.store)\/([A-Za-z0-9_-]{3,64})\/?$/i;
681
+
682
+ export function isHttpUrl(value) {
683
+ const v = String(value ?? "").trim();
684
+ if (!/^https?:\/\//i.test(v)) return false;
685
+ try { new URL(v); return true; } catch { return false; }
686
+ }
687
+
688
+ /** A whole short link pasted as a button value → its bare code (else null). */
689
+ export function codeFromShortLink(value) {
690
+ const m = String(value ?? "").trim().match(SHORT_LINK_RE);
691
+ return m ? m[1] : null;
692
+ }
693
+
694
+ /** Which --button values are pasted links, and whether their button can take one. */
695
+ export function planTrackedLinks(template, buttonParams) {
696
+ const plan = [];
697
+ const aborts = [];
698
+ const buttons = Array.isArray(template?.url_buttons) && template.url_buttons.length
699
+ ? template.url_buttons
700
+ : (template?.url_button?.present ? [{ param: "param1", url_base: template.url_button.url_base, text: template.url_button.text }] : []);
701
+ for (const [param, value] of Object.entries(buttonParams || {})) {
702
+ if (!isHttpUrl(value)) continue;
703
+ const btn = buttons.find((b) => b.param === param);
704
+ if (!btn) continue; // a value with no button is already a warning
705
+ let host = null;
706
+ try { host = new URL(String(btn.url_base || "").replace(/\{\{[^}]+\}\}/g, "x")).host; } catch { /* no host */ }
707
+ if (!host || !OUR_SHORT_HOST_RE.test(host)) {
708
+ aborts.push(`--button ${param} is a full link, but the template's "${btn.text ?? param}" button already points at ${host || "a fixed address"} — pass only the part after the slash`);
709
+ continue;
710
+ }
711
+ plan.push({ param, url: String(value).trim(), label: btn.text || param, host });
712
+ }
713
+ return { plan, aborts };
714
+ }
715
+
716
+ /** `--at "2026-08-05 09:00"` → `2026-08-05` for the campaign tag's date (else undefined). */
717
+ export function linkDateFromAt(at) {
718
+ const m = String(at ?? "").trim().match(/^(\d{4}-\d{2}-\d{2})/);
719
+ return m ? m[1] : undefined;
720
+ }
721
+
722
+ /** Today in THIS machine's time zone as YYYY-MM-DD — sent with every tracked
723
+ * link so the server (UTC) never dates a late-evening SAST send yesterday. */
724
+ export function localDateIso(d = new Date()) {
725
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
726
+ }
727
+
728
+ async function resolveTrackedButtons(orgId, plan, { campaign, dateIso, keep, dryRun }) {
729
+ const out = {};
730
+ console.log("");
731
+ console.log(dryRun ? "Tracked links — the pasted url(s) become short codes on --commit:" : "Tracked links — minting short codes:");
732
+ for (const p of plan) {
733
+ let resp;
734
+ try {
735
+ resp = await http.post("links", {
736
+ organization_id: orgId, action: "tracked_link", url: p.url,
737
+ campaign, content: p.label, mode: keep ? "keep" : "tracked", domain: p.host,
738
+ ...(dateIso ? { date: dateIso } : {}), dry_run: dryRun,
739
+ });
740
+ } catch (e) {
741
+ console.error(`ABORT — tracked link for ${p.param} failed: ${e.message}`);
742
+ if (e.body?.error) console.error(` ${e.body.error}`);
743
+ process.exit(1);
744
+ }
745
+ const r = resp.result || {};
746
+ if (!resp.success) { console.error(`ABORT — --button ${p.param} ${p.url}: ${r.note || r.state || "refused"}`); process.exit(1); }
747
+ const code = r.code || "<code-on-commit>";
748
+ console.log(` Button "${p.label}" (${p.param}): ${p.url}`);
749
+ console.log(` → ${p.host}/${code}${r.state === "reused" || r.state === "would_reuse" ? " (existing row — keeps its click history)" : ""}`);
750
+ if (resp.utm) console.log(` utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content} · utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium}`);
751
+ else console.log(" kept as pasted — no FlowIQ tags (clicks are counted; the sale will not show as WhatsApp)");
752
+ for (const rp of r.replaced || []) console.log(` ⚠ replaced ${rp.key}=${rp.from} with ${rp.to}`);
753
+ for (const n of r.notes || []) console.log(` ⚠ ${n}`);
754
+ if (r.full_url && r.full_url !== p.url) console.log(` final: ${r.full_url}`);
755
+ for (const n of resp.utm?.naming || []) console.log(` · ${n}`);
756
+ out[p.param] = code;
757
+ }
758
+ return out;
759
+ }
760
+
666
761
  // ---------------------------------------------------------------------------
667
762
  // CSV → python fan-out builders (v0.6.0). Exported for the test harness.
668
763
  // ---------------------------------------------------------------------------
@@ -1364,7 +1459,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1364
1459
  }
1365
1460
  const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
1366
1461
  const buttonParams = resolveButtonParams(opts.button, cfg);
1367
- const buttonLiteral = buttonParams.param1 ?? null;
1462
+ // A whole short link pasted as a value is just its code.
1463
+ for (const [k, v] of Object.entries(buttonParams)) {
1464
+ const code = codeFromShortLink(v);
1465
+ if (code) { buttonParams[k] = code; console.log(`ℹ --button ${k}: using the code ${code} from the short link you pasted`); }
1466
+ }
1467
+ let buttonLiteral = buttonParams.param1 ?? null;
1368
1468
 
1369
1469
  // introspect + validate params arithmetically (the #132000 guard, pre-send)
1370
1470
  let intro;
@@ -1406,8 +1506,22 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
1406
1506
  const buttonCheck = checkButtonParams(template, buttonParams);
1407
1507
  for (const w of buttonCheck.warnings) console.log(`⚠ ${w}`);
1408
1508
  aborts.push(...buttonCheck.aborts);
1509
+ const linkPlan = planTrackedLinks(template, buttonParams);
1510
+ aborts.push(...linkPlan.aborts);
1409
1511
  if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
1410
1512
 
1513
+ // Pasted links → tracked short codes (dry run previews, --commit mints).
1514
+ if (linkPlan.plan.length) {
1515
+ const codes = await resolveTrackedButtons(orgId, linkPlan.plan, {
1516
+ campaign: opts.linkCampaign || templateName,
1517
+ dateIso: linkDateFromAt(opts.at) || localDateIso(),
1518
+ keep: !!opts.keepLink,
1519
+ dryRun: !commitStage,
1520
+ });
1521
+ Object.assign(buttonParams, codes);
1522
+ buttonLiteral = buttonParams.param1 ?? null;
1523
+ }
1524
+
1411
1525
  // SCHEDULE (--at): validation above has already run, so a scheduled send is
1412
1526
  // gated by exactly the same checks as an immediate one (APPROVED, positional,
1413
1527
  // param arithmetic, header-media type). Instead of sending we queue the send
@@ -1,6 +1,7 @@
1
1
  // `flowiq templates pull <org_id>` / `list` — read WhatsApp templates from Meta.
2
2
  // `flowiq templates create <org> --request-file f.json` / `status` — §12 (create).
3
- // Pull is read-only via /cli/templates; create proxies /cli/meta-templates.
3
+ // `flowiq templates attempts <org>` — the submission ledger, failures included.
4
+ // Pull is read-only via /cli/templates; create/status/show/attempts use /cli/meta-templates.
4
5
 
5
6
  import fs from "node:fs/promises";
6
7
  import path from "node:path";
@@ -257,7 +258,10 @@ function renderDraft(d, opts) {
257
258
  }
258
259
  }
259
260
 
260
- const cards = Array.isArray(d.carouselCards) ? d.carouselCards : [];
261
+ // A standard-mode draft still carries the dialog's two DEFAULT cards in
262
+ // carouselCards (the form snapshot is saved whole), so only carousel mode
263
+ // renders them — otherwise `show` invents a 2-card carousel that never existed.
264
+ const cards = d.templateMode === "carousel" && Array.isArray(d.carouselCards) ? d.carouselCards : [];
261
265
  if (cards.length) {
262
266
  console.log(`\n CAROUSEL — ${cards.length} card(s)`);
263
267
  cards.forEach((c, i) => {
@@ -355,3 +359,64 @@ export async function show(orgId, name, opts = {}) {
355
359
  if (td.carousel?.cards) console.log(` carousel: ${td.carousel.cards.length} card(s) with stored header media`);
356
360
  console.log(`\n For the message structure as Meta holds it: flowiq templates pull ${orgId}`);
357
361
  }
362
+
363
+ // ── attempts ────────────────────────────────────────────────────────────────
364
+ // The submission ledger: one row per create-meta-template call (dashboard or
365
+ // CLI), including the ones our own guards or Meta refused. A FAILED submission
366
+ // writes no `templates` row and never reaches Meta, so until this existed the
367
+ // only record was the edge fn's logs — gone after 24 h. Yoga Life's two 10:11
368
+ // failures on 21 Sep 2026 were recoverable that day and would not have been the next.
369
+ function sast(iso) {
370
+ if (!iso) return "?";
371
+ try {
372
+ return new Date(iso).toLocaleString("en-ZA", {
373
+ timeZone: "Africa/Johannesburg", year: "numeric", month: "2-digit", day: "2-digit",
374
+ hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
375
+ }).replace(",", "");
376
+ } catch { return iso; }
377
+ }
378
+
379
+ export async function attempts(orgId, opts = {}) {
380
+ if (!UUID_RE.test(orgId)) {
381
+ console.error(`Error: "${orgId}" is not a valid organization UUID. Find it with: flowiq org list <name>`);
382
+ process.exit(1);
383
+ }
384
+ let resp;
385
+ try {
386
+ resp = await http.get("meta-templates", {
387
+ organization_id: orgId,
388
+ attempts: "1",
389
+ failed: opts.failed ? "1" : undefined,
390
+ limit: opts.limit ? String(opts.limit) : undefined,
391
+ name: opts.name,
392
+ });
393
+ } catch (e) {
394
+ console.error(`Attempts lookup failed: ${e.message}`);
395
+ process.exit(1);
396
+ }
397
+ if (!("attempts" in (resp || {}))) {
398
+ console.error("The API did not return a submission ledger — api/cli/meta-templates.js on the server is older than this CLI (needs the ?attempts=1 branch, CLI v0.8.1). Retry once the Vercel deploy of origin/main has landed.");
399
+ process.exit(1);
400
+ }
401
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
402
+
403
+ const rows = resp.attempts || [];
404
+ if (!rows.length) {
405
+ console.log(opts.failed ? "(no failed submissions on record for this org)" : "(no template submissions on record for this org — the ledger started 21 Sep 2026)");
406
+ return;
407
+ }
408
+ console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
409
+ for (const a of rows) {
410
+ const mark = a.outcome === "submitted" ? "✓" : "✗";
411
+ const who = a.source ? ` via ${a.source}` : "";
412
+ console.log(`\n ${mark} ${sast(a.created_at)} ${a.template_name || "(unnamed)"}${a.category ? ` [${a.category}]` : ""}${who}`);
413
+ if (a.outcome === "submitted") {
414
+ console.log(` submitted to Meta — id ${a.meta_template_id || "?"}, status at submit ${a.meta_status || "?"}`);
415
+ } else {
416
+ console.log(` FAILED (HTTP ${a.http_status ?? "?"}) ${a.error || ""}`);
417
+ if (a.message && a.message !== a.error) console.log(` ${a.message}`);
418
+ if (a.meta_error?.error_user_msg && a.meta_error.error_user_msg !== a.message) console.log(` Meta: ${a.meta_error.error_user_msg}`);
419
+ }
420
+ }
421
+ console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
422
+ }
package/src/index.js CHANGED
@@ -274,7 +274,7 @@ export function run(argv) {
274
274
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
275
275
  const templates = program.command("templates")
276
276
  .alias("tpl")
277
- .description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status)");
277
+ .description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status); attempts = the submission ledger, failures included");
278
278
  templates.command("pull <organization_id>")
279
279
  .description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
280
280
  .action((orgId) => templatesCmd.pull(orgId));
@@ -294,6 +294,13 @@ export function run(argv) {
294
294
  .option("--raw", "also print the raw body text (escaped), so invisible whitespace is visible")
295
295
  .option("--json", "raw JSON row output")
296
296
  .action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
297
+ templates.command("attempts <organization_id>")
298
+ .description("The submission ledger — every create call, dashboard or CLI, INCLUDING the ones that failed and why (a failed submit writes no template row and never reaches Meta)")
299
+ .option("--failed", "only the failed submissions")
300
+ .option("--name <substr>", "filter by template name substring")
301
+ .option("--limit <n>", "rows to return (default 20, max 100)")
302
+ .option("--json", "raw JSON output")
303
+ .action((orgId, opts) => templatesCmd.attempts(orgId, opts));
297
304
 
298
305
  // org (read-only org summary for the prompt-builder skill, creds stripped)
299
306
  const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
@@ -620,7 +627,9 @@ export function run(argv) {
620
627
  .option("--csv <file>", "path to the recipients CSV (per-row values)")
621
628
  .option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
622
629
  .option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
623
- .option("--button <k=v>", "with --tag: dynamic URL button value, repeatable for a second button, e.g. --button param1=<code> --button param2=<code>", broadcastCmd.collectKV, {})
630
+ .option("--button <k=v>", "with --tag: dynamic URL button value, repeatable for a second button, e.g. --button param1=<code> --button param2=<code>. v0.8.0: paste the REAL link instead (--button param1=https://store.co.za/collections/new) and it becomes a tracked short code on --commit", broadcastCmd.collectKV, {})
631
+ .option("--link-campaign <name>", "with a pasted --button link: the utm_campaign, normalised to Date_Campaign (default: the template name, e.g. new_arrivals_v3 → 18Sep_NewArrivalsV3)")
632
+ .option("--keep-link", "with a pasted --button link: send it exactly as pasted — a plain forward, no FlowIQ utm tags (clicks are counted; the sale will not show as WhatsApp)")
624
633
  .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
625
634
  .option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
626
635
  .option("--card-media <url>", "with --tag, carousel templates: card image/video URL in card order (repeatable — one per card; default: each card's stored template image)", (v, acc) => (acc || []).concat([v]), [])
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { isHttpUrl, codeFromShortLink, planTrackedLinks, linkDateFromAt, localDateIso } from "./commands/broadcast.js";
4
+
5
+ const ours = { dynamic_url_buttons: 2, url_button: { present: true, url_base: "https://linklnk.io/{{1}}", text: "Shop" },
6
+ url_buttons: [{ param: "param1", url_base: "https://linklnk.io/{{1}}", text: "Shop" }, { param: "param2", url_base: "https://barkyn.linklnk.io/{{1}}", text: "Plano" }] };
7
+ const theirs = { dynamic_url_buttons: 1, url_button: { present: true, url_base: "https://store.co.za/{{1}}", text: "Shop" },
8
+ url_buttons: [{ param: "param1", url_base: "https://store.co.za/{{1}}", text: "Shop" }] };
9
+
10
+ test("a pasted link is recognised, a code or a path is not", () => {
11
+ assert.equal(isHttpUrl("https://store.co.za/collections/new?ref=ig"), true);
12
+ assert.equal(isHttpUrl("aB3xY9"), false);
13
+ assert.equal(isHttpUrl("collections/new"), false);
14
+ assert.equal(isHttpUrl("store.co.za/collections/new"), false);
15
+ });
16
+
17
+ test("a whole short link pasted as a value gives back its code", () => {
18
+ assert.equal(codeFromShortLink("https://linklnk.io/aB3xY9"), "aB3xY9");
19
+ assert.equal(codeFromShortLink("https://barkyn.linklnk.io/pV2zqM/"), "pV2zqM");
20
+ assert.equal(codeFromShortLink("https://store.co.za/aB3xY9"), null);
21
+ assert.equal(codeFromShortLink("aB3xY9"), null);
22
+ });
23
+
24
+ test("links on our-domain buttons are planned, codes are left alone", () => {
25
+ const { plan, aborts } = planTrackedLinks(ours, { param1: "https://store.co.za/a?ref=ig", param2: "abc123" });
26
+ assert.deepEqual(aborts, []);
27
+ assert.deepEqual(plan, [{ param: "param1", url: "https://store.co.za/a?ref=ig", label: "Shop", host: "linklnk.io" }]);
28
+ });
29
+
30
+ test("a subdomain of ours counts as ours", () => {
31
+ const { plan } = planTrackedLinks(ours, { param1: "x", param2: "https://barkyn.com/plan" });
32
+ assert.equal(plan[0].host, "barkyn.linklnk.io");
33
+ });
34
+
35
+ test("a full link on a client-domain button aborts with the reason", () => {
36
+ const { plan, aborts } = planTrackedLinks(theirs, { param1: "https://store.co.za/collections/new" });
37
+ assert.deepEqual(plan, []);
38
+ assert.match(aborts[0], /already points at store.co.za/);
39
+ assert.match(aborts[0], /part after the slash/);
40
+ });
41
+
42
+ test("older server shape (url_button only) still plans param1", () => {
43
+ const { plan } = planTrackedLinks({ url_button: ours.url_button }, { param1: "https://store.co.za/a" });
44
+ assert.equal(plan.length, 1);
45
+ assert.equal(plan[0].param, "param1");
46
+ });
47
+
48
+ test("--at gives the campaign tag its date", () => {
49
+ assert.equal(linkDateFromAt("2026-09-24 10:00"), "2026-09-24");
50
+ assert.equal(linkDateFromAt(undefined), undefined);
51
+ assert.equal(linkDateFromAt("tomorrow"), undefined);
52
+ });
53
+
54
+ test("the tag's date is the machine's local day, not UTC", () => {
55
+ assert.equal(localDateIso(new Date(2026, 8, 18, 0, 47)), "2026-09-18");
56
+ assert.match(localDateIso(), /^\d{4}-\d{2}-\d{2}$/);
57
+ });