Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `question update --db <id>` flag — moves a saved card to a different database. Updates both the top-level `database_id` and `dataset_query.database` so the two never drift. Without this, `PUT /api/card/{id}` with a new `database_id` alone leaves `dataset_query.database` pointing at the old DB and the card keeps running against the original source.
- `alert create` / `notification create`: `--slack-channel <name>` flag for posting to a Slack channel by name. Emits a `notification-recipient/raw-value` recipient with `details.value: "#channel"`, which is the only shape v0.59+ Metabase accepts for channel-name targets.
- `alert create` / `alert update`: `--schedule <cron>` (Quartz/Spring), `--schedule-type hourly|daily|weekly|monthly`, and `--schedule-hour` flags. Schedules are now attached to the top-level `subscriptions[]` as `notification-subscription/cron` (the location v0.59+ expects).
- `notification create`: `--condition rows|has_result|goal_above|goal_below`, `--send-once`, and `--disable-links` flags so the full v0.59+ payload is reachable from the CLI.

### Fixed

- `alert create` / `notification create` with `--channel-type slack` no longer returns `400 Bad Request: {"specific-errors":{"handlers":[{"channel_type":["unknown error, received: :slack"]}]}}`. The CLI now canonicalizes the channel type to the `channel/slack` / `channel/email` form Metabase v0.59+ requires. Both the bare (`slack`) and prefixed (`channel/slack`) forms are accepted.
- `notification create --schedule` no longer attaches a raw cron string to `handlers[].schedule` (which the v0.59+ API silently ignored). The cron is now mounted at the notification level as `subscriptions[{type: "notification-subscription/cron", cron_schedule: "..."}]`, matching the server-side payload model.
- `AlertApi.create` / `AlertApi.update` translate `alert_condition` + `alert_above_goal` → `send_condition` (`has_result` / `goal_above` / `goal_below`) and `alert_first_only` → `send_once`, matching the renamed payload fields in v0.59+. Older callers can keep using the legacy field names; the translation is internal.

## [0.6.1] - 2026-04-20

### Added
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ metabase-cli question create --name "Revenue Trend" --sql-file trend.sql --db 1
metabase-cli question update 42 --name "New Name"
metabase-cli question update 42 --sql-file updated-query.sql --unsafe
metabase-cli question update 42 --display line --viz-file viz.json
metabase-cli question update 42 --db 35 --sql-file ch-query.sql --unsafe # Move card to a different DB

# Delete a question
metabase-cli question delete 42
Expand Down Expand Up @@ -274,10 +275,21 @@ metabase-cli alert list
metabase-cli alert show 3
metabase-cli alert create --card 42 --condition rows --first-only
metabase-cli alert create --card 42 --condition goal --above-goal --recipients 1,2,3

# Slack alert: post to a channel on every cron tick while the question has rows.
metabase-cli alert create \
--card 42 \
--condition rows \
--channel-type slack \
--slack-channel "#alerts" \
--schedule "0 0 * * * ?" # Quartz cron — hourly on the hour

metabase-cli alert update 3 --condition goal --above-goal
metabase-cli alert delete 3
```

`--channel-type` accepts the bare values `slack` / `email` or the prefixed `channel/slack` / `channel/email` — the CLI canonicalizes to the prefixed form Metabase v0.59+ requires. Slack handlers need `--slack-channel <#name>` (a `notification-recipient/raw-value` is emitted under the hood) — Slack user IDs alone aren't enough. `--schedule` accepts a Quartz/Spring cron string and is attached as a top-level `notification-subscription/cron`; for legacy callers the older `--schedule-type hourly|daily|weekly|monthly` + `--schedule-hour` still works and is translated to cron.

### Revisions

```bash
Expand Down Expand Up @@ -331,10 +343,21 @@ metabase-cli segment delete 1
metabase-cli notification list
metabase-cli notification show 1
metabase-cli notification create --card 42 --channel-type email --recipients "1,2,3"

# Slack channel post — raw-value recipient + top-level cron subscription.
metabase-cli notification create \
--card 42 \
--channel-type slack \
--slack-channel "#alerts" \
--schedule "0 0 * * * ?" \
--condition has_result

metabase-cli notification update 1 --active false
metabase-cli notification send 1
```

