diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d46c413..a4439a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,3 +64,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - No audit log for Guard decisions yet - Stripe billing wired up but not activated - Frontend pages for Guard and RAG not yet built + +## Unreleased + +### Added + +- **Notifications API** + +- Added `GET /api/v1/notifications/unread-count` endpoint to retrieve the current user's unread notification count. +- Added `POST /api/v1/notifications/read-all` endpoint to mark all unread notifications as read. +- Added `DELETE /api/v1/notifications/read` endpoint to delete all read notifications for the current user. +- Added unit tests covering unread count retrieval, mark-all-read functionality, and bulk deletion of read notifications while preserving notification ownership boundaries. diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index efaff647..8ff79ba9 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -70,6 +70,25 @@ def list_notifications( limit=limit, ) +@router.get("/unread-count") +def get_unread_count( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return the number of unread notifications for the current user.""" + + unread_count = ( + db.query(Notification) + .filter( + Notification.user_id == current_user.id, + Notification.is_read.is_(False), + ) + .count() + ) + + return { + "unread_count": unread_count + } @router.post("/read", status_code=status.HTTP_204_NO_CONTENT) def mark_notifications_read( @@ -89,6 +108,49 @@ def mark_notifications_read( db.commit() return None +@router.post("/read-all", status_code=status.HTTP_204_NO_CONTENT) +def mark_all_notifications_read( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Mark all notifications for the current user as read.""" + + ( + db.query(Notification) + .filter( + Notification.user_id == current_user.id, + Notification.is_read.is_(False), + ) + .update( + {Notification.is_read: True}, + synchronize_session=False, + ) + ) + + db.commit() + + return None + +@router.delete("/read", status_code=status.HTTP_204_NO_CONTENT) +def delete_read_notifications( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Delete all read notifications belonging to the current user.""" + + ( + db.query(Notification) + .filter( + Notification.user_id == current_user.id, + Notification.is_read.is_(True), + ) + .delete(synchronize_session=False) + ) + + db.commit() + + return None + @router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_notification( diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index cb94478f..523e10f0 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -201,3 +201,143 @@ def test_blocked_guard_scan_creates_notification(tmp_path): app.dependency_overrides.clear() db.close() + + +def test_get_unread_count_returns_correct_count(tmp_path): + client, db, user, _ = _make_client(tmp_path) + + db.add_all( + [ + Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Unread 1", + message="", + is_read=False, + ), + Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Unread 2", + message="", + is_read=False, + ), + Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Read", + message="", + is_read=True, + ), + ] + ) + db.commit() + + response = client.get("/api/v1/notifications/unread-count") + + assert response.status_code == 200 + assert response.json() == {"unread_count": 2} + + app.dependency_overrides.clear() + db.close() + +def test_mark_all_notifications_read(tmp_path): + client, db, user, other_user = _make_client(tmp_path) + + mine = Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Mine", + message="", + is_read=False, + ) + + other = Notification( + user_id=other_user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Other", + message="", + is_read=False, + ) + + db.add_all([mine, other]) + db.commit() + + response = client.post("/api/v1/notifications/read-all") + + assert response.status_code == 204 + + db.refresh(mine) + db.refresh(other) + + assert mine.is_read is True + assert other.is_read is False + + app.dependency_overrides.clear() + db.close() + +def test_delete_read_notifications(tmp_path): + client, db, user, other_user = _make_client(tmp_path) + + read_notification = Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Read", + message="", + is_read=True, + ) + + unread_notification = Notification( + user_id=user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Unread", + message="", + is_read=False, + ) + + other_user_read = Notification( + user_id=other_user.id, + notification_type=NotificationType.GUARD_BLOCK.value, + title="Other", + message="", + is_read=True, + ) + + db.add_all( + [ + read_notification, + unread_notification, + other_user_read, + ] + ) + db.commit() + read_id = read_notification.id + unread_id = unread_notification.id + other_id = other_user_read.id + response = client.delete("/api/v1/notifications/read") + + assert response.status_code == 204 + + assert ( + db.query(Notification) + .filter(Notification.id == read_id) + .first() + is None + ) + + assert ( + db.query(Notification) + .filter(Notification.id == unread_id) + .first() + is not None + ) + + assert ( + db.query(Notification) + .filter(Notification.id == other_id) + .first() + is not None + ) + + app.dependency_overrides.clear() + db.close() \ No newline at end of file