Fix QA review findings: source_id, N+1 queries, event bubbling, type mismatches

Critical fixes:
- C-01: Add receiver_umbral_name/receiver_preferred_name to frontend ConnectionRequest type
- C-02: Flush connection request before notification to populate source_id
- C-03: Add umbral_name to ProfileResponse/UserProfile, use in Settings Social card
- C-04: Remove dead code in sharing-overrides endpoint, merge instead of replace

Warning fixes:
- W-01/W-02: Batch-fetch settings in incoming/outgoing/list connection endpoints (N+1 fix)
- W-04: Add _purge_resolved_requests job for rejected/cancelled requests (30-day retention)
- W-10: Add e.stopPropagation() to notification mark-read and delete buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 02:29:04 +08:00
parent 3d22568b9c
commit 337b50c7ce
6 changed files with 64 additions and 30 deletions

View File

@ -26,6 +26,7 @@ from app.models.project import Project
from app.models.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage
from app.models.session import UserSession
from app.models.connection_request import ConnectionRequest
from app.services.ntfy import send_ntfy_notification
from app.services.ntfy_templates import (
build_event_notification,
@ -275,6 +276,18 @@ async def _purge_old_notifications(db: AsyncSession) -> None:
await db.commit()
async def _purge_resolved_requests(db: AsyncSession) -> None:
"""Remove rejected/cancelled connection requests older than 30 days."""
cutoff = datetime.now() - timedelta(days=30)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status.in_(["rejected", "cancelled"]),
ConnectionRequest.resolved_at < cutoff,
)
)
await db.commit()
# ── Entry point ───────────────────────────────────────────────────────────────
async def run_notification_dispatch() -> None:
@ -317,6 +330,7 @@ async def run_notification_dispatch() -> None:
await _purge_totp_usage(db)
await _purge_expired_sessions(db)
await _purge_old_notifications(db)
await _purge_resolved_requests(db)
except Exception:
# Broad catch: job failure must never crash the scheduler or the app

View File

@ -183,6 +183,7 @@ async def send_connection_request(
receiver_id=target.id,
)
db.add(conn_request)
await db.flush() # populate conn_request.id for source_id
# Create in-app notification for receiver
sender_settings = await _get_settings_for_user(db, current_user.id)
@ -196,7 +197,7 @@ async def send_connection_request(
message=f"{sender_display} wants to connect with you",
data={"sender_umbral_name": current_user.umbral_name},
source_type="connection_request",
source_id=None, # Will be set after flush
source_id=conn_request.id,
)
await log_audit_event(
@ -246,10 +247,18 @@ async def get_incoming_requests(
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch sender settings
receiver_settings = await _get_settings_for_user(db, current_user.id)
sender_ids = [req.sender_id for req in requests]
if sender_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
sender_settings = await _get_settings_for_user(db, req.sender_id)
receiver_settings = await _get_settings_for_user(db, current_user.id)
sender_settings = settings_by_user.get(req.sender_id)
responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings))
return responses
@ -279,10 +288,18 @@ async def get_outgoing_requests(
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch receiver settings
sender_settings = await _get_settings_for_user(db, current_user.id)
receiver_ids = [req.receiver_id for req in requests]
if receiver_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
sender_settings = await _get_settings_for_user(db, current_user.id)
receiver_settings = await _get_settings_for_user(db, req.receiver_id)
receiver_settings = settings_by_user.get(req.receiver_id)
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
return responses
@ -366,6 +383,8 @@ async def respond_to_request(
db.add(conn_a)
db.add(conn_b)
await db.flush() # populate conn_a.id for source_id
# Notification to sender
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
await create_notification(
@ -376,7 +395,7 @@ async def respond_to_request(
message=f"{receiver_display} accepted your connection request",
data={"connected_umbral_name": current_user.umbral_name},
source_type="user_connection",
source_id=None,
source_id=conn_b.id,
)
await log_audit_event(
@ -436,9 +455,17 @@ async def list_connections(
)
connections = result.scalars().all()
# Batch-fetch settings for connected users
connected_ids = [conn.connected_user_id for conn in connections]
if connected_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for conn in connections:
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
conn_settings = settings_by_user.get(conn.connected_user_id)
responses.append(ConnectionResponse(
id=conn.id,
connected_user_id=conn.connected_user_id,
@ -525,21 +552,7 @@ async def update_sharing_overrides(
current_user: User = Depends(get_current_user),
):
"""Update what YOU share with a specific connection."""
# Find the connection where the OTHER user connects to YOU
result = await db.execute(
select(UserConnection).where(
UserConnection.connected_user_id == current_user.id,
UserConnection.user_id != current_user.id,
)
)
# We need the reverse connection (where we are the connected_user)
# Actually, we need to find the connection from the counterpart's perspective
# The connection_id is OUR connection. The sharing overrides go on the
# counterpart's connection row (since they determine what they see from us).
# Wait — per the plan, sharing overrides control what WE share with THEM.
# So they go on their connection row pointing to us.
# First, get our connection to know who the counterpart is
# Get our connection to know who the counterpart is
our_conn = await db.execute(
select(UserConnection).where(
UserConnection.id == connection_id,
@ -561,14 +574,17 @@ async def update_sharing_overrides(
if not reverse_conn:
raise HTTPException(status_code=404, detail="Reverse connection not found")
# Build validated overrides dict — only SHAREABLE_FIELDS keys
overrides = {}
# Merge validated overrides — only SHAREABLE_FIELDS keys
existing = dict(reverse_conn.sharing_overrides or {})
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
if key in SHAREABLE_FIELDS:
overrides[key] = value
if value is None:
existing.pop(key, None)
else:
existing[key] = value
reverse_conn.sharing_overrides = overrides if overrides else None
reverse_conn.sharing_overrides = existing if existing else None
await db.commit()
return {"message": "Sharing overrides updated"}

View File

@ -199,6 +199,7 @@ class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
umbral_name: str
email: str | None
first_name: str | None
last_name: str | None

View File

@ -176,7 +176,7 @@ export default function NotificationsPage() {
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.is_read && (
<button
onClick={() => handleMarkRead(notification.id)}
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
title="Mark as read"
>
@ -184,7 +184,7 @@ export default function NotificationsPage() {
</button>
)}
<button
onClick={() => handleDelete(notification.id)}
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>

View File

@ -733,11 +733,11 @@ export default function SettingsPage() {
<Label>Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
value={profileQuery.data?.username ?? ''}
value={profileQuery.data?.umbral_name ?? ''}
disabled
className="opacity-70 cursor-not-allowed"
/>
<CopyableField value={profileQuery.data?.username ?? ''} label="Umbral name" />
<CopyableField value={profileQuery.data?.umbral_name ?? ''} label="Umbral name" />
</div>
<p className="text-sm text-muted-foreground">
How other Umbra users find you

View File

@ -371,6 +371,7 @@ export interface UpcomingResponse {
export interface UserProfile {
username: string;
umbral_name: string;
email: string | null;
first_name: string | null;
last_name: string | null;
@ -418,6 +419,8 @@ export interface ConnectionRequest {
id: number;
sender_umbral_name: string;
sender_preferred_name: string | null;
receiver_umbral_name: string;
receiver_preferred_name: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
created_at: string;
}