The `--condition` flag accepts `rows` / `has_result` / `goal_above` / `goal_below`. `--send-once` corresponds to the v0.59+ payload's `send_once` (the old `alert_first_only`).

## File Input

Commands that accept inline SQL or JSON also support reading from files. This is useful for complex multi-line queries or JSON configurations. Each `--flag` has a corresponding `--flag-file` alternative:
Expand Down
155 changes: 120 additions & 35 deletions src/api/alert.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import type { MetabaseClient } from "../client.js";
import {
canonicalizeChannelType,
cronSubscription,
type NotificationHandler,
type NotificationRecipient,
type NotificationSendCondition,
type NotificationSubscription,
slackChannelRecipient,
userRecipient,
} from "./notification.js";

export interface AlertChannel {
channel_type: string;
enabled: boolean;
/** User-id recipients (email handlers, occasionally Slack DMs). */
recipients?: { id: number }[];
details?: Record<string, unknown>;
schedule_type?: string;
/**
* Slack channel name (e.g. "#alerts"). The legacy API put this on
* `details.channel`; the v0.59+ API expects a raw-value recipient with
* `details.value`. The CLI normalizes either form into the new shape.
*/
details?: { channel?: string; [k: string]: unknown };
/** Legacy cadence fields, kept for back-compat with pre-v0.59 callers. */
schedule_type?: "hourly" | "daily" | "weekly" | "monthly" | string;
schedule_hour?: number;
schedule_day?: string;
/** Cron schedule (Quartz/Spring). Preferred over schedule_type on v0.59+. */
cron_schedule?: string;
}

