diff --git a/README.md b/README.md index cbfa35e..8646c50 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,19 @@ mb card query 1 --parameters '[{"type":"category","value":"A","target":["variabl | `--format-rows` | Streamed exports only: apply the card's visualization-settings formatting to values (default `false`). | | `--pivot-results` | Streamed exports only: emit the pivoted output for pivot questions (default `false`). | +### `mb card alerts ` + +List the alerts watching this card. Manage them with `mb alert create|update|send|archive`, which take the alert id printed here. + +```sh +mb card alerts 94 +mb card alerts 94 --include-inactive --json +``` + +| Flag | Description | +| -------------------- | ----------------------------------- | +| `--include-inactive` | Include archived (inactive) alerts. | + ### `mb card create` ```sh @@ -650,6 +663,19 @@ mb dashboard cards 1 mb dashboard cards 1 --json ``` +### `mb dashboard subscriptions ` + +List the subscriptions delivering this dashboard. Manage them with `mb subscription create|update|archive`, which take the subscription id printed here. + +```sh +mb dashboard subscriptions 10 +mb dashboard subscriptions 10 --json +``` + +| Flag | Description | +| ------------ | --------------------------------------------------- | +| `--archived` | Show archived subscriptions instead of active ones. | + #### Dashboard parameters (filters) A dashboard's `parameters` are its filter widgets. They're typed (`Parameter` schema): an invalid `type` is rejected at the CLI boundary with a message that echoes the full allowed enum (`string/=`, `string/contains`, `number/between`, `date/range`, `category`, `id`, `temporal-unit`, …). @@ -1070,6 +1096,163 @@ mb timeline-event delete 1 | ------- | ------------------ | | `--yes` | Skip confirmation. | +## Dashboard subscriptions + +Read and write dashboard subscriptions on `/api/pulse`. A subscription delivers a rendered dashboard on a schedule — by email, to a Slack channel, or to an HTTP webhook. It pins the dashboard's cards by both `id` (the card) and `dashboard_card_id` (its placement); `mb dashboard cards ` prints both. + +A subscription's `dashboard_id` and `collection_id` are fixed at creation. There is no delete — archiving is the terminal state, and it also disables every channel. + +### `mb subscription list` + +```sh +mb subscription list +mb subscription list --dashboard-id 10 --json +mb subscription list --archived --json +``` + +| Flag | Description | +| --------------------- | --------------------------------------------------- | +| `--dashboard-id ` | Only subscriptions on this dashboard. | +| `--archived` | Show archived subscriptions instead of active ones. | + +Listing from the dashboard side is `mb dashboard subscriptions `. + +### `mb subscription get ` + +```sh +mb subscription get 1 +mb subscription get 1 --full --json +``` + +The compact view returns `id`, `name`, `dashboard_id`, `collection_id`, `archived`, `skip_if_empty`, plus the pinned `cards` and the `channels` with their schedules and recipients. `--full` adds the hydrated creator, entity ids, and per-card download permissions. + +### `mb subscription create` + +The body needs `name`, `dashboard_id`, `cards`, and `channels`. + +Each channel names a `channel_type` (`email`, `slack`, `http`) and a `schedule_type` (`hourly`, `daily`, `weekly`, `monthly`) plus the fields that schedule needs: `daily` needs `schedule_hour` (0–23); `weekly` also needs `schedule_day` (`mon`…`sun`); `monthly` also needs `schedule_frame` (`first`, `mid`, `last`). Email recipients are `{"email":"a@b.com"}` or `{"id":}`; Slack targets a channel with `"details":{"channel":"#general"}`. A channel is `enabled` unless you say otherwise. + +```sh +mb subscription create --body '{"name":"Weekly orders","dashboard_id":10,"cards":[{"id":94,"dashboard_card_id":87,"include_csv":false,"include_xls":false}],"channels":[{"channel_type":"email","schedule_type":"daily","schedule_hour":8,"recipients":[{"email":"team@example.com"}]}]}' +cat subscription.json | mb subscription create +mb subscription create --file subscription.json +``` + +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | + +### `mb subscription update ` + +Patches `name`, `cards`, `channels`, `skip_if_empty`, `parameters`, `archived`. `cards` and `channels` each replace the whole list, so send every one you want to keep — `mb subscription get --full` prints the current set. + +The update reads the subscription first and carries `archived` and `skip_if_empty` forward when your patch omits them. That is load-bearing: `PUT /api/pulse/:id` defaults every omitted key, and both of those default to `false`, so a raw name-only PUT would un-archive the subscription and clear `skip_if_empty`. + +```sh +mb subscription update 1 --body '{"name":"Daily orders"}' +mb subscription update 1 --body '{"channels":[{"channel_type":"email","schedule_type":"weekly","schedule_hour":8,"schedule_day":"mon","recipients":[{"email":"team@example.com"}]}]}' +mb subscription update 1 --file patch.json +``` + +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | + +### `mb subscription archive ` + +Archive a subscription, stopping all deliveries. Also disables every channel on it, so restoring means un-archiving and then re-enabling the channels. + +```sh +mb subscription archive 1 +mb subscription archive 1 --json +``` + +## Question alerts + +Read and write question alerts on `/api/notification`. An alert watches one card and delivers it when a send condition fires on a schedule: `has_result` (the card returned any row), or `goal_above` / `goal_below` (both need a goal set on the card's visualization). + +Schedules are Quartz cron strings — `0 0 8 * * ? *` is daily at 08:00. `/api/notification` also carries Metabase's internal system-event notifications; `mb alert` scopes every request to card alerts, so they never appear. + +Archiving deactivates an alert rather than deleting it: `mb alert list --include-inactive` still finds it, and `mb alert update --body '{"active":true}'` brings it back. + +### `mb alert list` + +```sh +mb alert list +mb alert list --card-id 94 --json +mb alert list --include-inactive --json +``` + +| Flag | Description | +| --------------------- | ----------------------------------- | +| `--card-id ` | Only alerts watching this card. | +| `--creator-id ` | Only alerts created by this user. | +| `--recipient-id ` | Only alerts delivered to this user. | +| `--include-inactive` | Include archived (inactive) alerts. | + +Listing from the question side is `mb card alerts `. + +### `mb alert get ` + +```sh +mb alert get 9 +mb alert get 9 --full --json +``` + +The compact view returns `id`, `active`, `creator_id`, the `payload` (`card_id`, `send_condition`, `send_once`), the cron `subscriptions`, and the `handlers` with their recipients. `--full` adds the hydrated card the alert watches. + +### `mb alert create` + +The body needs `payload`, `subscriptions`, and `handlers`. Each handler names a `channel_type` (`channel/email`, `channel/slack`, `channel/http`) and its `recipients`; a recipient is `{"type":"notification-recipient/user","user_id":3}` or `{"type":"notification-recipient/raw-value","details":{"value":"a@b.com"}}`. + +```sh +mb alert create --body '{"payload":{"card_id":94,"send_condition":"has_result"},"subscriptions":[{"cron_schedule":"0 0 8 * * ? *"}],"handlers":[{"channel_type":"channel/email","recipients":[{"type":"notification-recipient/raw-value","details":{"value":"team@example.com"}}]}]}' +cat alert.json | mb alert create +mb alert create --file alert.json +``` + +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | + +### `mb alert update ` + +Patches the top-level fields you send: `payload`, `subscriptions`, `handlers`, `active`. Fields inside `payload` merge over the current ones, so `{"payload":{"send_condition":"goal_above"}}` keeps the card. `subscriptions` and `handlers` each replace the whole list — `mb alert get ` prints the current set. An alert cannot be moved to a different card. + +The update reads the alert first and merges your patch over it. That is load-bearing: `PUT /api/notification/:id` is a spec-diff, and a body whose `id` doesn't match the stored one makes Metabase delete the alert and insert a replacement under a fresh id. + +```sh +mb alert update 9 --body '{"payload":{"send_condition":"goal_above"}}' +mb alert update 9 --body '{"subscriptions":[{"cron_schedule":"0 0 9 * * ? *"}]}' +mb alert update 9 --body '{"active":true}' +``` + +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | + +### `mb alert send ` + +Send an alert now, off-schedule. Delivers to every handler, ignoring the send condition. The channel must be configured on the server (email needs SMTP set up). + +```sh +mb alert send 9 +mb alert send 9 --json +``` + +### `mb alert archive ` + +Archive an alert, stopping all deliveries and dropping its scheduled trigger. + +```sh +mb alert archive 9 +mb alert archive 9 --json +``` + ## Collections Read collections on `/api/collection`. Collections are the folders that contain cards, dashboards, and other collections. The list endpoint surfaces a virtual root collection (id `"root"`) alongside regular numeric ids; the get endpoint accepts only the numeric id. @@ -1664,15 +1847,19 @@ mb skills path core # one path Bundled skills: -| Name | Use | -| --------------------- | --------------------------------------------------------------------------------------------- | -| `core` | Top-level guide: auth, flag conventions, output flags, body input, every command group | -| `transform` | Authoring and running transforms (native SQL + MBQL 5), iteration, run inspection | -| `data-transformation` | Raw, normalized source database → clean, wide, analysis-ready tables for a non-technical user | -| `semantic-layer` | Turning clean tables into reusable segments, measures, and metrics for a non-technical user | -| `robot-data-engineer` | Front-door router for the whole journey (raw → tables → definitions → dashboards) | -| `document` | Authoring document bodies: the TipTap JSON tree, embedding cards, entity links | -| `git-sync` | Round-tripping Metabase content to/from a git remote | +| Name | Use | +| --------------- | --------------------------------------------------------------------------------------- | +| `core` | Top-level guide: auth, flag conventions, output flags, body input, every command group | +| `data-workflow` | Front-door router for the whole journey (raw → clean tables → definitions → dashboards) | +| `mbql` | Authoring and fixing MBQL 5 query bodies | +| `native-sql` | Native SQL query bodies: template tags, field filters, snippets, card references | +| `visualization` | Choosing a card's `display` and authoring `visualization_settings` | +| `dashboard` | Interactive dashboards: filter wiring, linked filters, cross-filtering, click behavior | +| `metadata` | Semantic types, FK targets, dropdown behavior, and the features each unlocks | +| `transform` | Authoring and running transforms (native SQL + MBQL 5), iteration, run inspection | +| `notification` | Scheduled delivery: question alerts and dashboard subscriptions | +| `document` | Authoring document bodies: the TipTap JSON tree, embedding cards, entity links | +| `git-sync` | Round-tripping Metabase content to/from a git remote | Discovery surfaces: diff --git a/missing-skills.md b/missing-skills.md deleted file mode 100644 index 3997fba..0000000 --- a/missing-skills.md +++ /dev/null @@ -1,86 +0,0 @@ -# Missing skills - -Three skills should be added, in priority order: **`native-sql`**, **`metadata`**, and **`dashboard`**. Each closes a gap where the `mb` CLI exposes real capability, the knowledge to use it correctly is non-obvious, and no existing skill covers it. A handful of smaller gaps are better fixed by extending `core` and `data-workflow` than by new skills, and several large Metabase topic areas are deliberately excluded because the CLI has no commands for them. - -The bar applied throughout: a skill must carry judgment, workflow, or a hard-to-guess contract that an agent gets wrong without it — not restate an endpoint the `mb __manifest` already documents. Complex API surfaces (like MBQL) qualify; plain CRUD does not. - -## Current coverage - -Seven user-facing skills ship today: `core` (foundations + per-resource footguns), `data-workflow` (the end-to-end analyst router, with `building-clean-tables` / `reusable-definitions` / `answering-questions` references), `mbql` (MBQL 5 query bodies), `visualization` (chart `display` + `visualization_settings`), `document`, `git-sync`, `transform`. Together they cover MBQL authoring, chart rendering, transforms/ETL, documents, and version control. - -The ideal the skills exist to reinforce is one semantic-layer hierarchy: **transforms build clean tables → segments and measures are defined on them → metrics and questions build on those → dashboards sit on top.** There is no separate "model" layer — a curated table _is_ a transform output. `data-workflow` already routes along this arc; the gaps below are the layers where the CLI can do meaningful work that none of the current skills teach. - ---- - -## 1. `native-sql` — the SQL counterpart to `mbql` - -**Trigger / when-to-use:** "write a SQL question", "add a filter widget to my SQL", "parameterize this query", "use a field filter", "reference a saved question in SQL", "use a snippet", "why is my `{{variable}}` returning no rows". - -**The gap.** `mbql` is the query-body skill, but it is MBQL-only — it explicitly punts on native SQL ("skips pre-flight"). Native SQL only appears elsewhere as embedding mechanics (`transform`) or a one-line "native template-tag ids" aside (`mbql`). Yet the CLI authors native SQL as a first-class surface: `card create` / `card update` with a native `dataset_query`, `transform` native sources, and `snippet`. The entire template-tag / parameter layer is undocumented. - -**Why it's non-obvious (not an API restatement).** Native SQL parameterization is a genuinely complex, error-prone contract — the SQL analogue of MBQL that the user's own rule says "deserves to be described": - -- **Template-tag types** (`src/metabase/lib/schema/template_tag.cljc`): `:text` / `:number` / `:date` / `:boolean` raw-value variables vs. `:dimension` **field filters** vs. `:card` (referencing another question, `{{#123}}`) vs. `:snippet` (`{{snippet: name}}`). Each has a different body shape and a different `parameter_mappings` target — a raw variable maps as `["variable", ["template-tag", tag]]`, a field filter as `["dimension", ["template-tag", tag]]`. Getting the pair wrong silently breaks the filter. -- **Field filters vs. basic variables** (`docs/questions/native-editor/field-filters.md`, `sql-parameters.md`): the single biggest native-SQL mistake per the docs. A field filter needs `:dimension` (a `[:field {} id]` ref) plus a `:widget-type`, and is written bare in SQL (`WHERE {{created_at}}`, not `WHERE created_at = {{created_at}}`); a basic variable is a literal substitution. Using a basic variable where a field filter belongs loses the date picker, the dropdown, and the dashboard mapping. -- **Widget-type ↔ parameter-type alignment**: the field filter's `:widget-type`, the dashboard parameter `type`, and the column's semantic type all have to line up (`allowed-for` sets in the backend). Mismatches produce empty or malformed widgets. -- **Referenced saved questions use _default_ parameter values, not live ones** — a documented footgun that makes `{{#123}}` results surprising. -- **Case-sensitivity and the read-only rule**: `WHERE plan = {{p}}` matches zero rows on Postgres if case differs; DDL / multi-statement SQL is unsupported. - -**Shape.** `SKILL.md` covering the template-tag taxonomy, the field-filter-vs-variable decision, the map-to-dashboard target table, and the author→run loop; a `references/template-tags.md` with the per-type body catalog and the widget-type/parameter-type alignment table. Cross-links: `mbql` (its opposite number — when MBQL can express it, prefer MBQL), `dashboard` (mapping a field filter to a dashboard filter), `visualization`, `core`. - ---- - -## 2. `metadata` — semantic types and the field/table metadata that powers everything downstream - -**Trigger / when-to-use:** "set this column as a currency / email / category", "mark this as a foreign key", "make this column show a dropdown", "why doesn't the query builder suggest a join", "hide this column", "set the entity key", "the linked filter shows values that shouldn't be there". - -**The gap.** `core` has one line: "`field update` is where you set a column's `semantic_type` or foreign-key target." `data-workflow/building-clean-tables` sets metadata as workflow steps. Nothing enumerates the semantic-type catalog or explains the causal chain from a metadata edit to the feature it unlocks. The CLI fully supports this (`field update`, `table update`), and it is cross-cutting — the same metadata drives joins, dashboard filters, dropdowns, and display everywhere. - -**Why it's non-obvious.** Metadata is a small set of fields with large, indirect, easy-to-get-wrong consequences (`src/metabase/types/core.cljc`, `warehouse_schema/models/field.clj`, `docs/data-modeling/semantic-types.md`): - -- **Semantic types are labels, not casts** — the top misconception in the docs. Setting `:type/Quantity` on a text column does not make it numeric; it only changes formatting and widget selection. Actual casting needs an expression or a transform. -- **The closed enum surface** an agent needs pinned: `:type/Category`, `:type/Email`, `:type/URL`, `:type/Currency`, `:type/City`/`State`/`Country`/`ZipCode`, `:type/Latitude`/`Longitude`, the timestamp semantics, plus the **relation** types `:type/PK` / `:type/FK` that live in the same `semantic_type` column but a different hierarchy. -- **The causal chain**, which no skill states: `fk_target_field_id` → implicit joins **and** dashboard linked filters **and** query-builder join suggestions; `has_field_values` (`auto-list` / `list` / `search` / `none`) → dropdown-vs-search widget and whether values get scanned; `visibility_type: sensitive`/`retired` → **blocks the query entirely** (not just a UI hide); entity key → detail view and record search. -- **Linked filters depend on table-metadata FKs, not joins defined inside a saved question** (`docs/dashboards/linked-filters.md`) — a subtle cross-feature dependency that explains a whole class of "the filter shows wrong values" bugs. - -**Shape.** `SKILL.md` with the metadata-edit → downstream-feature map and the "semantic types aren't casts" rule; a `references/semantic-types.md` cataloging the enum with when-to-use. Cross-links: `dashboard` (FKs → linked filters), `mbql` (implicit joins via FK targets), `data-workflow`. - ---- - -## 3. `dashboard` — building interactive dashboards, not just laying out cards - -**Trigger / when-to-use:** "wire a filter to these cards", "make a filter cascade / linked filter", "click a bar to filter the rest", "add a tab", "add a second series to this chart", "make clicking this row open another dashboard". - -**The gap.** The dashboard _mechanics_ exist — `core` has a dense paragraph on the 24-column grid, `parameters` array, and `parameter_mappings`; `visualization/references/settings.md` documents `click_behavior` on dashcards. But the pieces are scattered and there is no **workflow/judgment layer** for turning a set of cards into an interactive app. The CLI supports all of it (`dashboard create`/`update`/`update-dashcard`/`parameter-values`), and the wiring is where agents fail. - -**Why it's non-obvious.** Interactive dashboards are a system of interacting parts with strict, documented constraints (`docs/dashboards/*`, `dashboards/models/dashboard_card.clj`): - -- **The full filter-wiring loop**: define a `parameters` entry, then map it on every target dashcard's `parameter_mappings` — a parameter with no mapping does nothing, and editing replaces the whole array (read-modify-write). This is stated in `core` for mechanics but not as a workflow. -- **Linked (cascading) filters** work only on real database columns joined by **table-metadata FKs**, never on custom columns or joins defined inside a saved question — the deep reason lives in the `metadata` gap above. -- **Cross-filtering** (click a chart to filter others) has a specific recipe: the driver chart stays unmapped, all followers map to the parameter, via `click_behavior: crossfilter`. -- **Series** (`:series`) work only on line/area/bar; **time-grouping parameters** only bind to a datetime column in the query's _last_ stage; **click behavior** (`crossfilter` / `link` to card/dashboard/url) is authored only on dashcards and is inert on a standalone card. - -**Shape.** `SKILL.md` for the wiring workflow and the interactivity decision tree (filter vs. linked filter vs. cross-filter vs. click-through), pulling the scattered mechanics into one place and adding the judgment; reuse `visualization`'s `click_behavior` reference rather than duplicating it. This is the largest single gap by surface area. Cross-links: `visualization` (the cards themselves), `native-sql` / `metadata` (what makes a column filterable), `core`. - ---- - -## Better as enhancements than new skills - -These gaps are real but too thin for their own skill — the fix is a few sentences in an existing one: - -- **Collections / organization** → extend `core`. Add: official collections via `authority_level` on `collection create`; moving items by patching `collection_id`; the dashboard-vs-collection reusability trap (a question saved to a dashboard can't be reused). The rest of the docs' organization material (verification, pinning, bookmarks) is UI/EE and not CLI-exposed. -- **Card execution — parameters & exports** → extend `core` (or a short section in `native-sql`). The `--parameters` shape passed to `card query`, and `--export-format csv|xlsx` streaming, are slightly non-obvious but small. -- **Search strategy** → already adequately handled by `core`'s "search vs. list" note. - ---- - -## Deliberately not skills (the CLI has no commands for them) - -These are large, high-value Metabase topic areas with genuine user pain — but there is no `mb` command surface, so a skill would only teach an agent to attempt something and hit a wall. `data-workflow` already instructs agents to name the limit and offer the nearest CLI-doable thing. Listed so the decision is on record, not overlooked: - -- **Permissions & data sandboxing** — groups, data/collection permissions, row/column security. No `permission`/`group` noun. (Backend exists: `permissions/models/*`.) -- **Alerts & subscriptions (pulses)** — threshold alerts, scheduled dashboard email/Slack. No `alert`/`pulse` noun. (`pulse/models/pulse.clj`.) -- **Actions & writeback** — form-driven inserts/updates. No `action` noun. (`actions/schema.clj`.) -- **Embedding & sharing** — public links, signed/interactive embedding. Partly settings-driven, but no authoring surface. -- **Caching & performance** — cache policies. Settings-adjacent only; no workflow surface. -- **Admin config** (email/Slack, timezone, localization, appearance) — reachable only as raw `setting set` JSON, which is obvious API, not a skill. Revisit only if setting-driven configuration becomes a real multi-step workflow. diff --git a/package.json b/package.json index ec1dd49..48a9e98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/cli", - "version": "0.2.1", + "version": "0.2.2", "description": "Metabase CLI", "license": "AGPL-3.0", "repository": { diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index b4e21bc..caa954c 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -1,6 +1,6 @@ --- name: core -description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, card, dashboard, collection, segment, measure, timeline, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work. +description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, card, dashboard, collection, segment, measure, timeline, alert, subscription, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- @@ -12,8 +12,8 @@ Top-level command groups (run `mb --help` to discover verbs): ``` auth | db | table | field | upload | query | card | dashboard | snippet | segment | measure | collection | library -document | timeline | timeline-event | transform | transform-job | transform-tag | setting | search | git-sync | setup -eid | uuid | upgrade | skills +document | timeline | timeline-event | transform | transform-job | transform-tag | alert | subscription | setting +search | git-sync | setup | eid | uuid | upgrade | skills ``` The conventions below — auth, flags, output, body input — hold across **every** group. Per-command flags and examples live in each command's `--help`; add `--json` for the machine-readable form with the output JSON Schema. A few flows have their own skills (see "Specialized skills"). When a card needs a query, prefer MBQL over native SQL (portable, pre-flight-validated — load `mbql`); fall back to native SQL when MBQL can't express it. @@ -129,6 +129,7 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`. - **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). `create` accepts the **same** `dashcards` array in its initial body — lay out the whole dashboard in one call: negative ids for new cards, and `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. - **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop: pull the current set with `dashboard get --fields parameters --json`, add/change entries, and send the full array back via `dashboard update --body '{"parameters":[…]}'` (or supply it in `create`). Omitting a parameter deletes it. Each parameter is `{id, type, …}`. **`id` is a descriptive slug-like string you pick (e.g. `order_status`), unique within this dashboard — Metabase stores any non-blank string verbatim. Do NOT invent a random/opaque id by guessing; reuse the `slug`. If you genuinely need an opaque id, mint one with `mb uuid` — never fabricate one.** `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you: string ops `string/=` `string/!=` `string/contains` `string/does-not-contain` `string/starts-with` `string/ends-with`; number ops `number/=` `number/!=` `number/between` `number/>=` `number/<=`; date `date/single` `date/range` `date/relative` `date/month-year` `date/quarter-year` `date/all-options`; plus `category`, `id`, `boolean/=`, `temporal-unit`, and bare `number`/`text`/`date`/`boolean`. A parameter only filters a card once it is **mapped**: each dashcard's `parameter_mappings` is `[{parameter_id, target}]` where `parameter_id` must match a parameter's `id` exactly, and `target` is `["dimension", ["field", , null]]` for an MBQL card column, `["dimension", ["template-tag", ""]]` for a native field-filter tag, or `["variable", ["template-tag", ""]]` for a native raw-value tag. Populate a dropdown with `values_source_type`: `"static-list"` + `values_source_config.values`, or `"card"` + `{card_id, value_field, label_field}`; omit it to pull live distinct values from the mapped field. `dashboard parameter-values [--query ]` fetches those selectable values (`{values, has_more_values}`); `--query` is a case-insensitive substring search. +- **alert / subscription are two unrelated systems.** `alert` watches one **card** and fires on a send condition (`/api/notification`: a cron string, `channel/email`-prefixed handlers, typed recipients); `subscription` delivers one **dashboard** on a schedule (`/api/pulse`: structured `schedule_type` + hour/day/frame, bare `email` channels, `{id}|{email}` recipients). The bodies are not interchangeable. Both silently deliver nowhere if the server has no SMTP / Slack app — check `mb setting get 'email-configured?'` (quote it; the `?` is a shell glob) before creating either. Their list-valued fields (`handlers`/`subscriptions`, `channels`/`cards`) **replace wholesale** on update, so adding one recipient is a read-modify-write. `mb card alerts ` and `mb dashboard subscriptions ` list what's already attached to a card/dashboard; `archive` deactivates rather than deletes. Load the `notification` skill before authoring either body. - **snippet `--archived` is a swap, not a union** — list returns _either_ active _or_ archived rows, never both. (Same for `--filter archived` on dashboard/collection.) - **segment / measure.** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql`): segment = a filter, measure = exactly one aggregation. - **timeline / timeline-event.** Timelines are collection-scoped event annotations for time-series charts: a timeline's events render only on time-series questions saved in the **same collection** (`collection_id`; null = root) — sub-collections do **not** inherit, and events never draw on dashboard cards, only in the question (and collection) view. To annotate a question's chart, create the timeline in that question's collection, then add events; an event only draws when its `timestamp` falls inside the chart's displayed time range. Event `create` requires `timestamp` (ISO 8601), `timezone` (IANA name), `time_matters` (true = the time of day is significant, false = date-only), and `timeline_id` — the API never auto-creates a default timeline (that's UI-only). There is no `timeline-event list`; enumerate with `timeline events ` (`--archived` to include archived). Archiving a timeline cascades `archived` to its events; `delete` is a **hard** delete of the timeline and all its events — prefer `archive`. @@ -150,6 +151,7 @@ This file is enough for any single-command task. For anything deeper, load the r - **`visualization`** — choosing a card's `display` and authoring `visualization_settings`. The presentation counterpart to `mbql`. - **`dashboard`** — building interactive dashboards: wiring filters (parameters + mappings), linked/cascading filters, cross-filtering, click behavior, series, and tabs. Load beyond a plain card-layout task. - **`metadata`** — setting field/table metadata: semantic types, foreign-key targets, dropdown/scan behavior, and column visibility, and the downstream features each unlocks. Load when editing what a column _means_, not its data. +- **`notification`** — scheduled delivery: question alerts (`mb alert`) and dashboard subscriptions (`mb subscription`). Choosing between them, the two schedule/recipient contracts, channel prerequisites, testing a send. - **`transform`** — transform body JSON, create + run-with-wait, run inspection, tags, jobs. - **`document`** — Metabase documents (TipTap body, embedding cards). - **`git-sync`** — round-tripping content to/from a git remote. diff --git a/skill-data/notification/SKILL.md b/skill-data/notification/SKILL.md new file mode 100644 index 0000000..61eb11f --- /dev/null +++ b/skill-data/notification/SKILL.md @@ -0,0 +1,157 @@ +--- +name: notification +description: Scheduled delivery of Metabase content by email, Slack, or webhook — question alerts (`mb alert …`, one card, fires on a send condition) and dashboard subscriptions (`mb subscription …`, one dashboard, fires on a schedule). Covers choosing between them, the two different schedule and recipient contracts, channel prerequisites, and testing a delivery. Load when the user wants to "alert me when X drops below Y", "email this dashboard every Monday", "send this question to Slack", "set up a subscription", "who gets this report", "add a recipient", "stop these emails", "post to a webhook when the number spikes", or anything `mb alert …` / `mb subscription …`. +allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +--- + +# notification (alerts & subscriptions) + +Metabase delivers content on a schedule two different ways, and they share almost nothing under the hood: + +| | `mb alert` | `mb subscription` | +| ---------- | ---------------------------------------------------- | ------------------------------------------- | +| Delivers | one **card** | one **dashboard** (selected dashcards) | +| Fires | on a **send condition** each time the schedule ticks | every time the schedule ticks | +| Schedule | one **cron string** | structured `schedule_type` + hour/day/frame | +| Channels | `channel/email`, `channel/slack`, `channel/http` | `email`, `slack`, `http` | +| Recipients | typed union (`user` / `group` / `raw-value`) | `{id}` or `{email}` | +| API | `/api/notification` | `/api/pulse` | + +Pick by **what is being delivered**, not by how often. A user asking to "get emailed when signups drop below 100" wants an alert (a condition on one card). A user asking to "email the exec dashboard every Monday" wants a subscription. If they want a whole dashboard _conditionally_, there is no such thing — the closest is a subscription with `skip_if_empty`, or an alert on the one card that carries the condition. + +Because the two bodies look superficially alike and are not, **never adapt one body shape into the other by hand.** Authoring a subscription's `schedule_type` inside an alert (or a `cron_schedule` inside a subscription) fails or, worse, silently drops the schedule. + +## Before you create anything: is the channel configured? + +A delivery to an unconfigured channel is accepted by the server and then never sends. This is the single most common "it doesn't work" report, and nothing in the create response hints at it. Check first: + +```bash +mb setting get 'email-configured?' --profile --json | jq .value # → true if SMTP is set up +mb setting get 'slack-token-valid?' --profile --json | jq .value # → true if the Slack app is connected +``` + +Quote the key — the trailing `?` is a shell glob character and an unquoted `email-configured?` will not reach the CLI. + +`false` means an **admin** must configure SMTP or the Slack app in Metabase before any delivery works. Say so and stop; do not create a notification that silently goes nowhere. Slack has a second trap: to post to a **private** channel, someone must invite the Metabase app to that channel (`@Metabase` in the channel) — it will not appear as a target until then. + +## Alerts (`mb alert`) + +An alert body has exactly three parts: + +```json +{ + "payload": { "card_id": 94, "send_condition": "has_result", "send_once": false }, + "subscriptions": [{ "cron_schedule": "0 0 8 * * ? *" }], + "handlers": [ + { + "channel_type": "channel/email", + "recipients": [ + { "type": "notification-recipient/raw-value", "details": { "value": "team@example.com" } } + ] + } + ] +} +``` + +**`send_condition`** is the whole point of an alert: + +| Value | Fires when | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `has_result` | the card returns **any** row. The rare-event pattern: write the card so it returns rows _only_ when the thing you care about happened, then alert on `has_result`. | +| `goal_above` / `goal_below` | the result crosses the card's goal line. | + +`goal_above` / `goal_below` **require a goal already set on the card's visualization** (`visualization_settings.graph.goal_value` — see the `visualization` skill). Without one the alert can never fire, and the failure is silent. Check with `mb card get --fields visualization_settings --json` before choosing a goal condition; if there's no goal, either set one or reshape the card so `has_result` expresses the condition. + +`send_once: true` fires the alert at most once and then deactivates it — a one-shot "tell me when this finally happens." + +**`cron_schedule`** is a 7-field **Quartz** expression (`sec min hour day-of-month month day-of-week year`), not the 5-field Unix cron most people know. `0 0 8 * * ? *` is "every day at 08:00". The `?` in the day-of-week slot means "unspecified" and is mandatory when day-of-month is set (Quartz rejects `*` in both). The hour is evaluated in the **instance's report timezone**, not UTC and not yours — read it with `mb setting get report-timezone --json`, and say which zone you scheduled in when you report back to the user. + +**`handlers`** carry the delivery. The `channel_type` values are prefixed (`channel/email`, not `email` — that prefix is the subscription spelling). Recipients are a typed union: + +```text +{ "type": "notification-recipient/user", "user_id": 3 } a Metabase user +{ "type": "notification-recipient/group", "permissions_group_id": 5 } everyone in a group +{ "type": "notification-recipient/raw-value", "details": { "value": "a@b.com" } } an external address +``` + +`channel/http` is a **webhook**, and it is alerts-only — dashboard subscriptions cannot post to a webhook. It needs a webhook channel configured server-side; pass its id as `channel_id`. + +**Verbs.** `mb alert list` hides archived alerts unless you pass `--include-inactive`; `mb card alerts ` is the same list scoped to one card. `mb alert archive ` deactivates (`active: false`) and stops delivery — it is not a delete, and `mb alert update --body '{"active":true}'` brings it back. `mb alert send ` delivers **immediately**, ignoring both the schedule and the send condition — that's your end-to-end test that the channel really works. It sends to real recipients, so test with yourself as the only recipient first. + +## Subscriptions (`mb subscription`) + +```json +{ + "name": "Weekly orders", + "dashboard_id": 10, + "cards": [{ "id": 94, "dashboard_card_id": 87, "include_csv": false, "include_xls": false }], + "channels": [ + { + "channel_type": "email", + "schedule_type": "weekly", + "schedule_hour": 8, + "schedule_day": "mon", + "recipients": [{ "email": "team@example.com" }] + } + ] +} +``` + +**Each card needs two different ids.** `id` is the card id; `dashboard_card_id` is the id of the _placement_ of that card on that dashboard. Both come from `mb dashboard cards --json` — take them from the same row. Passing the card id in both slots is the most common subscription bug; it produces a subscription that sends the wrong content or nothing. + +**The schedule fields required depend on `schedule_type`:** + +| `schedule_type` | Also required | Meaning | +| --------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `hourly` | — | every hour | +| `daily` | `schedule_hour` (0–23) | every day at that hour | +| `weekly` | `schedule_hour` + `schedule_day` (`mon`…`sun`) | that weekday | +| `monthly` | `schedule_hour` + `schedule_frame` (`first` / `mid` / `last`) | that point in the month. With `first` / `last` you may add `schedule_day` to mean e.g. "first Monday"; with `mid` a `schedule_day` is rejected | + +Recipients are `{"email": "a@b.com"}` for an external address or `{"id": }` for a Metabase user. A Slack channel is **not** a recipient — it goes in `details`: `{"channel_type": "slack", "details": {"channel": "#general"}, …}`. + +**`skip_if_empty: true`** suppresses the send when the dashboard's questions return no rows — the way to build a "only tell me when something happened" dashboard report. **`parameters`** sets filter values for this subscription only, so one dashboard can feed several audiences a different slice (Pro/Enterprise). Its entries are dashboard parameter values — the parameter `id`s come from `mb dashboard get --fields parameters --json` (see the `dashboard` skill). + +`mb subscription list --archived` is a **swap, not a union** — it returns archived subscriptions _instead of_ active ones, never both. `mb dashboard subscriptions ` is the list scoped to one dashboard. `mb subscription archive ` also disables every channel on it, so restoring takes two steps: `update --body '{"archived":false}'`, then re-send the `channels` array with `enabled: true`. + +## Updating: the lists replace wholesale + +On both nouns, the collection-valued fields are **replaced**, not merged: + +- alert — `subscriptions` and `handlers` +- subscription — `cards` and `channels` + +So "add a recipient" is a read-modify-write, never a patch: + +```bash +mb alert get 9 --json > ./.scratch/alert.json # read the current handlers +# edit ./.scratch/alert.json — add the recipient to the existing handlers array +mb alert update 9 --body "$(jq -c '{handlers}' ./.scratch/alert.json)" +``` + +Sending a partial `handlers` array **deletes the recipients you left out.** The same holds for a subscription's `channels`: omit a channel and it's gone. + +Scalar fields _do_ merge, and inside an alert so does `payload` — `mb alert update 9 --body '{"payload":{"send_condition":"goal_above"}}'` keeps the card. An alert's `card_id` and a subscription's `dashboard_id` are fixed at creation; to point at different content, create a new one and archive the old. + +(Drive updates through `mb alert update` / `mb subscription update` only. Both endpoints have destructive raw-PUT semantics — the notification PUT is a spec-diff that will delete and recreate the row under a new id if the ids don't match, and the pulse PUT re-applies server defaults to omitted keys, silently un-archiving. The CLI's update verbs read-merge-write to neutralize both. A hand-rolled `curl` PUT will not.) + +## End-to-end recipe + +1. **Confirm the channel works** — `mb setting get 'email-configured?'` / `'slack-token-valid?'`. Stop and ask for an admin if `false`. +2. **Find the content id.** Alert: the card id (`mb card list`, `mb search`). Subscription: the dashboard id _and_ the `{id, dashboard_card_id}` pairs from `mb dashboard cards --json`. +3. **Check the condition is expressible.** Goal alert → the card must have a goal. Otherwise reshape toward `has_result`. +4. **Confirm the recipients with the user before creating.** A wrong address means real mail to a real person, and the fix is a full read-modify-write. +5. **Create** from a file in `./.scratch` (see `core` for body input). +6. **Test the delivery** — `mb alert send `. There is no equivalent for subscriptions; verify those by reading back `mb subscription get ` and waiting for the schedule. +7. **Report the schedule in the instance's timezone**, not the user's assumed one. + +## Don't + +- Don't create a notification against an unconfigured channel and call it done — it will never send, and nothing errors. +- Don't set a `goal_above` / `goal_below` alert on a card with no goal line. +- Don't reuse the card id as `dashboard_card_id`. +- Don't send a partial `handlers` / `channels` / `cards` / `subscriptions` array — you will delete the entries you omitted. +- Don't use `mb alert send` to "check it looks right" on an alert with real recipients — it emails all of them, immediately. To preview the content, run the card itself (`mb card query `), which delivers nothing. To rehearse the delivery, create the alert with yourself as the only recipient, `mb alert send` it, then add the real recipients via the read-modify-write above. +- Don't mix the spellings: `channel/email` is an alert, `email` is a subscription. + +Cross-links: `core` (auth, `--json`, body input), `dashboard` (dashcards and the `parameters` a subscription can override), `visualization` (the `graph.goal_value` a goal alert depends on). diff --git a/src/commands/alert/archive.ts b/src/commands/alert/archive.ts new file mode 100644 index 0000000..5790926 --- /dev/null +++ b/src/commands/alert/archive.ts @@ -0,0 +1,32 @@ +import { Notification, notificationView } from "../../domain/notification"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { patchAlert } from "./patch"; +import { describeAlert } from "./summary"; + +export default defineMetabaseCommand({ + meta: { + name: "archive", + description: "Archive a question alert by id, stopping all deliveries", + }, + details: + "Deactivates the alert and drops its scheduled trigger. `mb alert list --include-inactive` still shows it, and `mb alert update --body '{\"active\":true}'` brings it back.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Alert id", required: true }, + }, + outputSchema: Notification, + examples: ["mb alert archive 9", "mb alert archive 9 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const archived = await patchAlert(client, id, { active: false }); + renderSummary(archived, notificationView, describeAlert("Archived", archived), ctx); + }, +}); diff --git a/src/commands/alert/create.ts b/src/commands/alert/create.ts new file mode 100644 index 0000000..9f33302 --- /dev/null +++ b/src/commands/alert/create.ts @@ -0,0 +1,42 @@ +import { Notification, NotificationCreateInput, notificationView } from "../../domain/notification"; +import { renderSummary } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +import { describeAlert } from "./summary"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a question alert from JSON" }, + details: [ + "The JSON body needs `payload`, `subscriptions`, and `handlers`.", + "`payload` is `{card_id, send_condition, send_once}` — `send_condition` is has_result (the card returns any row), goal_above, or goal_below (both need a goal set on the card's visualization).", + "`subscriptions` is a list of `{cron_schedule}` in Quartz 7-field form (`0 0 8 * * ? *` = daily at 08:00 in the instance's report timezone).", + "`handlers` is a list of `{channel_type: channel/email|channel/slack|channel/http, recipients}`;", + 'each recipient is `{type: "notification-recipient/user", user_id}` or `{type: "notification-recipient/raw-value", details: {value: "a@b.com"}}`.', + ].join(" "), + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + inputSchema: NotificationCreateInput, + outputSchema: Notification, + examples: [ + 'mb alert create --body \'{"payload":{"card_id":94,"send_condition":"has_result"},"subscriptions":[{"cron_schedule":"0 0 8 * * ? *"}],"handlers":[{"channel_type":"channel/email","recipients":[{"type":"notification-recipient/raw-value","details":{"value":"team@example.com"}}]}]}\'', + "cat alert.json | mb alert create", + "mb alert create --file alert.json", + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, NotificationCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Notification, "/api/notification", { + method: "POST", + body, + }); + renderSummary(created, notificationView, describeAlert("Created", created), ctx); + }, +}); diff --git a/src/commands/alert/get.ts b/src/commands/alert/get.ts new file mode 100644 index 0000000..cd9d8d0 --- /dev/null +++ b/src/commands/alert/get.ts @@ -0,0 +1,28 @@ +import { Notification, notificationView } from "../../domain/notification"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { fetchAlert } from "./patch"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a question alert by id" }, + details: + "`--full` includes the hydrated card the alert watches, alongside its schedules and handlers.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Alert id", required: true }, + }, + outputSchema: Notification, + examples: ["mb alert get 9", "mb alert get 9 --full --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const alert = await fetchAlert(client, id); + renderItem(alert, notificationView, ctx); + }, +}); diff --git a/src/commands/alert/index.ts b/src/commands/alert/index.ts new file mode 100644 index 0000000..f8ef6d2 --- /dev/null +++ b/src/commands/alert/index.ts @@ -0,0 +1,21 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "alert", + description: "Manage Metabase question alerts (scheduled card delivery on a send condition)", + skills: [ + { + skill: "notification", + purpose: "send conditions, cron schedules, handlers and recipients, testing a send", + }, + { skill: "core", purpose: "the card an alert watches and how to find its id" }, + ], + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + send: () => import("./send").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + }, +}); diff --git a/src/commands/alert/list.ts b/src/commands/alert/list.ts new file mode 100644 index 0000000..d8d48be --- /dev/null +++ b/src/commands/alert/list.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { + CARD_PAYLOAD_TYPE, + Notification, + NotificationCompact, + notificationView, +} from "../../domain/notification"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseOptionalInteger } from "../parse-integer"; +import { defineMetabaseCommand } from "../runtime"; + +const NotificationApiList = z.array(Notification); + +export const AlertListEnvelope = listEnvelopeSchema(NotificationCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List question alerts" }, + details: + "Archived (inactive) alerts are hidden unless you pass --include-inactive. System-event notifications, which share the same API, are never listed here.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + cardId: { type: "string", description: "Only alerts watching this card id", alias: "card-id" }, + creatorId: { + type: "string", + description: "Only alerts created by this user id", + alias: "creator-id", + }, + recipientId: { + type: "string", + description: "Only alerts delivered to this user id", + alias: "recipient-id", + }, + includeInactive: { + type: "boolean", + description: "Include archived (inactive) alerts", + alias: "include-inactive", + }, + }, + outputSchema: AlertListEnvelope, + examples: [ + "mb alert list", + "mb alert list --card-id 94 --json", + "mb alert list --include-inactive --json", + ], + async run({ args, ctx, getClient }) { + const cardId = parseOptionalInteger(args.cardId, { name: "card-id", min: 1 }); + const creatorId = parseOptionalInteger(args.creatorId, { name: "creator-id", min: 1 }); + const recipientId = parseOptionalInteger(args.recipientId, { name: "recipient-id", min: 1 }); + const client = await getClient(); + const items = await client.requestParsed(NotificationApiList, "/api/notification", { + query: { + payload_type: CARD_PAYLOAD_TYPE, + card_id: cardId ?? undefined, + creator_id: creatorId ?? undefined, + recipient_id: recipientId ?? undefined, + include_inactive: args.includeInactive || undefined, + }, + }); + renderList(wrapList(items), notificationView, ctx); + }, +}); diff --git a/src/commands/alert/patch.test.ts b/src/commands/alert/patch.test.ts new file mode 100644 index 0000000..5853890 --- /dev/null +++ b/src/commands/alert/patch.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../../core/errors"; +import { Notification } from "../../domain/notification"; + +import { assertCardAlert, mergeAlertUpdate } from "./patch"; + +const STORED = Notification.parse({ + id: 9, + payload_type: "notification/card", + payload_id: 4, + payload: { id: 4, card_id: 94, send_condition: "has_result", send_once: false }, + active: true, + creator_id: 2, + subscriptions: [ + { id: 9, type: "notification-subscription/cron", cron_schedule: "0 0 8 * * ? *" }, + ], + handlers: [ + { + id: 9, + channel_type: "channel/email", + recipients: [ + { id: 10, type: "notification-recipient/raw-value", details: { value: "a@example.com" } }, + ], + }, + ], +}); + +describe("assertCardAlert", () => { + it("returns a card notification unchanged", () => { + expect(assertCardAlert(STORED)).toEqual(STORED); + }); + + it("rejects a system-event notification, which shares the /api/notification id space", () => { + const systemEvent = Notification.parse({ + ...STORED, + id: 1, + payload_type: "notification/system-event", + payload_id: null, + payload: null, + }); + + expect(() => assertCardAlert(systemEvent)).toThrow( + new ConfigError( + "notification 1 is a notification/system-event, not a question alert — `mb alert` manages card alerts only", + ), + ); + }); +}); + +describe("mergeAlertUpdate", () => { + it("keeps the notification id and payload id so the server updates in place", () => { + const merged = mergeAlertUpdate(STORED, { active: false }); + + expect(merged).toEqual({ ...STORED, active: false }); + }); + + it("merges a partial payload over the stored one, preserving card_id and the payload id", () => { + const merged = mergeAlertUpdate(STORED, { payload: { send_condition: "goal_above" } }); + + expect(merged).toEqual({ + ...STORED, + payload: { id: 4, card_id: 94, send_condition: "goal_above", send_once: false }, + }); + }); + + it("replaces subscriptions and handlers wholesale, matching the server's spec-diff semantics", () => { + const merged = mergeAlertUpdate(STORED, { + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], + handlers: [ + { + channel_type: "channel/slack", + recipients: [ + { type: "notification-recipient/raw-value", details: { value: "#general" } }, + ], + }, + ], + }); + + expect(merged).toEqual({ + ...STORED, + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: "0 0 9 * * ? *" }], + handlers: [ + { + channel_type: "channel/slack", + recipients: [ + { type: "notification-recipient/raw-value", details: { value: "#general" } }, + ], + }, + ], + }); + }); + + it("refuses to patch the payload of an alert whose card payload was deleted server-side", () => { + const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); + + expect(() => mergeAlertUpdate(orphaned, { payload: { send_once: true } })).toThrow( + new ConfigError("alert 9 has lost its card payload — it can be archived, but not patched"), + ); + }); + + it("still deactivates an alert whose card payload was deleted server-side", () => { + const orphaned = Notification.parse({ ...STORED, payload_id: null, payload: null }); + + expect(mergeAlertUpdate(orphaned, { active: false })).toEqual({ + ...orphaned, + active: false, + }); + }); +}); diff --git a/src/commands/alert/patch.ts b/src/commands/alert/patch.ts new file mode 100644 index 0000000..67921b7 --- /dev/null +++ b/src/commands/alert/patch.ts @@ -0,0 +1,67 @@ +import { ConfigError } from "../../core/errors"; +import type { Client } from "../../core/http/client"; +import { + CARD_PAYLOAD_TYPE, + Notification, + NotificationCardPayload, + type NotificationCardPayloadPatch, + type NotificationUpdateInput, +} from "../../domain/notification"; + +// /api/notification is shared with Metabase's internal system-event notifications, whose ids sit +// alongside the alerts'. Reading one through `mb alert` would be confusing, and sending one would +// fire an internal Metabase email, so every by-id verb loads the alert through here. +export async function fetchAlert(client: Client, id: number): Promise { + return assertCardAlert(await client.requestParsed(Notification, `/api/notification/${id}`)); +} + +export function assertCardAlert(notification: Notification): Notification { + if (notification.payload_type !== CARD_PAYLOAD_TYPE) { + throw new ConfigError( + `notification ${notification.id} is a ${notification.payload_type}, not a question alert — \`mb alert\` manages card alerts only`, + ); + } + return notification; +} + +// `PUT /api/notification/:id` is a spec-diff against the stored row, not a patch: a body whose +// `id` does not match the stored one makes Metabase delete the notification and insert a +// replacement under a fresh id, and the same holds for the nested `payload` row. So every update +// reads the current notification and merges the caller's patch over it, preserving both ids. +export function mergeAlertUpdate( + current: Notification, + patch: NotificationUpdateInput, +): Notification { + return Notification.parse({ + ...current, + ...patch, + id: current.id, + payload: patch.payload === undefined ? current.payload : mergePayload(current, patch.payload), + }); +} + +// A card alert whose payload row was deleted server-side comes back with `payload: null`. It can +// still be deactivated, but there is nothing to merge a payload patch into. +function mergePayload( + current: Notification, + patch: NotificationCardPayloadPatch, +): NotificationCardPayload { + if (current.payload === null) { + throw new ConfigError( + `alert ${current.id} has lost its card payload — it can be archived, but not patched`, + ); + } + return NotificationCardPayload.parse({ ...current.payload, ...patch }); +} + +export async function patchAlert( + client: Client, + id: number, + patch: NotificationUpdateInput, +): Promise { + const current = await fetchAlert(client, id); + return client.requestParsed(Notification, `/api/notification/${id}`, { + method: "PUT", + body: mergeAlertUpdate(current, patch), + }); +} diff --git a/src/commands/alert/send.ts b/src/commands/alert/send.ts new file mode 100644 index 0000000..37e2cf3 --- /dev/null +++ b/src/commands/alert/send.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import type { ResourceView } from "../../domain/view"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { fetchAlert } from "./patch"; + +export const AlertSendResult = z.object({ + id: z.number().int(), + sent: z.boolean(), +}); +export type AlertSendResultJson = z.infer; + +const alertSendResultView: ResourceView = { + compactPick: AlertSendResult, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "sent", label: "Sent" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "send", description: "Send a question alert now, off-schedule" }, + details: + "Delivers to every handler on the alert, ignoring its send condition and schedule. Requires the channel to be configured on the server (email needs SMTP set up).", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Alert id", required: true }, + }, + outputSchema: AlertSendResult, + examples: ["mb alert send 9", "mb alert send 9 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + await fetchAlert(client, id); + await client.requestRaw(`/api/notification/${id}/send`, { + method: "POST", + expectContentType: "binary", + }); + renderSummary({ id, sent: true }, alertSendResultView, `Sent alert ${id}.`, ctx); + }, +}); diff --git a/src/commands/alert/summary.ts b/src/commands/alert/summary.ts new file mode 100644 index 0000000..2a49164 --- /dev/null +++ b/src/commands/alert/summary.ts @@ -0,0 +1,8 @@ +import type { Notification } from "../../domain/notification"; + +export function describeAlert(verb: string, alert: Notification): string { + if (alert.payload === null) { + return `${verb} alert ${alert.id}.`; + } + return `${verb} alert ${alert.id} on card ${alert.payload.card_id}.`; +} diff --git a/src/commands/alert/update.ts b/src/commands/alert/update.ts new file mode 100644 index 0000000..f7c8dbd --- /dev/null +++ b/src/commands/alert/update.ts @@ -0,0 +1,42 @@ +import { Notification, NotificationUpdateInput, notificationView } from "../../domain/notification"; +import { renderSummary } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { patchAlert } from "./patch"; +import { describeAlert } from "./summary"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Update a question alert by id" }, + details: [ + "Patches only the top-level fields you send: `payload`, `subscriptions`, `handlers`, `active`.", + 'Fields inside `payload` merge over the current ones, so `{"payload":{"send_condition":"goal_above"}}` keeps the card.', + "`subscriptions` and `handlers` replace the whole list, so send every schedule and recipient you want to keep —", + "`mb alert get ` prints the current ones. An alert's card cannot be moved to a different card.", + ].join(" "), + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Alert id", required: true }, + }, + inputSchema: NotificationUpdateInput, + outputSchema: Notification, + examples: [ + 'mb alert update 9 --body \'{"payload":{"send_condition":"goal_above"}}\'', + 'mb alert update 9 --body \'{"subscriptions":[{"cron_schedule":"0 0 9 * * ? *"}]}\'', + "mb alert update 9 --body '{\"active\":true}'", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, NotificationUpdateInput); + const client = await getClient(); + const updated = await patchAlert(client, id, body); + renderSummary(updated, notificationView, describeAlert("Updated", updated), ctx); + }, +}); diff --git a/src/commands/card/alerts.ts b/src/commands/card/alerts.ts new file mode 100644 index 0000000..e082362 --- /dev/null +++ b/src/commands/card/alerts.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { CARD_PAYLOAD_TYPE, Notification, notificationView } from "../../domain/notification"; +import { renderList } from "../../output/render"; +import { wrapList } from "../../output/types"; +import { AlertListEnvelope } from "../alert/list"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const NotificationApiList = z.array(Notification); + +export default defineMetabaseCommand({ + meta: { name: "alerts", description: "List alerts watching a card" }, + details: + "Manage them with `mb alert create|update|send|archive`, which take the alert id printed here.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + includeInactive: { + type: "boolean", + description: "Include archived (inactive) alerts", + alias: "include-inactive", + }, + id: { type: "positional", description: "Card id", required: true }, + }, + outputSchema: AlertListEnvelope, + examples: ["mb card alerts 94", "mb card alerts 94 --include-inactive --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const items = await client.requestParsed(NotificationApiList, "/api/notification", { + query: { + payload_type: CARD_PAYLOAD_TYPE, + card_id: id, + include_inactive: args.includeInactive || undefined, + }, + }); + renderList(wrapList(items), notificationView, ctx); + }, +}); diff --git a/src/commands/card/index.ts b/src/commands/card/index.ts index b43fd77..2c2a0d3 100644 --- a/src/commands/card/index.ts +++ b/src/commands/card/index.ts @@ -11,6 +11,7 @@ export default defineCommandGroup({ list: () => import("./list").then((mod) => mod.default), get: () => import("./get").then((mod) => mod.default), query: () => import("./query").then((mod) => mod.default), + alerts: () => import("./alerts").then((mod) => mod.default), create: () => import("./create").then((mod) => mod.default), update: () => import("./update").then((mod) => mod.default), archive: () => import("./archive").then((mod) => mod.default), diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts index 88f1a46..98f2bd8 100644 --- a/src/commands/dashboard/index.ts +++ b/src/commands/dashboard/index.ts @@ -16,6 +16,7 @@ export default defineCommandGroup({ get: () => import("./get").then((mod) => mod.default), cards: () => import("./cards").then((mod) => mod.default), "parameter-values": () => import("./parameter-values").then((mod) => mod.default), + subscriptions: () => import("./subscriptions").then((mod) => mod.default), create: () => import("./create").then((mod) => mod.default), update: () => import("./update").then((mod) => mod.default), "update-dashcard": () => import("./update-dashcard").then((mod) => mod.default), diff --git a/src/commands/dashboard/subscriptions.ts b/src/commands/dashboard/subscriptions.ts new file mode 100644 index 0000000..c1f29ec --- /dev/null +++ b/src/commands/dashboard/subscriptions.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { Pulse, pulseView } from "../../domain/pulse"; +import { renderList } from "../../output/render"; +import { wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; +import { SubscriptionListEnvelope } from "../subscription/list"; + +const PulseApiList = z.array(Pulse); + +export default defineMetabaseCommand({ + meta: { name: "subscriptions", description: "List subscriptions on a dashboard" }, + details: + "Manage them with `mb subscription create|update|archive`, which take the subscription id printed here.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + archived: { + type: "boolean", + description: "Show archived subscriptions instead of active ones", + }, + id: { type: "positional", description: "Dashboard id", required: true }, + }, + outputSchema: SubscriptionListEnvelope, + examples: ["mb dashboard subscriptions 10", "mb dashboard subscriptions 10 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const items = await client.requestParsed(PulseApiList, "/api/pulse", { + query: { dashboard_id: id, archived: args.archived || undefined }, + }); + renderList(wrapList(items), pulseView, ctx); + }, +}); diff --git a/src/commands/subscription/archive.ts b/src/commands/subscription/archive.ts new file mode 100644 index 0000000..c4b4ba0 --- /dev/null +++ b/src/commands/subscription/archive.ts @@ -0,0 +1,31 @@ +import { Pulse, pulseView } from "../../domain/pulse"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { patchSubscription } from "./patch"; + +export default defineMetabaseCommand({ + meta: { + name: "archive", + description: "Archive a dashboard subscription by id, stopping all deliveries", + }, + details: + "Archiving also disables every channel on the subscription. Restore it with `mb subscription update --body '{\"archived\":false}'`, then re-enable its channels.", + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Subscription id", required: true }, + }, + outputSchema: Pulse, + examples: ["mb subscription archive 1", "mb subscription archive 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const archived = await patchSubscription(client, id, { archived: true }); + renderSummary(archived, pulseView, `Archived subscription ${archived.id}.`, ctx); + }, +}); diff --git a/src/commands/subscription/create.ts b/src/commands/subscription/create.ts new file mode 100644 index 0000000..d47691a --- /dev/null +++ b/src/commands/subscription/create.ts @@ -0,0 +1,45 @@ +import { Pulse, PulseCreateInput, pulseView } from "../../domain/pulse"; +import { renderSummary } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a dashboard subscription from JSON" }, + details: [ + "The JSON body needs `name`, `dashboard_id`, `cards`, and `channels`.", + "Each card is `{id, dashboard_card_id, include_csv, include_xls}` — get both ids from `mb dashboard cards `.", + "Each channel is `{channel_type: email|slack|http, schedule_type: hourly|daily|weekly|monthly}` plus the fields its schedule needs:", + "`daily` needs `schedule_hour` (0-23); `weekly` also needs `schedule_day` (mon..sun); `monthly` also needs `schedule_frame` (first|mid|last).", + 'Email recipients are `[{email: "a@b.com"} | {id: }]`; Slack targets a channel via `details: {channel: "#general"}`.', + ].join(" "), + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + inputSchema: PulseCreateInput, + outputSchema: Pulse, + examples: [ + 'mb subscription create --body \'{"name":"Weekly orders","dashboard_id":10,"cards":[{"id":94,"dashboard_card_id":87,"include_csv":false,"include_xls":false}],"channels":[{"channel_type":"email","schedule_type":"daily","schedule_hour":8,"recipients":[{"email":"team@example.com"}]}]}\'', + "cat subscription.json | mb subscription create", + "mb subscription create --file subscription.json", + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, PulseCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Pulse, "/api/pulse", { + method: "POST", + body, + }); + renderSummary( + created, + pulseView, + `Created subscription ${created.id} on dashboard ${created.dashboard_id}.`, + ctx, + ); + }, +}); diff --git a/src/commands/subscription/get.ts b/src/commands/subscription/get.ts new file mode 100644 index 0000000..ac6535c --- /dev/null +++ b/src/commands/subscription/get.ts @@ -0,0 +1,24 @@ +import { Pulse, pulseView } from "../../domain/pulse"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a dashboard subscription by id" }, + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Subscription id", required: true }, + }, + outputSchema: Pulse, + examples: ["mb subscription get 1", "mb subscription get 1 --full --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const subscription = await client.requestParsed(Pulse, `/api/pulse/${id}`); + renderItem(subscription, pulseView, ctx); + }, +}); diff --git a/src/commands/subscription/index.ts b/src/commands/subscription/index.ts new file mode 100644 index 0000000..f28b6de --- /dev/null +++ b/src/commands/subscription/index.ts @@ -0,0 +1,20 @@ +import { defineCommandGroup } from "../group"; + +export default defineCommandGroup({ + name: "subscription", + description: "Manage Metabase dashboard subscriptions (scheduled dashboard delivery)", + skills: [ + { + skill: "notification", + purpose: "channel schedules, recipients, skip_if_empty, per-subscription filter values", + }, + { skill: "dashboard", purpose: "the dashboard and dashcards a subscription delivers" }, + ], + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + }, +}); diff --git a/src/commands/subscription/list.ts b/src/commands/subscription/list.ts new file mode 100644 index 0000000..7950617 --- /dev/null +++ b/src/commands/subscription/list.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { Pulse, PulseCompact, pulseView } from "../../domain/pulse"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseOptionalInteger } from "../parse-integer"; +import { defineMetabaseCommand } from "../runtime"; + +const PulseApiList = z.array(Pulse); + +export const SubscriptionListEnvelope = listEnvelopeSchema(PulseCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List dashboard subscriptions" }, + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + dashboardId: { + type: "string", + description: "Only subscriptions on this dashboard id", + alias: "dashboard-id", + }, + archived: { + type: "boolean", + description: "Show archived subscriptions instead of active ones", + }, + }, + outputSchema: SubscriptionListEnvelope, + examples: [ + "mb subscription list", + "mb subscription list --dashboard-id 10 --json", + "mb subscription list --archived --json", + ], + async run({ args, ctx, getClient }) { + const dashboardId = parseOptionalInteger(args.dashboardId, { name: "dashboard-id", min: 1 }); + const client = await getClient(); + const items = await client.requestParsed(PulseApiList, "/api/pulse", { + query: { + dashboard_id: dashboardId ?? undefined, + archived: args.archived || undefined, + }, + }); + renderList(wrapList(items), pulseView, ctx); + }, +}); diff --git a/src/commands/subscription/patch.test.ts b/src/commands/subscription/patch.test.ts new file mode 100644 index 0000000..e9256f8 --- /dev/null +++ b/src/commands/subscription/patch.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { Pulse, type PulseUpdateInput } from "../../domain/pulse"; + +import { mergeSubscriptionUpdate } from "./patch"; + +const STORED = Pulse.parse({ + id: 1, + name: "Weekly orders", + creator_id: 2, + dashboard_id: 10, + collection_id: 4, + archived: true, + skip_if_empty: true, + parameters: [], + cards: [ + { + id: 94, + name: "Orders by status", + dashboard_card_id: 87, + include_csv: false, + include_xls: false, + }, + ], + channels: [ + { + channel_type: "email", + enabled: false, + schedule_type: "daily", + schedule_hour: 8, + schedule_day: null, + schedule_frame: null, + recipients: [{ email: "team@example.com" }], + }, + ], +}); + +describe("mergeSubscriptionUpdate", () => { + it("carries archived and skip_if_empty forward, which the server would otherwise default to false", () => { + expect(mergeSubscriptionUpdate(STORED, { name: "Daily orders" })).toEqual({ + name: "Daily orders", + archived: true, + skip_if_empty: true, + }); + }); + + it("lets the caller override both of the server-defaulted fields", () => { + expect(mergeSubscriptionUpdate(STORED, { archived: false, skip_if_empty: false })).toEqual({ + archived: false, + skip_if_empty: false, + }); + }); + + it("passes the caller's other fields through untouched", () => { + const channels: NonNullable = [ + { + channel_type: "email", + enabled: true, + schedule_type: "weekly", + schedule_hour: 6, + schedule_day: "mon", + recipients: [{ email: "team@example.com" }], + }, + ]; + + expect(mergeSubscriptionUpdate(STORED, { channels })).toEqual({ + channels, + archived: true, + skip_if_empty: true, + }); + }); +}); diff --git a/src/commands/subscription/patch.ts b/src/commands/subscription/patch.ts new file mode 100644 index 0000000..f9f111b --- /dev/null +++ b/src/commands/subscription/patch.ts @@ -0,0 +1,28 @@ +import type { Client } from "../../core/http/client"; +import { Pulse, type PulseUpdateInput } from "../../domain/pulse"; + +// `PUT /api/pulse/:id` applies the server's schema defaults to keys the body omits, and both +// `archived` and `skip_if_empty` default to false. A patch that leaves them out therefore +// un-archives the subscription and clears skip_if_empty behind the caller's back. Every update +// carries both forward from the stored pulse unless the caller sets them explicitly. The +// remaining fields (`name`, `cards`, `channels`, `collection_id`, `parameters`) are optional +// server-side and survive an omission untouched. +export function mergeSubscriptionUpdate(current: Pulse, patch: PulseUpdateInput): PulseUpdateInput { + return { + ...patch, + archived: patch.archived ?? current.archived, + skip_if_empty: patch.skip_if_empty ?? current.skip_if_empty, + }; +} + +export async function patchSubscription( + client: Client, + id: number, + patch: PulseUpdateInput, +): Promise { + const current = await client.requestParsed(Pulse, `/api/pulse/${id}`); + return client.requestParsed(Pulse, `/api/pulse/${id}`, { + method: "PUT", + body: mergeSubscriptionUpdate(current, patch), + }); +} diff --git a/src/commands/subscription/update.ts b/src/commands/subscription/update.ts new file mode 100644 index 0000000..07f13a1 --- /dev/null +++ b/src/commands/subscription/update.ts @@ -0,0 +1,41 @@ +import { Pulse, PulseUpdateInput, pulseView } from "../../domain/pulse"; +import { renderSummary } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { patchSubscription } from "./patch"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Update a dashboard subscription by id" }, + details: [ + "Patches only the fields you send: `name`, `cards`, `channels`, `skip_if_empty`, `parameters`, `archived`.", + "`cards` and `channels` replace the whole list, so send every card and channel you want to keep —", + "`mb subscription get --full` prints the current ones.", + "A subscription's `dashboard_id` and `collection_id` are fixed at creation and cannot be changed.", + ].join(" "), + capabilities: { minVersion: 58 }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Subscription id", required: true }, + }, + inputSchema: PulseUpdateInput, + outputSchema: Pulse, + examples: [ + 'mb subscription update 1 --body \'{"name":"Daily orders"}\'', + 'mb subscription update 1 --body \'{"channels":[{"channel_type":"email","schedule_type":"weekly","schedule_hour":8,"schedule_day":"mon","recipients":[{"email":"team@example.com"}]}]}\'', + "mb subscription update 1 --file patch.json", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, PulseUpdateInput); + const client = await getClient(); + const updated = await patchSubscription(client, id, body); + renderSummary(updated, pulseView, `Updated subscription ${updated.id}.`, ctx); + }, +}); diff --git a/src/domain/cron.ts b/src/domain/cron.ts new file mode 100644 index 0000000..31d9b98 --- /dev/null +++ b/src/domain/cron.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +// How a cron expression was authored in the UI. Shared by notification subscriptions and +// transform-job schedules — both store the server's `ui_display_type`. +export const CronUiDisplayType = z.enum(["cron/raw", "cron/builder"]); +export type CronUiDisplayType = z.infer; diff --git a/src/domain/notification.ts b/src/domain/notification.ts new file mode 100644 index 0000000..23b3fc1 --- /dev/null +++ b/src/domain/notification.ts @@ -0,0 +1,278 @@ +import { z } from "zod"; + +import { CronUiDisplayType } from "./cron"; +import type { ResourceView } from "./view"; + +export const NotificationPayloadType = z.enum([ + "notification/card", + "notification/dashboard", + "notification/system-event", + "notification/testing", +]); +export type NotificationPayloadType = z.infer; + +// The payload type of a question alert. /api/notification also serves Metabase's internal +// system-event notifications, so every `mb alert` request scopes itself to this one. +export const CARD_PAYLOAD_TYPE: NotificationPayloadType = "notification/card"; + +export const NotificationSendCondition = z.enum(["has_result", "goal_above", "goal_below"]); +export type NotificationSendCondition = z.infer; + +export const NotificationChannelType = z.enum(["channel/email", "channel/slack", "channel/http"]); +export type NotificationChannelType = z.infer; + +export const NotificationRecipientType = z.enum([ + "notification-recipient/user", + "notification-recipient/group", + "notification-recipient/raw-value", + "notification-recipient/template", +]); +export type NotificationRecipientType = z.infer; + +export const NotificationSubscriptionType = z.enum([ + "notification-subscription/cron", + "notification-subscription/system-event", +]); +export type NotificationSubscriptionType = z.infer; + +// `value` carries the email address (email channel) or the channel name (Slack channel). +export const NotificationRecipientDetails = z + .object({ + value: z.string().optional(), + channel_id: z.string().nullable().optional(), + }) + .loose(); +export type NotificationRecipientDetails = z.infer; + +const NotificationRecipientDetailsCompact = NotificationRecipientDetails.pick({ + value: true, + channel_id: true, +}).strip(); + +export const NotificationRecipient = z + .object({ + id: z.number().int().optional(), + type: NotificationRecipientType, + user_id: z.number().int().nullable().optional(), + permissions_group_id: z.number().int().nullable().optional(), + details: NotificationRecipientDetails.nullable().optional(), + }) + .loose(); +export type NotificationRecipient = z.infer; + +export const NotificationRecipientCompact = NotificationRecipient.pick({ + type: true, + user_id: true, + permissions_group_id: true, +}) + .strip() + .extend({ details: NotificationRecipientDetailsCompact.nullable().optional() }); +export type NotificationRecipientCompact = z.infer; + +export const NotificationHandler = z + .object({ + id: z.number().int().optional(), + channel_type: NotificationChannelType, + channel_id: z.number().int().nullable().optional(), + template_id: z.number().int().nullable().optional(), + active: z.boolean().optional(), + recipients: z.array(NotificationRecipient).optional(), + }) + .loose(); +export type NotificationHandler = z.infer; + +export const NotificationHandlerCompact = NotificationHandler.pick({ + channel_type: true, + channel_id: true, +}) + .strip() + .extend({ recipients: z.array(NotificationRecipientCompact).optional() }); +export type NotificationHandlerCompact = z.infer; + +export const NotificationSubscription = z + .object({ + id: z.number().int().optional(), + type: NotificationSubscriptionType, + cron_schedule: z.string().nullable().optional(), + event_name: z.string().nullable().optional(), + ui_display_type: CronUiDisplayType.nullable().optional(), + }) + .loose(); +export type NotificationSubscription = z.infer; + +export const NotificationSubscriptionCompact = NotificationSubscription.pick({ + type: true, + cron_schedule: true, +}).strip(); +export type NotificationSubscriptionCompact = z.infer; + +export const NotificationCardPayload = z + .object({ + id: z.number().int().optional(), + card_id: z.number().int(), + send_condition: NotificationSendCondition, + send_once: z.boolean(), + }) + .loose(); +export type NotificationCardPayload = z.infer; + +export const NotificationCardPayloadCompact = NotificationCardPayload.pick({ + card_id: true, + send_condition: true, + send_once: true, +}).strip(); +export type NotificationCardPayloadCompact = z.infer; + +// `payload` is null for system-event notifications, which share the /api/notification table with +// card alerts. +export const Notification = z + .object({ + id: z.number().int(), + payload_type: NotificationPayloadType, + payload_id: z.number().int().nullable(), + payload: NotificationCardPayload.nullable(), + active: z.boolean(), + creator_id: z.number().int().nullable(), + subscriptions: z.array(NotificationSubscription), + handlers: z.array(NotificationHandler), + }) + .loose(); +export type Notification = z.infer; + +export const NotificationCompact = Notification.pick({ + id: true, + payload_type: true, + active: true, + creator_id: true, +}) + .strip() + .extend({ + payload: NotificationCardPayloadCompact.nullable(), + subscriptions: z.array(NotificationSubscriptionCompact), + handlers: z.array(NotificationHandlerCompact), + }); +export type NotificationCompact = z.infer; + +const NullableCardPayload = NotificationCardPayload.nullable(); +const NotificationSubscriptionList = z.array(NotificationSubscription); +const NotificationHandlerList = z.array(NotificationHandler); + +export const notificationView: ResourceView = { + compactPick: NotificationCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "payload", label: "Card", format: formatPayload }, + { key: "subscriptions", label: "Schedule", format: formatSubscriptions }, + { key: "handlers", label: "Delivery", format: formatHandlers }, + { key: "active", label: "Active" }, + ], +}; + +function formatPayload(value: unknown): string { + const parsed = NullableCardPayload.safeParse(value); + if (!parsed.success || parsed.data === null) { + return ""; + } + const { card_id, send_condition, send_once } = parsed.data; + const once = send_once ? ", once" : ""; + return `${card_id} (${send_condition}${once})`; +} + +function formatSubscriptions(value: unknown): string { + const parsed = NotificationSubscriptionList.safeParse(value); + if (!parsed.success) { + return ""; + } + return parsed.data + .map((subscription) => subscription.cron_schedule ?? subscription.event_name ?? "") + .filter((label) => label !== "") + .join("; "); +} + +function formatHandlers(value: unknown): string { + const parsed = NotificationHandlerList.safeParse(value); + if (!parsed.success) { + return ""; + } + return parsed.data.map(describeHandler).join("; "); +} + +function describeHandler(handler: NotificationHandler): string { + const channel = handler.channel_type.replace("channel/", ""); + const recipients = (handler.recipients ?? []).map(describeRecipient).join(", "); + return recipients === "" ? channel : `${channel} → ${recipients}`; +} + +function describeRecipient(recipient: NotificationRecipient): string { + switch (recipient.type) { + case "notification-recipient/user": { + return `user:${recipient.user_id}`; + } + case "notification-recipient/group": { + return `group:${recipient.permissions_group_id}`; + } + case "notification-recipient/raw-value": { + return recipient.details?.value ?? ""; + } + case "notification-recipient/template": { + return "template"; + } + } +} + +const NotificationRecipientInput = z + .object({ + type: NotificationRecipientType, + user_id: z.number().int().positive().optional(), + permissions_group_id: z.number().int().positive().optional(), + details: NotificationRecipientDetails.optional(), + }) + .loose(); + +const NotificationHandlerInput = z + .object({ + channel_type: NotificationChannelType, + channel_id: z.number().int().positive().nullable().optional(), + active: z.boolean().optional(), + recipients: z.array(NotificationRecipientInput).optional(), + }) + .loose(); + +const NotificationSubscriptionInput = z + .object({ + type: NotificationSubscriptionType.default("notification-subscription/cron"), + cron_schedule: z.string().min(1), + }) + .loose(); + +const NotificationCardPayloadInput = z + .object({ + card_id: z.number().int().positive(), + send_condition: NotificationSendCondition.optional(), + send_once: z.boolean().optional(), + }) + .loose(); + +export const NotificationCreateInput = z + .object({ + payload_type: NotificationPayloadType.default(CARD_PAYLOAD_TYPE), + payload: NotificationCardPayloadInput, + subscriptions: z.array(NotificationSubscriptionInput).min(1), + handlers: z.array(NotificationHandlerInput).min(1), + active: z.boolean().optional(), + }) + .loose(); +export type NotificationCreateInput = z.infer; + +export const NotificationCardPayloadPatch = NotificationCardPayloadInput.partial(); +export type NotificationCardPayloadPatch = z.infer; + +export const NotificationUpdateInput = z + .object({ + payload: NotificationCardPayloadPatch.optional(), + subscriptions: z.array(NotificationSubscriptionInput).min(1).optional(), + handlers: z.array(NotificationHandlerInput).min(1).optional(), + active: z.boolean().optional(), + }) + .loose(); +export type NotificationUpdateInput = z.infer; diff --git a/src/domain/pulse.ts b/src/domain/pulse.ts new file mode 100644 index 0000000..90d9122 --- /dev/null +++ b/src/domain/pulse.ts @@ -0,0 +1,223 @@ +import { z } from "zod"; + +import { Parameter } from "./parameter"; +import type { ResourceView } from "./view"; + +export const PulseChannelType = z.enum(["email", "slack", "http"]); +export type PulseChannelType = z.infer; + +export const PulseScheduleType = z.enum(["hourly", "daily", "weekly", "monthly"]); +export type PulseScheduleType = z.infer; + +export const PulseScheduleDay = z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]); +export type PulseScheduleDay = z.infer; + +export const PulseScheduleFrame = z.enum(["first", "mid", "last"]); +export type PulseScheduleFrame = z.infer; + +export const PulseChannelDetails = z.object({ channel: z.string().optional() }).loose(); +export type PulseChannelDetails = z.infer; + +// A Metabase user recipient carries `id` plus profile fields; a plain external address carries +// only `email`. +export const PulseRecipient = z + .object({ + id: z.number().int().nullable().optional(), + email: z.string(), + }) + .loose(); +export type PulseRecipient = z.infer; + +export const PulseRecipientCompact = PulseRecipient.pick({ id: true, email: true }).strip(); +export type PulseRecipientCompact = z.infer; + +export const PulseChannel = z + .object({ + id: z.number().int().optional(), + channel_type: PulseChannelType, + channel_id: z.number().int().nullable().optional(), + enabled: z.boolean(), + schedule_type: PulseScheduleType, + schedule_hour: z.number().int().nullable(), + schedule_day: PulseScheduleDay.nullable(), + schedule_frame: PulseScheduleFrame.nullable(), + details: PulseChannelDetails.optional(), + recipients: z.array(PulseRecipient), + }) + .loose(); +export type PulseChannel = z.infer; + +export const PulseChannelCompact = PulseChannel.pick({ + channel_type: true, + enabled: true, + schedule_type: true, + schedule_hour: true, + schedule_day: true, + schedule_frame: true, + details: true, +}) + .strip() + .extend({ recipients: z.array(PulseRecipientCompact) }); +export type PulseChannelCompact = z.infer; + +export const PulseCard = z + .object({ + id: z.number().int(), + name: z.string(), + dashboard_card_id: z.number().int().nullable(), + include_csv: z.boolean(), + include_xls: z.boolean(), + }) + .loose(); +export type PulseCard = z.infer; + +export const PulseCardCompact = PulseCard.pick({ + id: true, + name: true, + dashboard_card_id: true, + include_csv: true, + include_xls: true, +}).strip(); +export type PulseCardCompact = z.infer; + +export const Pulse = z + .object({ + id: z.number().int(), + name: z.string().nullable(), + creator_id: z.number().int(), + dashboard_id: z.number().int().nullable(), + collection_id: z.number().int().nullable(), + archived: z.boolean(), + skip_if_empty: z.boolean(), + parameters: z.array(Parameter), + cards: z.array(PulseCard), + channels: z.array(PulseChannel), + }) + .loose(); +export type Pulse = z.infer; + +export const PulseCompact = Pulse.pick({ + id: true, + name: true, + dashboard_id: true, + collection_id: true, + archived: true, + skip_if_empty: true, +}) + .strip() + .extend({ + cards: z.array(PulseCardCompact), + channels: z.array(PulseChannelCompact), + }); +export type PulseCompact = z.infer; + +const PulseChannelList = z.array(PulseChannel); + +export const pulseView: ResourceView = { + compactPick: PulseCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "dashboard_id", label: "Dashboard" }, + { key: "channels", label: "Delivery", format: formatChannels }, + { key: "archived", label: "Archived" }, + ], +}; + +function formatChannels(value: unknown): string { + const parsed = PulseChannelList.safeParse(value); + if (!parsed.success) { + return ""; + } + return parsed.data.map(describeChannel).join("; "); +} + +function describeChannel(channel: PulseChannel): string { + const parts: string[] = [channel.channel_type, describeSchedule(channel)]; + const audience = describeAudience(channel); + if (audience !== null) { + parts.push(`→ ${audience}`); + } + if (!channel.enabled) { + parts.push("(disabled)"); + } + return parts.join(" "); +} + +function describeSchedule(channel: PulseChannel): string { + const parts: string[] = [channel.schedule_type]; + if (channel.schedule_frame !== null) { + parts.push(channel.schedule_frame); + } + if (channel.schedule_day !== null) { + parts.push(channel.schedule_day); + } + if (channel.schedule_hour !== null) { + parts.push(`${channel.schedule_hour}:00`); + } + return parts.join(" "); +} + +function describeAudience(channel: PulseChannel): string | null { + if (channel.recipients.length > 0) { + return channel.recipients.map((recipient) => recipient.email).join(", "); + } + const slackChannel = channel.details?.channel; + return slackChannel === undefined ? null : slackChannel; +} + +const PulseRecipientInput = z.union([ + z.object({ id: z.number().int().positive() }).loose(), + z.object({ email: z.string().min(1) }).loose(), +]); + +const PulseCardInput = z + .object({ + id: z.number().int().positive(), + include_csv: z.boolean(), + include_xls: z.boolean(), + dashboard_card_id: z.number().int().positive().nullable().optional(), + format_rows: z.boolean().optional(), + pivot_results: z.boolean().optional(), + }) + .loose(); + +// The server asserts `boolean? enabled` on every channel, so the CLI fills it in rather than +// letting an omission surface as a 500-shaped assertion failure. +const PulseChannelInput = z + .object({ + channel_type: PulseChannelType, + enabled: z.boolean().default(true), + schedule_type: PulseScheduleType, + schedule_hour: z.number().int().min(0).max(23).nullable().optional(), + schedule_day: PulseScheduleDay.nullable().optional(), + schedule_frame: PulseScheduleFrame.nullable().optional(), + channel_id: z.number().int().positive().nullable().optional(), + details: PulseChannelDetails.optional(), + recipients: z.array(PulseRecipientInput).optional(), + }) + .loose(); + +export const PulseCreateInput = z + .object({ + name: z.string().min(1), + dashboard_id: z.number().int().positive(), + cards: z.array(PulseCardInput).min(1), + channels: z.array(PulseChannelInput).min(1), + skip_if_empty: z.boolean().optional(), + parameters: z.array(Parameter).optional(), + }) + .loose(); +export type PulseCreateInput = z.infer; + +export const PulseUpdateInput = z + .object({ + name: z.string().min(1).optional(), + cards: z.array(PulseCardInput).min(1).optional(), + channels: z.array(PulseChannelInput).min(1).optional(), + skip_if_empty: z.boolean().optional(), + archived: z.boolean().optional(), + parameters: z.array(Parameter).optional(), + }) + .loose(); +export type PulseUpdateInput = z.infer; diff --git a/src/domain/transform-job.ts b/src/domain/transform-job.ts index af24098..9e5f96e 100644 --- a/src/domain/transform-job.ts +++ b/src/domain/transform-job.ts @@ -1,9 +1,8 @@ import { z } from "zod"; +import { CronUiDisplayType } from "./cron"; import type { ResourceView } from "./view"; -const JobUiDisplayType = z.enum(["cron/raw", "cron/builder"]); - const JobRunStatus = z.enum(["started", "succeeded", "failed", "timeout"]); const JobRunMethod = z.enum(["manual", "cron"]); @@ -34,7 +33,7 @@ export const TransformJob = z name: z.string(), description: z.string().nullable(), schedule: z.string(), - ui_display_type: JobUiDisplayType, + ui_display_type: CronUiDisplayType, active: z.boolean().optional(), entity_id: z.string().nullable(), created_at: z.string(), @@ -76,7 +75,7 @@ export const TransformJobCreateInput = z name: z.string().min(1), description: z.string().min(1).nullable().optional(), schedule: z.string().min(1), - ui_display_type: JobUiDisplayType.optional(), + ui_display_type: CronUiDisplayType.optional(), tag_ids: z.array(z.number().int().positive()).optional(), }) .loose(); @@ -87,7 +86,7 @@ export const TransformJobUpdateInput = z name: z.string().min(1).optional(), description: z.string().min(1).nullable().optional(), schedule: z.string().min(1).optional(), - ui_display_type: JobUiDisplayType.optional(), + ui_display_type: CronUiDisplayType.optional(), active: z.boolean().optional(), tag_ids: z.array(z.number().int().positive()).optional(), }) diff --git a/src/main.ts b/src/main.ts index 220879b..8765d50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,6 +19,8 @@ const main: CommandDef = defineCommand({ upload: () => import("./commands/upload").then((mod) => mod.default), card: () => import("./commands/card").then((mod) => mod.default), dashboard: () => import("./commands/dashboard").then((mod) => mod.default), + subscription: () => import("./commands/subscription").then((mod) => mod.default), + alert: () => import("./commands/alert").then((mod) => mod.default), collection: () => import("./commands/collection").then((mod) => mod.default), library: () => import("./commands/library").then((mod) => mod.default), document: () => import("./commands/document").then((mod) => mod.default), diff --git a/src/runtime/command-help.test.ts b/src/runtime/command-help.test.ts index 565ea2f..4cca4eb 100644 --- a/src/runtime/command-help.test.ts +++ b/src/runtime/command-help.test.ts @@ -267,6 +267,7 @@ const ALL_COMMANDS = [ "card list", "card get", "card query", + "card alerts", "card create", "card update", "card archive", @@ -274,10 +275,22 @@ const ALL_COMMANDS = [ "dashboard get", "dashboard cards", "dashboard parameter-values", + "dashboard subscriptions", "dashboard create", "dashboard update", "dashboard update-dashcard", "dashboard archive", + "subscription list", + "subscription get", + "subscription create", + "subscription update", + "subscription archive", + "alert list", + "alert get", + "alert create", + "alert update", + "alert send", + "alert archive", "collection list", "collection get", "collection items", diff --git a/tests/e2e/alert.e2e.test.ts b/tests/e2e/alert.e2e.test.ts new file mode 100644 index 0000000..bf81e8f --- /dev/null +++ b/tests/e2e/alert.e2e.test.ts @@ -0,0 +1,392 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { AlertListEnvelope } from "../../src/commands/alert/list"; +import { AlertSendResult } from "../../src/commands/alert/send"; +import { NotificationCompact, type NotificationCreateInput } from "../../src/domain/notification"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; + +const RECIPIENT = "team@example.com"; +const DAILY_AT_EIGHT = "0 0 8 * * ? *"; +const DAILY_AT_NINE = "0 0 9 * * ? *"; +const OTHER_CARD_ID = SEEDED.ordersCardId + 1000; + +const NEW_ALERT_BODY: NotificationCreateInput = { + payload_type: "notification/card", + payload: { card_id: SEEDED.ordersCardId, send_condition: "has_result", send_once: false }, + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: DAILY_AT_EIGHT }], + handlers: [ + { + channel_type: "channel/email", + recipients: [{ type: "notification-recipient/raw-value", details: { value: RECIPIENT } }], + }, + ], +}; + +function expectedCompact(id: number, creatorId: number | null): NotificationCompact { + return { + id, + payload_type: "notification/card", + active: true, + creator_id: creatorId, + payload: { + card_id: SEEDED.ordersCardId, + send_condition: "has_result", + send_once: false, + }, + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: DAILY_AT_EIGHT }], + handlers: [ + { + channel_type: "channel/email", + channel_id: null, + recipients: [ + { + type: "notification-recipient/raw-value", + user_id: null, + permissions_group_id: null, + details: { value: RECIPIENT }, + }, + ], + }, + ], + }; +} + +describe("alert e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createAlert(): Promise { + const result = await runCli({ + args: ["alert", "create", "--json"], + stdin: JSON.stringify(NEW_ALERT_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, NotificationCompact); + } + + it("list returns an empty envelope on a fresh restore, hiding the seeded system-event notifications", async () => { + const result = await runCli({ + args: ["alert", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("create returns the alert in compact form, without the hydrated card", async () => { + const result = await runCli({ + args: ["alert", "create", "--json"], + stdin: JSON.stringify(NEW_ALERT_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const created = parseJson(result.stdout, NotificationCompact); + expect(created).toEqual(expectedCompact(created.id, created.creator_id)); + }); + + it("create with an unknown send_condition fails on Zod validation before any request", async () => { + const result = await runCli({ + args: ["alert", "create", "--json"], + stdin: JSON.stringify({ + ...NEW_ALERT_BODY, + payload: { card_id: SEEDED.ordersCardId, send_condition: "when_i_feel_like_it" }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("create + list shows the new alert via the compact projection", async () => { + const created = await createAlert(); + + const result = await runCli({ + args: ["alert", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ + data: [expectedCompact(created.id, created.creator_id)], + returned: 1, + total: 1, + }); + }); + + it("list --card-id narrows to alerts watching that card", async () => { + const created = await createAlert(); + + const matching = await runCli({ + args: ["alert", "list", "--card-id", String(SEEDED.ordersCardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(matching.exitCode, matching.stderr).toBe(0); + expect(parseJson(matching.stdout, AlertListEnvelope)).toEqual({ + data: [expectedCompact(created.id, created.creator_id)], + returned: 1, + total: 1, + }); + + const other = await runCli({ + args: ["alert", "list", "--card-id", String(OTHER_CARD_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(other.exitCode, other.stderr).toBe(0); + expect(parseJson(other.stdout, AlertListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("`card alerts` lists the alerts watching that card", async () => { + const created = await createAlert(); + + const result = await runCli({ + args: ["card", "alerts", String(SEEDED.ordersCardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ + data: [expectedCompact(created.id, created.creator_id)], + returned: 1, + total: 1, + }); + }); + + it("`card alerts` is empty for a card with none", async () => { + const result = await runCli({ + args: ["card", "alerts", String(SEEDED.ordersCardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AlertListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("`card alerts` with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["card", "alerts", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get returns the alert by id in compact form", async () => { + const created = await createAlert(); + + const result = await runCli({ + args: ["alert", "get", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, NotificationCompact)).toEqual( + expectedCompact(created.id, created.creator_id), + ); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["alert", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing alert id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["alert", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: GET /api/notification/9999999."); + }); + + // A PUT whose body omits the stored id makes Metabase delete the notification and insert a + // replacement under a fresh id. `alert update` merges over the fetched object to prevent that, + // so the alert must still be readable at the same id afterwards. + it("update merges a partial payload over the stored one and keeps the alert at the same id", async () => { + const created = await createAlert(); + + const updateResult = await runCli({ + args: ["alert", "update", String(created.id), "--json"], + stdin: JSON.stringify({ payload: { send_condition: "goal_above" } }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(updateResult.exitCode, updateResult.stderr).toBe(0); + + const expected: NotificationCompact = { + ...expectedCompact(created.id, created.creator_id), + payload: { + card_id: SEEDED.ordersCardId, + send_condition: "goal_above", + send_once: false, + }, + }; + expect(parseJson(updateResult.stdout, NotificationCompact)).toEqual(expected); + + const getResult = await runCli({ + args: ["alert", "get", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode, getResult.stderr).toBe(0); + expect(parseJson(getResult.stdout, NotificationCompact)).toEqual(expected); + }); + + it("update replaces the schedule wholesale", async () => { + const created = await createAlert(); + + const result = await runCli({ + args: ["alert", "update", String(created.id), "--json"], + stdin: JSON.stringify({ + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: DAILY_AT_NINE }], + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, NotificationCompact)).toEqual({ + ...expectedCompact(created.id, created.creator_id), + subscriptions: [{ type: "notification-subscription/cron", cron_schedule: DAILY_AT_NINE }], + }); + }); + + it("archive deactivates the alert; --include-inactive still lists it and update reactivates it", async () => { + const created = await createAlert(); + const inactive: NotificationCompact = { + ...expectedCompact(created.id, created.creator_id), + active: false, + }; + + const archiveResult = await runCli({ + args: ["alert", "archive", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + expect(parseJson(archiveResult.stdout, NotificationCompact)).toEqual(inactive); + + const activeList = await runCli({ + args: ["alert", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(activeList.exitCode, activeList.stderr).toBe(0); + expect(parseJson(activeList.stdout, AlertListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + + const inactiveList = await runCli({ + args: ["alert", "list", "--include-inactive", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(inactiveList.exitCode, inactiveList.stderr).toBe(0); + expect(parseJson(inactiveList.stdout, AlertListEnvelope)).toEqual({ + data: [inactive], + returned: 1, + total: 1, + }); + + const reactivate = await runCli({ + args: ["alert", "update", String(created.id), "--json"], + stdin: JSON.stringify({ active: true }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(reactivate.exitCode, reactivate.stderr).toBe(0); + expect(parseJson(reactivate.stdout, NotificationCompact)).toEqual( + expectedCompact(created.id, created.creator_id), + ); + }); + + it("send delivers the alert off-schedule", async () => { + const created = await createAlert(); + + const result = await runCli({ + args: ["alert", "send", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AlertSendResult)).toEqual({ id: created.id, sent: true }); + }); + + it("send against a missing alert id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["alert", "send", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: GET /api/notification/9999999."); + }); +}); diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index d860df3..d9fb76e 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -16,6 +16,7 @@ const BUNDLED_VISIBLE_NAMES = [ "mbql", "metadata", "native-sql", + "notification", "transform", "visualization", ] as const; @@ -33,7 +34,7 @@ describe("skills e2e", () => { return dir; } - it("list returns the ten bundled non-hidden skills, sorted by name", async () => { + it("list returns the eleven bundled non-hidden skills, sorted by name", async () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome: await makeIsolatedConfigHome(), diff --git a/tests/e2e/subscription.e2e.test.ts b/tests/e2e/subscription.e2e.test.ts new file mode 100644 index 0000000..c714a74 --- /dev/null +++ b/tests/e2e/subscription.e2e.test.ts @@ -0,0 +1,441 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { SubscriptionListEnvelope } from "../../src/commands/subscription/list"; +import { + PulseCompact, + type PulseChannelCompact, + type PulseCreateInput, +} from "../../src/domain/pulse"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; + +const SUBSCRIPTION_NAME = "Weekly orders"; +const RECIPIENT = "team@example.com"; +const OTHER_DASHBOARD_ID = SEEDED.ordersDashboardId + 1000; + +const NEW_SUBSCRIPTION_BODY: PulseCreateInput = { + name: SUBSCRIPTION_NAME, + dashboard_id: SEEDED.ordersDashboardId, + cards: [ + { + id: SEEDED.ordersCardId, + dashboard_card_id: SEEDED.ordersDashcardId, + include_csv: false, + include_xls: false, + }, + ], + channels: [ + { + channel_type: "email", + enabled: true, + schedule_type: "daily", + schedule_hour: 8, + recipients: [{ email: RECIPIENT }], + }, + ], +}; + +function expectedChannel(enabled: boolean): PulseChannelCompact { + return { + channel_type: "email", + enabled, + schedule_type: "daily", + schedule_hour: 8, + schedule_day: null, + schedule_frame: null, + recipients: [{ email: RECIPIENT }], + }; +} + +function expectedCompact(id: number): PulseCompact { + return { + id, + name: SUBSCRIPTION_NAME, + dashboard_id: SEEDED.ordersDashboardId, + collection_id: SEEDED.defaultCollectionId, + archived: false, + skip_if_empty: false, + cards: [ + { + id: SEEDED.ordersCardId, + name: "Orders by status", + dashboard_card_id: SEEDED.ordersDashcardId, + include_csv: false, + include_xls: false, + }, + ], + channels: [expectedChannel(true)], + }; +} + +describe("subscription e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createSubscription(): Promise { + const result = await runCli({ + args: ["subscription", "create", "--json"], + stdin: JSON.stringify(NEW_SUBSCRIPTION_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, PulseCompact).id; + } + + it("list returns an empty envelope on a fresh restore", async () => { + const result = await runCli({ + args: ["subscription", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("create returns the subscription in compact form, with the dashboard's collection applied", async () => { + const result = await runCli({ + args: ["subscription", "create", "--json"], + stdin: JSON.stringify(NEW_SUBSCRIPTION_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const created = parseJson(result.stdout, PulseCompact); + expect(created).toEqual(expectedCompact(created.id)); + }); + + it("create with an unknown channel_type fails on Zod validation before any request", async () => { + const result = await runCli({ + args: ["subscription", "create", "--json"], + stdin: JSON.stringify({ + ...NEW_SUBSCRIPTION_BODY, + channels: [{ channel_type: "carrier-pigeon", schedule_type: "daily", schedule_hour: 8 }], + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("create + list shows the new subscription via the compact projection", async () => { + const id = await createSubscription(); + + const result = await runCli({ + args: ["subscription", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ + data: [expectedCompact(id)], + returned: 1, + total: 1, + }); + }); + + it("list --dashboard-id narrows to subscriptions on that dashboard", async () => { + const id = await createSubscription(); + + const matching = await runCli({ + args: ["subscription", "list", "--dashboard-id", String(SEEDED.ordersDashboardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(matching.exitCode, matching.stderr).toBe(0); + expect(parseJson(matching.stdout, SubscriptionListEnvelope)).toEqual({ + data: [expectedCompact(id)], + returned: 1, + total: 1, + }); + + const other = await runCli({ + args: ["subscription", "list", "--dashboard-id", String(OTHER_DASHBOARD_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(other.exitCode, other.stderr).toBe(0); + expect(parseJson(other.stdout, SubscriptionListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("`dashboard subscriptions` lists the subscriptions on that dashboard", async () => { + const id = await createSubscription(); + + const result = await runCli({ + args: ["dashboard", "subscriptions", String(SEEDED.ordersDashboardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ + data: [expectedCompact(id)], + returned: 1, + total: 1, + }); + }); + + it("`dashboard subscriptions` is empty for a dashboard with none", async () => { + const result = await runCli({ + args: ["dashboard", "subscriptions", String(SEEDED.ordersDashboardId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SubscriptionListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("`dashboard subscriptions` with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["dashboard", "subscriptions", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get returns the subscription by id in compact form", async () => { + const id = await createSubscription(); + + const result = await runCli({ + args: ["subscription", "get", String(id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, PulseCompact)).toEqual(expectedCompact(id)); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["subscription", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing subscription id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["subscription", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Not found: GET /api/pulse/9999999."); + }); + + it("update renames the subscription and replaces its delivery channels", async () => { + const id = await createSubscription(); + + const result = await runCli({ + args: ["subscription", "update", String(id), "--json"], + stdin: JSON.stringify({ + name: "Daily orders", + channels: [ + { + channel_type: "email", + schedule_type: "weekly", + schedule_hour: 6, + schedule_day: "mon", + recipients: [{ email: RECIPIENT }], + }, + ], + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, PulseCompact)).toEqual({ + ...expectedCompact(id), + name: "Daily orders", + channels: [ + { + channel_type: "email", + enabled: true, + schedule_type: "weekly", + schedule_hour: 6, + schedule_day: "mon", + schedule_frame: null, + recipients: [{ email: RECIPIENT }], + }, + ], + }); + }); + + // `PUT /api/pulse/:id` defaults every omitted key, and `archived`/`skip_if_empty` both default + // to false — so a name-only patch would un-archive the subscription and clear skip_if_empty + // unless the CLI carries the stored values forward. + it("update leaves an archived subscription archived when the patch does not mention it", async () => { + const id = await createSubscription(); + + const archiveResult = await runCli({ + args: ["subscription", "archive", String(id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + + const renameResult = await runCli({ + args: ["subscription", "update", String(id), "--json"], + stdin: JSON.stringify({ name: "Daily orders" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(renameResult.exitCode, renameResult.stderr).toBe(0); + expect(parseJson(renameResult.stdout, PulseCompact)).toEqual({ + ...expectedCompact(id), + name: "Daily orders", + archived: true, + channels: [expectedChannel(false)], + }); + }); + + it("update preserves skip_if_empty when the patch does not mention it", async () => { + const id = await createSubscription(); + + const enable = await runCli({ + args: ["subscription", "update", String(id), "--json"], + stdin: JSON.stringify({ skip_if_empty: true }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(enable.exitCode, enable.stderr).toBe(0); + + const rename = await runCli({ + args: ["subscription", "update", String(id), "--json"], + stdin: JSON.stringify({ name: "Daily orders" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(rename.exitCode, rename.stderr).toBe(0); + expect(parseJson(rename.stdout, PulseCompact)).toEqual({ + ...expectedCompact(id), + name: "Daily orders", + skip_if_empty: true, + }); + }); + + it("archive preserves skip_if_empty", async () => { + const id = await createSubscription(); + + const enable = await runCli({ + args: ["subscription", "update", String(id), "--json"], + stdin: JSON.stringify({ skip_if_empty: true }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(enable.exitCode, enable.stderr).toBe(0); + + const result = await runCli({ + args: ["subscription", "archive", String(id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, PulseCompact)).toEqual({ + ...expectedCompact(id), + archived: true, + skip_if_empty: true, + channels: [expectedChannel(false)], + }); + }); + + it("archive flips archived, disables the channels, and moves it under --archived", async () => { + const id = await createSubscription(); + + const archivedCompact: PulseCompact = { + ...expectedCompact(id), + archived: true, + channels: [expectedChannel(false)], + }; + + const archiveResult = await runCli({ + args: ["subscription", "archive", String(id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + expect(parseJson(archiveResult.stdout, PulseCompact)).toEqual(archivedCompact); + + const activeList = await runCli({ + args: ["subscription", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(activeList.exitCode, activeList.stderr).toBe(0); + expect(parseJson(activeList.stdout, SubscriptionListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + + const archivedList = await runCli({ + args: ["subscription", "list", "--archived", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archivedList.exitCode, archivedList.stderr).toBe(0); + expect(parseJson(archivedList.stdout, SubscriptionListEnvelope)).toEqual({ + data: [archivedCompact], + returned: 1, + total: 1, + }); + }); +});