diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index c45773db7..0b42b6d40 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -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", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 85568f8bd..adf7437ab 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -147,7 +147,7 @@ const NotificationsCreatePage = () => { /> } /> - {watchedType !== "matrix" && watchedType !== "telegram" && ( + {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && ( { } /> )} + {watchedType === "pushover" && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "matrix" && ( ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 83ca4fa33..81008504a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index eae4ccd7e..ed9f02a9f 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -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 diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 5f6d234dd..3b424bdde 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, 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: { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 1e3b20ba6..da529ed2b 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -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"; diff --git a/server/src/service/infrastructure/notificationProviders/pushover.ts b/server/src/service/infrastructure/notificationProviders/pushover.ts new file mode 100644 index 000000000..5d001892d --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/pushover.ts @@ -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): Promise { + 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 { + 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"); + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index a96ccecb6..0344ab30c 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -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; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index d4c47ae76..f095be770 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -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 { diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index e9705c1ad..5cdb44e95 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -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; diff --git a/server/test/unit/providers/notifications/pushoverProvider.test.ts b/server/test/unit/providers/notifications/pushoverProvider.test.ts new file mode 100644 index 000000000..8d32573b5 --- /dev/null +++ b/server/test/unit/providers/notifications/pushoverProvider.test.ts @@ -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"); + }); + }); +});