diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index be9ac39..ca15d2f 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -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() diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index 24d9126..bfd4f9a 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -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}, diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 354f587..b6418e3 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -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()) diff --git a/backend/app/services/connection.py b/backend/app/services/connection.py index ab6e838..09cbbb9 100644 --- a/backend/app/services/connection.py +++ b/backend/app/services/connection.py @@ -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( diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 99f003a..f2ce0b4 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -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({