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
91 changes: 86 additions & 5 deletions src/api/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationPayload>;
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<unknown[]> {
return this.client.get<unknown[]>("/api/alert");
const notifications = await this.client.get<unknown[]>("/api/notification", {
payload_type: "notification/card",
});
return notifications;
}

async get(id: number): Promise<unknown> {
return this.client.get<unknown>(`/api/alert/${id}`);
return this.client.get<unknown>(`/api/notification/${id}`);
}

async create(params: CreateAlertParams): Promise<unknown> {
return this.client.post<unknown>("/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<unknown>("/api/notification", body);
}

async update(id: number, params: UpdateAlertParams): Promise<unknown> {
return this.client.put<unknown>(`/api/alert/${id}`, params);
const body: NotificationUpdateBody = {
payload_type: "notification/card",
};

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 (Object.keys(payload).length > 0) body.payload = payload;

if (params.channels) {
body.handlers = translateChannelsToHandlers(params.channels);
}

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

async delete(id: number): Promise<void> {
await this.client.delete(`/api/alert/${id}`);
await this.client.put(`/api/notification/${id}`, { active: false });
}
}
20 changes: 12 additions & 8 deletions src/commands/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ?? ""),
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 27 additions & 14 deletions test/api-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,32 +50,32 @@ 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 }]);

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 });

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 });
Expand All @@ -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 });
Expand All @@ -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 });
});
});

Expand Down
Loading