Address QA warnings W-02 through W-07

W-02: Purge accepted connection requests after 90 days (rejected/cancelled stay at 30)
W-04: Rename shadowed `type` parameter to `notification_type` with alias
W-05: Extract notification type string literals to constants in connection service
W-06: Match notification list polling interval to unread count (15s when visible)
W-07: Add filter_to_shareable defence-in-depth gate on resolve_shared_profile output
W-03: Verified false positive — no double person lookup exists in accept flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-05 23:55:11 +08:00
parent 3fe344c3a0
commit 20692632f2
5 changed files with 32 additions and 14 deletions

View File

@ -277,17 +277,24 @@ async def _purge_old_notifications(db: AsyncSession) -> None:
async def _purge_resolved_requests(db: AsyncSession) -> None:
"""Remove rejected/cancelled connection requests older than 30 days.
"""Remove resolved connection requests after retention period.
Note: resolved_at must be set when changing status to rejected/cancelled.
Rows with NULL resolved_at are preserved (comparison with NULL yields NULL).
Any future cancel endpoint must set resolved_at = now on status change.
Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail).
resolved_at must be set when changing status. NULL resolved_at rows are
preserved (comparison with NULL yields NULL).
"""
cutoff = datetime.now() - timedelta(days=30)
reject_cutoff = datetime.now() - timedelta(days=30)
accept_cutoff = datetime.now() - timedelta(days=90)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status.in_(["rejected", "cancelled"]),
ConnectionRequest.resolved_at < cutoff,
ConnectionRequest.resolved_at < reject_cutoff,
)
)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status == "accepted",
ConnectionRequest.resolved_at < accept_cutoff,
)
)
await db.commit()

View File

@ -40,6 +40,8 @@ from app.schemas.connection import (
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.connection import (
NOTIF_TYPE_CONNECTION_ACCEPTED,
NOTIF_TYPE_CONNECTION_REQUEST,
SHAREABLE_FIELDS,
create_person_from_connection,
detach_umbral_contact,
@ -229,11 +231,11 @@ async def send_connection_request(
await create_notification(
db,
user_id=target.id,
type="connection_request",
type=NOTIF_TYPE_CONNECTION_REQUEST,
title="New Connection Request",
message=f"{sender_display} wants to connect with you",
data={"sender_umbral_name": current_user.umbral_name},
source_type="connection_request",
source_type=NOTIF_TYPE_CONNECTION_REQUEST,
source_id=conn_request.id,
)
@ -505,7 +507,7 @@ async def _respond_to_request_inner(
await create_notification(
db,
user_id=sender_id,
type="connection_accepted",
type=NOTIF_TYPE_CONNECTION_ACCEPTED,
title="Connection Accepted",
message=f"{receiver_display} accepted your connection request",
data={"connected_umbral_name": current_user.umbral_name},

View File

@ -23,7 +23,7 @@ router = APIRouter()
@router.get("/", response_model=NotificationListResponse)
async def list_notifications(
unread_only: bool = Query(False),
type: str | None = Query(None, max_length=50),
notification_type: str | None = Query(None, max_length=50, alias="type"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
@ -34,8 +34,8 @@ async def list_notifications(
if unread_only:
base = base.where(Notification.is_read == False) # noqa: E712
if type:
base = base.where(Notification.type == type)
if notification_type:
base = base.where(Notification.type == notification_type)
# Total count
count_q = select(func.count()).select_from(base.subquery())

View File

@ -18,6 +18,10 @@ from app.services.ntfy import send_ntfy_notification
logger = logging.getLogger(__name__)
# Notification type constants — keep in sync with notifications model CHECK constraint
NOTIF_TYPE_CONNECTION_REQUEST = "connection_request"
NOTIF_TYPE_CONNECTION_ACCEPTED = "connection_accepted"
# Single source of truth — only these fields can be shared via connections
SHAREABLE_FIELDS = frozenset({
"first_name", "last_name", "preferred_name", "email", "phone", "mobile",
@ -75,7 +79,12 @@ def resolve_shared_profile(
elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]:
result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None)
return result
return filter_to_shareable(result)
def filter_to_shareable(profile: dict) -> dict:
"""Strip any keys not in SHAREABLE_FIELDS. Defence-in-depth gate."""
return {k: v for k, v in profile.items() if k in SHAREABLE_FIELDS}
def create_person_from_connection(

View File

@ -64,7 +64,7 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
return data;
},
staleTime: 15_000,
refetchInterval: () => (visibleRef.current ? 60_000 : false),
refetchInterval: () => (visibleRef.current ? 15_000 : false),
});
const markReadMutation = useMutation({