@flowapt/flowiq-cli 0.6.7 → 0.7.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.
@@ -0,0 +1,76 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveCampaign, resolveContent, dateToken, parseDateToken, isConforming, pascal } from "./campaign-naming.js";
4
+
5
+ const NOW = new Date(2026, 8, 9); // 9 Sep 2026, local
6
+
7
+ test("Matt's worked example", () => {
8
+ const r = resolveCampaign("Spring Promotion", { now: NOW });
9
+ assert.equal(r.value, "9Sep_SpringPromotion");
10
+ assert.ok(r.changed);
11
+ });
12
+
13
+ test("already correct is left alone and reports no change", () => {
14
+ const r = resolveCampaign("9Sep_SpringPromotion", { now: NOW });
15
+ assert.equal(r.value, "9Sep_SpringPromotion");
16
+ assert.equal(r.changed, false);
17
+ assert.deepEqual(r.notes, []);
18
+ });
19
+
20
+ test("the house habit: fused date, no underscore (924 tags in 12 months)", () => {
21
+ assert.equal(resolveCampaign("10JunFathersDay", { now: NOW }).value, "10Jun_FathersDay");
22
+ });
23
+
24
+ test("Sept is corrected to Sep, and the date is kept", () => {
25
+ const r = resolveCampaign("5Sept_BraaiDayOffer", { now: NOW });
26
+ assert.equal(r.value, "5Sep_BraaiDayOffer");
27
+ assert.ok(r.notes.some((n) => n.includes("Sept")));
28
+ });
29
+
30
+ test("leading zero is dropped", () => {
31
+ assert.equal(resolveCampaign("09Sep_Foo", { now: NOW }).value, "9Sep_Foo");
32
+ });
33
+
34
+ test("--date overrides a send scheduled for another day", () => {
35
+ assert.equal(resolveCampaign("Heritage Day", { date: "2026-09-24", now: NOW }).value, "24Sep_HeritageDay");
36
+ assert.equal(resolveCampaign("24Sep_HeritageDay", { date: "25Sep", now: NOW }).value, "25Sep_HeritageDay");
37
+ });
38
+
39
+ test("--date accepts SA d/m/yyyy and long month names", () => {
40
+ assert.equal(parseDateToken("24/09/2026"), "24Sep");
41
+ assert.equal(parseDateToken("24 September"), "24Sep");
42
+ assert.equal(parseDateToken("2026-09-24"), "24Sep");
43
+ assert.equal(parseDateToken("banana"), null);
44
+ assert.equal(parseDateToken("24/13/2026"), null);
45
+ });
46
+
47
+ test("separators and casing all collapse to PascalCase", () => {
48
+ assert.equal(pascal("spring-promotion"), "SpringPromotion");
49
+ assert.equal(pascal("spring_promotion"), "SpringPromotion");
50
+ assert.equal(pascal(" spring promotion "), "SpringPromotion");
51
+ assert.equal(pascal("R500 promo"), "R500Promo");
52
+ });
53
+
54
+ test("an acronym keeps its case", () => {
55
+ assert.equal(resolveCampaign("CTWA Launch", { now: NOW }).value, "9Sep_CTWALaunch");
56
+ });
57
+
58
+ test("empty and title-less inputs error rather than minting rubbish", () => {
59
+ assert.ok(resolveCampaign("", { now: NOW }).error);
60
+ assert.ok(resolveCampaign("9Sep", { now: NOW }).error);
61
+ assert.ok(resolveCampaign("Spring", { date: "nonsense", now: NOW }).error);
62
+ });
63
+
64
+ test("utm_content is per-link and gets no date", () => {
65
+ assert.equal(resolveContent("View More").value, "ViewMore");
66
+ assert.equal(resolveContent("").value, "");
67
+ });
68
+
69
+ test("dateToken / isConforming", () => {
70
+ assert.equal(dateToken(NOW), "9Sep");
71
+ assert.equal(dateToken(new Date(2026, 11, 25)), "25Dec");
72
+ assert.ok(isConforming("9Sep_SpringPromotion"));
73
+ assert.ok(!isConforming("9Sept_SpringPromotion"));
74
+ assert.ok(!isConforming("SpringPromotion"));
75
+ assert.ok(!isConforming("9Sep_Spring_Promotion"));
76
+ });
@@ -0,0 +1,150 @@
1
+ // `flowiq doctor` — one command that answers "is my CLI healthy and current?"
2
+ //
3
+ // Written because the honest answer used to need three commands and some
4
+ // knowledge: `flowiq --version` (installed), `npm view` (latest), and nothing
5
+ // at all for "does the SERVER still speak my dialect?". A teammate — or a
6
+ // teammate's AI assistant — needs one place that says what is wrong and the
7
+ // exact command to fix it.
8
+ //
9
+ // Read-only, no org needed, safe to run any time. Exit code 1 when something
10
+ // actionable is wrong, so it can gate a script.
11
+
12
+ import { http, serverContract, cliVersion } from "../http.js";
13
+ import { cachedLatest, fetchLatestNow, cmpVersion } from "../update-check.js";
14
+ import { loadConfig, configDir } from "../config.js";
15
+
16
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
17
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
18
+ const amber = (s) => `\x1b[33m${s}\x1b[0m`;
19
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
20
+
21
+ const OK = green("✓");
22
+ const WARN = amber("⚠");
23
+ const BAD = red("✗");
24
+
25
+ function ageLabel(ts) {
26
+ if (!ts) return "never";
27
+ const h = (Date.now() - ts) / 3600000;
28
+ if (h < 1) return `${Math.round(h * 60)} min ago`;
29
+ if (h < 48) return `${h.toFixed(1)} h ago`;
30
+ return `${Math.round(h / 24)} days ago`;
31
+ }
32
+
33
+ export async function doctor(opts = {}) {
34
+ const installed = cliVersion();
35
+ const problems = [];
36
+ const out = {
37
+ cli_version: installed,
38
+ npm_latest: null,
39
+ server_min_version: null,
40
+ api_build: null,
41
+ api_url: null,
42
+ authenticated: false,
43
+ user: null,
44
+ problems: [],
45
+ };
46
+
47
+ const lines = [];
48
+ lines.push(`flowiq doctor`);
49
+ lines.push("");
50
+
51
+ // ── 1. installed vs npm latest ────────────────────────────────────────────
52
+ // Ask the registry LIVE here (with a short timeout). The background cache is
53
+ // refreshed at most once a day, so on the day of a release it is stale — which
54
+ // is precisely when someone runs doctor.
55
+ const live = await fetchLatestNow(3000);
56
+ const cached = cachedLatest();
57
+ const latest = live || cached.latest;
58
+ out.npm_latest = latest;
59
+
60
+ if (!latest) {
61
+ lines.push(` ${WARN} version installed ${installed} · could not reach npm ${dim("(offline?)")}`);
62
+ } else if (cmpVersion(latest, installed) > 0) {
63
+ lines.push(` ${BAD} version installed ${installed} · latest ${latest}`);
64
+ problems.push({
65
+ what: `CLI is out of date (${installed} → ${latest})`,
66
+ fix: "npm i -g @flowapt/flowiq-cli@latest",
67
+ });
68
+ } else if (cmpVersion(installed, latest) > 0) {
69
+ // A dev machine, a worktree build, or a version bumped but not yet
70
+ // published. Saying "(latest)" here would be a lie, and it is exactly the
71
+ // state in which someone wonders why teammates do not have their feature.
72
+ lines.push(` ${WARN} version installed ${installed} is AHEAD of npm (${latest}) ${dim("— unpublished build")}`);
73
+ } else {
74
+ lines.push(` ${OK} version ${installed} ${dim(live ? "(latest)" : `(latest, cached ${ageLabel(cached.checkedAt)})`)}`);
75
+ }
76
+
77
+ // ── 2. config + auth ──────────────────────────────────────────────────────
78
+ let cfg = {};
79
+ try { cfg = await loadConfig(); } catch { /* handled below */ }
80
+ out.api_url = cfg.api_url || null;
81
+ lines.push(` ${cfg.api_url ? OK : BAD} api ${cfg.api_url || "not configured"} ${dim(configDir())}`);
82
+ if (!cfg.api_url) problems.push({ what: "no api_url configured", fix: "flowiq auth login" });
83
+
84
+ if (!cfg.token) {
85
+ lines.push(` ${BAD} auth not authenticated`);
86
+ problems.push({ what: "not authenticated", fix: "flowiq auth login" });
87
+ } else {
88
+ // whoami doubles as the live server probe — it is how the response headers
89
+ // carrying the version contract reach us at all.
90
+ try {
91
+ // `whoami` is also the live server probe: it is how the response headers
92
+ // carrying the version contract reach us at all.
93
+ const who = await http.get("whoami");
94
+ out.authenticated = true;
95
+ out.user = who?.user_email || null;
96
+ out.super_admin = who?.is_super_admin === true;
97
+ const role = who?.is_super_admin ? "super admin" : red("NOT a super admin");
98
+ lines.push(` ${OK} auth ${out.user || "authenticated"} ${dim(`· ${role}`)}`);
99
+ if (who && who.is_super_admin !== true) {
100
+ problems.push({ what: "this key is not a super-admin key — most commands will 403", fix: "ask Matt to re-issue the staff key" });
101
+ }
102
+ } catch (e) {
103
+ lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
104
+ problems.push({
105
+ what: "the API rejected this key",
106
+ fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
107
+ });
108
+ }
109
+ }
110
+
111
+ // ── 3. the server's version contract ──────────────────────────────────────
112
+ out.server_min_version = serverContract.minVersion;
113
+ out.api_build = serverContract.apiBuild;
114
+
115
+ if (!serverContract.minVersion) {
116
+ lines.push(` ${WARN} server did not declare a minimum CLI version ${dim("(deploy predates the version contract)")}`);
117
+ } else if (cmpVersion(serverContract.minVersion, installed) > 0) {
118
+ lines.push(` ${BAD} server requires ${serverContract.minVersion}, you have ${installed}`);
119
+ problems.push({
120
+ what: `this CLI is older than the server's contract (needs ${serverContract.minVersion})`,
121
+ fix: "npm i -g @flowapt/flowiq-cli@latest",
122
+ });
123
+ } else {
124
+ lines.push(` ${OK} server contract ${serverContract.minVersion} or newer ${dim(`· build ${serverContract.apiBuild || "?"}`)}`);
125
+ }
126
+
127
+ // The REVERSE skew: a CLI newer than the deployment it is talking to. Not an
128
+ // error — it is the normal state for the minutes between a push and the
129
+ // Vercel deploy — but it explains "the command exists and still doesn't work".
130
+ if (latest && serverContract.minVersion && cmpVersion(installed, latest) === 0 && cmpVersion(installed, serverContract.minVersion) > 0) {
131
+ lines.push(` ${dim("i server min is " + serverContract.minVersion + " while you run " + installed + " — fine; newest features need the matching deploy")}`);
132
+ }
133
+
134
+ out.problems = problems;
135
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); return problems.length ? 1 : 0; }
136
+
137
+ console.log(lines.join("\n"));
138
+ console.log("");
139
+ if (!problems.length) {
140
+ console.log(green(" Everything checks out."));
141
+ } else {
142
+ console.log(red(` ${problems.length} thing${problems.length === 1 ? "" : "s"} to fix:`));
143
+ for (const p of problems) {
144
+ console.log(` • ${p.what}`);
145
+ console.log(` ${p.fix}`);
146
+ }
147
+ }
148
+ console.log("");
149
+ return problems.length ? 1 : 0;
150
+ }
@@ -0,0 +1,201 @@
1
+ // `flowiq insights …` — the Customer Insights Report from the terminal.
2
+ //
3
+ // flowiq insights status [org_id] [--off|--on] [--min-chats N] [--all] who has it, who does not, last report, chat volume
4
+ // flowiq insights enable <org_id> [--daily] [--weekly [day]] [--monthly [day]] [--report standard|advanced]
5
+ // [--store-only] [--recipient a@b.com …] [--dry-run] [--force]
6
+ // flowiq insights disable <org_id>
7
+ // flowiq insights run <org_id> [--period daily|weekly|monthly | --from D --to D] [--report …]
8
+ // [--recipient …] [--store-only] --commit
9
+ //
10
+ // `run` is DRY-RUN by default (it prints the exact engine, window and
11
+ // recipients) and fires only with --commit — it costs OpenAI tokens and can
12
+ // email a client. The server refuses the three ways an enabled org silently
13
+ // produces nothing (no OpenAI key, emailing with no recipients, an empty
14
+ // window); --force overrides all but an inactive org.
15
+
16
+ import { http } from "../http.js";
17
+ import { schedulesFromOpts, describeSchedule, sastRange } from "../insights-config.js";
18
+
19
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const pad = (s, n) => String(s ?? "").padEnd(n);
21
+ const padL = (s, n) => String(s ?? "").padStart(n);
22
+
23
+ function requireUuid(orgId) {
24
+ if (!UUID_RE.test(orgId || "")) {
25
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
26
+ process.exit(1);
27
+ }
28
+ }
29
+
30
+ function fmtWhen(iso) {
31
+ if (!iso) return "never";
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime())) return String(iso);
34
+ // SAST, minute precision
35
+ const s = new Date(d.getTime() + 2 * 3600 * 1000).toISOString();
36
+ return `${s.slice(0, 10)} ${s.slice(11, 16)}`;
37
+ }
38
+
39
+ function daysAgo(iso) {
40
+ if (!iso) return null;
41
+ return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
42
+ }
43
+
44
+ function printProblems(body) {
45
+ for (const e of body.errors || []) console.error(` ✗ ${e}`);
46
+ for (const w of body.warnings || []) console.error(` ⚠ ${w}`);
47
+ }
48
+
49
+ function fail(prefix, e) {
50
+ console.error(`${prefix}: ${e.message}`);
51
+ const b = e.body || {};
52
+ if (b.errors?.length || b.warnings?.length) printProblems(b);
53
+ if (b.needs_force) console.error(" Fix the cause, or re-run with --force to write anyway.");
54
+ process.exit(1);
55
+ }
56
+
57
+ // ── status ───────────────────────────────────────────────────────────────────
58
+
59
+ export async function status(orgId, opts = {}) {
60
+ if (orgId) return statusOne(orgId, opts);
61
+ let resp;
62
+ try { resp = await http.get("insights", { status: "1", all: opts.all ? "1" : undefined }); } catch (e) { fail("Status failed", e); }
63
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
64
+
65
+ let orgs = resp.orgs || [];
66
+ const total = orgs.length;
67
+ const enabledTotal = orgs.filter((o) => o.enabled).length;
68
+ if (opts.on) orgs = orgs.filter((o) => o.enabled);
69
+ if (opts.off) orgs = orgs.filter((o) => !o.enabled);
70
+ const minChats = Number(opts.minChats ?? 0);
71
+ if (minChats > 0) orgs = orgs.filter((o) => o.contacts_30d >= minChats);
72
+ const sort = opts.sort || "chats";
73
+ orgs.sort((a, b) =>
74
+ sort === "name" ? a.name.localeCompare(b.name)
75
+ : sort === "last" ? (new Date(b.last_report?.at || 0) - new Date(a.last_report?.at || 0))
76
+ : (b.contacts_30d - a.contacts_30d) || a.name.localeCompare(b.name));
77
+
78
+ console.log(`Customer Insights Report — ${enabledTotal} of ${total} org(s) enabled${resp.include_inactive ? " (incl. inactive)" : ""}` +
79
+ `${opts.on ? " · showing ON" : opts.off ? " · showing OFF" : ""}${minChats ? ` · ≥${minChats} chatting contacts/30d` : ""}\n`);
80
+ if (!orgs.length) { console.log("(no orgs match the filter)"); return; }
81
+
82
+ const nameW = Math.min(28, Math.max(4, ...orgs.map((o) => o.name.length)));
83
+ console.log(`${pad("NAME", nameW)} ${pad("STATE", 5)} ${padL("CHATS30D", 8)} ${pad("KEY", 3)} ${pad("LAST REPORT", 18)} ${pad("RCPT", 4)} SCHEDULES`);
84
+ for (const o of orgs) {
85
+ const state = o.enabled ? "ON" : o.configured ? "off" : "—";
86
+ const last = o.last_report ? `${fmtWhen(o.last_report.at)}${o.last_report.store_only ? " s" : ""}` : "never";
87
+ const sched = o.enabled ? o.schedules_described.join(", ") + (o.schedules_source === "implicit-daily" ? " (implicit)" : "") : "";
88
+ console.log(
89
+ `${pad(o.name.slice(0, nameW), nameW)} ${pad(state, 5)} ${padL(o.contacts_30d, 8)} ${pad(o.has_openai_key ? "yes" : "NO", 3)} ${pad(last, 18)} ${padL(o.recipients.length, 4)} ${sched}${o.inactive ? " (inactive)" : ""}`
90
+ );
91
+ }
92
+ console.log(`\n${orgs.length} org(s) shown · CHATS30D = distinct contacts who messaged in the last 30 days · KEY = has its own OpenAI key (required) · "s" = store-only`);
93
+ console.log("Enable one: flowiq insights enable <org_id> --weekly monday --recipient client@example.com");
94
+ }
95
+
96
+ async function statusOne(orgId, opts = {}) {
97
+ requireUuid(orgId);
98
+ let resp;
99
+ try { resp = await http.get("insights", { organization_id: orgId }); } catch (e) { fail("Status failed", e); }
100
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
101
+
102
+ console.log(`${resp.name} (${resp.id})${resp.inactive ? " [INACTIVE]" : ""}`);
103
+ console.log(` insights: ${resp.enabled ? "ENABLED" : resp.configured ? "disabled (config kept)" : "never configured"}`);
104
+ if (resp.enabled || resp.configured) {
105
+ console.log(` schedules: ${resp.schedules_described.join(", ") || "(none)"}${resp.schedules_source === "implicit-daily" ? " ⚠ no schedules stored — the scheduler runs DAILY standard by default" : resp.schedules_source === "legacy" ? " (legacy single-config shape)" : ""}`);
106
+ console.log(` recipients: ${resp.recipients.length ? resp.recipients.join(", ") : `(none — emails would go to ${resp.engine_fallback_email})`}`);
107
+ }
108
+ console.log(` openai key: ${resp.has_openai_key ? "own key present" : "NONE — the engines refuse to run"}${resp.has_spare_openai_key ? " (+ spare)" : ""}`);
109
+ console.log(` studio config: ${resp.studio_configured_at ? `saved ${fmtWhen(resp.studio_configured_at)} (advanced engine)` : "none (advanced runs on engine defaults)"}`);
110
+ console.log(` chats (30d): ${resp.contacts_30d} contacts · ${resp.messages_30d} messages · last inbound ${fmtWhen(resp.last_inbound_at)}`);
111
+ console.log(` reports (30d): ${resp.reports_30d}`);
112
+ if (resp.reports?.length) {
113
+ console.log("\n Recent reports (SAST):");
114
+ for (const r of resp.reports) {
115
+ console.log(` ${fmtWhen(r.at)} ${pad(r.kind || "?", 8)} ${padL(r.contacts ?? "?", 5)} contacts ${padL(r.messages ?? "?", 6)} msgs ` +
116
+ `${r.store_only ? "stored only" : `${r.emails_sent ?? 0} email(s)${r.emails_failed ? `, ${r.emails_failed} failed` : ""}`}` +
117
+ `${r.recipients?.length && !r.store_only ? ` → ${r.recipients.join(", ")}` : ""}${r.source && r.source !== "edge" ? ` [${r.source}]` : ""}`);
118
+ }
119
+ } else {
120
+ console.log("\n No reports yet.");
121
+ }
122
+ }
123
+
124
+ // ── enable / disable ─────────────────────────────────────────────────────────
125
+
126
+ export async function enable(orgId, opts = {}) {
127
+ requireUuid(orgId);
128
+ let schedules;
129
+ try { schedules = schedulesFromOpts({ daily: opts.daily, weekly: opts.weekly, monthly: opts.monthly, report: opts.report, storeOnly: opts.storeOnly }); }
130
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
131
+ if (!schedules && (opts.report || opts.storeOnly)) {
132
+ console.error("Error: --report / --store-only describe a schedule — add --daily, --weekly or --monthly to say which.");
133
+ process.exit(1);
134
+ }
135
+ const recipients = {};
136
+ if (opts.recipient?.length) recipients.replace = opts.recipient;
137
+ if (opts.addRecipient?.length) recipients.add = opts.addRecipient;
138
+ if (opts.removeRecipient?.length) recipients.remove = opts.removeRecipient;
139
+
140
+ const body = { organization_id: orgId, action: "enable", dry_run: opts.dryRun === true, force: opts.force === true };
141
+ if (schedules) body.schedules = schedules;
142
+ if (Object.keys(recipients).length) body.recipients = recipients;
143
+
144
+ let resp;
145
+ try { resp = await http.post("insights", body); } catch (e) { fail("Enable failed", e); }
146
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
147
+
148
+ const o = resp.organization;
149
+ const after = resp.after;
150
+ console.log(`${resp.dry_run ? "DRY RUN — " : ""}${o.name}: insights ${resp.before?.enabled ? "already on, config updated" : "ENABLED"}${resp.forced ? " (FORCED past pre-flight)" : ""}`);
151
+ console.log(` schedules: ${(after.schedules || []).map(describeSchedule).join(", ")} [${resp.schedules_source}]`);
152
+ console.log(` recipients: ${after.recipient_emails?.length ? after.recipient_emails.join(", ") : "(none)"}`);
153
+ printProblems(resp);
154
+ if (resp.dry_run) {
155
+ console.log(resp.would_write ? "\n Would write. Re-run without --dry-run." : "\n Would NOT write — fix the ✗ items (or --force).");
156
+ } else {
157
+ console.log("\n Next run: 09:00 SAST on the next matching day (cron 72). Fire one now: flowiq insights run " + orgId + " --commit");
158
+ console.log(" Undo: flowiq insights disable " + orgId);
159
+ }
160
+ }
161
+
162
+ export async function disable(orgId, opts = {}) {
163
+ requireUuid(orgId);
164
+ let resp;
165
+ try { resp = await http.post("insights", { organization_id: orgId, action: "disable" }); } catch (e) { fail("Disable failed", e); }
166
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
167
+ console.log(`${resp.organization.name}: insights ${resp.changed ? "DISABLED" : "already disabled"} (schedules + recipients kept; re-enable with \`flowiq insights enable\`)`);
168
+ }
169
+
170
+ // ── run (Run Now) ────────────────────────────────────────────────────────────
171
+
172
+ export async function run(orgId, opts = {}) {
173
+ requireUuid(orgId);
174
+ const body = { organization_id: orgId, action: "run", commit: opts.commit === true, force: opts.force === true, store_only: opts.storeOnly === true };
175
+ if (opts.from || opts.to) {
176
+ if (!opts.from || !opts.to) { console.error("Error: --from and --to go together (YYYY-MM-DD, SAST days)."); process.exit(1); }
177
+ try { const r = sastRange(opts.from, opts.to); body.start_date = r.start; body.end_date = r.end; }
178
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
179
+ } else if (opts.period) {
180
+ if (!["daily", "weekly", "monthly"].includes(opts.period)) { console.error("Error: --period must be daily, weekly or monthly."); process.exit(1); }
181
+ body.period = opts.period;
182
+ }
183
+ if (opts.report) body.report_type = opts.report;
184
+ if (opts.recipient?.length) body.recipients = opts.recipient;
185
+
186
+ let resp;
187
+ try { resp = await http.post("insights", body); } catch (e) { fail("Run failed", e); }
188
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
189
+
190
+ const o = resp.organization;
191
+ console.log(`${resp.dry_run ? "DRY RUN — " : "STARTED — "}${o.name}: ${resp.period} ${resp.report_type} report via ${resp.engine}`);
192
+ console.log(` window: ${fmtWhen(resp.window.start)} → ${fmtWhen(resp.window.end)} SAST (${resp.messages_in_window ?? "?"} customer messages)`);
193
+ console.log(` delivery: ${resp.store_only ? "store only — no emails" : `email → ${resp.recipients.join(", ")}`}`);
194
+ printProblems(resp);
195
+ if (resp.dry_run) {
196
+ console.log(resp.would_run ? "\n Would run. Add --commit to fire it." : "\n Would NOT run — fix the ✗ items (or --force).");
197
+ } else {
198
+ console.log(`\n job ${resp.job_id || "?"} — python-render is working in the background (a few minutes for a large org).`);
199
+ console.log(` Check: flowiq insights status ${orgId} (the report appears under "Recent reports")`);
200
+ }
201
+ }
@@ -5,16 +5,26 @@
5
5
  // in the dialog: utm_source=whatsapp, utm_medium=whatsapp_paid fixed, campaign +
6
6
  // content yours.
7
7
  //
8
- // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign 13Aug_Seeds --content 13Aug_Seeds
9
- // flowiq links shorten <org> --file ./message.txt --campaign X --content X --domain linklnk.io
8
+ // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
9
+ // flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
10
10
  // flowiq links list <org> [--campaign X] [--limit 25]
11
11
  //
12
+ // NAMING — you do not have to get the tag right yourself. The house convention
13
+ // is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
14
+ // then the campaign title in PascalCase), and --campaign is normalised to it:
15
+ // today's date is prefixed when you omit one, `Sept`→`Sep`, a fused
16
+ // `10JunFathersDay` gains its underscore, and spaces/hyphens become PascalCase.
17
+ // The resolved tag is printed before anything is minted (shorten is dry-run by
18
+ // default). `--date <9Sep|2026-09-24|24/9/2026>` for a send going out later;
19
+ // `--raw-campaign` to bypass normalisation entirely.
20
+ //
12
21
  // Dry-run FIRST (`--dry-run`) on anything going into a real campaign: it shows
13
22
  // the exact destination URL each short link will carry, and whether an identical
14
23
  // row already exists (so you can see you are about to reuse an OLD campaign tag).
15
24
 
16
25
  import fs from "node:fs";
17
26
  import { http } from "../http.js";
27
+ import { resolveCampaign, resolveContent, isConforming } from "../campaign-naming.js";
18
28
 
19
29
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
30
  const URL_PATTERN = /(https?:\/\/[^\s\)]+)/g;
@@ -59,14 +69,50 @@ export async function shorten(orgId, opts = {}) {
59
69
 
60
70
  const dryRun = opts.dryRun !== false && opts.commit !== true;
61
71
 
72
+ // ── Campaign naming (Date_Campaign) ───────────────────────────────────────
73
+ // Normalise what was typed into the house convention so staff do not have to
74
+ // carry it in their heads. --raw-campaign opts out; the agent's own `ai_<org>`
75
+ // links are minted server-side and never come through here.
76
+ let campaign = opts.campaign;
77
+ let content = opts.content;
78
+ const namingNotes = [];
79
+ if (campaign && !opts.rawCampaign) {
80
+ const r = resolveCampaign(campaign, { date: opts.date });
81
+ if (r.error) {
82
+ console.error(`Error: ${r.error}`);
83
+ console.error(`Convention is Date_Campaign, e.g. --campaign "Spring Promotion" → 9Sep_SpringPromotion.`);
84
+ console.error(`Pass --raw-campaign to use "${campaign}" exactly as typed.`);
85
+ process.exit(1);
86
+ }
87
+ if (r.changed) namingNotes.push(`campaign "${campaign}" → ${r.value}`, ...r.notes.map((n) => ` · ${n}`));
88
+ campaign = r.value;
89
+ } else if (campaign && opts.rawCampaign && !isConforming(campaign)) {
90
+ namingNotes.push(`campaign "${campaign}" used raw (--raw-campaign) — does not match Date_Campaign`);
91
+ }
92
+ // utm_content is per-LINK (which button / which product). Default it to the
93
+ // campaign, which is what the paired-flags API expects and what most sends do.
94
+ if (campaign && !content) {
95
+ content = campaign;
96
+ namingNotes.push(`content defaulted to ${content} (pass --content for a per-link tag, e.g. ViewMore)`);
97
+ } else if (content && !opts.rawCampaign) {
98
+ const rc = resolveContent(content);
99
+ if (rc.changed) namingNotes.push(`content "${content}" → ${rc.value}`);
100
+ content = rc.value;
101
+ }
102
+ if (namingNotes.length && !opts.json) {
103
+ console.log("Naming (Date_Campaign):");
104
+ for (const n of namingNotes) console.log(` ${n}`);
105
+ console.log("");
106
+ }
107
+
62
108
  let resp;
63
109
  try {
64
110
  resp = await http.post("links", {
65
111
  organization_id: orgId,
66
112
  action: "shorten",
67
113
  urls: unique,
68
- campaign: opts.campaign,
69
- content: opts.content,
114
+ campaign,
115
+ content,
70
116
  domain: opts.domain,
71
117
  dry_run: dryRun,
72
118
  });
@@ -0,0 +1,151 @@
1
+ // `flowiq org flags …` — an org's feature flags (the Settings → Profile
2
+ // switches) from the terminal.
3
+ //
4
+ // flowiq org flags show <org_id> what this org carries (secrets redacted)
5
+ // flowiq org flags list --key <path> [--all] which orgs carry ONE flag, and its value
6
+ // flowiq org flags set <org_id> <key> <value> [--yes] write a registered key (type-checked server-side)
7
+ // flowiq org flags unset <org_id> <key> --yes remove a key (revert to the platform default)
8
+ // flowiq org flags keys what is settable, by Settings section
9
+ //
10
+ // The server (api/cli/org-flags + _org-flags-registry) is the only validator:
11
+ // only keys the Profile tab manages can be set, credentials are never shown
12
+ // or written, dangerous keys need --yes.
13
+
14
+ import { http } from "../http.js";
15
+ import { parseFlagValue, fmtValue } from "../flag-values.js";
16
+
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const pad = (s, n) => String(s ?? "").padEnd(n);
19
+
20
+ function requireUuid(orgId) {
21
+ if (!UUID_RE.test(orgId || "")) {
22
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ function fail(prefix, e) {
28
+ console.error(`${prefix}: ${e.message}`);
29
+ const b = e.body || {};
30
+ if (b.needs_confirm) {
31
+ if (b.danger) console.error(` ⚠ ${b.danger}`);
32
+ if (b.before !== undefined) console.error(` current: ${fmtValue(b.before)}`);
33
+ if (b.would_set !== undefined) console.error(` would set: ${fmtValue(b.would_set)}`);
34
+ console.error(" Re-run with --yes to confirm.");
35
+ } else if (b.settable === false) {
36
+ console.error(" `flowiq org flags keys` lists what can be set; `flowiq org flags show <org>` still READS it.");
37
+ }
38
+ process.exit(1);
39
+ }
40
+
41
+ export async function keys(opts = {}) {
42
+ let resp;
43
+ try { resp = await http.get("org-flags", { keys: "1" }); } catch (e) { fail("Lookup failed", e); }
44
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
45
+ const rows = (resp.keys || []).filter((k) => !opts.section || k.section.toLowerCase().includes(opts.section.toLowerCase()));
46
+ const bySection = new Map();
47
+ for (const k of rows) {
48
+ if (!bySection.has(k.section)) bySection.set(k.section, []);
49
+ bySection.get(k.section).push(k);
50
+ }
51
+ console.log(`Settable feature flags (${rows.length}) — the Settings → Profile switches, by section.\n`);
52
+ for (const [section, list] of bySection) {
53
+ console.log(`${section}`);
54
+ for (const k of list) {
55
+ const type = k.type === "integer" && k.min !== undefined ? `integer ${k.min}-${k.max}` : k.type;
56
+ console.log(` ${pad(k.key, 46)} ${pad(type, 14)}${k.danger ? " ⚠ needs --yes" : ""}`);
57
+ if (k.note) console.log(` ${pad("", 46)} ${k.note}`);
58
+ }
59
+ console.log("");
60
+ }
61
+ console.log("Wildcards (`x.*`) take one more segment, e.g. integrations_visible.instagram.");
62
+ console.log("Anything else on an org is readable via `flowiq org flags show` but not settable here.");
63
+ }
64
+
65
+ export async function show(orgId, opts = {}) {
66
+ requireUuid(orgId);
67
+ let resp;
68
+ try { resp = await http.get("org-flags", { organization_id: orgId }); } catch (e) { fail("Lookup failed", e); }
69
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
70
+
71
+ const o = resp.organization;
72
+ console.log(`${o.name} (${o.id})${o.inactive ? " [INACTIVE]" : ""} — ${resp.key_count} top-level flag key(s)\n`);
73
+ const known = Object.entries(resp.known || {});
74
+ if (known.length) {
75
+ const bySection = new Map();
76
+ for (const [key, v] of known) {
77
+ if (!bySection.has(v.section)) bySection.set(v.section, []);
78
+ bySection.get(v.section).push([key, v.value]);
79
+ }
80
+ for (const [section, list] of bySection) {
81
+ console.log(section);
82
+ for (const [key, value] of list) console.log(` ${pad(key, 46)} ${fmtValue(value, 70)}`);
83
+ console.log("");
84
+ }
85
+ } else {
86
+ console.log("(none of the registered flags are set on this org)\n");
87
+ }
88
+ if (resp.unknown_keys?.length) {
89
+ console.log(`Other keys on this org (readable with --json, not settable from the CLI): ${resp.unknown_keys.join(", ")}`);
90
+ }
91
+ if (resp.secret_keys?.length) {
92
+ console.log(`Credentials present (never shown): ${resp.secret_keys.join(", ")}`);
93
+ }
94
+ console.log("\nChange one: flowiq org flags set <org_id> <key> <value> (see `flowiq org flags keys`)");
95
+ }
96
+
97
+ export async function list(opts = {}) {
98
+ if (!opts.key) { console.error("Error: --key <path> is required (e.g. --key export_insights.enabled)."); process.exit(1); }
99
+ let resp;
100
+ try { resp = await http.get("org-flags", { list: "1", key: opts.key, all: opts.all ? "1" : undefined }); } catch (e) { fail("Lookup failed", e); }
101
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
102
+
103
+ let rows = resp.rows || [];
104
+ if (opts.on) rows = rows.filter((r) => r.value === true);
105
+ if (opts.off) rows = rows.filter((r) => r.value !== true);
106
+ if (opts.present) rows = rows.filter((r) => r.present);
107
+ if (opts.absent) rows = rows.filter((r) => !r.present);
108
+
109
+ const s = resp.summary;
110
+ const label = resp.registered ? ""
111
+ : resp.registered_parent ? ` (sub-key of \`${resp.registered_parent}\` — set it through the parent${resp.registered_parent === "export_insights" ? ", or \`flowiq insights enable\`" : ""})`
112
+ : " (not a registered key — read-only)";
113
+ console.log(`${resp.key}${label} across ${s.orgs} org(s)${resp.include_inactive ? " incl. inactive" : ""}: ` +
114
+ `${s.present} present · ${s.absent} absent` + (s.true || s.false ? ` · ${s.true} true · ${s.false} false` : "") + "\n");
115
+ if (!rows.length) { console.log("(no orgs match the filter)"); return; }
116
+ const nameW = Math.min(34, Math.max(4, ...rows.map((r) => (r.name || "").length)));
117
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} VALUE`);
118
+ for (const r of rows) {
119
+ console.log(`${pad((r.name || "").slice(0, nameW), nameW)} ${pad(r.id, 36)} ${r.present ? fmtValue(r.value, 60) : "(absent)"}${r.inactive ? " (inactive)" : ""}`);
120
+ }
121
+ console.log(`\n${rows.length} org(s) shown`);
122
+ }
123
+
124
+ export async function set(orgId, key, value, opts = {}) {
125
+ requireUuid(orgId);
126
+ let parsed;
127
+ try { parsed = parseFlagValue(value); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
128
+ let resp;
129
+ try {
130
+ resp = await http.post("org-flags", { organization_id: orgId, action: "set", key, value: parsed, confirm: opts.yes === true });
131
+ } catch (e) { fail("Set failed", e); }
132
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
133
+ console.log(`${resp.organization.name} — ${resp.key}`);
134
+ console.log(` ${fmtValue(resp.before, 70)} → ${fmtValue(resp.after, 70)}${resp.changed ? "" : " (no change)"}`);
135
+ if (resp.danger) console.log(` ⚠ ${resp.danger}`);
136
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
137
+ console.log(` Undo: flowiq org flags ${resp.before === null ? `unset ${orgId} ${resp.key} --yes` : `set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`}`);
138
+ }
139
+
140
+ export async function unset(orgId, key, opts = {}) {
141
+ requireUuid(orgId);
142
+ let resp;
143
+ try {
144
+ resp = await http.post("org-flags", { organization_id: orgId, action: "unset", key, confirm: opts.yes === true });
145
+ } catch (e) { fail("Unset failed", e); }
146
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
147
+ console.log(`${resp.organization.name} — removed ${resp.key}`);
148
+ console.log(` was: ${fmtValue(resp.before, 70)}`);
149
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
150
+ console.log(` Undo: flowiq org flags set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`);
151
+ }