feat(FN-2339): add configurable ntfy server support

- Add ntfy base URL to global settings schema/types with persistence coverage and regression tests
- Extend dashboard settings API/routes and Settings modal UI to edit and save a custom ntfy server
- Update notifier runtime to use configured ntfy base URL when sending notifications
- Document the new setting and include a changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-24 15:57:35 -07:00
committed by gsxdsm
parent 726a12de1c
commit a8f5591126
13 changed files with 385 additions and 10 deletions

View File

@@ -1098,7 +1098,7 @@ describe("NtfyNotifier", () => {
});
describe("custom base URL", () => {
it("uses custom ntfy base URL when provided", async () => {
it("uses custom ntfy base URL when provided in notifier options", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
fetchMock.mockResolvedValue({ ok: true });
@@ -1114,6 +1114,67 @@ describe("NtfyNotifier", () => {
expect.any(Object)
);
});
it("uses ntfyBaseUrl from settings when configured", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyBaseUrl: "https://ntfy.internal.example///",
});
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("FN-101", "Configured URL Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.internal.example/test-topic",
expect.any(Object),
);
});
it("falls back to default ntfy.sh when settings ntfyBaseUrl is blank", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyBaseUrl: " ",
});
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("FN-102", "Blank URL Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.any(Object),
);
});
it("applies updated ntfyBaseUrl from settings changes at runtime", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store);
await notifier.start();
store.triggerTaskMoved(createTask("FN-103", "Before Update"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/test-topic", expect.any(Object));
store.setSettings({ ntfyBaseUrl: "https://ntfy.changed.example" });
store.triggerTaskMoved(createTask("FN-104", "After Update"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenLastCalledWith(
"https://ntfy.changed.example/test-topic",
expect.any(Object),
);
});
});
describe("stop()", () => {

View File

@@ -10,6 +10,8 @@ export interface NtfyNotifierOptions {
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"in-review",
"merged",
@@ -71,6 +73,14 @@ interface NtfyNotifierStore {
off(event: string, listener: (...args: any[]) => void): void;
}
function resolveNtfyBaseUrl(baseUrl: string | undefined, fallback = DEFAULT_NTFY_BASE_URL): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return fallback;
}
return trimmed.replace(/\/+$/, "");
}
export function resolveNtfyEvents(events?: NtfyNotificationEvent[]): NtfyNotificationEvent[] {
return events ? [...events] : [...DEFAULT_NTFY_EVENTS];
}
@@ -108,7 +118,7 @@ export function buildNtfyClickUrl(options: {
* Errors are logged and swallowed so callers can treat delivery as best-effort.
*/
export async function sendNtfyNotification({
ntfyBaseUrl = "https://ntfy.sh",
ntfyBaseUrl,
topic,
title,
message,
@@ -127,7 +137,8 @@ export async function sendNtfyNotification({
headers.Click = clickUrl;
}
const response = await fetch(`${ntfyBaseUrl}/${topic}`, {
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
method: "POST",
headers,
body: message,
@@ -158,6 +169,7 @@ export class NtfyNotifier {
events: [...DEFAULT_NTFY_EVENTS],
};
private ntfyBaseUrl: string;
private readonly defaultNtfyBaseUrl: string;
private projectId?: string;
private notifiedEvents: Set<string> = new Set();
private abortController: AbortController | null = null;
@@ -166,7 +178,8 @@ export class NtfyNotifier {
private store: NtfyNotifierStore,
options: NtfyNotifierOptions = {},
) {
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
this.defaultNtfyBaseUrl = resolveNtfyBaseUrl(options.ntfyBaseUrl);
this.ntfyBaseUrl = this.defaultNtfyBaseUrl;
this.projectId = options.projectId;
}
@@ -314,6 +327,7 @@ export class NtfyNotifier {
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
settings.ntfyTopic !== previous.ntfyTopic ||
settings.ntfyBaseUrl !== previous.ntfyBaseUrl ||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
const wasEnabled = this.config.enabled;
@@ -325,6 +339,8 @@ export class NtfyNotifier {
schedulerLog.log("NtfyNotifier disabled");
} else if (this.config.topic !== previous.ntfyTopic) {
schedulerLog.log("NtfyNotifier topic updated");
} else if (this.ntfyBaseUrl !== resolveNtfyBaseUrl(previous.ntfyBaseUrl)) {
schedulerLog.log("NtfyNotifier base URL updated");
} else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
schedulerLog.log("NtfyNotifier dashboard host updated");
} else if (JSON.stringify(this.config.events) !== JSON.stringify(previous.ntfyEvents)) {
@@ -340,6 +356,7 @@ export class NtfyNotifier {
dashboardHost: settings.ntfyDashboardHost,
events: resolveNtfyEvents(settings.ntfyEvents),
};
this.ntfyBaseUrl = resolveNtfyBaseUrl(settings.ntfyBaseUrl, this.defaultNtfyBaseUrl);
}
private isEventEnabled(event: TaskNotificationEvent): boolean {