Skip to content
Open
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
89 changes: 88 additions & 1 deletion src/components/config/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
Expand All @@ -103,6 +103,7 @@ export function NotificationSettings({
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
</SelectContent>
</Select>
</div>
Expand Down Expand Up @@ -307,6 +308,92 @@ export function NotificationSettings({
</div>
)}

{/* Gotify configuration */}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Gotify Settings</h3>

<div className="space-y-2">
<Label htmlFor="gotify-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Gotify server
</p>
</div>

<div className="space-y-2">
<Label htmlFor="gotify-token" className="text-sm">
Application token <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Create an application in Gotify and paste its token here
</p>
</div>

<div className="space-y-2">
<Label htmlFor="gotify-priority" className="text-sm">
Default priority (0-10)
</Label>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
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)),
},
})
}
/>
<p className="text-xs text-muted-foreground">
Error notifications always use priority 8 regardless of this setting
</p>
</div>
</div>
)}

{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
Expand Down
9 changes: 8 additions & 1 deletion src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof notificationConfigSchema>;
Expand Down
49 changes: 49 additions & 0 deletions src/lib/notification-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/notification-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}` };
}
Expand Down Expand Up @@ -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";
Expand Down
106 changes: 106 additions & 0 deletions src/lib/providers/gotify.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock>;

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");
});
});
17 changes: 17 additions & 0 deletions src/lib/providers/gotify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";

export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"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()}`);
}
14 changes: 14 additions & 0 deletions src/pages/api/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading