diff --git a/src/components/config/NotificationSettings.tsx b/src/components/config/NotificationSettings.tsx index d8263d0..7754322 100644 --- a/src/components/config/NotificationSettings.tsx +++ b/src/components/config/NotificationSettings.tsx @@ -93,7 +93,7 @@ export function NotificationSettings({ @@ -307,6 +308,92 @@ export function NotificationSettings({ )} + {/* Gotify configuration */} + {notificationConfig.provider === "gotify" && ( +
+

Gotify Settings

+ +
+ + + onNotificationChange({ + ...notificationConfig, + gotify: { + ...notificationConfig.gotify!, + url: e.target.value, + token: notificationConfig.gotify?.token || "", + priority: notificationConfig.gotify?.priority ?? 5, + }, + }) + } + /> +

+ URL of your Gotify server +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + gotify: { + ...notificationConfig.gotify!, + url: notificationConfig.gotify?.url || "", + token: e.target.value, + priority: notificationConfig.gotify?.priority ?? 5, + }, + }) + } + /> +

+ Create an application in Gotify and paste its token here +

+
+ +
+ + + onNotificationChange({ + ...notificationConfig, + gotify: { + ...notificationConfig.gotify!, + url: notificationConfig.gotify?.url || "", + token: notificationConfig.gotify?.token || "", + priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)), + }, + }) + } + /> +

+ Error notifications always use priority 8 regardless of this setting +

+
+
+ )} + {/* Event toggles */}

Notification Events

diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index c965803..1f21dcf 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({ tag: z.string().optional(), }); +export const gotifyConfigSchema = z.object({ + url: z.string().default(""), + token: z.string().default(""), + priority: z.number().int().min(0).max(10).default(5), +}); + export const notificationConfigSchema = z.object({ enabled: z.boolean().default(false), - provider: z.enum(["ntfy", "apprise"]).default("ntfy"), + provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"), notifyOnSyncError: z.boolean().default(true), notifyOnSyncSuccess: z.boolean().default(false), notifyOnNewRepo: z.boolean().default(false), ntfy: ntfyConfigSchema.optional(), apprise: appriseConfigSchema.optional(), + gotify: gotifyConfigSchema.optional(), }); export type NotificationConfig = z.infer; diff --git a/src/lib/notification-service.test.ts b/src/lib/notification-service.test.ts index b068d7e..c2d2849 100644 --- a/src/lib/notification-service.test.ts +++ b/src/lib/notification-service.test.ts @@ -71,6 +71,32 @@ describe("sendNotification", () => { expect(url).toBe("http://apprise:8000/notify/my-token"); }); + test("sends gotify notification when provider is gotify", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "gotify", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + gotify: { + url: "https://gotify.example.com", + token: "my-app-token", + priority: 5, + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe("https://gotify.example.com/message"); + expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token"); + }); + test("does not throw when fetch fails", async () => { mockFetch = mock(() => Promise.reject(new Error("Network error"))); globalThis.fetch = mockFetch as any; @@ -140,6 +166,29 @@ describe("sendNotification", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + + test("skips notification when gotify token is missing", async () => { + const config: NotificationConfig = { + enabled: true, + provider: "gotify", + notifyOnSyncError: true, + notifyOnSyncSuccess: true, + notifyOnNewRepo: false, + gotify: { + url: "https://gotify.example.com", + token: "", + priority: 5, + }, + }; + + await sendNotification(config, { + title: "Test", + message: "Test message", + type: "sync_success", + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe("testNotification", () => { diff --git a/src/lib/notification-service.ts b/src/lib/notification-service.ts index 0753e24..0e68039 100644 --- a/src/lib/notification-service.ts +++ b/src/lib/notification-service.ts @@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config"; import type { NotificationEvent } from "./providers/ntfy"; import { sendNtfyNotification } from "./providers/ntfy"; import { sendAppriseNotification } from "./providers/apprise"; +import { sendGotifyNotification } from "./providers/gotify"; import { db, configs } from "@/lib/db"; import { eq, sql } from "drizzle-orm"; import { decrypt } from "@/lib/utils/encryption"; @@ -52,6 +53,12 @@ export async function sendNotification( return; } await sendAppriseNotification(config.apprise, event); + } else if (config.provider === "gotify") { + if (!config.gotify?.url || !config.gotify?.token) { + console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification"); + return; + } + await sendGotifyNotification(config.gotify, event); } } catch (error) { console.error("[NotificationService] Failed to send notification:", error); @@ -83,6 +90,11 @@ export async function testNotification( return { success: false, error: "Apprise URL and token are required" }; } await sendAppriseNotification(notificationConfig.apprise, event); + } else if (notificationConfig.provider === "gotify") { + if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) { + return { success: false, error: "Gotify URL and token are required" }; + } + await sendGotifyNotification(notificationConfig.gotify, event); } else { return { success: false, error: `Unknown provider: ${notificationConfig.provider}` }; } @@ -166,6 +178,12 @@ export async function triggerJobNotification({ token: decrypt(decryptedConfig.apprise.token), }; } + if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) { + decryptedConfig.gotify = { + ...decryptedConfig.gotify, + token: decrypt(decryptedConfig.gotify.token), + }; + } // Build event const repoLabel = repositoryName || organizationName || "Unknown"; diff --git a/src/lib/providers/gotify.test.ts b/src/lib/providers/gotify.test.ts new file mode 100644 index 0000000..9868937 --- /dev/null +++ b/src/lib/providers/gotify.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; +import { sendGotifyNotification } from "./gotify"; +import type { NotificationEvent } from "./ntfy"; +import type { GotifyConfig } from "@/types/config"; + +describe("sendGotifyNotification", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(() => + Promise.resolve(new Response("ok", { status: 200 })) + ); + globalThis.fetch = mockFetch as any; + }); + + const baseConfig: GotifyConfig = { + url: "https://gotify.example.com", + token: "AbCdEf123456", + priority: 5, + }; + + const baseEvent: NotificationEvent = { + title: "Test Notification", + message: "This is a test", + type: "sync_success", + }; + + test("constructs correct URL from config", async () => { + await sendGotifyNotification(baseConfig, baseEvent); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("https://gotify.example.com/message"); + }); + + test("strips trailing slash from URL", async () => { + await sendGotifyNotification( + { ...baseConfig, url: "https://gotify.example.com/" }, + baseEvent + ); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe("https://gotify.example.com/message"); + }); + + test("sends token via X-Gotify-Key header", async () => { + await sendGotifyNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456"); + }); + + test("sends title and message in JSON body", async () => { + await sendGotifyNotification(baseConfig, baseEvent); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.title).toBe("Test Notification"); + expect(body.message).toBe("This is a test"); + }); + + test("uses priority 8 for sync_error events", async () => { + const errorEvent: NotificationEvent = { + ...baseEvent, + type: "sync_error", + }; + await sendGotifyNotification(baseConfig, errorEvent); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.priority).toBe(8); + }); + + test("uses config priority for non-error events", async () => { + await sendGotifyNotification( + { ...baseConfig, priority: 2 }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.priority).toBe(2); + }); + + test("defaults to priority 5 when not configured", async () => { + await sendGotifyNotification( + { ...baseConfig, priority: undefined as any }, + baseEvent + ); + + const [, opts] = mockFetch.mock.calls[0]; + const body = JSON.parse(opts.body); + expect(body.priority).toBe(5); + }); + + test("throws on non-200 response", async () => { + mockFetch = mock(() => + Promise.resolve(new Response("unauthorized", { status: 401 })) + ); + globalThis.fetch = mockFetch as any; + + expect( + sendGotifyNotification(baseConfig, baseEvent) + ).rejects.toThrow("Gotify error: 401"); + }); +}); diff --git a/src/lib/providers/gotify.ts b/src/lib/providers/gotify.ts new file mode 100644 index 0000000..8e227ec --- /dev/null +++ b/src/lib/providers/gotify.ts @@ -0,0 +1,17 @@ +import type { GotifyConfig } from "@/types/config"; +import type { NotificationEvent } from "./ntfy"; + +export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise { + const url = `${config.url.replace(/\/$/, "")}/message`; + const headers: Record = { + "Content-Type": "application/json", + "X-Gotify-Key": config.token, + }; + const body = JSON.stringify({ + title: event.title, + message: event.message, + priority: event.type === "sync_error" ? 8 : (config.priority ?? 5), + }); + const resp = await fetch(url, { method: "POST", body, headers }); + if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`); +} diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts index 69e84ac..ed00439 100644 --- a/src/pages/api/config/index.ts +++ b/src/pages/api/config/index.ts @@ -162,6 +162,13 @@ export const POST: APIRoute = async ({ request, locals }) => { token: encrypt(processedNotificationConfig.apprise.token), }; } + // Encrypt gotify token if present + if (processedNotificationConfig.gotify?.token) { + processedNotificationConfig.gotify = { + ...processedNotificationConfig.gotify, + token: encrypt(processedNotificationConfig.gotify.token), + }; + } } if (existingConfig) { @@ -344,6 +351,13 @@ export const GET: APIRoute = async ({ request, locals }) => { notificationConfig.apprise = { ...notificationConfig.apprise, token: "" }; } } + if (notificationConfig.gotify?.token) { + try { + notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) }; + } catch { + notificationConfig.gotify = { ...notificationConfig.gotify, token: "" }; + } + } } return new Response(JSON.stringify({ diff --git a/src/types/config.ts b/src/types/config.ts index 75760c4..b07a6d9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -119,14 +119,21 @@ export interface AppriseConfig { tag?: string; } +export interface GotifyConfig { + url: string; + token: string; + priority: number; +} + export interface NotificationConfig { enabled: boolean; - provider: "ntfy" | "apprise"; + provider: "ntfy" | "apprise" | "gotify"; notifyOnSyncError: boolean; notifyOnSyncSuccess: boolean; notifyOnNewRepo: boolean; ntfy?: NtfyConfig; apprise?: AppriseConfig; + gotify?: GotifyConfig; } export interface Config extends ConfigType {}