From 50399adf445630fc6f69f2e816150ecb89355235 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sun, 1 Mar 2026 12:10:10 -0500 Subject: [PATCH] feat(notifications): add Test button for ntfy and RSS with inline result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/notifications/test/ntfy — sends a real push using current form values (not saved settings) so auth can be verified before saving; returns status + HTTP detail on success or error message on failure - POST /api/notifications/test/rss — confirms the feed token exists and returns event count; no bill FK required - NtfyTestRequest + NotificationTestResult schemas added - Frontend: Test button next to Save on both ntfy and RSS sections; result shown inline as a green/red pill; uses current form state for ntfy so the user can test before committing All future notification types should follow the same test-before-save pattern. Authored-By: Jack Levy --- backend/app/api/notifications.py | 69 ++++++++++- backend/app/schemas/schemas.py | 14 +++ frontend/app/notifications/page.tsx | 178 +++++++++++++++++++++------- frontend/lib/api.ts | 16 +++ 4 files changed, 231 insertions(+), 46 deletions(-) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 25e6916..8c026c1 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -1,9 +1,11 @@ """ Notifications API — user notification settings and per-user RSS feed. """ +import base64 import secrets from xml.etree.ElementTree import Element, SubElement, tostring +import httpx from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response from sqlalchemy import select @@ -13,7 +15,12 @@ from app.core.dependencies import get_current_user from app.database import get_db from app.models.notification import NotificationEvent from app.models.user import User -from app.schemas.schemas import NotificationSettingsResponse, NotificationSettingsUpdate +from app.schemas.schemas import ( + NotificationSettingsResponse, + NotificationSettingsUpdate, + NotificationTestResult, + NtfyTestRequest, +) router = APIRouter() @@ -98,6 +105,66 @@ async def reset_rss_token( return _prefs_to_response(user.notification_prefs or {}, user.rss_token) +@router.post("/test/ntfy", response_model=NotificationTestResult) +async def test_ntfy( + body: NtfyTestRequest, + current_user: User = Depends(get_current_user), +): + """Send a test push notification to verify ntfy settings.""" + url = body.ntfy_topic_url.strip() + if not url: + return NotificationTestResult(status="error", detail="Topic URL is required") + + headers: dict[str, str] = { + "Title": "PocketVeto — Test Notification", + "Priority": "default", + "Tags": "white_check_mark", + } + if body.ntfy_auth_method == "token" and body.ntfy_token.strip(): + headers["Authorization"] = f"Bearer {body.ntfy_token.strip()}" + elif body.ntfy_auth_method == "basic" and body.ntfy_username.strip(): + creds = base64.b64encode( + f"{body.ntfy_username.strip()}:{body.ntfy_password}".encode() + ).decode() + headers["Authorization"] = f"Basic {creds}" + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + url, + content="Your PocketVeto notification settings are working correctly.".encode("utf-8"), + headers=headers, + ) + resp.raise_for_status() + return NotificationTestResult(status="ok", detail=f"Test notification sent (HTTP {resp.status_code})") + except httpx.HTTPStatusError as e: + return NotificationTestResult(status="error", detail=f"HTTP {e.response.status_code}: {e.response.text[:200]}") + except httpx.RequestError as e: + return NotificationTestResult(status="error", detail=f"Connection error: {e}") + + +@router.post("/test/rss", response_model=NotificationTestResult) +async def test_rss( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Verify the user's RSS feed is reachable and return its event count.""" + user = await db.get(User, current_user.id) + if not user.rss_token: + return NotificationTestResult(status="error", detail="RSS token not generated — save settings first") + + count_result = await db.execute( + select(NotificationEvent).where(NotificationEvent.user_id == user.id) + ) + event_count = len(count_result.scalars().all()) + + return NotificationTestResult( + status="ok", + detail=f"RSS feed is active with {event_count} event{'s' if event_count != 1 else ''}. Subscribe to the URL shown above.", + event_count=event_count, + ) + + @router.get("/feed/{rss_token}.xml", include_in_schema=False) async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)): """Public tokenized RSS feed — no auth required.""" diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 4c9c9e4..cbef498 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -28,6 +28,20 @@ class NotificationSettingsUpdate(BaseModel): ntfy_enabled: Optional[bool] = None rss_enabled: Optional[bool] = None + +class NtfyTestRequest(BaseModel): + ntfy_topic_url: str + ntfy_auth_method: str = "none" + ntfy_token: str = "" + ntfy_username: str = "" + ntfy_password: str = "" + + +class NotificationTestResult(BaseModel): + status: str # "ok" | "error" + detail: str + event_count: Optional[int] = None # RSS only + T = TypeVar("T") diff --git a/frontend/app/notifications/page.tsx b/frontend/app/notifications/page.tsx index 3440032..95c6671 100644 --- a/frontend/app/notifications/page.tsx +++ b/frontend/app/notifications/page.tsx @@ -2,8 +2,8 @@ import { useState, useEffect } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { Bell, Rss, CheckCircle, Copy, RefreshCw } from "lucide-react"; -import { notificationsAPI } from "@/lib/api"; +import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical } from "lucide-react"; +import { notificationsAPI, type NotificationTestResult } from "@/lib/api"; const AUTH_METHODS = [ { value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" }, @@ -36,6 +36,12 @@ export default function NotificationsPage() { const [password, setPassword] = useState(""); const [ntfySaved, setNtfySaved] = useState(false); + // Test state + const [ntfyTesting, setNtfyTesting] = useState(false); + const [ntfyTestResult, setNtfyTestResult] = useState(null); + const [rssTesting, setRssTesting] = useState(false); + const [rssTestResult, setRssTestResult] = useState(null); + // RSS state const [rssSaved, setRssSaved] = useState(false); const [copied, setCopied] = useState(false); @@ -71,6 +77,38 @@ export default function NotificationsPage() { ); }; + const testNtfy = async () => { + setNtfyTesting(true); + setNtfyTestResult(null); + try { + const result = await notificationsAPI.testNtfy({ + ntfy_topic_url: topicUrl, + ntfy_auth_method: authMethod, + ntfy_token: authMethod === "token" ? token : "", + ntfy_username: authMethod === "basic" ? username : "", + ntfy_password: authMethod === "basic" ? password : "", + }); + setNtfyTestResult(result); + } catch { + setNtfyTestResult({ status: "error", detail: "Request failed — check your topic URL" }); + } finally { + setNtfyTesting(false); + } + }; + + const testRss = async () => { + setRssTesting(true); + setRssTestResult(null); + try { + const result = await notificationsAPI.testRss(); + setRssTestResult(result); + } catch { + setRssTestResult({ status: "error", detail: "Feed check failed" }); + } finally { + setRssTesting(false); + } + }; + const rssUrl = settings?.rss_token ? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml` : null; @@ -185,23 +223,47 @@ export default function NotificationsPage() { )} {/* Actions */} -
- - {settings?.ntfy_enabled && ( +
+
+ + {settings?.ntfy_enabled && ( + + )} +
+ {ntfyTestResult && ( +
+ {ntfyTestResult.status === "ok" + ? + : } + {ntfyTestResult.detail} +
)}
@@ -245,35 +307,61 @@ export default function NotificationsPage() {
)} -
- {!settings?.rss_enabled ? ( - - ) : ( - - )} - {rssUrl && ( - +
+
+ {!settings?.rss_enabled ? ( + + ) : ( + + )} + {rssUrl && ( + <> + + + + )} +
+ {rssTestResult && ( +
+ {rssTestResult.status === "ok" + ? + : } + {rssTestResult.detail} +
)}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index f8d5ec2..9846f83 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -165,6 +165,12 @@ export interface AnalysisStats { remaining: number; } +export interface NotificationTestResult { + status: "ok" | "error"; + detail: string; + event_count?: number; +} + // Notifications export const notificationsAPI = { getSettings: () => @@ -173,6 +179,16 @@ export const notificationsAPI = { apiClient.put("/api/notifications/settings", data).then((r) => r.data), resetRssToken: () => apiClient.post("/api/notifications/settings/rss-reset").then((r) => r.data), + testNtfy: (data: { + ntfy_topic_url: string; + ntfy_auth_method: string; + ntfy_token: string; + ntfy_username: string; + ntfy_password: string; + }) => + apiClient.post("/api/notifications/test/ntfy", data).then((r) => r.data), + testRss: () => + apiClient.post("/api/notifications/test/rss").then((r) => r.data), }; // Admin