@flowapt/flowiq-cli 0.7.5 → 0.9.0
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 +123 -6
- package/TEAM-GUIDE.md +9 -0
- package/package.json +1 -1
- package/src/campaign-naming.js +13 -0
- package/src/campaign-naming.test.mjs +13 -0
- package/src/commands/broadcast.js +115 -1
- package/src/commands/popups.js +367 -0
- package/src/commands/templates.js +67 -2
- package/src/index.js +68 -2
- package/src/tracked-link.test.mjs +57 -0
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
|
|
@@ -909,6 +930,7 @@ ids and prefixes — never a key.
|
|
|
909
930
|
| `agent-config` | config | before + after snapshot + the changed keys |
|
|
910
931
|
| `agents` / `org` / `meta-templates` | create | the created record / submitted request |
|
|
911
932
|
| `messaging-webhooks` / `webhooks` | push, reconnect | full before + after |
|
|
933
|
+
| `popups` | create / push / duplicate / delete / upload | **full before + after document** (popup + webhooks) |
|
|
912
934
|
| `pinboard` | push | full prior row + the new one |
|
|
913
935
|
| `agent-updates` | resolve | prior status + the client-facing note |
|
|
914
936
|
| `broadcast` | **send (committed)**, retry | the exact payload sent + the resulting broadcastId |
|
|
@@ -1003,6 +1025,85 @@ Push refuses an `auth_type` whose credentials are incomplete (e.g. `bearer`
|
|
|
1003
1025
|
with no `auth_config.token`), because that would make every delivery fail
|
|
1004
1026
|
closed — FlowIQ never falls back to an unauthenticated send.
|
|
1005
1027
|
|
|
1028
|
+
### Popups — `flowiq popups schema|list|pull|push|create|duplicate|activate|deactivate|live|delete|upload` (alias `pp`) (v0.9.0)
|
|
1029
|
+
|
|
1030
|
+
Every popup in FlowIQ — the ones built in the dashboard designer and the
|
|
1031
|
+
ones built here — is written through ONE endpoint, the popup service's
|
|
1032
|
+
`/api/popup-settings`. That endpoint owns the validator, the write rules
|
|
1033
|
+
and the schema; this CLI holds **no popup logic**. So a popup feature that
|
|
1034
|
+
ships in the popup service works from the terminal the same day, with no
|
|
1035
|
+
CLI release, and `flowiq popups schema` always prints what a popup may
|
|
1036
|
+
contain **today**.
|
|
1037
|
+
|
|
1038
|
+
```bash
|
|
1039
|
+
flowiq popups schema # what a document may contain (content, rules, design, webhooks); --json for enums + limits
|
|
1040
|
+
flowiq popups list <org_id> # every popup: id, active/live, views, signups, what a signup triggers
|
|
1041
|
+
flowiq popups pull <org_id> [popup_id] # → ./.flowiq/popups/<org-slug>/<name>-<id8>.json (popup + webhooks + etag); no id = all
|
|
1042
|
+
flowiq popups push <file> [--dry-run] # apply the document; refused (409) if someone edited it since the pull (--force overrides)
|
|
1043
|
+
flowiq popups create <org_id> <file> [--dry-run] # create from a document (a pulled file from ANY org works — ids are stripped); created INACTIVE
|
|
1044
|
+
flowiq popups duplicate <org_id> <popup_id> [--name …] [--from-org <id>] [--no-webhooks]
|
|
1045
|
+
flowiq popups activate|deactivate <org_id> <popup_id>
|
|
1046
|
+
flowiq popups live <org_id> <popup_id> [--off] # which ACTIVE popup the store embed shows (one per org; prints what it demoted)
|
|
1047
|
+
flowiq popups delete <org_id> <popup_id> [--confirm]
|
|
1048
|
+
flowiq popups upload <org_id> <popup_id> <file> --slot main | --font "Gilmer" [--weight 500] [--italic]
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
**The document.** One JSON file per popup:
|
|
1052
|
+
|
|
1053
|
+
```json
|
|
1054
|
+
{
|
|
1055
|
+
"organization_id": "…",
|
|
1056
|
+
"etag": "6939ff535be8f3f6",
|
|
1057
|
+
"popup": { "id": "…", "name": "…", "slug": "spin", "is_active": true,
|
|
1058
|
+
"content_settings": { "welcome": {…}, "success": {…}, "wheel": {…} },
|
|
1059
|
+
"style_settings": { "design": {…} },
|
|
1060
|
+
"rules_settings": { "when_to_show": {…}, "whom_to_show": {…}, … },
|
|
1061
|
+
"stats": { "sales_stats": false } },
|
|
1062
|
+
"webhooks": [ { "id": "…", "name": "Discount", "url": "…/functions/v1/send-discount-code-popup",
|
|
1063
|
+
"discount_config": { "enabled": true, "template_name": "welcome_v1", … } } ]
|
|
1064
|
+
}
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
- **A popup with no webhook sends NOTHING on signup.** The WhatsApp welcome +
|
|
1068
|
+
discount code is the `send-discount-code-popup` row; Klaviyo / Omnisend /
|
|
1069
|
+
Marsello / Passes / Flows are their own rows. `flowiq popups schema` lists
|
|
1070
|
+
every FlowIQ function with its URL and config keys. **One row per function
|
|
1071
|
+
per popup** — every row fires on every signup, so two rows = two messages;
|
|
1072
|
+
the server refuses a push that would create the duplicate.
|
|
1073
|
+
- **`push` is id-keyed for webhooks** and **MERGES integration configs**
|
|
1074
|
+
(`discount_config` / `pass_config` / `flows_config`) over what the row
|
|
1075
|
+
holds — a file that knows three keys can never wipe a fourth (`null`
|
|
1076
|
+
deletes a key on purpose). A webhook without an `id` but with a URL one
|
|
1077
|
+
existing row already has UPDATES that row. Webhooks missing from the file
|
|
1078
|
+
are left alone unless `--replace-webhooks --confirm-delete-webhooks`.
|
|
1079
|
+
- **`push` never changes which popup is live** (`currently_active_on_store`
|
|
1080
|
+
and `image_history` are not sent from the file). Use `popups live`.
|
|
1081
|
+
- **Validation is the server's** (the editor gets the same answers): answer
|
|
1082
|
+
keys (`choice_fields`, `questions`, deck cards, wheel segment ids) must
|
|
1083
|
+
match `[a-zA-Z0-9_-]{1,64}` or the submissions API drops them; an enabled
|
|
1084
|
+
wheel needs 2–12 wedges with at least one weight; an enabled teaser needs
|
|
1085
|
+
text; `size.width` is kept equal to `size.maxWidth`. Errors block, warnings
|
|
1086
|
+
ride back and are printed (`⚠`). Unknown keys are **stored with a
|
|
1087
|
+
warning**, never dropped — popups evolve faster than any allowlist.
|
|
1088
|
+
- **`create` and `duplicate` land INACTIVE** with no slug and zero stats;
|
|
1089
|
+
`activate` when checked. `duplicate` copies the webhooks too (`--from-org`
|
|
1090
|
+
copies a popup from another org, webhooks excluded — templates, lists and
|
|
1091
|
+
pass designs belong to the source client).
|
|
1092
|
+
- **Kill switch = `deactivate`** (`is_active=false`: store embed, pinned id
|
|
1093
|
+
and the `popup.flowapt.com/p/…` link all 404). `live --off` only unselects
|
|
1094
|
+
it — a store embed that pins its id still serves it while active.
|
|
1095
|
+
- **`delete` removes the signups, webhooks and delivery history with it**;
|
|
1096
|
+
a live popup or one with signups needs `--confirm`.
|
|
1097
|
+
- **`upload`** puts an image in the private `popups_images` bucket (1-year
|
|
1098
|
+
signed URL) or a font in the PUBLIC `popup_fonts` bucket, and prints the
|
|
1099
|
+
URL to paste into the design.
|
|
1100
|
+
- **Concurrency:** the file's `etag` is sent as `if_match`; if the popup was
|
|
1101
|
+
edited in the dashboard since the pull, push is refused with 409 — pull,
|
|
1102
|
+
re-apply, push. `--force` overwrites. Every push refreshes the file (new
|
|
1103
|
+
etag), and a rename moves it to its new file name.
|
|
1104
|
+
- **Audit:** create / push / duplicate / delete / upload are audited with the
|
|
1105
|
+
full before + after document (`flowiq audit <org> --endpoint popups`).
|
|
1106
|
+
|
|
1006
1107
|
### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
|
|
1007
1108
|
|
|
1008
1109
|
Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
|
|
@@ -1241,25 +1342,41 @@ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send,
|
|
|
1241
1342
|
(`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
|
|
1242
1343
|
(`flowiq audit --endpoint team-updates`).
|
|
1243
1344
|
|
|
1244
|
-
### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
|
|
1345
|
+
### WhatsApp templates — `flowiq templates pull|list|show|create|status|attempts` (alias `tpl`)
|
|
1245
1346
|
|
|
1246
1347
|
Read an org's live templates straight from Meta (read-only), render any single
|
|
1247
|
-
row **including an unsubmitted DRAFT**,
|
|
1248
|
-
`create-meta-template` edge function
|
|
1348
|
+
row **including an unsubmitted DRAFT**, submit new ones through the
|
|
1349
|
+
`create-meta-template` edge function, and read the **submission ledger** — every
|
|
1350
|
+
create call, including the ones that failed and why.
|
|
1249
1351
|
|
|
1250
1352
|
```bash
|
|
1251
1353
|
flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
|
|
1252
1354
|
flowiq templates status <organization_id> --name booking # poll approval
|
|
1253
1355
|
flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
|
|
1254
1356
|
flowiq templates create <organization_id> --request-file req.json
|
|
1357
|
+
flowiq templates attempts <organization_id> --failed # why a submit was refused (v0.8.1)
|
|
1255
1358
|
```
|
|
1256
1359
|
|
|
1360
|
+
**`attempts` is the only record of a FAILED submission (v0.8.1).** A refused
|
|
1361
|
+
submission writes no template row (so `status` cannot show it), never reaches
|
|
1362
|
+
Meta (so `pull` cannot either), and the server logs that used to be the only
|
|
1363
|
+
evidence expire after 24 hours. Every create call since 21 Sep 2026 — from the
|
|
1364
|
+
dashboard or this CLI — now writes a `template_submission_attempts` row, and
|
|
1365
|
+
`attempts` prints them newest first in SAST: `✓` submitted (Meta id + status at
|
|
1366
|
+
submit) or `✗` FAILED with the HTTP status, the short label and the sentence
|
|
1367
|
+
that names the cause, e.g. `HEADER format is DOCUMENT but the media at file_url
|
|
1368
|
+
is video/mp4 — point file_url at a document (pdf)`. That sentence is what to
|
|
1369
|
+
tell the client. `--failed` filters to refusals, `--name <substr>` to one
|
|
1370
|
+
template, `--limit <n>` (default 20, max 100), `--json` for the rows.
|
|
1371
|
+
|
|
1257
1372
|
**`show` is the only way to read a DRAFT from the terminal (v0.6.7).** A draft
|
|
1258
1373
|
never reaches Meta, so `templates pull` cannot see it and neither can the
|
|
1259
1374
|
broadcast introspector — before this, reviewing one meant opening the dialog.
|
|
1260
1375
|
`show` prints the body with its examples substituted (as the customer will read
|
|
1261
|
-
it), then
|
|
1262
|
-
|
|
1376
|
+
it), then — for a carousel draft only (v0.8.1; a standard draft used to print
|
|
1377
|
+
the dialog's two default cards as a phantom carousel) — every carousel card:
|
|
1378
|
+
media URL, source filename, whether the Meta asset handle is present, card
|
|
1379
|
+
body, and each button's resolved URL. `--raw`
|
|
1263
1380
|
also prints the escaped body so invisible whitespace is visible; `--json` gives
|
|
1264
1381
|
the row.
|
|
1265
1382
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -86,6 +86,13 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
86
86
|
| Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
87
87
|
| Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
88
88
|
| Make a keyword/button **hand the chat to a team or person** (assign in the inbox + email/WhatsApp them) | keyword action `{"type":"assign_chat","team_id":"…","assignee_user_id":"…","notify_member":true}` — see *Keywords* in `flowiq guide --reference`. Also in the dashboard (action type "Assign Chat") |
|
|
89
|
+
| **See a client's popups** and what each one sends on signup | `flowiq popups list <org_id>` — "sends to: NOTHING" means a signup goes nowhere; add a `send-discount-code-popup` webhook |
|
|
90
|
+
| **Change a popup's copy, wheel, rules or webhooks** | `flowiq popups pull <org_id> <popup_id>` → edit the JSON → `flowiq popups push <file> --dry-run` → `… push <file>`. Same rules as the dashboard (it is the same endpoint); if someone edited it in between you get a 409 — pull again |
|
|
91
|
+
| **What can a popup contain right now?** (wheel, deck, intro page, scratch card, questions…) | `flowiq popups schema` — served by the popup service, so it is never stale |
|
|
92
|
+
| **Build a client's popup from a proven one** | `flowiq popups duplicate <client_org> <popup_id> --from-org <source_org> --name "Client — welcome"`, then `pull` → edit → `push`, add its webhooks, `activate`, `live` |
|
|
93
|
+
| Copy a popup inside the same client (webhooks included) | `flowiq popups duplicate <org_id> <popup_id>` — lands inactive with zero stats |
|
|
94
|
+
| Switch a popup on / off / choose which one the store shows | `flowiq popups activate\|deactivate <org_id> <popup_id>` · `flowiq popups live <org_id> <popup_id>` (prints which popup it demoted) |
|
|
95
|
+
| Put a picture or the client's own font on a popup | `flowiq popups upload <org_id> <popup_id> ./hero.jpg --slot main` · `… ./Gilmer-Medium.otf --font Gilmer --weight 500` — paste the printed URL into the design, then push |
|
|
89
96
|
| **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
|
|
90
97
|
| See broadcast plans waiting for Flowapt review, across every client | `flowiq plans list --status pending_review`, then `flowiq plans show <plan_id>` for the copy, second message, buttons, creative and audience |
|
|
91
98
|
| See one client's broadcast plans | `flowiq plans list <org_id>` (open plans) or `flowiq plans list <org_id> --status all --since 2026-09-01` |
|
|
@@ -167,6 +174,7 @@ several.
|
|
|
167
174
|
| 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
175
|
| 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
176
|
| 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. |
|
|
177
|
+
| 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
178
|
| 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
179
|
| 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
180
|
| 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 +192,7 @@ several.
|
|
|
184
192
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
185
193
|
| **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
194
|
| 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 |
|
|
195
|
+
| **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
196
|
| Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
|
|
188
197
|
| Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
|
|
189
198
|
| 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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/campaign-naming.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// `flowiq popups …` — create, read, change, copy, switch on and delete popups.
|
|
2
|
+
//
|
|
3
|
+
// The CLI knows NOTHING about what a popup may contain. A popup is a JSON
|
|
4
|
+
// document; the popup service validates it and owns every write rule, and
|
|
5
|
+
// `flowiq popups schema` prints what it accepts TODAY. So a popup feature that
|
|
6
|
+
// ships in the popup service works from here the same day, with no CLI release.
|
|
7
|
+
//
|
|
8
|
+
// flowiq popups schema [--json]
|
|
9
|
+
// flowiq popups list <org_id> [--json]
|
|
10
|
+
// flowiq popups pull <org_id> [popup_id] (no id = every popup)
|
|
11
|
+
// flowiq popups push <file> [--dry-run] [--replace-webhooks] [--confirm-delete-webhooks] [--force]
|
|
12
|
+
// flowiq popups create <org_id> <file> [--dry-run]
|
|
13
|
+
// flowiq popups duplicate <org_id> <popup_id> [--name …] [--from-org <id>] [--no-webhooks]
|
|
14
|
+
// flowiq popups activate|deactivate <org_id> <popup_id>
|
|
15
|
+
// flowiq popups live <org_id> <popup_id> [--off]
|
|
16
|
+
// flowiq popups delete <org_id> <popup_id> [--confirm]
|
|
17
|
+
// flowiq popups upload <org_id> <popup_id> <file> --slot <name> | --font <family> [--weight n] [--italic]
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { http } from "../http.js";
|
|
22
|
+
|
|
23
|
+
const POPUPS_DIR = path.resolve(process.cwd(), ".flowiq", "popups");
|
|
24
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
|
|
26
|
+
// What `push` sends from the file. currently_active_on_store and
|
|
27
|
+
// image_history are deliberately NOT here: which popup a store shows is
|
|
28
|
+
// changed with `popups live`, never as a side effect of pushing an old file.
|
|
29
|
+
const PUSH_COLUMNS = ["name", "slug", "is_active", "content_settings", "style_settings", "rules_settings", "stats"];
|
|
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
|
+
function die(msg) {
|
|
37
|
+
console.error(msg);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireUuid(value, what) {
|
|
42
|
+
if (!UUID_RE.test(String(value || ""))) die(`Error: "${value}" is not a valid ${what} UUID.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fileExists(p) {
|
|
46
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A failed call: print the service's own errors + warnings, then exit. */
|
|
50
|
+
function fail(prefix, e) {
|
|
51
|
+
const body = e.body || {};
|
|
52
|
+
console.error(`${prefix}: ${e.message}`);
|
|
53
|
+
const errors = Array.isArray(body.errors) ? body.errors : [];
|
|
54
|
+
if (errors.length > 1) for (const err of errors.slice(1)) console.error(` also: ${err}`);
|
|
55
|
+
printWarnings(body.warnings);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printWarnings(warnings) {
|
|
60
|
+
if (!Array.isArray(warnings) || warnings.length === 0) return;
|
|
61
|
+
console.log(`\n ${warnings.length} warning${warnings.length === 1 ? "" : "s"}:`);
|
|
62
|
+
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function docPath(orgSlug, doc) {
|
|
66
|
+
const popup = doc.popup || {};
|
|
67
|
+
const base = `${slugify(popup.slug || popup.name, "popup")}-${String(popup.id || "").slice(0, 8)}`;
|
|
68
|
+
return path.join(POPUPS_DIR, orgSlug, `${base}.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function writeDoc(resp) {
|
|
72
|
+
const doc = resp.data;
|
|
73
|
+
const orgSlug = slugify(resp.organization_slug || resp.organization_name, doc.organization_id);
|
|
74
|
+
const filePath = docPath(orgSlug, doc);
|
|
75
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
76
|
+
const overwriting = await fileExists(filePath);
|
|
77
|
+
const onDisk = {
|
|
78
|
+
document_version: doc.document_version,
|
|
79
|
+
organization_id: doc.organization_id,
|
|
80
|
+
organization_name: resp.organization_name,
|
|
81
|
+
pulled_at: new Date().toISOString(),
|
|
82
|
+
etag: doc.etag,
|
|
83
|
+
popup: doc.popup,
|
|
84
|
+
webhooks: doc.webhooks,
|
|
85
|
+
};
|
|
86
|
+
await fs.writeFile(filePath, JSON.stringify(onDisk, null, 2) + "\n", "utf8");
|
|
87
|
+
return { filePath, overwriting };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describe(popup) {
|
|
91
|
+
const flags = [popup.is_active ? "active" : "inactive"];
|
|
92
|
+
if (popup.currently_active_on_store) flags.push("LIVE on store");
|
|
93
|
+
if (popup.slug) flags.push(`/${popup.slug}`);
|
|
94
|
+
return flags.join(" · ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export async function schema(opts = {}) {
|
|
100
|
+
let resp;
|
|
101
|
+
try { resp = await http.get("popups", { op: "schema" }); } catch (e) { fail("Schema failed", e); }
|
|
102
|
+
const s = resp.data;
|
|
103
|
+
if (opts.json) return console.log(JSON.stringify(s, null, 2));
|
|
104
|
+
|
|
105
|
+
console.log(`Popup document (version ${s.document_version}) — served by the popup service, so this is what it accepts TODAY.\n`);
|
|
106
|
+
console.log("content_settings:");
|
|
107
|
+
for (const [k, v] of Object.entries(s.content_settings)) {
|
|
108
|
+
console.log(` ${k}${v.required ? " (required)" : v.enable_flag ? ` (on when ${k}.${v.enable_flag} is true)` : ""}`);
|
|
109
|
+
console.log(` ${v.summary}`);
|
|
110
|
+
}
|
|
111
|
+
console.log("\nrules_settings:");
|
|
112
|
+
for (const [k, v] of Object.entries(s.rules_settings)) console.log(` ${k}${v.required ? " (required)" : ""}\n ${v.summary}`);
|
|
113
|
+
console.log("\nstyle_settings:");
|
|
114
|
+
for (const [k, v] of Object.entries(s.style_settings)) console.log(` ${k}\n ${v}`);
|
|
115
|
+
console.log("\nwebhooks (what a signup triggers — a popup with none sends nothing):");
|
|
116
|
+
for (const [slug, v] of Object.entries(s.webhooks)) console.log(` ${slug} → ${v.config_column}\n ${v.url}\n ${v.summary}`);
|
|
117
|
+
console.log("\nrules:");
|
|
118
|
+
for (const r of s.rules) console.log(` • ${r}`);
|
|
119
|
+
console.log("\n--json prints the whole schema, enums and limits included.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function list(orgId, opts = {}) {
|
|
123
|
+
requireUuid(orgId, "organization");
|
|
124
|
+
let resp;
|
|
125
|
+
try { resp = await http.get("popups", { op: "list", organization_id: orgId }); } catch (e) { fail("List failed", e); }
|
|
126
|
+
const popups = resp.data?.popups || [];
|
|
127
|
+
if (opts.json) return console.log(JSON.stringify(popups, null, 2));
|
|
128
|
+
|
|
129
|
+
console.log(`${resp.organization_name} (${orgId}) — ${popups.length} popup${popups.length === 1 ? "" : "s"}\n`);
|
|
130
|
+
for (const p of popups) {
|
|
131
|
+
console.log(` ${p.id} ${p.name}`);
|
|
132
|
+
console.log(` ${describe(p)} · ${p.total_impressions || 0} views · ${p.total_submissions || 0} signups`);
|
|
133
|
+
console.log(` sends to: ${p.webhook_functions?.length ? p.webhook_functions.join(", ") : "NOTHING (no webhooks)"}`);
|
|
134
|
+
}
|
|
135
|
+
if (popups.length) console.log(`\nflowiq popups pull ${orgId} <popup_id> writes one to ./.flowiq/popups/`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function pull(orgId, popupId) {
|
|
139
|
+
requireUuid(orgId, "organization");
|
|
140
|
+
let ids = [];
|
|
141
|
+
if (popupId) {
|
|
142
|
+
requireUuid(popupId, "popup");
|
|
143
|
+
ids = [popupId];
|
|
144
|
+
} else {
|
|
145
|
+
let resp;
|
|
146
|
+
try { resp = await http.get("popups", { op: "list", organization_id: orgId }); } catch (e) { fail("Pull failed", e); }
|
|
147
|
+
ids = (resp.data?.popups || []).map((p) => p.id);
|
|
148
|
+
if (ids.length === 0) return console.log(`${resp.organization_name} has no popups.`);
|
|
149
|
+
}
|
|
150
|
+
for (const id of ids) {
|
|
151
|
+
let resp;
|
|
152
|
+
try { resp = await http.get("popups", { op: "get", organization_id: orgId, popup_id: id }); } catch (e) { fail(`Pull failed (${id})`, e); }
|
|
153
|
+
const { filePath, overwriting } = await writeDoc(resp);
|
|
154
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
155
|
+
console.log(` ${resp.data.popup.name} — ${describe(resp.data.popup)} · ${resp.data.webhooks.length} webhook(s)`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function isFile(p) {
|
|
160
|
+
try { return (await fs.stat(p)).isFile(); } catch { return false; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readDoc(file) {
|
|
164
|
+
if (!file || !String(file).trim()) die("Error: give the popup document to read (a path, or a name under ./.flowiq/popups/).");
|
|
165
|
+
const candidates = path.isAbsolute(file) ? [file] : [path.resolve(file), path.join(POPUPS_DIR, file), path.join(POPUPS_DIR, `${file}.json`)];
|
|
166
|
+
for (const c of candidates) {
|
|
167
|
+
if (await isFile(c)) {
|
|
168
|
+
const raw = await fs.readFile(c, "utf8");
|
|
169
|
+
try { return { filePath: c, doc: JSON.parse(raw) }; } catch (e) { die(`Invalid JSON in ${c}: ${e.message}`); }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
die(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const pickPushColumns = (popup) => Object.fromEntries(PUSH_COLUMNS.filter((c) => popup[c] !== undefined).map((c) => [c, popup[c]]));
|
|
176
|
+
|
|
177
|
+
function printDiff(diff) {
|
|
178
|
+
const changed = diff?.changed || [];
|
|
179
|
+
const w = diff?.webhooks || {};
|
|
180
|
+
console.log(` changed: ${changed.length ? changed.join(", ") : "nothing"}`);
|
|
181
|
+
console.log(` webhooks: +${w.insert || 0} new · ~${w.update || 0} updated · -${w.delete || 0} removed`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function push(file, opts = {}) {
|
|
185
|
+
const { filePath, doc } = await readDoc(file);
|
|
186
|
+
if (!doc.organization_id || !UUID_RE.test(doc.organization_id)) die(`${filePath}: organization_id missing or invalid`);
|
|
187
|
+
if (!doc.popup || typeof doc.popup !== "object") die(`${filePath}: no "popup" object`);
|
|
188
|
+
if (!doc.popup.id) die(`${filePath}: popup.id is missing — this document has never been created. Use:\n flowiq popups create ${doc.organization_id} ${file}`);
|
|
189
|
+
|
|
190
|
+
const body = {
|
|
191
|
+
op: "push",
|
|
192
|
+
organization_id: doc.organization_id,
|
|
193
|
+
popup_id: doc.popup.id,
|
|
194
|
+
popup: pickPushColumns(doc.popup),
|
|
195
|
+
webhooks: Array.isArray(doc.webhooks) ? doc.webhooks : undefined,
|
|
196
|
+
webhooks_mode: opts.replaceWebhooks ? "replace" : undefined,
|
|
197
|
+
confirm_delete_webhooks: !!opts.confirmDeleteWebhooks,
|
|
198
|
+
if_match: doc.etag,
|
|
199
|
+
force: !!opts.force,
|
|
200
|
+
dry_run: !!opts.dryRun,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
let resp;
|
|
204
|
+
try { resp = await http.post("popups", body); } catch (e) {
|
|
205
|
+
if (e.status === 409) {
|
|
206
|
+
console.error(`Push refused: ${e.message}`);
|
|
207
|
+
console.error(`\n Pull it again (flowiq popups pull ${doc.organization_id} ${doc.popup.id}), re-apply your edit, and push — or --force to overwrite.`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
fail("Push failed", e);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log(`${opts.dryRun ? "DRY RUN" : "Pushed"} ${filePath}`);
|
|
214
|
+
console.log(` org: ${resp.organization_name} (${doc.organization_id})`);
|
|
215
|
+
console.log(` popup: ${doc.popup.name} (${doc.popup.id})`);
|
|
216
|
+
printDiff(resp.diff);
|
|
217
|
+
printWarnings(resp.warnings);
|
|
218
|
+
if (opts.dryRun) return console.log("\nDry run only — nothing was written. Re-run without --dry-run to apply.");
|
|
219
|
+
|
|
220
|
+
// The file now carries the NEW etag, so the next push from it is accepted.
|
|
221
|
+
const { filePath: refreshed } = await writeDoc(resp);
|
|
222
|
+
// A rename moves the document to its new file name; the old one would
|
|
223
|
+
// otherwise linger with a stale etag and push a stale name later.
|
|
224
|
+
if (path.resolve(refreshed) !== path.resolve(filePath)) await fs.rm(filePath, { force: true });
|
|
225
|
+
console.log(`\n✓ Saved. ${refreshed} refreshed from the server (new etag).`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function create(orgId, file, opts = {}) {
|
|
229
|
+
requireUuid(orgId, "organization");
|
|
230
|
+
const { filePath, doc } = await readDoc(file);
|
|
231
|
+
const popup = doc.popup && typeof doc.popup === "object" ? doc.popup : doc;
|
|
232
|
+
if (!popup.name) die(`${filePath}: popup.name is required`);
|
|
233
|
+
|
|
234
|
+
// A document pulled from another popup carries ids that belong to IT.
|
|
235
|
+
const webhooks = (Array.isArray(doc.webhooks) ? doc.webhooks : []).map(({ id, popup_id, total_attempts, successful_deliveries, failed_deliveries, last_success_at, last_failure_at, created_at, updated_at, ...rest }) => rest);
|
|
236
|
+
const body = {
|
|
237
|
+
op: "create",
|
|
238
|
+
organization_id: orgId,
|
|
239
|
+
popup: { ...pickPushColumns(popup), is_active: false },
|
|
240
|
+
webhooks,
|
|
241
|
+
dry_run: !!opts.dryRun,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let resp;
|
|
245
|
+
try { resp = await http.post("popups", body); } catch (e) { fail("Create failed", e); }
|
|
246
|
+
|
|
247
|
+
if (opts.dryRun) {
|
|
248
|
+
console.log(`DRY RUN — would create "${popup.name}" in ${resp.organization_name} with ${webhooks.length} webhook(s), inactive.`);
|
|
249
|
+
printWarnings(resp.warnings);
|
|
250
|
+
return console.log("\nNothing was written. Re-run without --dry-run to create it.");
|
|
251
|
+
}
|
|
252
|
+
const { filePath: written } = await writeDoc(resp);
|
|
253
|
+
const created = resp.data.popup;
|
|
254
|
+
console.log(`✓ Created "${created.name}" in ${resp.organization_name}`);
|
|
255
|
+
console.log(` id: ${created.id}`);
|
|
256
|
+
console.log(` state: INACTIVE — check it, then: flowiq popups activate ${orgId} ${created.id}`);
|
|
257
|
+
console.log(` webhooks: ${resp.data.webhooks.length}`);
|
|
258
|
+
console.log(` file: ${written}`);
|
|
259
|
+
printWarnings(resp.warnings);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function duplicate(orgId, popupId, opts = {}) {
|
|
263
|
+
requireUuid(orgId, "organization");
|
|
264
|
+
requireUuid(popupId, "popup");
|
|
265
|
+
if (opts.fromOrg) requireUuid(opts.fromOrg, "source organization");
|
|
266
|
+
let resp;
|
|
267
|
+
try {
|
|
268
|
+
resp = await http.post("popups", {
|
|
269
|
+
op: "duplicate",
|
|
270
|
+
organization_id: orgId,
|
|
271
|
+
popup_id: popupId,
|
|
272
|
+
name: opts.name,
|
|
273
|
+
source_organization_id: opts.fromOrg,
|
|
274
|
+
include_webhooks: opts.webhooks === false ? false : undefined,
|
|
275
|
+
});
|
|
276
|
+
} catch (e) { fail("Duplicate failed", e); }
|
|
277
|
+
const { filePath } = await writeDoc(resp);
|
|
278
|
+
const created = resp.data.popup;
|
|
279
|
+
console.log(`✓ Copied into ${resp.organization_name} as "${created.name}"`);
|
|
280
|
+
console.log(` id: ${created.id}`);
|
|
281
|
+
console.log(` state: INACTIVE, no link, zero stats`);
|
|
282
|
+
console.log(` webhooks: ${resp.data.webhooks.length} copied`);
|
|
283
|
+
console.log(` file: ${filePath}`);
|
|
284
|
+
printWarnings(resp.warnings);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function setColumns(orgId, popupId, popup, done) {
|
|
288
|
+
requireUuid(orgId, "organization");
|
|
289
|
+
requireUuid(popupId, "popup");
|
|
290
|
+
let resp;
|
|
291
|
+
try { resp = await http.post("popups", { op: "push", organization_id: orgId, popup_id: popupId, popup }); } catch (e) { fail("Update failed", e); }
|
|
292
|
+
console.log(`✓ ${resp.data.popup.name}: ${done(resp.data.popup)}`);
|
|
293
|
+
for (const d of resp.demoted || []) {
|
|
294
|
+
console.log(` ↳ demoted "${d.name}" (${d.id}) — it was the live popup. Put it back with:\n flowiq popups live ${orgId} ${d.id}`);
|
|
295
|
+
}
|
|
296
|
+
printWarnings(resp.warnings);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const activate = (orgId, popupId) =>
|
|
300
|
+
setColumns(orgId, popupId, { is_active: true }, () => "ACTIVE — it can now be served and accept signups.");
|
|
301
|
+
|
|
302
|
+
export const deactivate = (orgId, popupId) =>
|
|
303
|
+
setColumns(orgId, popupId, { is_active: false }, () => "INACTIVE — it is served nowhere (store embed, pinned id and its link all 404).");
|
|
304
|
+
|
|
305
|
+
export const live = (orgId, popupId, opts = {}) =>
|
|
306
|
+
setColumns(orgId, popupId, { currently_active_on_store: !opts.off }, (p) =>
|
|
307
|
+
p.currently_active_on_store
|
|
308
|
+
? `is now the popup this organization's store embed shows (any other live popup was demoted).${p.is_active ? "" : " ⚠ It is INACTIVE, so nothing will show until you activate it."}`
|
|
309
|
+
: "is no longer selected as the store's live popup. NOTE: a store embed that pins this popup's id still serves it while it is active — deactivate it to take it down."
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
export async function remove(orgId, popupId, opts = {}) {
|
|
313
|
+
requireUuid(orgId, "organization");
|
|
314
|
+
requireUuid(popupId, "popup");
|
|
315
|
+
try {
|
|
316
|
+
await http.post("popups", { op: "delete", organization_id: orgId, popup_id: popupId, confirm: !!opts.confirm });
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (e.status === 409) {
|
|
319
|
+
console.error(`Delete refused: ${e.message}`);
|
|
320
|
+
console.error("\n Deleting a popup deletes its signups, webhooks and delivery history with it. Re-run with --confirm if that is what you want.");
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
fail("Delete failed", e);
|
|
324
|
+
}
|
|
325
|
+
console.log(`✓ Deleted popup ${popupId}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const CONTENT_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", svg: "image/svg+xml", avif: "image/avif", otf: "font/otf", ttf: "font/ttf", woff: "font/woff", woff2: "font/woff2" };
|
|
329
|
+
|
|
330
|
+
export async function upload(orgId, popupId, file, opts = {}) {
|
|
331
|
+
requireUuid(orgId, "organization");
|
|
332
|
+
requireUuid(popupId, "popup");
|
|
333
|
+
const isFont = !!opts.font;
|
|
334
|
+
if (!isFont && !opts.slot) die("Error: give --slot <name> for an image (main, main-mobile, textimage, success-main, intro-logo, deck-<key>…) or --font <family> for a font.");
|
|
335
|
+
const abs = path.resolve(file);
|
|
336
|
+
if (!(await fileExists(abs))) die(`File not found: ${abs}`);
|
|
337
|
+
const ext = path.extname(abs).replace(/^\./, "").toLowerCase();
|
|
338
|
+
const target = {
|
|
339
|
+
organization_id: orgId,
|
|
340
|
+
popup_id: popupId,
|
|
341
|
+
kind: isFont ? "font" : "image",
|
|
342
|
+
slot: opts.slot,
|
|
343
|
+
family: opts.font,
|
|
344
|
+
weight: opts.weight ? Number(opts.weight) : undefined,
|
|
345
|
+
style: opts.italic ? "italic" : undefined,
|
|
346
|
+
ext,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
let ticket;
|
|
350
|
+
try { ticket = await http.post("popups", { op: "upload-url", ...target }); } catch (e) { fail("Upload failed", e); }
|
|
351
|
+
|
|
352
|
+
const bytes = await fs.readFile(abs);
|
|
353
|
+
const put = await fetch(ticket.data.signed_url, {
|
|
354
|
+
method: "PUT",
|
|
355
|
+
headers: { "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", "x-upsert": "true" },
|
|
356
|
+
body: bytes,
|
|
357
|
+
});
|
|
358
|
+
if (!put.ok) die(`Upload failed: storage answered HTTP ${put.status} ${(await put.text()).slice(0, 200)}`);
|
|
359
|
+
|
|
360
|
+
let ref;
|
|
361
|
+
try { ref = await http.post("popups", { op: "file-url", ...target }); } catch (e) { fail("Upload succeeded but the URL lookup failed", e); }
|
|
362
|
+
console.log(`✓ Uploaded ${path.basename(abs)} (${Math.round(bytes.length / 1024)} KB) → ${ref.data.bucket}/${ref.data.path}`);
|
|
363
|
+
console.log(`\n${ref.data.url}\n`);
|
|
364
|
+
console.log(isFont
|
|
365
|
+
? `Reference it in style_settings.design.customFonts: [{ "family": "${opts.font}", "faces": [{ "weight": ${target.weight || 400}, "style": "${target.style || "normal"}", "url": "<the URL above>" }] }]`
|
|
366
|
+
: "Paste that URL where the design wants the image (e.g. style_settings.design.image.url), then flowiq popups push.");
|
|
367
|
+
}
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
@@ -11,6 +11,7 @@ import * as questionnairesCmd from "./commands/questionnaires.js";
|
|
|
11
11
|
import * as messagesCmd from "./commands/messages.js";
|
|
12
12
|
import * as fineTuningCmd from "./commands/fine-tuning.js";
|
|
13
13
|
import * as messagingWebhooksCmd from "./commands/messaging-webhooks.js";
|
|
14
|
+
import * as popupsCmd from "./commands/popups.js";
|
|
14
15
|
import * as webhooksCmd from "./commands/webhooks.js";
|
|
15
16
|
import * as flowmodCmd from "./commands/flowmod.js";
|
|
16
17
|
import * as groupsCmd from "./commands/groups.js";
|
|
@@ -137,6 +138,62 @@ export function run(argv) {
|
|
|
137
138
|
.option("--dry-run", "preview the diff without writing anything")
|
|
138
139
|
.action((id, opts) => messagingWebhooksCmd.push(id, opts));
|
|
139
140
|
|
|
141
|
+
// popups — the popup service's /api/popup-settings is the ONE home for popup
|
|
142
|
+
// writes (editor + CLI). This CLI holds no popup logic: `schema` reads what a
|
|
143
|
+
// popup may contain from the server, so new popup features need no release.
|
|
144
|
+
const popups = program.command("popups")
|
|
145
|
+
.alias("pp")
|
|
146
|
+
.description("Popups: list / pull / push / create / duplicate / activate / live / delete / upload — one JSON document per popup, validated by the popup service");
|
|
147
|
+
popups.command("schema")
|
|
148
|
+
.description("What a popup document may contain TODAY (content, rules, design, webhooks) — served by the popup service, never stale")
|
|
149
|
+
.option("--json", "print the full schema as JSON (enums + limits included)")
|
|
150
|
+
.action((opts) => popupsCmd.schema(opts));
|
|
151
|
+
popups.command("list <organization_id>")
|
|
152
|
+
.description("Every popup of an org: id, state, views, signups, and what a signup triggers")
|
|
153
|
+
.option("--json", "print as JSON")
|
|
154
|
+
.action((orgId, opts) => popupsCmd.list(orgId, opts));
|
|
155
|
+
popups.command("pull <organization_id> [popup_id]")
|
|
156
|
+
.description("Write a popup's full document (popup + webhooks + etag) to ./.flowiq/popups/<org>/; no popup_id = every popup")
|
|
157
|
+
.action((orgId, popupId) => popupsCmd.pull(orgId, popupId));
|
|
158
|
+
popups.command("push <file>")
|
|
159
|
+
.description("Apply a pulled document back: id-keyed webhook upsert, integration configs MERGED, refused if someone else edited it since the pull")
|
|
160
|
+
.option("--dry-run", "show what would change without writing")
|
|
161
|
+
.option("--replace-webhooks", "webhooks not in the file are REMOVED (needs --confirm-delete-webhooks)")
|
|
162
|
+
.option("--confirm-delete-webhooks", "allow the push to remove webhooks")
|
|
163
|
+
.option("--force", "overwrite even if the popup changed since the pull")
|
|
164
|
+
.action((file, opts) => popupsCmd.push(file, opts));
|
|
165
|
+
popups.command("create <organization_id> <file>")
|
|
166
|
+
.description("Create a popup from a JSON document (a pulled file from ANY org works — ids are stripped); created INACTIVE")
|
|
167
|
+
.option("--dry-run", "validate only")
|
|
168
|
+
.action((orgId, file, opts) => popupsCmd.create(orgId, file, opts));
|
|
169
|
+
popups.command("duplicate <organization_id> <popup_id>")
|
|
170
|
+
.description("Copy a popup (design, content, behaviour AND webhooks) — inactive, no link, zero stats")
|
|
171
|
+
.option("--name <name>", "name for the copy (default: \"<name> (copy)\")")
|
|
172
|
+
.option("--from-org <organization_id>", "copy a popup that lives in ANOTHER org (webhooks are not copied across orgs)")
|
|
173
|
+
.option("--no-webhooks", "do not copy the webhooks")
|
|
174
|
+
.action((orgId, popupId, opts) => popupsCmd.duplicate(orgId, popupId, opts));
|
|
175
|
+
popups.command("activate <organization_id> <popup_id>")
|
|
176
|
+
.description("Switch a popup ON (is_active) — the real on/off switch")
|
|
177
|
+
.action((orgId, popupId) => popupsCmd.activate(orgId, popupId));
|
|
178
|
+
popups.command("deactivate <organization_id> <popup_id>")
|
|
179
|
+
.description("Switch a popup OFF everywhere (store, pinned id and its link)")
|
|
180
|
+
.action((orgId, popupId) => popupsCmd.deactivate(orgId, popupId));
|
|
181
|
+
popups.command("live <organization_id> <popup_id>")
|
|
182
|
+
.description("Make this the popup the org's store embed shows (one per org; others are demoted)")
|
|
183
|
+
.option("--off", "unselect it (NOT a kill switch — use deactivate for that)")
|
|
184
|
+
.action((orgId, popupId, opts) => popupsCmd.live(orgId, popupId, opts));
|
|
185
|
+
popups.command("delete <organization_id> <popup_id>")
|
|
186
|
+
.description("Delete a popup and its signups; a live popup or one with signups needs --confirm")
|
|
187
|
+
.option("--confirm", "delete even if live or it has signups")
|
|
188
|
+
.action((orgId, popupId, opts) => popupsCmd.remove(orgId, popupId, opts));
|
|
189
|
+
popups.command("upload <organization_id> <popup_id> <file>")
|
|
190
|
+
.description("Upload an image (--slot) or a custom font (--font) for a popup and print the URL to put in its design")
|
|
191
|
+
.option("--slot <name>", "image slot: main, main-mobile, textimage, textimage-mobile, success-main, success-mobile, intro-logo, deck-<key>…")
|
|
192
|
+
.option("--font <family>", "font family name (uploads to the public popup_fonts bucket)")
|
|
193
|
+
.option("--weight <n>", "font weight (default 400)")
|
|
194
|
+
.option("--italic", "italic face")
|
|
195
|
+
.action((orgId, popupId, file, opts) => popupsCmd.upload(orgId, popupId, file, opts));
|
|
196
|
+
|
|
140
197
|
// webhooks (Shopify/WooCommerce platform webhooks; auto-detects platform + ip_whitelist proxy)
|
|
141
198
|
const wh = program.command("webhooks")
|
|
142
199
|
.alias("wh")
|
|
@@ -274,7 +331,7 @@ export function run(argv) {
|
|
|
274
331
|
// templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
|
|
275
332
|
const templates = program.command("templates")
|
|
276
333
|
.alias("tpl")
|
|
277
|
-
.description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status)");
|
|
334
|
+
.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
335
|
templates.command("pull <organization_id>")
|
|
279
336
|
.description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
|
|
280
337
|
.action((orgId) => templatesCmd.pull(orgId));
|
|
@@ -294,6 +351,13 @@ export function run(argv) {
|
|
|
294
351
|
.option("--raw", "also print the raw body text (escaped), so invisible whitespace is visible")
|
|
295
352
|
.option("--json", "raw JSON row output")
|
|
296
353
|
.action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
|
|
354
|
+
templates.command("attempts <organization_id>")
|
|
355
|
+
.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)")
|
|
356
|
+
.option("--failed", "only the failed submissions")
|
|
357
|
+
.option("--name <substr>", "filter by template name substring")
|
|
358
|
+
.option("--limit <n>", "rows to return (default 20, max 100)")
|
|
359
|
+
.option("--json", "raw JSON output")
|
|
360
|
+
.action((orgId, opts) => templatesCmd.attempts(orgId, opts));
|
|
297
361
|
|
|
298
362
|
// org (read-only org summary for the prompt-builder skill, creds stripped)
|
|
299
363
|
const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
|
|
@@ -620,7 +684,9 @@ export function run(argv) {
|
|
|
620
684
|
.option("--csv <file>", "path to the recipients CSV (per-row values)")
|
|
621
685
|
.option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
|
|
622
686
|
.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
|
|
687
|
+
.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, {})
|
|
688
|
+
.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)")
|
|
689
|
+
.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
690
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
625
691
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
626
692
|
.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
|
+
});
|