Skip to content

Commit 3f7113b

Browse files
Merge pull request #135 from bg-playground/copilot/add-notification-system
Add notification system with bell UI and event hooks
2 parents bbcbd7a + b7a1506 commit 3f7113b

16 files changed

Lines changed: 1052 additions & 1 deletion

backend/app/api/links.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ async def review_suggestion(
187187
resource_id=str(suggestion_id),
188188
details={"feedback": review.feedback, "status": review.status.value},
189189
)
190+
191+
try:
192+
from app.services.notification_service import notify_suggestion_reviewed
193+
194+
await notify_suggestion_reviewed(
195+
db, reviewer_user_id=current_user.id, suggestion_count=1, status=review.status.value
196+
)
197+
except Exception:
198+
pass
199+
190200
return reviewed
191201

192202

@@ -214,4 +224,13 @@ async def bulk_review_suggestions(
214224
},
215225
)
216226

227+
try:
228+
from app.services.notification_service import notify_suggestion_reviewed
229+
230+
await notify_suggestion_reviewed(
231+
db, reviewer_user_id=current_user.id, suggestion_count=reviewed, status=request.status.value
232+
)
233+
except Exception:
234+
pass
235+
217236
return {"message": f"Reviewed {reviewed} suggestions", "count": reviewed, "status": request.status}

backend/app/api/notifications.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""API endpoints for Notifications"""
2+
3+
from uuid import UUID
4+
5+
from fastapi import APIRouter, Depends, HTTPException, Query, status
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from app.auth.dependencies import get_current_user
9+
from app.crud.notification import get_notifications, get_unread_count, mark_all_as_read, mark_as_read
10+
from app.db.session import get_db
11+
from app.models.user import User
12+
from app.schemas.notification import NotificationListResponse, NotificationResponse
13+
14+
router = APIRouter()
15+
16+
17+
@router.get("/notifications", response_model=NotificationListResponse)
18+
async def list_notifications(
19+
unread_only: bool = Query(False, description="Return only unread notifications"),
20+
limit: int = Query(50, ge=1, le=200, description="Maximum results to return"),
21+
offset: int = Query(0, ge=0, description="Number of results to skip"),
22+
db: AsyncSession = Depends(get_db),
23+
current_user: User = Depends(get_current_user),
24+
):
25+
"""List the current user's notifications."""
26+
notifications, total = await get_notifications(
27+
db,
28+
user_id=current_user.id,
29+
unread_only=unread_only,
30+
limit=limit,
31+
offset=offset,
32+
)
33+
unread_count = await get_unread_count(db, user_id=current_user.id)
34+
return NotificationListResponse(
35+
notifications=notifications,
36+
unread_count=unread_count,
37+
total=total,
38+
)
39+
40+
41+
@router.get("/notifications/unread-count", response_model=dict)
42+
async def unread_count(
43+
db: AsyncSession = Depends(get_db),
44+
current_user: User = Depends(get_current_user),
45+
):
46+
"""Get unread notification count for the current user."""
47+
count = await get_unread_count(db, user_id=current_user.id)
48+
return {"unread_count": count}
49+
50+
51+
@router.patch("/notifications/{notification_id}/read", response_model=NotificationResponse)
52+
async def mark_notification_read(
53+
notification_id: UUID,
54+
db: AsyncSession = Depends(get_db),
55+
current_user: User = Depends(get_current_user),
56+
):
57+
"""Mark a single notification as read."""
58+
notification = await mark_as_read(db, notification_id=notification_id, user_id=current_user.id)
59+
if not notification:
60+
raise HTTPException(
61+
status_code=status.HTTP_404_NOT_FOUND,
62+
detail=f"Notification {notification_id} not found",
63+
)
64+
return notification
65+
66+
67+
@router.post("/notifications/mark-all-read", response_model=dict)
68+
async def mark_all_notifications_read(
69+
db: AsyncSession = Depends(get_db),
70+
current_user: User = Depends(get_current_user),
71+
):
72+
"""Mark all notifications as read for the current user."""
73+
count = await mark_all_as_read(db, user_id=current_user.id)
74+
return {"marked_read": count}

backend/app/api/requirements.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ async def create_requirement(
4949
details={"title": new_requirement.title},
5050
)
5151

