Fix toast accept stale closure + harden backend error responses

Toast accept button captured a stale `respond` reference from the
Sonner closure. Use respondRef pattern so clicks always dispatch
through the current mutation. Backend respond endpoint now catches
unhandled exceptions and returns proper JSON with detail field
instead of plain-text 500s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-05 16:54:28 +08:00
parent 6b59d61bf3
commit 2fb41e0cf4
2 changed files with 25 additions and 2 deletions

View File

@ -9,6 +9,7 @@ Security:
- Audit logging for all connection events - Audit logging for all connection events
""" """
import asyncio import asyncio
import logging
from datetime import date as date_type, datetime, timedelta, timezone from datetime import date as date_type, datetime, timedelta, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
@ -49,6 +50,7 @@ from app.services.connection import (
from app.services.notification import create_notification from app.services.notification import create_notification
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# ── Helpers ────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────
@ -355,6 +357,24 @@ async def respond_to_request(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'.""" """Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'."""
try:
return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user)
except HTTPException:
raise
except Exception:
# get_db middleware auto-rollbacks on unhandled exceptions
logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id)
raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})")
async def _respond_to_request_inner(
body: RespondRequest,
request: Request,
background_tasks: BackgroundTasks,
request_id: int,
db: AsyncSession,
current_user: User,
) -> RespondAcceptResponse | RespondRejectResponse:
now = datetime.now() now = datetime.now()
# Atomic update — only succeeds if status is still 'pending' and receiver is current user # Atomic update — only succeeds if status is still 'pending' and receiver is current user

View File

@ -16,6 +16,9 @@ export default function NotificationToaster() {
const prevUnreadRef = useRef(0); const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked // Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set()); const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondRef = useRef(respond);
respondRef.current = respond;
const handleConnectionRespond = useCallback( const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => {
@ -30,7 +33,7 @@ export default function NotificationToaster() {
); );
try { try {
await respond({ requestId, action }); await respondRef.current({ requestId, action });
toast.dismiss(loadingId); toast.dismiss(loadingId);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) { } catch (err) {
@ -40,7 +43,7 @@ export default function NotificationToaster() {
respondingRef.current.delete(requestId); respondingRef.current.delete(requestId);
} }
}, },
[respond], [],
); );
// Track unread count changes to force-refetch the list // Track unread count changes to force-refetch the list