@flowapt/flowiq-cli 0.8.1 → 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 +80 -0
- package/TEAM-GUIDE.md +7 -0
- package/package.json +1 -1
- package/src/commands/popups.js +367 -0
- package/src/index.js +57 -0
package/README.md
CHANGED
|
@@ -930,6 +930,7 @@ ids and prefixes — never a key.
|
|
|
930
930
|
| `agent-config` | config | before + after snapshot + the changed keys |
|
|
931
931
|
| `agents` / `org` / `meta-templates` | create | the created record / submitted request |
|
|
932
932
|
| `messaging-webhooks` / `webhooks` | push, reconnect | full before + after |
|
|
933
|
+
| `popups` | create / push / duplicate / delete / upload | **full before + after document** (popup + webhooks) |
|
|
933
934
|
| `pinboard` | push | full prior row + the new one |
|
|
934
935
|
| `agent-updates` | resolve | prior status + the client-facing note |
|
|
935
936
|
| `broadcast` | **send (committed)**, retry | the exact payload sent + the resulting broadcastId |
|
|
@@ -1024,6 +1025,85 @@ Push refuses an `auth_type` whose credentials are incomplete (e.g. `bearer`
|
|
|
1024
1025
|
with no `auth_config.token`), because that would make every delivery fail
|
|
1025
1026
|
closed — FlowIQ never falls back to an unauthenticated send.
|
|
1026
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
|
+
|
|
1027
1107
|
### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
|
|
1028
1108
|
|
|
1029
1109
|
Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
|
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` |
|
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": {
|
|
@@ -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
|
+
}
|
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")
|