52+
try:
53+
from app.services.notification_service import notify_requirement_created
54+
55+
await notify_requirement_created(
56+
db,
57+
creator_user_id=current_user.id,
58+
requirement_title=new_requirement.title,
59+
metadata={"requirement_id": str(new_requirement.id)},
60+
)
61+
except Exception:
62+
pass
63+
5264
# Trigger auto-suggestion generation in background if enabled
5365
if settings.AUTO_SUGGESTIONS_ENABLED:
5466
background_tasks.add_task(

backend/app/api/suggestions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,18 @@ async def generate_suggestions(
165165
details=result,
166166
)
167167

168+
try:
169+
from app.services.notification_service import notify_suggestions_generated
170+
171+
await notify_suggestions_generated(
172+
db,
173+
user_id=current_user.id,
174+
suggestions_created=result.get("suggestions_created", 0),
175+
pairs_analyzed=result.get("pairs_analyzed", 0),
176+
)
177+
except Exception:
178+
pass
179+
168180
return {"message": "Suggestion generation completed", "results": result}
169181

170182
except ImportError as e:

backend/app/api/test_cases.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ async def create_test_case(
4545
details={"title": new_test_case.title},
4646
)
4747

48+
try:
49+
from app.services.notification_service import notify_test_case_created
50+
51+
await notify_test_case_created(
52+
db,
53+
creator_user_id=current_user.id,
54+
test_case_title=new_test_case.title,
55+
metadata={"test_case_id": str(new_test_case.id)},
56+
)
57+
except Exception:
58+
pass
59+
4860
# Trigger auto-suggestion generation in background if enabled
4961
if settings.AUTO_SUGGESTIONS_ENABLED:
5062
background_tasks.add_task(

backend/app/crud/notification.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""CRUD operations for Notifications"""
2+
3+
from typing import Any
4+
from uuid import UUID
5+
6+
from sqlalchemy import func, select, update
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.models.notification import Notification, NotificationType
10+
from app.models.user import User
11+
12+
13+
async def create_notification(
14+
db: AsyncSession,
15+
user_id: UUID,
16+
type: NotificationType,
17+
title: str,
18+
message: str,
19+
metadata: dict[str, Any] | None = None,
20+
) -> Notification:
21+
"""Create a single notification for a user."""
22+
notification = Notification(
23+
user_id=user_id,
24+
type=type,
25+
title=title,
26+
message=message,
27+
metadata_=metadata,
28+
)
29+
db.add(notification)
30+
await db.commit()
31+
await db.refresh(notification)
32+
return notification
33+
34+
35+
async def create_notification_for_all_users(
36+
db: AsyncSession,
37+
type: NotificationType,
38+
title: str,
39+
message: str,
40+
metadata: dict[str, Any] | None = None,
41+
exclude_user_id: UUID | None = None,
42+
) -> list[Notification]:
43+
"""Create a notification for all active users, optionally excluding one."""
44+
query = select(User).where(User.is_active.is_(True)) # type: ignore[attr-defined]
45+
if exclude_user_id is not None:
46+
query = query.where(User.id != exclude_user_id)
47+
result = await db.execute(query)
48+
users = result.scalars().all()
49+
50+
notifications = [
51+
Notification(
52+
user_id=u.id,
53+
type=type,
54+
title=title,
55+
message=message,
56+
metadata_=metadata,
57+
)
58+
for u in users
59+
]
60+
db.add_all(notifications)
61+
await db.commit()
62+
return notifications
63+
64+
65+
async def get_notifications(
66+
db: AsyncSession,
67+
user_id: UUID,
68+
unread_only: bool = False,
69+
limit: int = 50,
70+
offset: int = 0,
71+
) -> tuple[list[Notification], int]:
72+
"""Get notifications for a user. Returns (notifications, total)."""
73+
query = select(Notification).where(Notification.user_id == user_id)
74+
if unread_only:
75+
query = query.where(Notification.read.is_(False)) # type: ignore[attr-defined]
76+
77+
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
78+
total = count_result.scalar_one()
79+
80+
query = query.order_by(Notification.created_at.desc()).offset(offset).limit(limit)
81+
result = await db.execute(query)
82+
return list(result.scalars().all()), total
83+
84+
85+
async def get_unread_count(db: AsyncSession, user_id: UUID) -> int:
86+
"""Get unread notification count for a user."""
87+
result = await db.execute(
88+
select(func.count()).where(Notification.user_id == user_id, Notification.read.is_(False)) # type: ignore[attr-defined]
89+
)
90+
return result.scalar_one()
91+
92+
93+
async def mark_as_read(db: AsyncSession, notification_id: UUID, user_id: UUID) -> Notification | None:
94+
"""Mark a single notification as read. Returns None if not found or not owned by user."""
95+
result = await db.execute(
96+
select(Notification).where(Notification.id == notification_id, Notification.user_id == user_id)
97+
)
98+
notification = result.scalar_one_or_none()
99+
if notification is None:
100+
return None
101+
notification.read = True
102+
await db.commit()
103+
await db.refresh(notification)
104+
return notification
105+
106+
107+
async def mark_all_as_read(db: AsyncSession, user_id: UUID) -> int:
108+
"""Mark all notifications as read for a user. Returns number of updated rows."""
109+
result = await db.execute(
110+
update(Notification)
111+
.where(Notification.user_id == user_id, Notification.read.is_(False)) # type: ignore[attr-defined]
112+
.values(read=True)
113+
)
114+
await db.commit()
115+
return result.rowcount # type: ignore[attr-defined]

backend/app/main.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@
33
from fastapi import FastAPI
44
from fastapi.middleware.cors import CORSMiddleware
55

6-
from app.api import analytics, audit_log, auth, links, requirements, suggestions, test_cases, traceability, users
6+
from app.api import (
7+
analytics,
8+
audit_log,
9+
auth,
10+
links,
11+
notifications,
12+
requirements,
13+
suggestions,
14+
test_cases,
15+
traceability,
16+
users,
17+
)
718
from app.config import settings
819
from app.db.session import init_db
920

@@ -28,6 +39,7 @@
2839
app.include_router(analytics.router, prefix=settings.API_V1_PREFIX, tags=["analytics"])
2940
app.include_router(audit_log.router, prefix=settings.API_V1_PREFIX, tags=["audit_log"])
3041
app.include_router(users.router, prefix=settings.API_V1_PREFIX, tags=["users"])
42+
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX, tags=["notifications"])
3143

3244

3345
@app.on_event("startup")

backend/app/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .audit_log import AuditLog
44
from .base import Base, TimestampMixin
55
from .link import LinkSource, LinkType, RequirementTestCaseLink
6+
from .notification import Notification, NotificationType
67
from .requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType
78
from .suggestion import LinkSuggestion, SuggestionMethod, SuggestionStatus
89
from .test_case import AutomationStatus, TestCase, TestCaseStatus, TestCaseType
@@ -12,6 +13,8 @@
1213
"AuditLog",
1314
"Base",
1415
"TimestampMixin",
16+
"Notification",
17+
"NotificationType",
1518
"Requirement",
1619
"RequirementType",
1720
"PriorityLevel",

backend/app/models/notification.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Notification model"""
2+
3+
import enum
4+
import uuid
5+
6+
from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, String, Text
7+
from sqlalchemy.sql import func
8+
9+
from .base import Base
10+
from .requirement import GUID, JSON
11+
12+
13+
class NotificationType(str, enum.Enum):
14+
SUGGESTIONS_GENERATED = "suggestions_generated"
15+
COVERAGE_DROP = "coverage_drop"
16+
SUGGESTION_REVIEWED = "suggestion_reviewed"
17+
REQUIREMENT_CREATED = "requirement_created"
18+
TEST_CASE_CREATED = "test_case_created"
19+
20+
21+
class Notification(Base):
22+
__tablename__ = "notifications"
23+
24+
id = Column(GUID(), primary_key=True, default=uuid.uuid4)
25+
user_id = Column(GUID(), ForeignKey("users.id"), nullable=False, index=True)
26+
type = Column(Enum(NotificationType), nullable=False)
27+
title = Column(String(255), nullable=False)
28+
message = Column(Text, nullable=False)
29+
read = Column(Boolean, default=False, nullable=False)
30+
metadata_ = Column("metadata", JSON(), nullable=True)
31+
created_at = Column(DateTime, nullable=False, server_default=func.now())
32+
33+
def __repr__(self):
34+
return f"<Notification(id={self.id}, type={self.type}, user_id={self.user_id})>"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Schemas for Notification"""
2+
3+
from datetime import datetime
4+
from typing import Any
5+
from uuid import UUID
6+
7+
from pydantic import BaseModel, ConfigDict
8+
9+
from app.models.notification import NotificationType
10+
11+
12+
class NotificationResponse(BaseModel):
13+
id: UUID
14+
user_id: UUID
15+
type: NotificationType
16+
title: str
17+
message: str
18+
read: bool
19+
metadata_: dict[str, Any] | None
20+
created_at: datetime
21+
22+
model_config = ConfigDict(from_attributes=True)
23+
24+
25+
class NotificationListResponse(BaseModel):
26+
notifications: list[NotificationResponse]
27+
unread_count: int
28+
total: int

0 commit comments

Comments
 (0)