export interface CreateAlertParams {
Expand All @@ -26,58 +45,116 @@ export interface UpdateAlertParams {
channels?: AlertChannel[];
}

interface NotificationHandler {
channel_type: string;
recipients?: { type: string; user_id: number }[];
schedule?: {
schedule_type?: string;
schedule_hour?: number;
schedule_day?: string;
};
}

interface NotificationPayload {
card_id: number;
alert_condition?: "rows" | "goal";
alert_first_only?: boolean;
alert_above_goal?: boolean;
send_once?: boolean;
send_condition?: NotificationSendCondition;
}

interface NotificationCreateBody {
payload_type: "notification/card";
payload: NotificationPayload;
handlers: NotificationHandler[];
subscriptions?: NotificationSubscription[];
active: boolean;
}

interface NotificationUpdateBody {
payload_type?: "notification/card";
payload?: Partial<NotificationPayload>;
handlers?: NotificationHandler[];
subscriptions?: NotificationSubscription[];
active?: boolean;
}

function translateChannelsToHandlers(channels: AlertChannel[]): NotificationHandler[] {
function mapSendCondition(
condition: "rows" | "goal",
aboveGoal?: boolean,
): NotificationSendCondition {
if (condition === "rows") return "has_result";
return aboveGoal ? "goal_above" : "goal_below";
}

// Translate the legacy schedule_type/schedule_hour shape into a cron expression
// the new subscriptions[] API understands. Returns undefined if the channel
// carries no schedule info.
function channelToCron(ch: AlertChannel): string | undefined {
if (ch.cron_schedule) return ch.cron_schedule;
const t = ch.schedule_type;
if (!t) return undefined;
// Quartz/Spring cron: seconds minutes hours day-of-month month day-of-week
const hour = ch.schedule_hour ?? 0;
switch (t) {
case "hourly":
return "0 0 * * * ?";
case "daily":
return `0 0 ${hour} * * ?`;
case "weekly": {
// Map day name to Quartz DOW (1=Sun..7=Sat)
const dow: Record<string, number> = {
sun: 1,
mon: 2,
tue: 3,
wed: 4,
thu: 5,
fri: 6,
sat: 7,
};
const d = ch.schedule_day ? dow[ch.schedule_day.toLowerCase().slice(0, 3)] : 2;
return `0 0 ${hour} ? * ${d ?? 2}`;
}
case "monthly":
return `0 0 ${hour} 1 * ?`;
default:
return undefined;
}
}

export function translateChannelsToHandlers(channels: AlertChannel[]): NotificationHandler[] {
return channels.map((ch) => {
const handler: NotificationHandler = {
channel_type: ch.channel_type,
};
const channelType = canonicalizeChannelType(ch.channel_type);
const recipients: NotificationRecipient[] = [];

if (ch.recipients) {
handler.recipients = ch.recipients.map((r) => ({
type: "notification-recipient/user",
user_id: r.id,
}));
for (const r of ch.recipients) {
recipients.push(userRecipient(r.id));
}
}
if (ch.schedule_type || ch.schedule_hour !== undefined || ch.schedule_day) {
handler.schedule = {};
if (ch.schedule_type) handler.schedule.schedule_type = ch.schedule_type;
if (ch.schedule_hour !== undefined) handler.schedule.schedule_hour = ch.schedule_hour;
if (ch.schedule_day) handler.schedule.schedule_day = ch.schedule_day;

// Slack channel name can arrive as legacy details.channel or details.value;
// accept either and emit the raw-value recipient the v0.59+ API requires.
const slackChannel =
typeof ch.details?.channel === "string"
? ch.details.channel
: typeof (ch.details as { value?: string } | undefined)?.value === "string"
? (ch.details as { value?: string }).value
: undefined;
if (channelType === "channel/slack" && slackChannel) {
recipients.push(slackChannelRecipient(slackChannel));
}
return handler;

return { channel_type: channelType, recipients };
});
}

export function translateChannelsToSubscriptions(
channels: AlertChannel[],
): NotificationSubscription[] {
const subs: NotificationSubscription[] = [];
// The new API attaches a single schedule at the notification level. If
// multiple channels carry conflicting schedules we take the first one
// found, since that matches how the legacy API behaved (the schedule was
// notification-wide even when stored per-channel).
for (const ch of channels) {
const cron = channelToCron(ch);
if (cron) {
subs.push(cronSubscription(cron));
break;
}
}
return subs;
}

export class AlertApi {
constructor(private client: MetabaseClient) {}

Expand All @@ -93,15 +170,16 @@ export class AlertApi {
}

async create(params: CreateAlertParams): Promise<unknown> {
const subscriptions = translateChannelsToSubscriptions(params.channels);
const body: NotificationCreateBody = {
payload_type: "notification/card",
payload: {
card_id: params.card.id,
alert_condition: params.alert_condition,
alert_first_only: params.alert_first_only,
alert_above_goal: params.alert_above_goal,
send_once: params.alert_first_only,
send_condition: mapSendCondition(params.alert_condition, params.alert_above_goal),
},
handlers: translateChannelsToHandlers(params.channels),
...(subscriptions.length > 0 ? { subscriptions } : {}),
active: true,
};
return this.client.post<unknown>("/api/notification", body);
Expand All @@ -114,13 +192,20 @@ export class AlertApi {

const payload: Partial<NotificationPayload> = {};
if (params.card) payload.card_id = params.card.id;
if (params.alert_condition !== undefined) payload.alert_condition = params.alert_condition;
if (params.alert_first_only !== undefined) payload.alert_first_only = params.alert_first_only;
if (params.alert_above_goal !== undefined) payload.alert_above_goal = params.alert_above_goal;
if (params.alert_condition !== undefined || params.alert_above_goal !== undefined) {
// If only --above-goal is supplied, it implies a goal condition; falling
// back to "rows" here would silently map a goal alert to has_result.
const condition =
params.alert_condition ?? (params.alert_above_goal !== undefined ? "goal" : "rows");
payload.send_condition = mapSendCondition(condition, params.alert_above_goal);
}
if (params.alert_first_only !== undefined) payload.send_once = params.alert_first_only;
if (Object.keys(payload).length > 0) body.payload = payload;

if (params.channels) {
body.handlers = translateChannelsToHandlers(params.channels);
const subs = translateChannelsToSubscriptions(params.channels);
if (subs.length > 0) body.subscriptions = subs;
}

return this.client.put<unknown>(`/api/notification/${id}`, body);
Expand Down
Loading
Loading