@flowapt/flowiq-cli 0.10.0 → 0.12.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.
@@ -7,7 +7,9 @@
7
7
  //
8
8
  // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
9
9
  // flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
10
- // flowiq links list <org> [--campaign X] [--limit 25]
10
+ // flowiq links shorten <org> --url <u> --slug spring-sale --commit (readable code)
11
+ // flowiq links list <org> [--campaign X] [--code abc123] [--to /collections/x] [--limit 25] [--all]
12
+ // flowiq links repoint <org> <code> --to <url> [--commit] (move a live code)
11
13
  //
12
14
  // NAMING — you do not have to get the tag right yourself. The house convention
13
15
  // is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
@@ -114,11 +116,13 @@ export async function shorten(orgId, opts = {}) {
114
116
  campaign,
115
117
  content,
116
118
  domain: opts.domain,
119
+ slug: opts.slug,
117
120
  dry_run: dryRun,
118
121
  confirm_new_campaign: !!opts.newCampaign,
119
122
  });
120
123
  } catch (e) {
121
124
  console.error(`Shorten failed: ${e.message}`);
125
+ if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
122
126
  process.exit(1);
123
127
  }
124
128
 
@@ -127,7 +131,7 @@ export async function shorten(orgId, opts = {}) {
127
131
  const utm = resp.utm
128
132
  ? `utm_source=${resp.utm.source} · utm_medium=${resp.utm.medium} · utm_campaign=${resp.utm.campaign} · utm_content=${resp.utm.content}`
129
133
  : "no UTM tags (plain short link)";
130
- console.log(`${resp.organization_name} · ${resp.domain}`);
134
+ console.log(`${resp.organization_name} · ${resp.domain}${resp.slug ? ` · slug ${resp.slug}` : ""}`);
131
135
  console.log(`${utm}\n`);
132
136
  if (resp.campaign_conflicts?.length) {
133
137
  const c = resp.campaign_conflicts[0];
@@ -169,30 +173,69 @@ export async function shorten(orgId, opts = {}) {
169
173
 
170
174
  export async function list(orgId, opts = {}) {
171
175
  requireOrg(orgId);
172
- let resp;
173
- try {
174
- resp = await http.post("links", {
175
- organization_id: orgId,
176
- action: "list",
177
- campaign: opts.campaign,
178
- limit: opts.limit,
179
- });
180
- } catch (e) {
181
- console.error(`List failed: ${e.message}`);
182
- process.exit(1);
176
+ // --all walks every page (500 a call) so a whole campaign or a whole org can
177
+ // be exported; without it the first page is the answer and has_more says so.
178
+ const pageSize = opts.all ? 500 : (opts.limit ? Number(opts.limit) : 25);
179
+ const links = [];
180
+ let offset = 0, resp, hasMore = false;
181
+ for (;;) {
182
+ try {
183
+ resp = await http.post("links", {
184
+ organization_id: orgId,
185
+ action: "list",
186
+ campaign: opts.campaign,
187
+ code: opts.code,
188
+ to: opts.to,
189
+ limit: pageSize,
190
+ offset,
191
+ });
192
+ } catch (e) {
193
+ console.error(`List failed: ${e.message}`);
194
+ process.exit(1);
195
+ }
196
+ links.push(...(resp.links || []));
197
+ hasMore = !!resp.has_more;
198
+ if (!opts.all || !hasMore || !(resp.links || []).length) break;
199
+ offset += (resp.links || []).length;
200
+ if (offset > 50000) break;
183
201
  }
184
202
 
185
- if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
203
+ if (opts.json) { console.log(JSON.stringify({ ...resp, count: links.length, links, has_more: hasMore }, null, 2)); return; }
186
204
 
187
- if (!resp.links.length) {
188
- console.log(opts.campaign ? `No links on ${resp.organization_name} for campaign "${opts.campaign}".` : `No links on ${resp.organization_name}.`);
205
+ const what = [opts.campaign && `campaign "${opts.campaign}"`, opts.code && `code "${opts.code}"`, opts.to && `destination containing "${opts.to}"`].filter(Boolean).join(", ");
206
+ if (!links.length) {
207
+ console.log(what ? `No links on ${resp.organization_name} for ${what}.` : `No links on ${resp.organization_name}.`);
189
208
  return;
190
209
  }
191
210
  const pad = (s, n) => String(s ?? "").padEnd(n);
192
- console.log(`${pad("CODE", 10)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
193
- for (const l of resp.links) {
211
+ const codeW = Math.min(50, Math.max(10, ...links.map((l) => String(l.code || "").length + 2)));
212
+ console.log(`${pad("CODE", codeW)}${pad("CLICKS", 8)}${pad("CREATED", 12)}URL`);
213
+ for (const l of links) {
194
214
  const day = String(l.created_at || "").slice(0, 10);
195
- console.log(`${pad(l.code, 10)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}`);
215
+ console.log(`${pad(l.code, codeW)}${pad(l.clicks ?? 0, 8)}${pad(day, 12)}${l.full_url}${l.is_active === false ? " (inactive)" : ""}${l.per_message ? " (per-message)" : ""}`);
216
+ }
217
+ console.log(`\n${links.length} link(s)${what ? ` · ${what}` : ""}${hasMore ? ` · more exist (--all, or --limit up to 500)` : ""}`);
218
+ }
219
+
220
+ // `flowiq links repoint <org> <code> --to <url> [--commit]` — move where a
221
+ // live code sends people. The code stays in every message that carries it.
222
+ export async function repoint(orgId, code, opts = {}) {
223
+ requireOrg(orgId);
224
+ if (!code) { console.error("Error: pass the short code, e.g. flowiq links repoint <org> VVSSh2 --to https://…"); process.exit(1); }
225
+ if (!opts.to) { console.error("Error: --to <url> is required (the new destination page)."); process.exit(1); }
226
+ let resp;
227
+ try {
228
+ resp = await http.post("links", { organization_id: orgId, action: "repoint", code, url: opts.to, dry_run: !opts.commit, confirm: !!opts.commit });
229
+ } catch (e) {
230
+ console.error(`Repoint failed: ${e.message}`);
231
+ if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
232
+ process.exit(1);
196
233
  }
197
- console.log(`\n${resp.count} link(s)${opts.campaign ? ` · campaign "${opts.campaign}"` : ""}`);
234
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
235
+ console.log(`${resp.organization_name} · ${resp.code} (${resp.clicks ?? 0} clicks${resp.is_active === false ? ", inactive" : ""})`);
236
+ console.log(` from: ${resp.from}`);
237
+ console.log(` to: ${resp.to}`);
238
+ if (resp.unchanged) { console.log("\nAlready points there — nothing to do."); return; }
239
+ if (resp.dry_run) console.log("\nDRY RUN — nothing changed. Add --commit to repoint.");
240
+ else console.log(`\n✓ repointed. Undo: flowiq links repoint ${orgId} ${resp.code} --to '${resp.from}' --commit`);
198
241
  }
@@ -0,0 +1,108 @@
1
+ // `flowiq members list|add|role|rename|remove` + `flowiq users lookup` — an
2
+ // org's people and one user across orgs (server: api/cli/members.js).
3
+
4
+ import { http } from "../http.js";
5
+ import { fmtSast } from "./audit.js";
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const pad = (s, n) => String(s ?? "").padEnd(n);
9
+ function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
10
+ const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
11
+ const who = (v) => (UUID_RE.test(v || "") ? { user_id: v } : { email: v });
12
+
13
+ export async function list(orgId, opts = {}) {
14
+ requireOrg(orgId);
15
+ let resp;
16
+ try { resp = await http.get("members", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
17
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
18
+ console.log(`${resp.organization_name}: ${resp.count} member(s)${resp.pending_invitations.length ? ` · ${resp.pending_invitations.length} pending invitation(s)` : ""}`);
19
+ console.log("");
20
+ console.log(` ${pad("ROLE", 7)} ${pad("NAME", 24)} ${pad("EMAIL", 34)} ${pad("LAST SIGN-IN", 18)} SINCE`);
21
+ const order = { admin: 0, member: 1, viewer: 2 };
22
+ for (const m of [...resp.members].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9) || String(a.full_name || "").localeCompare(String(b.full_name || "")))) {
23
+ const flags = [m.is_owner && "owner", m.status !== "active" && m.status, m.view_only && "view-only", m.account_missing && "NO ACCOUNT", m.profile_deleted && "profile deleted"].filter(Boolean).join(", ");
24
+ console.log(` ${pad(m.role, 7)} ${pad((m.full_name || "—").slice(0, 24), 24)} ${pad((m.email || "?").slice(0, 34), 34)} ${pad(m.last_sign_in_at ? fmtSast(m.last_sign_in_at) : "never", 18)} ${String(m.since || "").slice(0, 10)}${flags ? ` (${flags})` : ""}`);
25
+ }
26
+ for (const i of resp.pending_invitations) console.log(` ${pad("invite", 7)} ${pad("—", 24)} ${pad(i.email, 34)} ${pad("pending", 18)} ${String(i.created_at || "").slice(0, 10)} (${i.role})`);
27
+ console.log(`\nflowiq members add ${orgId} <email> --role member · role ${orgId} <email> admin|member|viewer · remove ${orgId} <email> --confirm`);
28
+ }
29
+
30
+ export async function add(orgId, emailOrId, opts = {}) {
31
+ requireOrg(orgId);
32
+ if (!emailOrId) { console.error("Error: pass the person's email (they must already have an account)."); process.exit(1); }
33
+ let resp;
34
+ try { resp = await http.post("members", { action: "add", organization_id: orgId, ...who(emailOrId), role: opts.role || "member" }); } catch (e) { fail("Add failed", e); }
35
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
36
+ if (resp.unchanged) { console.log(resp.note); return; }
37
+ console.log(`✓ ${resp.email} is now a ${resp.role}${resp.reactivated ? " again" : ""}. Undo: flowiq members remove ${orgId} ${resp.email} --confirm`);
38
+ }
39
+
40
+ // `flowiq members invite <org> <email> [--role member]` — the app's door for
41
+ // someone with no account: an invitations row they are shown at sign-up.
42
+ export async function invite(orgId, email, opts = {}) {
43
+ requireOrg(orgId);
44
+ if (!email) { console.error("Error: flowiq members invite <org> <email> [--role member]"); process.exit(1); }
45
+ let resp;
46
+ try { resp = await http.post("members", { action: "invite", organization_id: orgId, email, role: opts.role || "member", dry_run: !!opts.dryRun }); } catch (e) { fail("Invite failed", e); }
47
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
48
+ if (resp.dry_run) { console.log(`Would invite ${resp.would_insert.email} to ${resp.organization_name} as ${resp.would_insert.role}.`); return; }
49
+ if (resp.unchanged) { console.log(resp.note); return; }
50
+ console.log(`✓ invited ${resp.email} as ${resp.role} (invitation ${resp.invitation_id}).\n ${resp.note}.`);
51
+ }
52
+
53
+ export async function role(orgId, emailOrId, newRole, opts = {}) {
54
+ requireOrg(orgId);
55
+ if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
56
+ let resp;
57
+ try { resp = await http.post("members", { action: "role", organization_id: orgId, ...who(emailOrId), role: newRole }); } catch (e) { fail("Role change failed", e); }
58
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
59
+ if (resp.unchanged) { console.log(`Already ${resp.role}.`); return; }
60
+ console.log(`✓ ${resp.email}: ${resp.from} → ${resp.to}. Undo: flowiq members role ${orgId} ${resp.email} ${resp.from}`);
61
+ }
62
+
63
+ export async function rename(emailOrId, name, opts = {}) {
64
+ if (!emailOrId || !name) { console.error("Error: flowiq members rename <email> \"Full Name\""); process.exit(1); }
65
+ let resp;
66
+ try { resp = await http.post("members", { action: "rename", ...who(emailOrId), full_name: name }); } catch (e) { fail("Rename failed", e); }
67
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
68
+ console.log(`✓ ${resp.email}: "${resp.from ?? ""}" → "${resp.to}" (profiles.full_name, shown in every org they belong to)`);
69
+ }
70
+
71
+ export async function remove(orgId, emailOrId, opts = {}) {
72
+ requireOrg(orgId);
73
+ if (!emailOrId) { console.error("Error: flowiq members remove <org> <email> --confirm"); process.exit(1); }
74
+ let resp;
75
+ try { resp = await http.post("members", { action: "remove", organization_id: orgId, ...who(emailOrId), confirm: !!opts.confirm }); } catch (e) { fail("Remove failed", e); }
76
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
77
+ if (resp.dry_run) { console.log(`Would remove ${resp.email} (${resp.role}${resp.is_owner ? ", OWNER" : ""}) from this org. Add --confirm.`); return; }
78
+ console.log(`✓ removed ${resp.email} (${resp.role}). Undo: flowiq members add ${orgId} ${resp.email} --role ${resp.role}`);
79
+ }
80
+
81
+ // `flowiq users lookup <email>` — the account behind an address, across orgs.
82
+ export async function lookup(email, opts = {}) {
83
+ if (!email) { console.error("Error: flowiq users lookup <email>"); process.exit(1); }
84
+ let resp;
85
+ try { resp = await http.get("members", { email }); }
86
+ catch (e) {
87
+ if (e.status === 404 || /No account/.test(e.message)) {
88
+ console.log(`No account for ${email}.`);
89
+ const inv = e.body?.invitations || [];
90
+ if (inv.length) { console.log(` invitations:`); for (const i of inv) console.log(` ${i.status.padEnd(9)} ${i.role.padEnd(7)} org ${i.organization_id} ${String(i.created_at).slice(0, 10)}`); }
91
+ else console.log(" no invitation on record either — they were never invited, or were invited under another address.");
92
+ return;
93
+ }
94
+ fail("Lookup failed", e);
95
+ }
96
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
97
+ const u = resp.user;
98
+ console.log(`${u.email} ${u.id}${resp.super_admin ? " (Flowapt super admin)" : ""}`);
99
+ console.log(` name: ${resp.full_name ?? "(no profile name)"}${resp.profile_deleted ? " (profile deleted)" : ""}`);
100
+ console.log(` signs in with: ${u.providers.join(", ")}${u.email_confirmed_at ? "" : " (email NOT confirmed)"}${u.banned_until ? ` BANNED until ${u.banned_until}` : ""}`);
101
+ console.log(` created: ${fmtSast(u.created_at)} SAST · last sign-in ${u.last_sign_in_at ? `${fmtSast(u.last_sign_in_at)} SAST` : "never"}`);
102
+ if (!resp.organizations.length) console.log(" organizations: none");
103
+ else {
104
+ console.log(` organizations: ${resp.organizations.length}`);
105
+ for (const o of resp.organizations) console.log(` ${pad(o.role, 7)} ${pad((o.organization_name || o.organization_id).slice(0, 30), 30)} ${o.organization_id}${o.status !== "active" ? ` (${o.status})` : ""}${o.inactive ? " (org inactive)" : ""}`);
106
+ }
107
+ console.log(`\n Password-reset emails are sent by Supabase Auth and are not recorded here; a stuck login is usually a wrong provider (Google vs password) or an unconfirmed email.`);
108
+ }
@@ -77,6 +77,72 @@ export async function pull(contactId, opts = {}) {
77
77
  const noStatus = out.filter((m) => m.whatsapp_message_id && m.message_status == null && !m.sent_receipt_received && !m.delivered_receipt_received && !m.read_receipt_received).length;
78
78
  console.log(` delivery: ${out.length} outbound WhatsApp · ${delivered} delivered/read · ${failed} failed · ${noStatus} with NO status at all${noStatus ? " ⚠ accepted by Meta, never confirmed (flowiq messages failures <org>)" : ""}`);
79
79
  }
80
+ // Who sent each bot row: the agent, a broadcast, or an automation (the
81
+ // notification_* columns are in the JSON; this names them so "was 'Hey MRS'
82
+ // the agent or the order confirmation?" is answered here, not in SQL).
83
+ const sources = {};
84
+ for (const m of rows.filter((x) => String(x.sender_type || "").startsWith("bot-"))) sources[sentBy(m)] = (sources[sentBy(m)] || 0) + 1;
85
+ const keys = Object.keys(sources);
86
+ if (keys.length) console.log(` sent by: ${keys.sort().map((k) => `${k} ${sources[k]}`).join(" · ")}`);
87
+ }
88
+
89
+ export function sentBy(m) {
90
+ if (m.broadcast_id) return "broadcast";
91
+ if (m.notification_template) return `automation: ${m.notification_template}${m.notification_event ? ` (${m.notification_event})` : ""}`;
92
+ if (m.notification_rule_id) return `rule ${String(m.notification_rule_id).slice(0, 8)}`;
93
+ if (m.notification_event) return `automation (${m.notification_event})`;
94
+ return "agent";
95
+ }
96
+
97
+ // `flowiq messages purge <org> --subject-regex <re> | --text <s> | --sender-type a,b [--since] [--before] --confirm`
98
+ // Org-wide delete by pattern (the 71 Lego auto-replies across 69 contacts).
99
+ export async function purge(orgId, opts = {}) {
100
+ requireOrg(orgId);
101
+ const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
102
+ if (!opts.subjectRegex && !opts.text && !senderTypes) { console.error("Error: give at least one of --subject-regex, --text, --sender-type."); process.exit(1); }
103
+ const body = { action: "delete", organization_id: orgId, subject_regex: opts.subjectRegex, text: opts.text, sender_types: senderTypes, since: opts.since, before: opts.before };
104
+ let preview;
105
+ try { preview = await http.post("messages", { ...body, dry_run: true }); } catch (e) { fail("Purge failed", e); }
106
+ if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
107
+ console.log(`${preview.organization_name}: ${preview.count} message(s) across ${preview.contacts} contact(s) match${preview.capped ? " (capped at 5,000 a run; re-run for the rest)" : ""}`);
108
+ for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
109
+ if (preview.top_subjects.length) { console.log(" subjects:"); for (const s of preview.top_subjects) console.log(` ${String(s.n).padStart(5)} × ${s.subject}`); }
110
+ if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
111
+ for (const c of preview.sample_contacts.slice(0, 8)) console.log(` ${String(c.n).padStart(5)} × ${c.contact?.full_name ?? ""} ${c.contact?.whatsapp_id ?? ""} (${c.contact_id})`);
112
+ if (!preview.count) return;
113
+ if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the organization id).`); return; }
114
+ const readline = await import("node:readline");
115
+ const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the organization id (${orgId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
116
+ if (typed !== orgId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
117
+ let resp;
118
+ try { resp = await http.post("messages", { ...body, dry_run: false, confirm: true }); } catch (e) { fail("Purge failed", e); }
119
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
120
+ console.log(`✓ deleted ${resp.deleted} message(s) across ${resp.contacts} contact(s). The audit row keeps ids, types and times (flowiq audit --endpoint messages).`);
121
+ if (resp.error) console.error(` ⚠ ${resp.error}`);
122
+ }
123
+
124
+ // `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
125
+ export async function remove(contactId, opts = {}) {
126
+ if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
127
+ const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
128
+ let preview;
129
+ try { preview = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: true }); }
130
+ catch (e) { fail("Delete failed", e); }
131
+ if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
132
+ console.log(`${preview.contact_name || "(no name)"} ${preview.contact_whatsapp_id || ""} on ${preview.organization_name}: ${preview.count} message(s)${preview.before ? ` older than ${fmtSast(preview.before)} SAST` : ""}${senderTypes ? ` of type ${senderTypes.join(", ")}` : ""}`);
133
+ for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
134
+ if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
135
+ if (!preview.count) return;
136
+ if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the contact id).`); return; }
137
+ const readline = await import("node:readline");
138
+ const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the contact id (${contactId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
139
+ if (typed !== contactId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
140
+ let resp;
141
+ try { resp = await http.post("messages", { action: "delete", contact_id: contactId, before: opts.before, sender_types: senderTypes, dry_run: false, confirm: true }); }
142
+ catch (e) { fail("Delete failed", e); }
143
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
144
+ console.log(`✓ deleted ${resp.deleted} message(s). The audit row keeps their ids, types and times (flowiq audit --endpoint messages).`);
145
+ if (resp.error) console.error(` ⚠ ${resp.error}`);
80
146
  }
81
147
 
82
148
  function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
@@ -98,12 +164,12 @@ export async function search(orgId, textParts, opts = {}) {
98
164
  const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
99
165
  if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
100
166
  let resp;
101
- try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, limit: opts.limit }); }
167
+ try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, tool_calls: opts.toolCalls ? "1" : undefined, limit: opts.limit }); }
102
168
  catch (e) { fail("Search failed", e); }
103
169
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
104
- console.log(`${resp.organization_name}: ${resp.count} message(s) containing "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates ? "" : " (template sends excluded; --include-templates)"}`);
170
+ console.log(`${resp.organization_name}: ${resp.count} ${opts.toolCalls ? "tool call(s) whose request or response contains" : "message(s) containing"} "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates || opts.toolCalls ? "" : " (template sends excluded; --include-templates)"}`);
105
171
  for (const r of resp.rows) {
106
- console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${snippetAround(r.message, text)}`);
172
+ console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(opts.toolCalls ? (r.tool_name ?? "tool") : r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${opts.toolCalls ? String(r.message).replace(/\s+/g, " ").slice(0, 160) : snippetAround(r.message, text)}`);
107
173
  }
108
174
  if (resp.rows.length) console.log(`\n contact ids: flowiq messages search … --json | jq '.rows[].contact_id' · full chat: flowiq messages pull <contact_id>`);
109
175
  }
@@ -0,0 +1,63 @@
1
+ // `flowiq steer list|add|clear <contact_id>` + `flowiq notes list|add
2
+ // <contact_id>` — the internal rows in a contact's chat (server: api/cli/notes.js).
3
+
4
+ import { http } from "../http.js";
5
+ import { fmtSast } from "./audit.js";
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
9
+ const requireContact = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid contact UUID.`); process.exit(1); } };
10
+
11
+ async function listKind(contactId, kind, opts) {
12
+ requireContact(contactId);
13
+ let resp;
14
+ try { resp = await http.get("notes", { contact_id: contactId, kind, limit: opts.limit }); } catch (e) { fail("List failed", e); }
15
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
16
+ const label = kind === "notes" ? "internal note" : "steer";
17
+ console.log(`${resp.contact_name || "(no name)"} ${resp.contact_whatsapp_id || ""} on ${resp.organization_name}: ${resp.count} ${label}(s)${kind === "steers" ? ` · AI ${resp.bot_status === false ? "OFF" : "on"}${resp.steer_messages_flag ? "" : " · steer_messages flag off (inbox hides the control; vert still applies rows)"}` : ""}`);
18
+ for (const r of resp.rows) {
19
+ const head = kind === "notes" ? `${r.author ?? "?"}` : `${r.mode}${r.mode === "once" ? (r.consumed ? ", consumed" : ", pending") : ""}`;
20
+ console.log(`\n ${fmtSast(r.created_at)} SAST · ${head}${kind === "steers" && !r.active ? " · inactive" : ""}\n ${r.id}\n ${String(r.text).split("\n").join("\n ")}`);
21
+ }
22
+ if (kind === "steers" && resp.rows.some((r) => r.active)) console.log(`\n Remove: flowiq steer clear ${contactId} [--id <id>] --confirm`);
23
+ }
24
+
25
+ export const steerList = (contactId, opts = {}) => listKind(contactId, "steers", opts);
26
+ export const notesList = (contactId, opts = {}) => listKind(contactId, "notes", opts);
27
+
28
+ export async function steerAdd(contactId, textParts, opts = {}) {
29
+ requireContact(contactId);
30
+ const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
31
+ if (!text) { console.error("Error: flowiq steer add <contact_id> \"instruction for the agent\" [--once]"); process.exit(1); }
32
+ let resp;
33
+ try { resp = await http.post("notes", { action: "add_steer", contact_id: contactId, text, mode: opts.once ? "once" : "persistent" }); } catch (e) { fail("Steer failed", e); }
34
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
35
+ console.log(`✓ ${resp.mode === "once" ? "one-shot" : "ongoing"} steer added (${resp.id}) as ${resp.author}. ${resp.mode === "once" ? "The agent applies it on the next reply, then it is spent." : "The agent applies it on EVERY reply until: flowiq steer clear " + contactId + " --id " + resp.id + " --confirm"}`);
36
+ for (const w of resp.warnings || []) console.error(` ⚠ ${w}`);
37
+ }
38
+
39
+ export async function steerClear(contactId, opts = {}) {
40
+ requireContact(contactId);
41
+ const ids = opts.id ? [].concat(opts.id) : undefined;
42
+ let resp;
43
+ try { resp = await http.post("notes", { action: "clear_steers", contact_id: contactId, steer_ids: ids, confirm: !!opts.confirm }); } catch (e) { fail("Clear failed", e); }
44
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
45
+ if (resp.dry_run) {
46
+ if (!resp.count) { console.log("No steers to remove."); return; }
47
+ console.log(`Would delete ${resp.count} steer(s):`);
48
+ for (const s of resp.steers) console.log(` ${s.id} ${s.mode}${s.consumed ? " (consumed)" : ""} ${String(s.text).slice(0, 80)}`);
49
+ console.log("Add --confirm to delete (the texts are kept in the audit row).");
50
+ return;
51
+ }
52
+ console.log(`✓ deleted ${resp.deleted} steer(s).`);
53
+ }
54
+
55
+ export async function notesAdd(contactId, textParts, opts = {}) {
56
+ requireContact(contactId);
57
+ const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
58
+ if (!text) { console.error("Error: flowiq notes add <contact_id> \"note for the team\""); process.exit(1); }
59
+ let resp;
60
+ try { resp = await http.post("notes", { action: "add_note", contact_id: contactId, text }); } catch (e) { fail("Note failed", e); }
61
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
62
+ console.log(`✓ internal note added (${resp.id}) as ${resp.author}; it shows on the Customer panel, never to the customer.`);
63
+ }
@@ -13,31 +13,78 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
13
13
  export async function list(search, opts = {}) {
14
14
  let resp;
15
15
  try {
16
- resp = await http.get("org", { list: "1", search: search || undefined });
16
+ resp = await http.get("org", { list: "1", search: search || undefined, number: opts.number || undefined });
17
17
  } catch (e) {
18
18
  console.error(`Lookup failed: ${e.message}`);
19
+ if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`);
19
20
  process.exit(1);
20
21
  }
21
22
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
22
23
 
23
- const orgs = (resp.orgs || []).filter((o) => (opts.all ? true : !o.inactive));
24
+ // A number lookup shows parked orgs too: the number is the question.
25
+ const orgs = (resp.orgs || []).filter((o) => (opts.all || opts.number ? true : !o.inactive));
24
26
  if (!orgs.length) {
25
- console.log(search ? `No organizations match "${search}".` : "No organizations found.");
27
+ console.log(opts.number ? `No organization has WhatsApp number / phone id / WABA ${opts.number}.` : search ? `No organizations match "${search}".` : "No organizations found.");
26
28
  return;
27
29
  }
28
30
  const pad = (s, n) => String(s ?? "").padEnd(n);
29
31
  const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
30
- console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${pad("PLATFORM", 9)}STORE`);
32
+ const byNumber = !!opts.number;
33
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${byNumber ? `${pad("NUMBER", 16)}${pad("PHONE ID", 18)}${pad("WABA", 18)}` : `${pad("PLATFORM", 9)}STORE`}`);
31
34
  for (const o of orgs) {
32
35
  console.log(
33
36
  `${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
34
- `${pad(o.platform || "—", 9)}${o.store || ""}${o.inactive ? " (inactive)" : ""}`
37
+ (byNumber
38
+ ? `${pad(o.phone_number || "—", 16)}${pad(o.phone_id || "—", 18)}${pad(o.waba_id || "—", 18)}`
39
+ : `${pad(o.platform || "—", 9)}${o.store || ""}`) +
40
+ `${o.inactive ? " (inactive)" : ""}`
35
41
  );
36
42
  }
37
43
  const hidden = (resp.orgs || []).length - orgs.length;
38
44
  console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
39
45
  }
40
46
 
47
+ // `flowiq org agent <org> on|off [--commit] [--yes]` — the agent master switch
48
+ // (organizations.wati_webhook_status). Off = no customer gets the AI. On with
49
+ // vert.active:false + an allowlist = test mode. On with vert.active true =
50
+ // live for everyone, which is the shape that needs --yes.
51
+ export async function agentSwitch(orgId, state, opts = {}) {
52
+ requireOrg(orgId);
53
+ const s = String(state || "").toLowerCase();
54
+ if (!["on", "off"].includes(s)) { console.error('Error: pass "on" or "off", e.g. flowiq org agent <org> off --commit'); process.exit(1); }
55
+ let resp;
56
+ try { resp = await http.post("org", { action: "agent_switch", organization_id: orgId, enabled: s === "on", dry_run: !opts.commit, confirm: !!opts.yes }); }
57
+ catch (e) { fail("Switch failed", e); }
58
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
59
+ console.log(`${resp.organization_name} — agent master switch`);
60
+ console.log(` now: ${resp.from ? "ON" : "OFF"} · vert.active ${resp.vert_active ?? "unset"}${resp.allowed_whatsapp_ids?.length ? ` · allowlist ${resp.allowed_whatsapp_ids.join(", ")}` : ""} · active agent ${resp.active_whatsapp_agent ?? "NONE"}`);
61
+ if (resp.unchanged) { console.log(` already ${resp.to ? "ON" : "OFF"} — nothing to do.`); return; }
62
+ console.log(` ${resp.dry_run ? "would set" : "✓ set"}: ${resp.to ? "ON" : "OFF"} → ${resp.mode}`);
63
+ if (resp.dry_run) {
64
+ console.log(`\nDRY RUN — add --commit to switch${resp.needs_confirm ? ", and --yes because this makes the agent LIVE for every customer (set vert.allowed_whatsapp_ids + vert.active false first for a test mode)" : ""}.`);
65
+ } else {
66
+ console.log(`\nUndo: flowiq org agent ${orgId} ${resp.from ? "on" : "off"} --commit${resp.from ? " --yes" : ""}`);
67
+ }
68
+ }
69
+
70
+ // `flowiq org park <org> [--unpark] --confirm` — organizations.inactive.
71
+ export async function park(orgId, opts = {}) {
72
+ requireOrg(orgId);
73
+ let resp;
74
+ try { resp = await http.post("org", { action: "park", organization_id: orgId, inactive: !opts.unpark, confirm: !!opts.confirm }); }
75
+ catch (e) { fail("Park failed", e); }
76
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
77
+ if (resp.unchanged) { console.log(`${resp.organization_name} is already ${resp.inactive ? "parked (inactive)" : "active"}.`); return; }
78
+ const verb = opts.unpark ? "unpark" : "park";
79
+ if (resp.dry_run) {
80
+ console.log(`Would ${verb} ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " Its agent master switch is still ON — parking hides the org from censuses and crons, it does not switch the agent off (flowiq org agent <org> off --commit)." : ""}`);
81
+ console.log("Add --confirm to write.");
82
+ return;
83
+ }
84
+ console.log(`✓ ${verb}ed ${resp.organization_name} (inactive ${resp.from} → ${resp.to}).${resp.master_switch && !opts.unpark ? " ⚠ agent master switch still ON." : ""}`);
85
+ console.log(`Undo: flowiq org park ${orgId}${opts.unpark ? "" : " --unpark"} --confirm`);
86
+ }
87
+
41
88
  export async function info(orgId, opts = {}) {
42
89
  if (!UUID_RE.test(orgId)) {
43
90
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -150,8 +197,9 @@ export async function whatsapp(orgId, opts = {}) {
150
197
  console.log(`${resp.checked} Meta number(s) checked at Meta, ${resp.unhealthy} with a problem (${resp.skipped_not_meta_or_unconnected} org(s) skipped: not Meta or not connected)`);
151
198
  const pad = (s, n) => String(s ?? "").padEnd(n);
152
199
  console.log("");
153
- console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("TIER", 12)} PROBLEMS`);
154
- for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "?", 12)} ${(o.problems || []).join("; ")}`);
200
+ console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("LIMIT", 14)} PROBLEMS`);
201
+ for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "—", 14)} ${(o.problems || []).join("; ")}${o.notes?.length ? `${o.problems?.length ? " · " : ""}info: ${o.notes.join("; ")}` : ""}`);
202
+ console.log(`\n LIMIT = the business portfolio's messaging limit (Meta sets it per portfolio now; the per-number tier field is deprecated). "—" = Meta returned neither field for this number.`);
155
203
  return;
