From c8bf5456cc8eb4bb39eb9d3d72b3954999e528d1 Mon Sep 17 00:00:00 2001 From: rohanraarora Date: Tue, 7 Apr 2026 19:06:40 +0530 Subject: [PATCH 1/2] Migrate alert commands from deprecated /api/alert to Notification API The Metabase pulse.api.alert namespace is deprecated. This migrates all alert CRUD operations to use /api/notification endpoints instead: - list() filters by payload_type=notification/card - get/create/update use /api/notification/{id} endpoints - delete archives via PUT with active=false (no DELETE on notifications) - Alert params translated to notification payload/handlers format - User-facing CLI interface remains identical Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/alert.ts | 91 ++++++++++++++++++++++++++++++++++++++++--- src/commands/alert.ts | 20 ++++++---- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/api/alert.ts b/src/api/alert.ts index aa28e83..735ea7f 100644 --- a/src/api/alert.ts +++ b/src/api/alert.ts @@ -26,26 +26,107 @@ 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; +} + +interface NotificationCreateBody { + payload_type: "notification/card"; + payload: NotificationPayload; + handlers: NotificationHandler[]; + active: boolean; +} + +interface NotificationUpdateBody { + payload_type?: "notification/card"; + payload?: Partial; + handlers?: NotificationHandler[]; + active?: boolean; +} + +function translateChannelsToHandlers(channels: AlertChannel[]): NotificationHandler[] { + return channels.map((ch) => { + const handler: NotificationHandler = { + channel_type: ch.channel_type, + }; + if (ch.recipients) { + handler.recipients = ch.recipients.map((r) => ({ + type: "notification-recipient/user", + user_id: 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; + } + return handler; + }); +} + export class AlertApi { constructor(private client: MetabaseClient) {} async list(): Promise { - return this.client.get("/api/alert"); + const notifications = await this.client.get("/api/notification", { + payload_type: "notification/card", + }); + return notifications; } async get(id: number): Promise { - return this.client.get(`/api/alert/${id}`); + return this.client.get(`/api/notification/${id}`); } async create(params: CreateAlertParams): Promise { - return this.client.post("/api/alert", params); + 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, + }, + handlers: translateChannelsToHandlers(params.channels), + active: true, + }; + return this.client.post("/api/notification", body); } async update(id: number, params: UpdateAlertParams): Promise { - return this.client.put(`/api/alert/${id}`, params); + const body: NotificationUpdateBody = { + payload_type: "notification/card", + }; + + const payload: Partial = {}; + 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 (Object.keys(payload).length > 0) body.payload = payload; + + if (params.channels) { + body.handlers = translateChannelsToHandlers(params.channels); + } + + return this.client.put(`/api/notification/${id}`, body); } async delete(id: number): Promise { - await this.client.delete(`/api/alert/${id}`); + await this.client.put(`/api/notification/${id}`, { active: false }); } } diff --git a/src/commands/alert.ts b/src/commands/alert.ts index aa90834..bd92a92 100644 --- a/src/commands/alert.ts +++ b/src/commands/alert.ts @@ -4,16 +4,20 @@ import { formatEntityTable, formatJson } from "../utils/output.js"; import { resolveClient } from "./helpers.js"; export function alertCommand(): Command { - const cmd = new Command("alert").description("Manage alerts").addHelpText( - "after", - ` + const cmd = new Command("alert") + .description( + "Manage alerts. Note: Alerts use the Notification API internally. For full notification control, use 'metabase-cli notification'.", + ) + .addHelpText( + "after", + ` Examples: $ metabase-cli alert list $ metabase-cli alert show 3 $ metabase-cli alert create --card 42 --condition rows --first-only $ metabase-cli alert update 3 --condition goal --above-goal $ metabase-cli alert delete 3`, - ); + ); cmd .command("list") @@ -38,9 +42,9 @@ Examples: const rows = (alerts as any[]).map((a) => ({ id: a.id, - card_name: a.card?.name ?? a.name ?? "", - alert_condition: a.alert_condition, - alert_first_only: a.alert_first_only, + card_name: a.payload?.card?.name ?? a.payload?.card_id ?? "", + alert_condition: a.payload?.alert_condition ?? "", + alert_first_only: a.payload?.alert_first_only ?? "", creator: a.creator ? `${a.creator.first_name} ${a.creator.last_name}` : (a.creator_id ?? ""), @@ -169,7 +173,7 @@ Examples: const api = new AlertApi(client); const alertId = parseInt(id); await api.delete(alertId); - console.log(`Alert #${alertId} deleted.`); + console.log(`Alert #${alertId} deleted (archived).`); }); return cmd; From 71218d14bfd56a55066b737ed0b17a3251e0043d Mon Sep 17 00:00:00 2001 From: rohanraarora Date: Tue, 7 Apr 2026 19:19:02 +0530 Subject: [PATCH 2/2] Fix AlertApi tests to match /api/notification migration Update test expectations to verify the migrated AlertApi correctly translates calls to /api/notification endpoints with proper payload and handler translation. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/api-modules.test.ts | 41 ++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/test/api-modules.test.ts b/test/api-modules.test.ts index bd656f8..27911e5 100644 --- a/test/api-modules.test.ts +++ b/test/api-modules.test.ts @@ -50,7 +50,7 @@ afterEach(() => { // ─── AlertApi ──────────────────────────────────────────────────────────────── describe("AlertApi", () => { - it("list() → GET /api/alert", async () => { + it("list() → GET /api/notification with payload_type query param", async () => { const client = new MetabaseClient(makeProfile()); const api = new AlertApi(client); globalThis.fetch = mockFetch([{ id: 1 }]); @@ -58,12 +58,12 @@ describe("AlertApi", () => { const result = await api.list(); const [url, opts] = (globalThis.fetch as any).mock.calls[0]; - expect(url).toBe("https://metabase.test.com/api/alert"); + expect(url).toBe("https://metabase.test.com/api/notification?payload_type=notification%2Fcard"); expect(opts.method).toBe("GET"); expect(result).toEqual([{ id: 1 }]); }); - it("get(1) → GET /api/alert/1", async () => { + it("get(1) → GET /api/notification/1", async () => { const client = new MetabaseClient(makeProfile()); const api = new AlertApi(client); globalThis.fetch = mockFetch({ id: 1 }); @@ -71,11 +71,11 @@ describe("AlertApi", () => { await api.get(1); const [url, opts] = (globalThis.fetch as any).mock.calls[0]; - expect(url).toBe("https://metabase.test.com/api/alert/1"); + expect(url).toBe("https://metabase.test.com/api/notification/1"); expect(opts.method).toBe("GET"); }); - it("create(params) → POST /api/alert", async () => { + it("create(params) → POST /api/notification with translated body", async () => { const client = new MetabaseClient(makeProfile()); const api = new AlertApi(client); globalThis.fetch = mockFetch({ id: 1 }); @@ -89,12 +89,21 @@ describe("AlertApi", () => { await api.create(params); const [url, opts] = (globalThis.fetch as any).mock.calls[0]; - expect(url).toBe("https://metabase.test.com/api/alert"); + expect(url).toBe("https://metabase.test.com/api/notification"); expect(opts.method).toBe("POST"); - expect(JSON.parse(opts.body)).toEqual(params); + expect(JSON.parse(opts.body)).toEqual({ + payload_type: "notification/card", + payload: { + card_id: 10, + alert_condition: "rows", + alert_first_only: false, + }, + handlers: [{ channel_type: "email" }], + active: true, + }); }); - it("update(1, params) → PUT /api/alert/1", async () => { + it("update(1, params) → PUT /api/notification/1 with translated body", async () => { const client = new MetabaseClient(makeProfile()); const api = new AlertApi(client); globalThis.fetch = mockFetch({ id: 1 }); @@ -103,21 +112,25 @@ describe("AlertApi", () => { await api.update(1, params); const [url, opts] = (globalThis.fetch as any).mock.calls[0]; - expect(url).toBe("https://metabase.test.com/api/alert/1"); + expect(url).toBe("https://metabase.test.com/api/notification/1"); expect(opts.method).toBe("PUT"); - expect(JSON.parse(opts.body)).toEqual(params); + expect(JSON.parse(opts.body)).toEqual({ + payload_type: "notification/card", + payload: { alert_first_only: true }, + }); }); - it("delete(1) → DELETE /api/alert/1", async () => { + it("delete(1) → PUT /api/notification/1 with { active: false }", async () => { const client = new MetabaseClient(makeProfile()); const api = new AlertApi(client); - globalThis.fetch = mockFetchVoid(); + globalThis.fetch = mockFetch({}); await api.delete(1); const [url, opts] = (globalThis.fetch as any).mock.calls[0]; - expect(url).toBe("https://metabase.test.com/api/alert/1"); - expect(opts.method).toBe("DELETE"); + expect(url).toBe("https://metabase.test.com/api/notification/1"); + expect(opts.method).toBe("PUT"); + expect(JSON.parse(opts.body)).toEqual({ active: false }); }); });