@flowapt/flowiq-cli 0.1.12 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,10 +155,123 @@ flowiq ct list
155
155
  ```
156
156
 
157
157
  - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
158
+ - **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
158
159
  - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Bad payloads are rejected before anything writes.
159
160
  - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
160
161
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
161
162
 
163
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|list` (alias `bc`)
164
+
165
+ Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
166
+ template's variables **per row** from the CSV's own columns. The
167
+ highest-stakes command in the CLI — built as a map → preview → commit
168
+ pipeline with several layers of deliberate friction.
169
+
170
+ ```bash
171
+ # 1) Build the column→param mapping interactively (saved per campaign, no send)
172
+ flowiq bc map <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
173
+
174
+ # 2) Dry-run: validation + exact rendered messages for sample rows (default)
175
+ flowiq bc send <org_id> --template rewards_referral_v1 --csv ./people.csv --campaign july-referrals
176
+
177
+ # 3) Commit: upsert contacts, then send per row @ ≤8/s
178
+ flowiq bc send <org_id> --template … --csv … --campaign july-referrals --commit
179
+ # → prompts: type the campaign name to confirm
180
+
181
+ # 4) Interrupted? Continue only unsent rows:
182
+ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
183
+ ```
184
+
185
+ - **Per-row values**: each template slot maps to a CSV **column**, a
186
+ **literal**, or a **concat transform** (e.g. `Country Code` + `Phone`,
187
+ leading-zero handled). The mapper auto-suggests from the template's
188
+ semantic labels + body text and you confirm each slot once; the mapping is
189
+ saved to `.flowiq/campaigns/<campaign>.json` and reused.
190
+ - **v1 scope**: Meta orgs, POSITIONAL templates, text/no header. NAMED,
191
+ carousel, media-header templates and WATI orgs are refused with a clear
192
+ message.
193
+ - **Validation before anything sends**: APPROVED-only, every slot mapped,
194
+ contiguous params (the Meta `#132000` guard — a stray key is structurally
195
+ impossible), phone validity, illegal characters (Meta `#100`; `reject` by
196
+ default), duplicates, and **live broadcast-safety** — opted-out / archived /
197
+ blocked contacts are skipped, always.
198
+ - **Dry-run is the default**; `--commit` + typing the campaign name is the
199
+ only way to send (`--yes` for CI skips the typing, never the dry-run).
200
+ - **Write-ahead status log** (`.flowiq/campaigns/<campaign>.status.jsonl`):
201
+ every row logs `sending` *before* the POST and `sent`/`failed` after. A
202
+ crash mid-row leaves an *ambiguous* row that is **never auto-resent** —
203
+ it's surfaced for manual review. `resume` sends only never-attempted rows
204
+ (`--retry-failed` adds confirmed failures).
205
+ - **Error handling**: `#132000` aborts the run (config bug — every row would
206
+ fail); rate errors back off exponentially and halve the send rate; a Meta
207
+ daily-cap soft-stops cleanly with a resume hint.
208
+ - Contacts are **pre-upserted** (`/cli/contacts-upsert`, ≤4000/batch, retries
209
+ then aborts) so the send payload never needs a `name` key — the classic
210
+ `#132000` footgun.
211
+
212
+ **Tag mode** — send to everyone carrying a tag (e.g. a `segments` batch tag)
213
+ instead of a CSV. Same guards, same status log, same resume:
214
+
215
+ ```bash
216
+ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
217
+ --body param1="Hi {{first_name}}" [--button param1=<short-code>] --commit
218
+ ```
219
+
220
+ Values are shared across the tag (use `{{first_name}}` etc. for per-contact
221
+ personalization — resolved server-side). Opted-out / archived / blocked
222
+ contacts under the tag are excluded automatically. Tags with >2000 contacts
223
+ are refused — slice them with `flowiq segments` first.
224
+
225
+ ### Segments — `flowiq segments plan|apply|list|untag` (alias `seg`)
226
+
227
+ Slice a big contact cohort into fixed-size **batch tags** (default 75) so
228
+ sends respect Meta tier limits and stay controllable — the batch tag is your
229
+ throttle *and* your brake. Tags only; the send is `broadcast send --tag` (one
230
+ batch at a time) or the dashboard broadcaster.
231
+
232
+ ```bash
233
+ flowiq seg plan <org_id> --tag-prefix july-promo --ids-file ./cohort.txt # or --from-segment snapshot.json
234
+ # → exclusions (opt-out/archived/blocked) applied BEFORE slicing → .flowiq/segments/<slug>.json
235
+ flowiq seg apply <org_id> july-promo # DRY-RUN
236
+ flowiq seg apply <org_id> july-promo --commit # append the tags (idempotent — re-runs report already_had)
237
+ flowiq seg list <org_id> --prefix july-promo # VERIFY: tag → count
238
+ flowiq seg untag <org_id> july-promo --commit --confirm # ROLLBACK (its own tags only)
239
+ ```
240
+
241
+ - The cohort comes in as **contact UUIDs** (a snapshot JSON's `contact_ids[]`
242
+ or a plain file); defining the cohort with SQL stays upstream.
243
+ - Apply is **append-only** — it never touches a contact's other tags, names,
244
+ or anything else, and never double-adds.
245
+ - Point-in-time warning: the plan tags snapshot ids; re-derive the cohort SQL
246
+ if freshness matters (someone who ordered since won't auto-drop).
247
+
248
+ ### Keywords — `flowiq keywords pull|push|list` (alias `kw`)
249
+
250
+ Round-trips an org's **keyword auto-reply engine** — the `keywords` +
251
+ `keyword_actions` rows that fire instant replies / contact updates when a
252
+ customer message matches a trigger word. This is the **safe editor** for the
253
+ attribute-write actions (competition entries) that the dashboard editor
254
+ corrupts on save.
255
+
256
+ ```bash
257
+ flowiq kw pull <org_id> # → ./.flowiq/keywords/<slug>.json (all keywords + actions)
258
+ flowiq kw push <slug> --dry-run # plan: created/updated/deleted at both levels, no write
259
+ flowiq kw push <slug> # id-keyed upsert (never touches keywords absent from the file)
260
+ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from the file (dry-run first!)
261
+ ```
262
+
263
+ - **Identity = the `id`s.** Keep an id to update that row; drop the id to
264
+ create a new one; remove an *action* from a kept keyword to delete it.
265
+ A file id that doesn't exist for the org aborts (stale file → re-pull).
266
+ - **Pull immediately before editing** — clients edit auto-reply copy live.
267
+ - `text` is stored **verbatim** (byte-identical — required when the reply
268
+ must match a prompt quote exactly). `position` must be ≥ 1 (0 silently
269
+ collapses to 1 at runtime — rejected).
270
+ - `field:"attributes"` actions are warned (full jsonb replace; constant
271
+ values only) but applied — this CLI is their only safe editing surface.
272
+ - Scheduled keywords: the keyword-scheduler cron reconciles `active` from
273
+ `start_date`/`end_date` within ~30 min of your push.
274
+
162
275
  ### Messages — `flowiq messages pull <contact_id>` (alias `m`)
163
276
 
164
277
  Read-only pull of a contact's `helpdesk_messages` history. Anchors on the
@@ -303,15 +416,28 @@ Media headers: pass `media_header.file_url` (a public URL) — the edge function
303
416
  uploads it to Meta server-side. Approval is async; re-`pull` for the
304
417
  authoritative Meta status.
305
418
 
306
- ### Org info — `flowiq org info <organization_id>`
419
+ ### Org — `flowiq org create` / `flowiq org info <organization_id>`
307
420
 
308
- Read-only platform detect + active-agent lookup for an org (raw store/Meta
421
+ Create a brand-new organization, or look one up (read-only; raw store/Meta
309
422
  credentials are stripped server-side).
310
423
 
311
424
  ```bash
425
+ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.com] [--provider wati]
426
+ # → org row + admin membership. Owner defaults to YOU; --owner requires the
427
+ # person to already have an account (otherwise invite them later in the app).
428
+ # Provider defaults to meta. Prints the new org id + the next-step commands.
429
+
312
430
  flowiq org info <organization_id> # platform, storefront url, active agent
313
431
  ```
314
432
 
433
+ Full new-client bootstrap from the terminal:
434
+ ```bash
435
+ flowiq org create --name "New Client"
436
+ flowiq agent create <new_org_id> --name "Zara" --make-active
437
+ flowiq agent config <new_org_id> --model gpt-5.4-mini --use-settings-prompt
438
+ flowiq prompts pull <new_org_id> # edit → push
439
+ ```
440
+
315
441
  ### Agents — `flowiq agent list|create <org>`
316
442
 
317
443
  List an org's agents (to discover ids) and create a new one.
@@ -365,19 +491,30 @@ the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field
365
491
  `shopify_products_web_chat`), and `discount.enabled`. Anything else is rejected;
366
492
  every change is reported before → after.
367
493
 
368
- ### Agent updates — `flowiq agent-updates pull|list` (alias `au`)
494
+ ### Agent updates — `flowiq agent-updates pull|list|resolve` (alias `au`)
369
495
 
370
- Read-only pull of an org's client-raised "change the agent" requests
371
- (`agent_updates`), each with the chat context around the triggering message +
372
- the linked contact. Attachments are downloaded locally.
496
+ Pull an org's client-raised "change the agent" requests (`agent_updates`),
497
+ each with the chat context around the triggering message + the linked contact
498
+ (attachments downloaded locally) — then, once the fix is verified, resolve the
499
+ ticket from the same terminal.
373
500
 
374
501
  ```bash
375
502
  flowiq au pull <organization_id> # pending, with context
376
503
  flowiq au pull <organization_id> --status all --titles "EFT, bank"
377
504
  flowiq au pull <organization_id> --before 20 --after 10 --no-files
378
505
  # → ./.flowiq/agent-updates/<slug>.json + files/<update-id>/<attachment>
506
+
507
+ flowiq au resolve <update_id> --note "We updated the agent so it no longer …"
508
+ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
379
509
  ```
380
510
 
511
+ - **`--note` is what the client reads** (rendered as `FlowIQ: <note>` in their
512
+ console) and is **required** when resolving; `--internal` is staff-only.
513
+ - The typical flow is `prompts push → flowiq test → au resolve` — resolve is
514
+ the LAST step, after a live test proves the fix. Don't resolve on hope.
515
+ - An interactive confirm shows exactly what the client will read; `--yes`
516
+ skips the confirm (but never the `--note` requirement).
517
+
381
518
  ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
382
519
 
383
520
  Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
package/TEAM-GUIDE.md ADDED
@@ -0,0 +1,136 @@
1
+ # FlowIQ CLI — Team Guide
2
+
3
+ The `flowiq` CLI is how Flowapt staff work on client agents and org data from
4
+ the terminal: prompts, questionnaires, fine-tuning, knowledge sources, custom
5
+ tools, webhooks, templates, chat reads, live agent testing, and more — all
6
+ **without a database credential ever touching your laptop**. Every command goes
7
+ to `api.flowiq.live` with your personal staff key, is validated server-side,
8
+ and is logged under your name.
9
+
10
+ This guide is the "how we work" version. The full command reference with every
11
+ flag is [README.md](./README.md).
12
+
13
+ ---
14
+
15
+ ## Get set up (2 minutes)
16
+
17
+ ```bash
18
+ npm i -g @flowapt/flowiq-cli
19
+ flowiq auth login
20
+ ```
21
+
22
+ `auth login` works like `npm login`:
23
+
24
+ 1. Your terminal shows a short code, e.g. `FQ4X-9KTB`.
25
+ 2. Press ENTER — your browser opens `app.flowiq.live/cli-auth`.
26
+ 3. A logged-in **super admin** (you, if you are one) checks the code on the
27
+ page matches the one in your terminal and clicks **Approve**.
28
+ 4. Back in the terminal: you're logged in. A personal key was minted for this
29
+ device — you never see it, never paste it.
30
+
31
+ Confirm with:
32
+
33
+ ```bash
34
+ flowiq auth whoami
35
+ ```
36
+
37
+ > **Approving someone else's login:** when a teammate runs `auth login`, they
38
+ > read you their code (or you open the link they send). On `/cli-auth`, check
39
+ > the code AND the device name match what they told you, then Approve.
40
+ > **Never approve a code you can't verify came from a teammate's terminal
41
+ > right now** — that's the one way this flow can be abused.
42
+
43
+ ## The golden rules
44
+
45
+ 1. **Pull before you edit.** Local files under `./.flowiq/` are working
46
+ copies, not the source of truth. Someone may have edited in the UI since
47
+ your last pull — always re-pull first.
48
+ 2. **`--dry-run` before any push to a production agent** (where the command
49
+ supports it: `custom-tools`, `messaging-webhooks`). It shows exactly what
50
+ would change before anything writes.
51
+ 3. **Pushes are full-replace.** What's in your file becomes the whole surface
52
+ (all prompt sections, all tools, all webhooks). Deleting an entry from the
53
+ file deletes it from the platform.
54
+ 4. **`--agent <id>` targets a specific agent**; without it you get the org's
55
+ *active* agent. Files remember which agent they came from, so a push always
56
+ goes back to the agent you pulled — never "whatever is active now".
57
+ 5. Run the CLI from the repo root when you can — `./.flowiq/` is gitignored
58
+ there.
59
+
60
+ ## Everyday tasks
61
+
62
+ | I want to… | Run |
63
+ |---|---|
64
+ | Edit a client agent's system prompt | `flowiq prompts pull <org_id>` → edit the JSON → `flowiq prompts push <slug>` |
65
+ | Edit the questionnaire answers | `flowiq q pull <org_id>` → edit → `flowiq q push <slug>` |
66
+ | Edit fine-tuning instructions / Q&A | `flowiq ft pull <org_id>` → edit → `flowiq ft push <slug>` |
67
+ | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
68
+ | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
69
+ | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
70
+ | Edit keyword auto-replies (incl. competition entry keywords) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug>` |
71
+ | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
72
+ | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
73
+ | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
74
+ | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
75
+ | Split a big cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
76
+ | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
77
+ | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
78
+ | Export an org's full chat history | `flowiq export chats <org_id>` |
79
+ | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
80
+ | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
81
+ | Manage outbound messaging webhooks | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
82
+ | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
83
+ | See a client's pending change requests | `flowiq au pull <org_id>` |
84
+ | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
85
+ | Check an org's platform + active agent | `flowiq org info <org_id>` |
86
+ | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
87
+
88
+ The flow is the same everywhere: **pull → edit the JSON → push**. Slugs are the
89
+ filename the pull printed (e.g. `phytoceutics`), or pass a file path.
90
+
91
+ ### Example: fix a line in a client's prompt
92
+
93
+ ```bash
94
+ flowiq prompts pull 08becb51-9734-432e-a1e8-3a2115c0f3eb
95
+ # → wrote ./.flowiq/prompts/phytoceutics.json
96
+ # edit the section's "content" in that file (keep section "id"s intact)
97
+ flowiq prompts push phytoceutics
98
+ # server regenerates the full system prompt from the sections
99
+ flowiq test send 08becb51-… "question that exercises your change"
100
+ ```
101
+
102
+ ### Example: safely change custom tools
103
+
104
+ ```bash
105
+ flowiq ct pull <org_id>
106
+ # edit tools[] in the JSON
107
+ flowiq ct push <slug> --dry-run
108
+ # → "diff: +1 added (…); ~1 changed (…); 8 unchanged" + any warnings
109
+ flowiq ct push <slug>
110
+ ```
111
+
112
+ Custom tools define real HTTP calls the agent can execute, so the server
113
+ validates hard (names, URLs, methods, parameter shapes) and warns about typo'd
114
+ keys or `{{placeholders}}` it doesn't recognise. Take the warnings seriously.
115
+
116
+ ## Keys, rotation, logging out
117
+
118
+ - **`flowiq auth refresh`** — rotates this device's key in place (a fresh key
119
+ is minted, the old one is revoked server-side). Run it whenever you want a
120
+ new secret, e.g. after using the CLI on a machine you don't fully trust.
121
+ - **`flowiq auth logout`** — deletes the key file from this machine only. The
122
+ key itself stays valid server-side, so if the machine is lost/compromised,
123
+ run `refresh` first (kills the old key) or ask a super admin to revoke it.
124
+ - Keys live at `~/.config/flowiq/auth.json` (chmod 600). CI can use
125
+ `FLOWIQ_TOKEN=…` instead.
126
+
127
+ ## Troubleshooting
128
+
129
+ | Symptom | Fix |
130
+ |---|---|
131
+ | `npm i -g …` says the new version doesn't exist | `npm i -g @flowapt/flowiq-cli@<version> --prefer-online` (npm's cache is stale for a few minutes after a publish) |
132
+ | `401 Unauthorized` on every command | `flowiq auth refresh` (falls back to browser login if the key is dead) |
133
+ | `403 … doesn't cover "<scope>"` | Your admin access has been scoped down — ask another super admin to widen your `allowed_scopes` |
134
+ | "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
135
+ | Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
136
+ | Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.12",
3
+ "version": "0.2.4",
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": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "README.md"
12
+ "README.md",
13
+ "TEAM-GUIDE.md"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"
@@ -4,6 +4,7 @@
4
4
 
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import readline from "node:readline";
7
8
  import { http } from "../http.js";
8
9
 
9
10
  const AU_DIR = path.resolve(process.cwd(), ".flowiq", "agent-updates");
@@ -184,3 +185,72 @@ export async function list() {
184
185
  }
185
186
  }
186
187
  }
188
+
189
+ // --- resolve (write-back) ---------------------------------------------------
190
+ // Flips a client-raised ticket to resolved/declined and writes the note the
191
+ // CLIENT reads ("FlowIQ: <note>"). --internal is staff-only. Deliberately a
192
+ // separate explicit step after `flowiq test` proves the fix — never automate
193
+ // it off the back of a push ("don't resolve on hope").
194
+
195
+ const UUID_RE_RESOLVE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
196
+
197
+ function confirmPrompt(question) {
198
+ return new Promise((resolve) => {
199
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
200
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
201
+ });
202
+ }
203
+
204
+ export async function resolve(updateId, opts = {}) {
205
+ if (!UUID_RE_RESOLVE.test(updateId)) {
206
+ console.error(`Error: "${updateId}" is not a valid update UUID (get it from agent-updates pull).`);
207
+ process.exit(1);
208
+ }
209
+ const status = opts.status || "resolved";
210
+ if (!["resolved", "declined"].includes(status)) {
211
+ console.error(`Error: --status must be resolved or declined (got "${status}").`);
212
+ process.exit(1);
213
+ }
214
+ // R-3: a client-facing note is mandatory on resolve (--yes does NOT waive it).
215
+ if (status === "resolved" && (!opts.note || !opts.note.trim())) {
216
+ console.error('Refusing to resolve without --note (the client reads this as "FlowIQ: …").');
217
+ console.error('Pass --note "…", or use --status declined.');
218
+ process.exit(1);
219
+ }
220
+
221
+ if (!opts.yes) {
222
+ console.log(`About to mark update ${updateId} as ${status.toUpperCase()}.`);
223
+ if (opts.note) console.log(`The client will read: FlowIQ: ${opts.note}`);
224
+ if (opts.internal) console.log(`Staff-only note: ${opts.internal}`);
225
+ const answer = await confirmPrompt("Type y to confirm: ");
226
+ if (answer !== "y" && answer !== "yes") {
227
+ console.log("Aborted — nothing changed.");
228
+ process.exit(0);
229
+ }
230
+ }
231
+
232
+ let resp;
233
+ try {
234
+ resp = await http.post("agent-updates", {
235
+ action: "resolve",
236
+ update_id: updateId,
237
+ status,
238
+ superadmin_comment: opts.note ?? null,
239
+ internal_notes: opts.internal ?? null,
240
+ superadmin_response_image: opts.image ?? null,
241
+ });
242
+ } catch (e) {
243
+ console.error(`Resolve failed: ${e.message}`);
244
+ if (e.body?.error) console.error(` ${e.body.error}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ console.log(`Update ${resp.update_id} → ${resp.status.toUpperCase()}`);
249
+ console.log(` org: ${resp.organization_name ?? resp.organization_id}`);
250
+ console.log(` title: ${resp.title}`);
251
+ console.log(` was: ${resp.previous_status}`);
252
+ console.log(` by: ${resp.resolved_by_email ?? resp.resolved_by}`);
253
+ if (resp.superadmin_comment) console.log(` client: FlowIQ: ${resp.superadmin_comment}`);
254
+ if (resp.internal_notes) console.log(` internal: ${resp.internal_notes}`);
255
+ if (resp.already_resolved) console.log(` ⚠ was already terminal (${resp.previous_status}) — note/status overwritten.`);
256
+ }