156
204
  }
157
205
  const resp = await view(orgId, "whatsapp");
@@ -162,11 +210,11 @@ export async function whatsapp(orgId, opts = {}) {
162
210
  const p = w.phone || {};
163
211
  console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
164
212
  console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
165
- console.log(` quality: ${p.quality_rating ?? "?"} · limit tier ${p.messaging_limit_tier ?? "?"}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
213
+ console.log(` quality: ${p.quality_rating ?? "?"} · messaging limit ${w.messaging_limit ?? "not returned"}${w.messaging_limit_source ? ` (${w.messaging_limit_source})` : ""}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
166
214
  console.log(` name: ${p.name_status ?? "?"}${p.new_name_status && p.new_name_status !== "NONE" ? ` · pending "${p.new_display_name}" (${p.new_name_status})` : ""} · OBA ${yn(p.is_official_business_account)} · code verification ${p.code_verification_status ?? "?"}`);
167
215
  console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
168
216
  if (w.waba && !w.waba.error) console.log(` waba: ${w.waba.name ?? ""} · review ${w.waba.account_review_status ?? "?"} · ${w.waba.ownership_type ?? ""}`);
169
- console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}`);
217
+ console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}${w.notes?.length ? ` (info: ${w.notes.join("; ")})` : ""}`);
170
218
  }
