feat: add Pushover notification channel

Adds Pushover as a new notification channel, allowing users to receive
push notifications on their devices via the Pushover API.

Backend: provider, type registration, validation, service wiring
Frontend: form UI with app token and user key fields, validation, i18n
Tests: full unit test suite (15 tests, 100% coverage on provider)

Closes #3445

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
egeoztassandClaude Opus 4.6 committed 2026-04-16 16:25:24 +03:00
1 parent 2be1b48995
commit 97353bc7d2
13 files changed
+313 -3

No files matched your search

+8
View File
@@ -60,6 +60,14 @@ function buildDefaults(data: Notification | null): NotificationFormData {
address: data.address || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && watchedType !== "telegram" && (
{watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -214,6 +214,50 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
+1
View File
@@ -7,6 +7,7 @@ export const NotificationChannels = [
"matrix",
"teams",
"telegram",
"pushover",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+7
View File
@@ -56,6 +56,12 @@ const telegramSchema = baseSchema.extend({
accessToken: z.string().min(1, "Bot token is required"),
});
const pushoverSchema = baseSchema.extend({
type: z.literal("pushover"),
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -65,6 +71,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
matrixSchema,
teamsSchema,
telegramSchema,
pushoverSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+8
View File
@@ -959,6 +959,14 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover configuration",
"description": "Configure Pushover to receive push notifications on your devices.",
"optionAppToken": "Application API token",
"placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi",
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
}
},
"table": {
+3
View File
@@ -29,6 +29,7 @@ import {
MatrixProvider,
TeamsProvider,
TelegramProvider,
PushoverProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -298,6 +299,7 @@ export const initializeServices = async ({
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -310,6 +312,7 @@ export const initializeServices = async ({
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
settingsService,
logger,
notificationMessageBuilder
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[],
required: true,
},
notificationName: {
+1
View File
@@ -30,6 +30,7 @@ export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
export * from "@/service/infrastructure/notificationProviders/pushover.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -0,0 +1,109 @@
const SERVICE_NAME = "PushoverProvider";
import type { Notification } from "@/types/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class PushoverProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.pushover.net/1/messages.json", {
form: {
token: notification.accessToken,
user: notification.address,
message: getTestMessage(),
title: "Checkmate Test Notification",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Pushover test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
const text = this.buildPushoverText(message);
try {
await got.post("https://api.pushover.net/1/messages.json", {
form: {
token: notification.accessToken,
user: notification.address,
message: text,
title: message.content.title,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Pushover notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Pushover alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildPushoverText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
@@ -34,6 +34,7 @@ export class NotificationsService implements INotificationsService {
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -49,6 +50,7 @@ export class NotificationsService implements INotificationsService {
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -63,6 +65,7 @@ export class NotificationsService implements INotificationsService {
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -102,6 +105,8 @@ export class NotificationsService implements INotificationsService {
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
case "pushover":
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -164,6 +169,8 @@ export class NotificationsService implements INotificationsService {
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
case "pushover":
return await this.pushoverProvider.sendTestAlert(notification);
default:
return false;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
@@ -72,6 +72,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
}),
// Pushover notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
type: z.literal("pushover"),
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
]);
export const testNotificationBodyValidation = createNotificationBodyValidation;
@@ -0,0 +1,115 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
const mockGotPost = jest.fn().mockResolvedValue({});
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
const { PushoverProvider } = await import("../../../../src/service/infrastructure/notificationProviders/pushover.ts");
const createProvider = () => {
const logger = createMockLogger();
return { provider: new PushoverProvider(logger as any), logger };
};
testNotificationProviderContract("PushoverProvider", {
create: () => {
mockGotPost.mockResolvedValue({});
return createProvider().provider;
},
makeNotification: () => makeNotification(),
});
describe("PushoverProvider", () => {
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
describe("sendTestAlert", () => {
it("sends to Pushover API and returns true", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
expect(mockGotPost).toHaveBeenCalledWith(
"https://api.pushover.net/1/messages.json",
expect.objectContaining({ form: expect.objectContaining({ token: "token-abc", user: "https://hooks.example.com/webhook" }) })
);
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
});
it("handles non-Error thrown values in sendTestAlert", async () => {
mockGotPost.mockRejectedValue("string error");
const { provider, logger } = createProvider();
expect(await provider.sendTestAlert(makeNotification())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
});
});
describe("sendMessage", () => {
it("sends plain text message and returns true", async () => {
const { provider } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
const form = mockGotPost.mock.calls[0][1].form;
expect(form.message).toContain("Monitor Details:");
expect(form.title).toBeDefined();
});
it("returns false when address is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
});
it("returns false when accessToken is missing", async () => {
expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
});
it("returns false and logs on error", async () => {
mockGotPost.mockRejectedValue(new Error("fail"));
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
});
it("handles non-Error thrown values in sendMessage", async () => {
mockGotPost.mockRejectedValue(42);
const { provider, logger } = createProvider();
expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
});
it("includes thresholds in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
expect(mockGotPost.mock.calls[0][1].form.message).toContain("CPU");
});
it("includes incident link in text", async () => {
const { provider } = createProvider();
await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
expect(mockGotPost.mock.calls[0][1].form.message).toContain("View Incident");
});
it("omits optional sections when not present", async () => {
const { provider } = createProvider();
const msg = makeMessage();
msg.content.thresholds = undefined;
msg.content.details = undefined;
msg.content.incident = undefined;
await provider.sendMessage(makeNotification() as any, msg);
const text = mockGotPost.mock.calls[0][1].form.message;
expect(text).not.toContain("Threshold");
expect(text).not.toContain("Additional");
expect(text).not.toContain("View Incident");
});
});
});