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:
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +223,8 @@ 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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => saveNtfy(true)}
|
onClick={() => saveNtfy(true)}
|
||||||
disabled={!topicUrl.trim() || update.isPending}
|
disabled={!topicUrl.trim() || update.isPending}
|
||||||
@@ -194,6 +233,16 @@ export default function NotificationsPage() {
|
|||||||
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
{ntfySaved ? <CheckCircle className="w-3.5 h-3.5" /> : <Bell className="w-3.5 h-3.5" />}
|
||||||
{ntfySaved ? "Saved!" : "Save & Enable"}
|
{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 && (
|
{settings?.ntfy_enabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => saveNtfy(false)}
|
onClick={() => saveNtfy(false)}
|
||||||
@@ -204,6 +253,19 @@ export default function NotificationsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RSS */}
|
{/* RSS */}
|
||||||
@@ -245,7 +307,8 @@ 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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{!settings?.rss_enabled ? (
|
{!settings?.rss_enabled ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleRss(true)}
|
onClick={() => toggleRss(true)}
|
||||||
@@ -265,6 +328,17 @@ export default function NotificationsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{rssUrl && (
|
{rssUrl && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={testRss}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{rssTesting
|
||||||
|
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <FlaskConical className="w-3.5 h-3.5" />}
|
||||||
|
{rssTesting ? "Checking…" : "Test"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => resetRss.mutate()}
|
onClick={() => resetRss.mutate()}
|
||||||
disabled={resetRss.isPending}
|
disabled={resetRss.isPending}
|
||||||
@@ -274,6 +348,20 @@ export default function NotificationsPage() {
|
|||||||
<RefreshCw className="w-3 h-3" />
|
<RefreshCw className="w-3 h-3" />
|
||||||
Regenerate URL
|
Regenerate URL
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user