feat(notifications): add Test button for ntfy and RSS with inline result

- 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
This commit is contained in:
Jack Levy
2026-03-01 12:10:10 -05:00
parent 2e2fefb795
commit 50399adf44
4 changed files with 231 additions and 46 deletions

View File

@@ -1,9 +1,11 @@
""" """
Notifications API — user notification settings and per-user RSS feed. Notifications API — user notification settings and per-user RSS feed.
""" """
import base64
import secrets import secrets
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy import select from sqlalchemy import select
@@ -13,7 +15,12 @@ from app.core.dependencies import get_current_user
from app.database import get_db from app.database import get_db
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
from app.models.user import User from app.models.user import User
from app.schemas.schemas import NotificationSettingsResponse, NotificationSettingsUpdate from app.schemas.schemas import (
NotificationSettingsResponse,
NotificationSettingsUpdate,
NotificationTestResult,
NtfyTestRequest,
)
router = APIRouter() router = APIRouter()
@@ -98,6 +105,66 @@ async def reset_rss_token(
return _prefs_to_response(user.notification_prefs or {}, user.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) @router.get("/feed/{rss_token}.xml", include_in_schema=False)
async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)): async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)):
"""Public tokenized RSS feed — no auth required.""" """Public tokenized RSS feed — no auth required."""

View File

@@ -28,6 +28,20 @@ class NotificationSettingsUpdate(BaseModel):
ntfy_enabled: Optional[bool] = None ntfy_enabled: Optional[bool] = None
rss_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") T = TypeVar("T")

View File

@@ -2,8 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Bell, Rss, CheckCircle, Copy, RefreshCw } from "lucide-react"; import { Bell, Rss, CheckCircle, Copy, RefreshCw, XCircle, FlaskConical } from "lucide-react";
import { notificationsAPI } from "@/lib/api"; import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
const AUTH_METHODS = [ const AUTH_METHODS = [
{ value: "none", label: "No authentication", hint: "Public ntfy.sh topics or open self-hosted servers" }, { 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 [password, setPassword] = useState("");
const [ntfySaved, setNtfySaved] = useState(false); const [ntfySaved, setNtfySaved] = useState(false);
// Test state
const [ntfyTesting, setNtfyTesting] = useState(false);
const [ntfyTestResult, setNtfyTestResult] = useState<NotificationTestResult | null>(null);
const [rssTesting, setRssTesting] = useState(false);
const [rssTestResult, setRssTestResult] = useState<NotificationTestResult | null>(null);
// RSS state // RSS state
const [rssSaved, setRssSaved] = useState(false); const [rssSaved, setRssSaved] = useState(false);
const [copied, setCopied] = 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 const rssUrl = settings?.rss_token
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml` ? `${typeof window !== "undefined" ? window.location.origin : ""}/api/notifications/feed/${settings.rss_token}.xml`
: null; : null;
@@ -185,23 +223,47 @@ export default function NotificationsPage() {
)} )}
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3 pt-1 border-t border-border"> <div className="space-y-3 pt-1 border-t border-border">
<button <div className="flex items-center gap-3">
onClick={() => saveNtfy(true)}
disabled={!topicUrl.trim() || update.isPending}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{ntfySaved ? "Saved!" : "Save & Enable"}
</button>
{settings?.ntfy_enabled && (
<button <button
onClick={() => saveNtfy(false)} onClick={() => saveNtfy(true)}
disabled={update.isPending} disabled={!topicUrl.trim() || update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
Disable {ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
{ntfySaved ? "Saved!" : "Save & Enable"}
</button> </button>
<button
onClick={testNtfy}
disabled={!topicUrl.trim() || ntfyTesting}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
>
{ntfyTesting
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <FlaskConical className="w-3.5 h-3.5" />}
{ntfyTesting ? "Sending…" : "Test"}
</button>
{settings?.ntfy_enabled && (
<button
onClick={() => saveNtfy(false)}
disabled={update.isPending}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Disable
</button>
)}
</div>
{ntfyTestResult && (
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
ntfyTestResult.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{ntfyTestResult.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{ntfyTestResult.detail}
</div>
)} )}
</div> </div>
</section> </section>
@@ -245,35 +307,61 @@ export default function NotificationsPage() {
</div> </div>
)} )}
<div className="flex items-center gap-3 pt-1 border-t border-border"> <div className="space-y-3 pt-1 border-t border-border">
{!settings?.rss_enabled ? ( <div className="flex items-center gap-3">
<button {!settings?.rss_enabled ? (
onClick={() => toggleRss(true)} <button
disabled={update.isPending} onClick={() => toggleRss(true)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors" disabled={update.isPending}
> className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
{rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />} >
{rssSaved ? "Enabled!" : "Enable RSS"} {rssSaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Rss className="w-3.5 h-3.5" />}
</button> {rssSaved ? "Enabled!" : "Enable RSS"}
) : ( </button>
<button ) : (
onClick={() => toggleRss(false)} <button
disabled={update.isPending} onClick={() => toggleRss(false)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" disabled={update.isPending}
> className="text-xs text-muted-foreground hover:text-foreground transition-colors"
Disable RSS >
</button> Disable RSS
)} </button>
{rssUrl && ( )}
<button {rssUrl && (
onClick={() => resetRss.mutate()} <>
disabled={resetRss.isPending} <button
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" onClick={testRss}
title="Generate a new URL — old URL will stop working" disabled={rssTesting}
> className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md disabled:opacity-50 transition-colors"
<RefreshCw className="w-3 h-3" /> >
Regenerate URL {rssTesting
</button> ? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
: <FlaskConical className="w-3.5 h-3.5" />}
{rssTesting ? "Checking…" : "Test"}
</button>
<button
onClick={() => resetRss.mutate()}
disabled={resetRss.isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Generate a new URL — old URL will stop working"
>
<RefreshCw className="w-3 h-3" />
Regenerate URL
</button>
</>
)}
</div>
{rssTestResult && (
<div className={`flex items-start gap-2 text-xs rounded-md px-3 py-2 ${
rssTestResult.status === "ok"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400"
: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400"
}`}>
{rssTestResult.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
: <XCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />}
{rssTestResult.detail}
</div>
)} )}
</div> </div>
</section> </section>

View File

@@ -165,6 +165,12 @@ export interface AnalysisStats {
remaining: number; remaining: number;
} }
export interface NotificationTestResult {
status: "ok" | "error";
detail: string;
event_count?: number;
}
// Notifications // Notifications
export const notificationsAPI = { export const notificationsAPI = {
getSettings: () => getSettings: () =>
@@ -173,6 +179,16 @@ export const notificationsAPI = {
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data), apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
resetRssToken: () => resetRssToken: () =>
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data), apiClient.post<NotificationSettings>("/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<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
testRss: () =>
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
}; };
// Admin // Admin