171
219
 
172
220
  export async function health(orgId, opts = {}) {
@@ -175,8 +223,10 @@ export async function health(orgId, opts = {}) {
175
223
  const h = resp.health;
176
224
  console.log(`${resp.organization_name} — is the agent ready? ${h.ready ? "✓ yes" : `✗ ${h.problems.length} problem(s)`}`);
177
225
  for (const p of h.problems) console.log(` ✗ ${p}`);
226
+ for (const w of h.warnings || []) console.log(` ⚠ ${w}`);
178
227
  console.log(` switch: master ${h.master_switch ? "ON" : "OFF"} · vert.active ${yn(h.vert_active)}${h.allowed_whatsapp_ids ? ` (only ${h.allowed_whatsapp_ids})` : ""} · provider ${h.provider}${h.coexistence ? ` · ${h.coexistence}` : ""}${h.phone_number ? ` · ${h.phone_number}` : ""}`);
179
- console.log(` keys: OpenAI ${h.keys.openai.state}${h.keys.openai.detail ? ` (${h.keys.openai.detail})` : ""} · Claude ${h.keys.anthropic_present ? "present" : "none"}`);
228
+ const spare = h.keys.openai_spare;
229
+ console.log(` keys: OpenAI ${h.keys.openai.state}${h.keys.openai.detail ? ` (${h.keys.openai.detail})` : ""}${spare ? ` · spare ${spare.state}${spare.detail ? ` (${spare.detail})` : ""}` : ""} · Claude ${h.keys.anthropic_present ? "present" : "none"}`);
180
230
  console.log(` agent: ${h.agent ? `${h.agent.name} · ${h.agent.prompt_chars.toLocaleString()} prompt chars · prompt ${h.agent.use_settings_prompt === false ? "OFF" : "on"} · ${h.agent.model ?? "house model"} · product_lookup ${yn(h.agent.product_lookup)} · tickets ${yn(h.agent.ticket_tool_status)}` : "NONE"}`);
181
231
  console.log(` catalogue: ${h.catalogue.products} product rows · ${h.catalogue.embedded} with embeddings${h.catalogue.last_sync ? ` · last sync ${when(h.catalogue.last_sync.created_at)} ${h.catalogue.last_sync.success === false ? "FAILED" : "ok"} (+${h.catalogue.last_sync.added ?? 0} ~${h.catalogue.last_sync.updated ?? 0} -${h.catalogue.last_sync.deleted ?? 0})` : " · no sync recorded"}`);
182
232
  const s = h.store;
@@ -187,6 +237,22 @@ export async function health(orgId, opts = {}) {
187
237
 
188
238
  export async function automations(orgId, opts = {}) {
189
239
  requireOrg(orgId);
240
+ // --set <type> --template <name> / --body k=v,… / --buttons k=v,…: repoint
241
+ // one type at a new template version or fix its param mapping.
242
+ if (opts.set) {
243
+ const parseMap = (v, flag) => { if (v === undefined) return undefined; if (v === "none" || v === "null") return null; const out = {}; for (const pair of String(v).split(",").map((s) => s.trim()).filter(Boolean)) { const i = pair.indexOf("="); if (i < 1) { console.error(`Error: ${flag} takes param1=full_name,param2=order_number (got "${pair}")`); process.exit(1); } out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); } return out; };
244
+ const body = { action: "automations_set", organization_id: orgId, type: opts.set, template_name: opts.template, body_parameters: parseMap(opts.body, "--body"), button_parameters: parseMap(opts.buttons, "--buttons"), dry_run: !opts.commit };
245
+ if (body.template_name === undefined && body.body_parameters === undefined && body.button_parameters === undefined) { console.error("Error: with --set <type> pass --template <name>, --body k=v,… and/or --buttons k=v,… (\"none\" clears a mapping)."); process.exit(1); }
246
+ let resp;
247
+ try { resp = await http.post("org", body); } catch (e) { fail("Set failed", e); }
248
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
249
+ if (resp.unchanged) { console.log(`${opts.set}: nothing to change (template ${resp.current?.template_name ?? "none"}, mapping ${JSON.stringify(resp.current?.meta_parameter_mapping ?? null)}).`); return; }
250
+ console.log(`${resp.organization_name} · ${resp.type}:`);
251
+ for (const c of resp.changes) console.log(` ${c}`);
252
+ if (resp.warning) console.log(` ⚠ ${resp.warning}`);
253
+ console.log(resp.dry_run ? "\nDRY RUN — add --commit to write." : "\n✓ written (the next order event sends with these values).");
254
+ return;
255
+ }
190
256
  if (opts.enable || opts.disable) {
191
257
  const type = opts.enable || opts.disable;
192
258
  let resp;
@@ -208,6 +274,7 @@ export async function automations(orgId, opts = {}) {
208
274
  if (t.warning) console.log(` ⚠ ${t.warning}`);
209
275
  }
210
276
  console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
277
+ console.log(`flowiq org automations ${orgId} --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code [--commit]`);
211
278
  }
212
279
 
213
280
  export async function abandoned(orgId, opts = {}) {
@@ -230,6 +230,56 @@ export async function show(planId, opts = {}) {
230
230
  console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
231
231
  }
232
232
 
233
+ // `flowiq plans create <org> --topic … --type utility|marketing --date YYYY-MM-DD [--time HH:MM] …`
234
+ // Files a plan on the client's Planning board (the MCP submit_plan row). Staff
235
+ // are exempt from the client lead time. Dry run unless --commit.
236
+ export function parseButton(spec) {
237
+ // type:text[=value] e.g. quick_reply:VIEW MORE · url:CLICK HERE=https://x · phone:Call us=+27… · copy_code:SAVE10
238
+ const m = /^(quick_reply|url|phone|copy_code):(.*)$/.exec(String(spec).trim());
239
+ if (!m) { console.error(`Error: --button must be type:text[=value] (got "${spec}"); types quick_reply | url | phone | copy_code`); process.exit(1); }
240
+ const [, type, rest] = m;
241
+ if (type === "copy_code") return { type, code: rest.trim() };
242
+ const eq = rest.indexOf("=");
243
+ const text = (eq >= 0 ? rest.slice(0, eq) : rest).trim();
244
+ const value = eq >= 0 ? rest.slice(eq + 1).trim() : "";
245
+ if (type === "url") return { type, text, url: value };
246
+ if (type === "phone") return { type, text, phone_number: value };
247
+ return { type, text };
248
+ }
249
+
250
+ export async function create(orgId, opts = {}) {
251
+ if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
252
+ const buttons = (opts.button || []).map(parseButton);
253
+ for (const f of opts.reply || []) {
254
+ const i = f.indexOf("=");
255
+ if (i < 1) { console.error(`Error: --reply must be <button text>=<follow-up message> (got "${f}")`); process.exit(1); }
256
+ const b = buttons.find((x) => x.type === "quick_reply" && x.text.toLowerCase() === f.slice(0, i).trim().toLowerCase());
257
+ if (!b) { console.error(`Error: --reply names "${f.slice(0, i).trim()}" but no quick_reply button has that text`); process.exit(1); }
258
+ b.follow_up = f.slice(i + 1).trim();
259
+ }
260
+ const body = {
261
+ action: "create", organization_id: orgId, topic: opts.topic, type: opts.type, scheduled_date: opts.date, scheduled_time: opts.time,
262
+ product_focus: opts.product, audience: opts.audience, line_1: opts.line1, line_2: opts.line2, follow_up_message: opts.followUp, landing_page_url: opts.landing,
263
+ message: opts.message, buttons: buttons.length ? buttons : undefined, graphic_brief: opts.graphic, media_url: opts.media, save_as_draft: !!opts.draft, dry_run: !opts.commit,
264
+ };
265
+ let resp;
266
+ try { resp = await http.post("plans", body); }
267
+ catch (e) {
268
+ console.error(`Create failed: ${e.message}`);
269
+ for (const p of e.body?.problems || []) console.error(` - ${p}`);
270
+ process.exit(1);
271
+ }
272
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
273
+ console.log(`${resp.organization_name}: "${resp.topic}" (${resp.type}) · ${resp.send} · audience: ${resp.audience} · files as ${resp.files_as}`);
274
+ console.log(` copy: ${String(resp.copy).replace(/\n/g, "\n ")}`);
275
+ if (resp.buttons?.length) console.log(` buttons: ${resp.buttons.map((b) => `${b.type}:${b.text ?? b.code}${b.url ? `=${b.url}` : ""}`).join(" · ")}`);
276
+ if (resp.follow_ups && Object.keys(resp.follow_ups).length) console.log(` follow-ups: ${JSON.stringify(resp.follow_ups)}`);
277
+ console.log(` graphic: ${resp.graphic}`);
278
+ for (const n of resp.notes || []) console.log(` ⚠ ${n}`);
279
+ if (resp.dry_run) { console.log("\nDRY RUN — nothing filed. Add --commit to file it on the Planning board."); return; }
280
+ console.log(`\n✓ filed plan ${resp.plan_id} (${resp.status}). ${resp.alert?.note ?? ""}\n flowiq plans show ${resp.plan_id}`);
281
+ }
282
+
233
283
  export async function status(planId, opts = {}) {
234
284
  if (!UUID_RE.test(planId)) {
235
285
  console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);