Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
62 changes: 62 additions & 0 deletions backend/app/api/v1/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
140 changes: 140 additions & 0 deletions backend/tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading