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:
@@ -650,7 +650,7 @@ export function updatePiExtensions(disabledIds: string[], projectId?: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
|
||||
method: "POST",
|
||||
body: config ? JSON.stringify(config) : undefined,
|
||||
|
||||
@@ -486,9 +486,11 @@ export function SettingsModal({
|
||||
|
||||
setTestNotificationLoading(true);
|
||||
try {
|
||||
const ntfyBaseUrl = form.ntfyBaseUrl?.trim();
|
||||
const result = await testNtfyNotification({
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(ntfyBaseUrl ? { ntfyBaseUrl } : {}),
|
||||
}, projectId);
|
||||
if (result.success) {
|
||||
addToast("Test notification sent — check your ntfy app!", "success");
|
||||
@@ -500,7 +502,7 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setTestNotificationLoading(false);
|
||||
}
|
||||
}, [addToast, form.ntfyEnabled, form.ntfyTopic, projectId]);
|
||||
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, projectId]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
@@ -2811,6 +2813,25 @@ export function SettingsModal({
|
||||
Topic must be 1–64 alphanumeric, hyphen, or underscore characters
|
||||
</small>
|
||||
)}
|
||||
<details className="ntfy-advanced-disclosure">
|
||||
<summary>Advanced</summary>
|
||||
<div className="ntfy-advanced-content">
|
||||
<label htmlFor="ntfyBaseUrl">Custom ntfy server URL (optional)</label>
|
||||
<input
|
||||
id="ntfyBaseUrl"
|
||||
type="url"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={form.ntfyBaseUrl || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyBaseUrl: value || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
|
||||
@@ -46,6 +46,7 @@ const defaultSettings: SettingsWithAutoArchive = {
|
||||
defaultPresetBySize: {},
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyBaseUrl: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review", "planning-awaiting-input"],
|
||||
taskStuckTimeoutMs: undefined,
|
||||
maxStuckKills: 6,
|
||||
@@ -2298,6 +2299,34 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides advanced ntfy server field by default and reveals it on disclosure", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
|
||||
|
||||
const disclosure = screen.getByText("Advanced").closest("details") as HTMLDetailsElement;
|
||||
expect(disclosure.open).toBe(false);
|
||||
|
||||
const advancedInput = screen.getByLabelText("Custom ntfy server URL (optional)");
|
||||
expect(advancedInput).not.toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced"));
|
||||
expect(disclosure.open).toBe(true);
|
||||
expect(screen.getByLabelText("Custom ntfy server URL (optional)")).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides advanced ntfy server controls entirely when ntfy is disabled", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
|
||||
expect(screen.queryByText("Advanced")).toBeNull();
|
||||
expect(screen.queryByLabelText("Custom ntfy server URL (optional)")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
@@ -2377,6 +2406,29 @@ describe("SettingsModal", () => {
|
||||
expect(payload.ntfyTopic).toBeNull(); // null means "explicitly clear this field"
|
||||
});
|
||||
|
||||
it("ntfy custom server submits null when cleared (null-as-delete semantics)", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "existing-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Notifications/ }));
|
||||
fireEvent.click(screen.getByText("Advanced"));
|
||||
const baseUrlInput = screen.getByLabelText("Custom ntfy server URL (optional)") as HTMLInputElement;
|
||||
fireEvent.change(baseUrlInput, { target: { value: "" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.ntfyBaseUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("ntfy topic shows validation error for invalid input", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
@@ -2914,6 +2966,49 @@ describe("SettingsModal", () => {
|
||||
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
|
||||
});
|
||||
|
||||
it("Test notification call includes custom ntfy server when populated", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
|
||||
|
||||
const topicInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(topicInput, { target: { value: "my-valid-topic" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Advanced"));
|
||||
const baseUrlInput = screen.getByLabelText("Custom ntfy server URL (optional)") as HTMLInputElement;
|
||||
fireEvent.change(baseUrlInput, { target: { value: "https://ntfy.internal.example/" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test notification" }));
|
||||
|
||||
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
|
||||
expect(testNtfyNotification).toHaveBeenCalledWith(
|
||||
{
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-valid-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example/",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("Test notification call omits custom ntfy server when blank", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
fireEvent.click(screen.getByText("Notifications"));
|
||||
fireEvent.click(screen.getByLabelText("Enable ntfy.sh notifications"));
|
||||
|
||||
const topicInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||||
fireEvent.change(topicInput, { target: { value: "my-valid-topic" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test notification" }));
|
||||
|
||||
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
|
||||
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
|
||||
});
|
||||
|
||||
it("Success toast is shown when test notification succeeds", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -3695,6 +3695,34 @@ input[type="range"]:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* === Notifications Settings === */
|
||||
.ntfy-advanced-disclosure {
|
||||
margin-top: var(--space-md);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.ntfy-advanced-disclosure > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ntfy-advanced-disclosure > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ntfy-advanced-content {
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* === Memory Settings === */
|
||||
.memory-status-message {
|
||||
display: flex;
|
||||
|
||||
@@ -13327,6 +13327,61 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(options?.body).toBe("Fusion test notification — your notifications are working!");
|
||||
});
|
||||
|
||||
it("uses configured ntfyBaseUrl from settings when present", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyBaseUrl: "https://ntfy.internal.example///",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url).toBe("https://ntfy.internal.example/my-topic");
|
||||
});
|
||||
|
||||
it("uses request ntfyBaseUrl override when provided", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyBaseUrl: "https://ntfy.override.example//" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyBaseUrl: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url).toBe("https://ntfy.saved.example/my-topic");
|
||||
});
|
||||
|
||||
it("returns 400 when ntfy is not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
@@ -13351,6 +13406,44 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(res.body.error).toContain("not configured or invalid");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when request ntfyBaseUrl uses non-http protocol", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyBaseUrl: "ftp://ntfy.example.com" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("http:// or https://");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when request ntfyBaseUrl is malformed", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyBaseUrl: "not-a-url" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("must be a valid URL");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memory Routes ─────────────────────────────────────────────
|
||||
|
||||
@@ -3735,6 +3735,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Returns: { success: true } on success, { error: string } on failure.
|
||||
*/
|
||||
router.post("/settings/test-ntfy", async (req, res) => {
|
||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw badRequest("ntfy server URL cannot be empty");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("ntfy server URL must use http:// or https://");
|
||||
}
|
||||
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
@@ -3750,8 +3770,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("ntfy topic is not configured or invalid");
|
||||
}
|
||||
|
||||
// Send test notification to ntfy.sh
|
||||
const ntfyBaseUrl = "https://ntfy.sh";
|
||||
const overrideValue = req.body?.ntfyBaseUrl;
|
||||
if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") {
|
||||
throw badRequest("ntfy server URL must be a string");
|
||||
}
|
||||
|
||||
const requestOverride = typeof overrideValue === "string" && overrideValue.trim()
|
||||
? normalizeNtfyBaseUrl(overrideValue, "request")
|
||||
: undefined;
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -3765,7 +3795,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `ntfy.sh returned ${response.status}: ${response.statusText}`);
|
||||
throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
Reference in New Issue
Block a user