feat: notification center backend core (PR-1)
- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration
Quality gates: pytest 23 new passed, ruff clean
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""add notifications table
|
||||
|
||||
Revision ID: 2026_05_29_add_notifications_table
|
||||
Revises: 2026_05_28_add_monitoring_tables
|
||||
Create Date: 2026-05-29
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_add_notifications_table"
|
||||
down_revision: str | None = "2026_05_28_add_monitoring_tables"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("category", sa.String(length=32), nullable=False),
|
||||
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("source_type", sa.String(length=64), nullable=True),
|
||||
sa.Column("source_id", sa.Uuid(), nullable=True),
|
||||
sa.Column(
|
||||
"metadata",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_notifications_user_created_at",
|
||||
"notifications",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_notifications_user_unread",
|
||||
"notifications",
|
||||
["user_id", "read_at"],
|
||||
postgresql_where=sa.text("read_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_notifications_user_unread", table_name="notifications")
|
||||
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
|
||||
op.drop_table("notifications")
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "events_router", "users_router"]
|
||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Notification API endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
from src.services.notification_service import notification_service
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str
|
||||
severity: str
|
||||
title: str
|
||||
message: str | None
|
||||
source_type: str | None
|
||||
source_id: uuid.UUID | None
|
||||
notification_metadata: dict = Field(serialization_alias="metadata")
|
||||
read_at: datetime | None
|
||||
dismissed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class MarkAllReadResponse(BaseModel):
|
||||
marked_count: int
|
||||
|
||||
|
||||
async def _get_mute_categories(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[str]:
|
||||
"""Read notification mute categories from user config."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
return []
|
||||
mute_categories = config.config.get("notification_mute_categories", [])
|
||||
if isinstance(mute_categories, list):
|
||||
return mute_categories
|
||||
return []
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationListResponse:
|
||||
"""List notifications for the authenticated user."""
|
||||
mute_categories = await _get_mute_categories(session, user.id)
|
||||
items, total = await notification_service.list_notifications(
|
||||
session,
|
||||
user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
unread_only=unread_only,
|
||||
mute_categories=mute_categories,
|
||||
)
|
||||
return NotificationListResponse(
|
||||
items=[NotificationItem.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UnreadCountResponse:
|
||||
"""Get unread notification count for the authenticated user."""
|
||||
count = await notification_service.get_unread_count(session, user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationItem)
|
||||
async def mark_notification_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationItem:
|
||||
"""Mark a single notification as read."""
|
||||
try:
|
||||
notification = await notification_service.mark_read(
|
||||
session, notification_id, user.id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
return NotificationItem.model_validate(notification)
|
||||
|
||||
|
||||
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
|
||||
async def mark_all_read(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> MarkAllReadResponse:
|
||||
"""Mark all unread notifications as read."""
|
||||
marked = await notification_service.mark_all_read(session, user.id)
|
||||
return MarkAllReadResponse(marked_count=marked)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def dismiss_notification(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Soft-delete (dismiss) a notification."""
|
||||
try:
|
||||
await notification_service.dismiss(session, notification_id, user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
@@ -21,9 +21,11 @@ from src.api.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.config import Settings
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.database import init_database
|
||||
from src.logging_config import (
|
||||
@@ -156,4 +158,5 @@ app.include_router(sessions_router)
|
||||
app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -3,6 +3,7 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.instance_event import InstanceEvent
|
||||
from src.models.notification import Notification
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"GitRepository",
|
||||
"HealthCheck",
|
||||
"InstanceEvent",
|
||||
"Notification",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"TerminalSessionModel",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Notification SQLAlchemy model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Notification(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True
|
||||
)
|
||||
notification_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, nullable=False, default=dict
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Notification persistence service."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.notification import Notification
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Singleton notification persistence service.
|
||||
|
||||
All methods filter by user_id to enforce strict ownership isolation.
|
||||
"""
|
||||
|
||||
async def create_notification(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
category: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
source_type: str | None = None,
|
||||
source_id: uuid.UUID | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Notification:
|
||||
"""Insert a new notification row.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
user_id: Owner of the notification.
|
||||
category: Notification category (e.g., instance, system, health).
|
||||
severity: Severity level (e.g., info, warning, error, success).
|
||||
title: Short notification title.
|
||||
message: Optional longer message body.
|
||||
source_type: Optional source entity type.
|
||||
source_id: Optional source entity UUID.
|
||||
metadata: Optional JSON metadata dictionary.
|
||||
|
||||
Returns:
|
||||
The newly created Notification instance.
|
||||
"""
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=message,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
notification_metadata=metadata or {},
|
||||
)
|
||||
session.add(notification)
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
return notification
|
||||
|
||||
async def list_notifications(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
unread_only: bool = False,
|
||||
mute_categories: list[str] | None = None,
|
||||
) -> tuple[list[Notification], int]:
|
||||
"""Return paginated notifications for a user.
|
||||
|
||||
Excludes dismissed notifications and applies optional filtering.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
user_id: Owner of the notifications.
|
||||
limit: Maximum number of items to return.
|
||||
offset: Number of items to skip.
|
||||
unread_only: If True, only return unread notifications.
|
||||
mute_categories: Categories to exclude from results.
|
||||
|
||||
Returns:
|
||||
A tuple of (items, total_count).
|
||||
"""
|
||||
where_clauses = [
|
||||
Notification.user_id == user_id,
|
||||
Notification.dismissed_at.is_(None),
|
||||
]
|
||||
|
||||
if unread_only:
|
||||
where_clauses.append(Notification.read_at.is_(None))
|
||||
|
||||
if mute_categories:
|
||||
where_clauses.append(Notification.category.not_in(mute_categories))
|
||||
|
||||
total_stmt = (
|
||||
select(func.count()).select_from(Notification).where(*where_clauses)
|
||||
)
|
||||
total_result = await session.execute(total_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
items_stmt = (
|
||||
select(Notification)
|
||||
.where(*where_clauses)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
items_result = await session.execute(items_stmt)
|
||||
items = list(items_result.scalars().all())
|
||||
|
||||
return items, total
|
||||
|
||||
async def get_unread_count(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""Count unread, non-dismissed notifications for a user.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
user_id: Owner of the notifications.
|
||||
|
||||
Returns:
|
||||
Number of unread notifications.
|
||||
"""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
Notification.dismissed_at.is_(None),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
async def mark_read(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
notification_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Notification:
|
||||
"""Mark a single notification as read.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
notification_id: UUID of the notification to mark.
|
||||
user_id: Owner of the notification.
|
||||
|
||||
Returns:
|
||||
The updated Notification instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the notification does not exist or is not owned by the user.
|
||||
"""
|
||||
notification = await self._get_owned_notification(
|
||||
session, notification_id, user_id
|
||||
)
|
||||
notification.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
return notification
|
||||
|
||||
async def mark_all_read(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""Mark all unread notifications as read for a user.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
user_id: Owner of the notifications.
|
||||
|
||||
Returns:
|
||||
Number of rows updated.
|
||||
"""
|
||||
stmt = (
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
Notification.dismissed_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
async def dismiss(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
notification_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Soft-delete a notification by setting dismissed_at.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
notification_id: UUID of the notification to dismiss.
|
||||
user_id: Owner of the notification.
|
||||
|
||||
Raises:
|
||||
ValueError: If the notification does not exist or is not owned by the user.
|
||||
"""
|
||||
notification = await self._get_owned_notification(
|
||||
session, notification_id, user_id
|
||||
)
|
||||
notification.dismissed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
async def _get_owned_notification(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
notification_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Notification:
|
||||
"""Fetch a notification and verify ownership.
|
||||
|
||||
Args:
|
||||
session: Database session.
|
||||
notification_id: UUID of the notification.
|
||||
user_id: Expected owner.
|
||||
|
||||
Returns:
|
||||
The Notification instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the notification does not exist or is not owned.
|
||||
"""
|
||||
notification = await session.get(Notification, notification_id)
|
||||
if notification is None or notification.user_id != user_id:
|
||||
raise ValueError("Notification not found")
|
||||
return notification
|
||||
|
||||
|
||||
# Module-level singleton instance
|
||||
notification_service = NotificationService()
|
||||
@@ -16,7 +16,6 @@ def test_base_metadata_collects_declared_tables() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_shared_mixins_define_expected_columns() -> None:
|
||||
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
||||
assert "created_at" in TimestampMixin.__dict__
|
||||
@@ -24,20 +23,26 @@ def test_shared_mixins_define_expected_columns() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_expected_tables_are_registered() -> None:
|
||||
assert set(Base.metadata.tables) == {
|
||||
"refresh_tokens",
|
||||
"config_profile_includes",
|
||||
"config_profiles",
|
||||
"git_repositories",
|
||||
"health_checks",
|
||||
"instance_events",
|
||||
"notifications",
|
||||
"projects",
|
||||
"ssh_keys",
|
||||
"terminal_sessions",
|
||||
"tool_definition_manifests",
|
||||
"tool_instances",
|
||||
"tool_types",
|
||||
"user_configs",
|
||||
"users",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_user_table_has_required_columns() -> None:
|
||||
columns = User.__table__.columns
|
||||
|
||||
@@ -56,7 +61,6 @@ def test_user_table_has_required_columns() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
||||
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
||||
@@ -68,7 +72,6 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
||||
def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
||||
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
||||
@@ -84,9 +87,13 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
||||
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
|
||||
user = User(
|
||||
email="dev@headquarter.local",
|
||||
name="Dev User",
|
||||
authentik_id="dev-user",
|
||||
avatar_url=None,
|
||||
)
|
||||
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Integration tests for notifications API."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
from src.services.notification_service import NotificationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_service() -> NotificationService:
|
||||
return NotificationService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_a(db_session: AsyncSession) -> User:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user-a@headquarter.local",
|
||||
name="User A",
|
||||
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||
avatar_url=None,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db_session: AsyncSession) -> User:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user-b@headquarter.local",
|
||||
name="User B",
|
||||
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||
avatar_url=None,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _mint_cookie_for_user(test_client: TestClient, user_id: uuid.UUID) -> None:
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
cookie = create_session_cookie(
|
||||
settings=settings,
|
||||
user_id=str(user_id),
|
||||
)
|
||||
test_client.cookies.set("session", cookie)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_requires_auth(test_client: TestClient) -> None:
|
||||
response = test_client.get("/notifications")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_returns_only_own_notifications(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
async def create_notifications() -> None:
|
||||
await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="A"
|
||||
)
|
||||
await notification_service.create_notification(
|
||||
db_session, user_b.id, category="instance", severity="info", title="B"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_notifications())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.get("/notifications")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["title"] == "A"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_pagination(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def create_many() -> None:
|
||||
for i in range(25):
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title=f"Notification {i}",
|
||||
)
|
||||
n.created_at = datetime.now(timezone.utc) - timedelta(seconds=i)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(n)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_many())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.get("/notifications?limit=10&offset=10")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 10
|
||||
assert data["total"] == 25
|
||||
assert data["limit"] == 10
|
||||
assert data["offset"] == 10
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_unread_count_endpoint(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def create_unread() -> None:
|
||||
for _ in range(3):
|
||||
await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Unread",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_unread())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.get("/notifications/unread")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 3
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mark_read_endpoint(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def create_and_get() -> uuid.UUID:
|
||||
n = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="To read"
|
||||
)
|
||||
return n.id
|
||||
|
||||
import asyncio
|
||||
|
||||
nid = asyncio.run(create_and_get())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["read_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mark_read_404_for_other_user(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
async def create_and_get() -> uuid.UUID:
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Owned by A",
|
||||
)
|
||||
return n.id
|
||||
|
||||
import asyncio
|
||||
|
||||
nid = asyncio.run(create_and_get())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mark_all_read_endpoint(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def create_unread() -> None:
|
||||
for _ in range(4):
|
||||
await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Unread",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_unread())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.post("/notifications/mark-all-read")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["marked_count"] == 4
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dismiss_endpoint(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def create_and_get() -> uuid.UUID:
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="To dismiss",
|
||||
)
|
||||
return n.id
|
||||
|
||||
import asyncio
|
||||
|
||||
nid = asyncio.run(create_and_get())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||
assert response.status_code == 204
|
||||
|
||||
response = authenticated_client.get("/notifications")
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_dismiss_404_for_other_user(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
async def create_and_get() -> uuid.UUID:
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Owned by A",
|
||||
)
|
||||
return n.id
|
||||
|
||||
import asyncio
|
||||
|
||||
nid = asyncio.run(create_and_get())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mute_categories_filter_in_list(
|
||||
authenticated_client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
async def setup() -> None:
|
||||
config = UserConfig(
|
||||
user_id=user_a.id, config={"notification_mute_categories": ["instance"]}
|
||||
)
|
||||
db_session.add(config)
|
||||
await db_session.commit()
|
||||
|
||||
await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Instance",
|
||||
)
|
||||
await notification_service.create_notification(
|
||||
db_session, user_a.id, category="system", severity="info", title="System"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(setup())
|
||||
|
||||
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||
response = authenticated_client.get("/notifications")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["title"] == "System"
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Unit tests for NotificationService."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.notification import Notification
|
||||
from src.models.user import User
|
||||
from src.services.notification_service import NotificationService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_service() -> NotificationService:
|
||||
return NotificationService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_a(db_session: AsyncSession) -> User:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user-a@headquarter.local",
|
||||
name="User A",
|
||||
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||
avatar_url=None,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_b(db_session: AsyncSession) -> User:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="user-b@headquarter.local",
|
||||
name="User B",
|
||||
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||
avatar_url=None,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_notification(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
notification = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Container started",
|
||||
message="Instance is running",
|
||||
source_type="tool_instances",
|
||||
source_id=uuid.uuid4(),
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
|
||||
assert notification.user_id == user_a.id
|
||||
assert notification.category == "instance"
|
||||
assert notification.severity == "info"
|
||||
assert notification.title == "Container started"
|
||||
assert notification.message == "Instance is running"
|
||||
assert notification.source_type == "tool_instances"
|
||||
assert notification.notification_metadata == {"key": "value"}
|
||||
assert notification.read_at is None
|
||||
assert notification.dismissed_at is None
|
||||
assert notification.created_at is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notifications_orders_by_created_at_desc(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n1 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="First"
|
||||
)
|
||||
n1.created_at = datetime.now(timezone.utc) - timedelta(seconds=2)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(n1)
|
||||
|
||||
n2 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Second"
|
||||
)
|
||||
n2.created_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(n2)
|
||||
|
||||
n3 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Third"
|
||||
)
|
||||
|
||||
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
||||
|
||||
assert total == 3
|
||||
assert [item.id for item in items] == [n3.id, n2.id, n1.id]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notifications_excludes_dismissed(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n1 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Visible"
|
||||
)
|
||||
n2 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Dismissed"
|
||||
)
|
||||
await notification_service.dismiss(db_session, n2.id, user_a.id)
|
||||
|
||||
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
||||
|
||||
assert total == 1
|
||||
assert items[0].id == n1.id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notifications_unread_only(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n1 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
||||
)
|
||||
n2 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Read"
|
||||
)
|
||||
await notification_service.mark_read(db_session, n2.id, user_a.id)
|
||||
|
||||
items, total = await notification_service.list_notifications(
|
||||
db_session, user_a.id, unread_only=True
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert items[0].id == n1.id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unread_count(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title=f"Notification {i}",
|
||||
)
|
||||
if i >= 3:
|
||||
await notification_service.mark_read(db_session, n.id, user_a.id)
|
||||
|
||||
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||
|
||||
assert count == 3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_read_sets_read_at(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
||||
)
|
||||
|
||||
updated = await notification_service.mark_read(db_session, n.id, user_a.id)
|
||||
|
||||
assert updated.read_at is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_all_read_affects_all_unread(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
for i in range(4):
|
||||
await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title=f"Notification {i}",
|
||||
)
|
||||
|
||||
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
||||
|
||||
assert marked == 4
|
||||
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismiss_sets_dismissed_at(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="To dismiss"
|
||||
)
|
||||
|
||||
await notification_service.dismiss(db_session, n.id, user_a.id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Notification).where(Notification.id == n.id)
|
||||
)
|
||||
row = result.scalar_one()
|
||||
assert row.dismissed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_read_wrong_owner_raises(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
n = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Notification not found"):
|
||||
await notification_service.mark_read(db_session, n.id, user_b.id)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismiss_wrong_owner_raises(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
n = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Notification not found"):
|
||||
await notification_service.dismiss(db_session, n.id, user_b.id)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notifications_mute_categories(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title="Instance"
|
||||
)
|
||||
n2 = await notification_service.create_notification(
|
||||
db_session, user_a.id, category="system", severity="info", title="System"
|
||||
)
|
||||
|
||||
items, total = await notification_service.list_notifications(
|
||||
db_session, user_a.id, mute_categories=["instance"]
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert items[0].id == n2.id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unread_count_excludes_dismissed(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
) -> None:
|
||||
n = await notification_service.create_notification(
|
||||
db_session,
|
||||
user_a.id,
|
||||
category="instance",
|
||||
severity="info",
|
||||
title="Unread dismissed",
|
||||
)
|
||||
await notification_service.dismiss(db_session, n.id, user_a.id)
|
||||
|
||||
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_all_read_affects_only_caller(
|
||||
db_session: AsyncSession,
|
||||
notification_service: NotificationService,
|
||||
user_a: User,
|
||||
user_b: User,
|
||||
) -> None:
|
||||
for i in range(3):
|
||||
await notification_service.create_notification(
|
||||
db_session, user_a.id, category="instance", severity="info", title=f"A-{i}"
|
||||
)
|
||||
for i in range(2):
|
||||
await notification_service.create_notification(
|
||||
db_session, user_b.id, category="instance", severity="info", title=f"B-{i}"
|
||||
)
|
||||
|
||||
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
||||
|
||||
assert marked == 3
|
||||
count_a = await notification_service.get_unread_count(db_session, user_a.id)
|
||||
count_b = await notification_service.get_unread_count(db_session, user_b.id)
|
||||
assert count_a == 0
|
||||
assert count_b == 2
|
||||
@@ -0,0 +1,5 @@
|
||||
name: notification-center
|
||||
description: Modular centralized notification management with UI notification center
|
||||
owner: Gentle AI
|
||||
created_at: 2026-05-28
|
||||
status: in-progress
|
||||
@@ -0,0 +1,139 @@
|
||||
# PR-1 Apply Report: Backend Core for Notification Center
|
||||
|
||||
## Status: COMPLETE
|
||||
|
||||
All 11 tasks for PR-1 (NC-PR1-001 through NC-PR1-011) have been implemented, tested, and validated.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Database Layer
|
||||
- **Alembic migration** (`alembic/versions/2026_05_29_add_notifications_table.py`)
|
||||
- Creates `notifications` table with all design-spec columns
|
||||
- FK `user_id` -> `users.id` with `ON DELETE CASCADE`
|
||||
- Index `idx_notifications_user_created_at` on `(user_id, created_at DESC)`
|
||||
- Partial index `idx_notifications_user_unread` on `(user_id, read_at)` where `read_at IS NULL`
|
||||
|
||||
- **SQLAlchemy model** (`src/models/notification.py`)
|
||||
- `Notification` class with `UUIDPrimaryKeyMixin` + `Base`
|
||||
- `notification_metadata` attribute mapped to DB column `"metadata"` (avoids SQLAlchemy `Base.metadata` conflict)
|
||||
- Exported from `src/models/__init__.py`
|
||||
|
||||
### Service Layer
|
||||
- **NotificationService singleton** (`src/services/notification_service.py`)
|
||||
- `create_notification(session, user_id, ...)` — inserts row, returns `Notification`
|
||||
- `list_notifications(session, user_id, ...)` — returns `(items, total)` tuple, excludes dismissed, supports `unread_only` and `mute_categories`
|
||||
- `get_unread_count(session, user_id)` — counts unread + non-dismissed
|
||||
- `mark_read(session, notification_id, user_id)` — sets `read_at = now()`
|
||||
- `mark_all_read(session, user_id)` — bulk update, returns count
|
||||
- `dismiss(session, notification_id, user_id)` — soft-delete via `dismissed_at = now()`
|
||||
- All methods enforce `user_id` filtering; wrong-owner raises `ValueError("Notification not found")`
|
||||
|
||||
### API Layer
|
||||
- **FastAPI router** (`src/api/notifications.py`) mounted at `/notifications`
|
||||
- `GET /notifications` — paginated list with `limit`, `offset`, `unread_only` query params; `limit` capped at 100
|
||||
- `GET /notifications/unread` — returns `{count: int}`
|
||||
- `PATCH /notifications/{id}/read` — marks single notification read
|
||||
- `POST /notifications/mark-all-read` — returns `{marked_count: int}`
|
||||
- `DELETE /notifications/{id}` — soft-delete (dismiss), returns `204`
|
||||
- Reads `notification_mute_categories` from `UserConfig.config` JSON blob and passes to `list_notifications`
|
||||
- Returns `404` for non-owned or missing notifications
|
||||
- Pydantic `NotificationItem` serializes `notification_metadata` as `"metadata"` via `Field(serialization_alias="metadata")`
|
||||
|
||||
### Registration
|
||||
- Router imported and included in `src/main.py`
|
||||
- `Notification` model imported in `src/main.py` with `# noqa: F401` for Alembic autogenerate discovery
|
||||
- `notifications_router` exported from `src/api/__init__.py`
|
||||
|
||||
### Tests
|
||||
- **13 unit tests** (`tests/unit/test_notification_service.py`) covering:
|
||||
- Create, list, unread count, mark read, mark all read, dismiss
|
||||
- Cross-user isolation, wrong-owner 404-equivalent, mute categories filtering
|
||||
- Dismissed excluded from unread count, mark-all-read affects only caller
|
||||
- **10 integration tests** (`tests/integration/test_notifications_api.py`) covering:
|
||||
- Auth requirements, ownership isolation, pagination
|
||||
- Mark read / dismiss endpoints and 404 for other users
|
||||
- Mute categories filter at API layer
|
||||
|
||||
## Changed Files
|
||||
|
||||
1. `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` *(new)*
|
||||
2. `apps/api/src/models/notification.py` *(new)*
|
||||
3. `apps/api/src/models/__init__.py`
|
||||
4. `apps/api/src/services/notification_service.py` *(new)*
|
||||
5. `apps/api/src/api/notifications.py` *(new)*
|
||||
6. `apps/api/src/api/__init__.py`
|
||||
7. `apps/api/src/main.py`
|
||||
8. `apps/api/tests/unit/test_notification_service.py` *(new)*
|
||||
9. `apps/api/tests/integration/test_notifications_api.py` *(new)*
|
||||
10. `apps/api/tests/integration/test_models.py`
|
||||
|
||||
## Test Evidence
|
||||
|
||||
### RED -> GREEN -> TRIANGULATE Cycles
|
||||
|
||||
| Cycle | Task | RED | GREEN | Result |
|
||||
|-------|------|-----|-------|--------|
|
||||
| 1 | Service unit tests (basic CRUD) | 13 tests written against missing service | Implemented `NotificationService` | 13 passed |
|
||||
| 2 | Service edge cases | Wrong-owner, mute categories, cross-user tests added | Already green from implementation | 13 passed |
|
||||
| 3 | API integration tests (basic endpoints) | 10 tests written against missing router | Implemented router + schemas | 10 passed |
|
||||
| 4 | API edge cases | Pagination, 404 ownership, mute categories at API layer | Already green from implementation | 10 passed |
|
||||
| 5 | REFACTOR | — | Ruff clean, no regressions | All new files pass ruff |
|
||||
|
||||
### Commands Run
|
||||
|
||||
```bash
|
||||
# NotificationService unit tests (13 tests)
|
||||
cd apps/api && python -m pytest tests/unit/test_notification_service.py -v
|
||||
# Exit: 0 — 13 passed
|
||||
|
||||
# Notifications API integration tests (10 tests)
|
||||
cd apps/api && python -m pytest tests/integration/test_notifications_api.py -v
|
||||
# Exit: 0 — 10 passed
|
||||
|
||||
# Combined new tests
|
||||
cd apps/api && python -m pytest tests/unit/test_notification_service.py tests/integration/test_notifications_api.py -v
|
||||
# Exit: 0 — 23 passed
|
||||
|
||||
# Existing unit suite (no regressions from our changes)
|
||||
cd apps/api && python -m pytest tests/unit/ -v
|
||||
# Exit: 1 — 223 passed, 4 failed (pre-existing failures in test_config.py and test_git_repository_clone_preflight.py)
|
||||
|
||||
# Ruff linting on all new/modified files
|
||||
cd apps/api && python -m ruff check \
|
||||
src/models/notification.py src/models/__init__.py \
|
||||
src/services/notification_service.py \
|
||||
src/api/notifications.py src/api/__init__.py src/main.py \
|
||||
alembic/versions/2026_05_29_add_notifications_table.py \
|
||||
tests/unit/test_notification_service.py \
|
||||
tests/integration/test_notifications_api.py \
|
||||
tests/integration/test_models.py
|
||||
# Exit: 0 — All checks passed
|
||||
|
||||
# Smoke tests
|
||||
# GET /health -> 200
|
||||
# GET /notifications (unauthenticated) -> 401
|
||||
```
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
1. **SQLAlchemy `metadata` column name conflict:** `Base.metadata` is reserved by SQLAlchemy DeclarativeBase. Used `notification_metadata` as the Python attribute name with DB column name `"metadata"`. In the Pydantic response model, used `Field(serialization_alias="metadata")` so the JSON API still exposes `"metadata"` as specified in the design.
|
||||
|
||||
2. **Datetime types in Pydantic schemas:** Used `datetime` instead of `str` for `read_at`, `dismissed_at`, and `created_at` to leverage FastAPI's automatic ISO-8601 serialization.
|
||||
|
||||
## Surprises / Decisions
|
||||
|
||||
1. **SQLite `func.now()` timestamp resolution:** `test_list_notifications_orders_by_created_at_desc` initially failed because multiple rapid INSERTs received identical timestamps. Fixed by explicitly setting `created_at` offsets in the test after creation.
|
||||
|
||||
2. **Pre-existing integration test failures:** Approximately 40 integration tests fail due to missing `asyncpg` module and direct PostgreSQL connection attempts in their custom setup code. These failures are unrelated to our changes.
|
||||
|
||||
3. **Pre-existing `test_models.py` outdated:** The `test_expected_tables_are_registered` assertion had a hardcoded set missing many newer tables (including our new `notifications` table). Updated it to include all current tables.
|
||||
|
||||
## PR Boundary
|
||||
|
||||
This PR covers PR-1 only (NC-PR1-001 through NC-PR1-011). PR-2 (backend integration — wiring lifecycle_hooks.py and health_monitor.py) and PR-3/PR-4 (frontend) are out of scope and await this PR.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Low:** The migration uses `sa.JSON()` which is compatible with both PostgreSQL and SQLite. The partial index uses `postgresql_where` which is PostgreSQL-specific but safely ignored by SQLite.
|
||||
- **Low:** `notification_metadata` -> `"metadata"` serialization alias is a new pattern in the codebase but is explicitly tested via integration tests.
|
||||
- **None:** No changes to existing production code paths; all changes are additive.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Apply Progress: PR-1 Backend Core for Notification Center
|
||||
|
||||
## TDD Cycle Evidence
|
||||
|
||||
| Cycle | Task | Test File | RED | GREEN | Evidence |
|
||||
|-------|------|-----------|-----|-------|----------|
|
||||
| 1 | NC-PR1-004 (basic CRUD) | `tests/unit/test_notification_service.py` | 13 tests written against missing service | All 13 pass | `pytest tests/unit/test_notification_service.py` → 13 passed |
|
||||
| 2 | NC-PR1-005 (edge cases) | `tests/unit/test_notification_service.py` | Already included in cycle 1 | Added wrong-owner, mute-categories, cross-user isolation | Same 13 tests pass |
|
||||
| 3 | NC-PR1-007 (basic endpoints) | `tests/integration/test_notifications_api.py` | 10 tests written against missing router | All 10 pass | `pytest tests/integration/test_notifications_api.py` → 10 passed |
|
||||
| 4 | NC-PR1-008 (API edge cases) | `tests/integration/test_notifications_api.py` | Already included in cycle 3 | Pagination, 404 ownership, mute categories at API layer | Same 10 tests pass |
|
||||
| 5 | NC-PR1-010 (REFACTOR) | All files | — | ruff clean, no regressions | `ruff check` passes on all new files; existing unit tests 223 passed (4 pre-existing failures unrelated) |
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
- [x] NC-PR1-001: Alembic migration for `notifications` table
|
||||
- [x] NC-PR1-002: SQLAlchemy `Notification` model (`apps/api/src/models/notification.py`)
|
||||
- [x] NC-PR1-003: Export `Notification` in `models/__init__.py`
|
||||
- [x] NC-PR1-004: NotificationService unit tests — basic CRUD (RED)
|
||||
- [x] NC-PR1-005: Implement `NotificationService` singleton (GREEN)
|
||||
- [x] NC-PR1-006: Service edge-case and isolation tests (TRIANGULATE)
|
||||
- [x] NC-PR1-007: API integration tests — basic endpoints (RED)
|
||||
- [x] NC-PR1-008: Implement FastAPI router + Pydantic schemas (GREEN)
|
||||
- [x] NC-PR1-009: API edge-case and ownership tests (TRIANGULATE)
|
||||
- [x] NC-PR1-010: Register router in `main.py` + import `Notification` for Alembic
|
||||
- [x] NC-PR1-011: Code quality pass — ruff, test regressions, smoke tests (REFACTOR)
|
||||
|
||||
## Files Changed
|
||||
|
||||
1. `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` *(new)* — Alembic migration
|
||||
2. `apps/api/src/models/notification.py` *(new)* — SQLAlchemy model
|
||||
3. `apps/api/src/models/__init__.py` — Export `Notification`
|
||||
4. `apps/api/src/services/notification_service.py` *(new)* — `NotificationService` singleton
|
||||
5. `apps/api/src/api/notifications.py` *(new)* — FastAPI router + Pydantic schemas
|
||||
6. `apps/api/src/api/__init__.py` — Export `notifications_router`
|
||||
7. `apps/api/src/main.py` — Register router, import `Notification` for Alembic
|
||||
8. `apps/api/tests/unit/test_notification_service.py` *(new)* — 13 unit tests
|
||||
9. `apps/api/tests/integration/test_notifications_api.py` *(new)* — 10 integration tests
|
||||
10. `apps/api/tests/integration/test_models.py` — Updated expected tables list
|
||||
|
||||
## Test Commands & Exit Codes
|
||||
|
||||
```bash
|
||||
# Unit tests for NotificationService (13 tests)
|
||||
cd apps/api && python -m pytest tests/unit/test_notification_service.py -v
|
||||
# Exit: 0 — 13 passed
|
||||
|
||||
# Integration tests for notifications API (10 tests)
|
||||
cd apps/api && python -m pytest tests/integration/test_notifications_api.py -v
|
||||
# Exit: 0 — 10 passed
|
||||
|
||||
# Combined new tests
|
||||
cd apps/api && python -m pytest tests/unit/test_notification_service.py tests/integration/test_notifications_api.py -v
|
||||
# Exit: 0 — 23 passed
|
||||
|
||||
# Existing unit tests (no regressions in our code)
|
||||
cd apps/api && python -m pytest tests/unit/ -v
|
||||
# Exit: 1 — 223 passed, 4 failed (pre-existing failures in test_config.py and test_git_repository_clone_preflight.py)
|
||||
|
||||
# Ruff linting on all new/modified files
|
||||
cd apps/api && python -m ruff check \
|
||||
src/models/notification.py \
|
||||
src/models/__init__.py \
|
||||
src/services/notification_service.py \
|
||||
src/api/notifications.py \
|
||||
src/api/__init__.py \
|
||||
src/main.py \
|
||||
alembic/versions/2026_05_29_add_notifications_table.py \
|
||||
tests/unit/test_notification_service.py \
|
||||
tests/integration/test_notifications_api.py \
|
||||
tests/integration/test_models.py
|
||||
# Exit: 0 — All checks passed
|
||||
|
||||
# Smoke tests
|
||||
health: 200
|
||||
notifications unauth: 401
|
||||
```
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
- **SQLAlchemy `metadata` column name conflict:** `Base.metadata` is reserved by SQLAlchemy DeclarativeBase. Used `notification_metadata` as the Python attribute name with DB column name `"metadata"`. In the Pydantic response model, used `Field(serialization_alias="metadata")` so the JSON API still exposes `metadata` as specified.
|
||||
- **`created_at` type in Pydantic:** Used `datetime` instead of `str` to leverage FastAPI's automatic ISO serialization.
|
||||
|
||||
## Surprises / Decisions
|
||||
|
||||
1. **SQLite `func.now()` resolution:** `test_list_notifications_orders_by_created_at_desc` failed because multiple rapid INSERTs got identical timestamps. Fixed by explicitly setting `created_at` offsets in the test after creation.
|
||||
2. **Pre-existing integration test failures:** ~40 integration tests fail due to missing `asyncpg` module and direct PostgreSQL connection attempts in their custom setup code. These are unrelated to our changes.
|
||||
3. **Pre-existing `test_models.py` outdated:** The `test_expected_tables_are_registered` assertion had a hardcoded set missing many newer tables. Updated it to include all current tables (including `notifications`).
|
||||
|
||||
## Remaining Tasks
|
||||
|
||||
None — PR-1 is complete.
|
||||
|
||||
## PR Boundary
|
||||
|
||||
This PR covers PR-1 only (NC-PR1-001 through NC-PR1-011). PR-2 (backend integration) and PR-3/PR-4 (frontend) are out of scope.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
Below is the complete **explore.md** content for the Notification Center SDD phase. Please write it to:
|
||||
|
||||
**`/home/alex/projects/headquarter/openspec/changes/notification-center/explore.md`**
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
# SDD Explore — Notification Center
|
||||
|
||||
## 1. Current State of Notification Infrastructure
|
||||
|
||||
### Backend
|
||||
- **InstanceEventBus** (`apps/api/src/services/event_bus.py`) — Singleton in-memory pub/sub bus with typed callbacks. Supports per-event-type subscriptions and a wildcard `"*"` catch-all. Exceptions are isolated so one failing subscriber does not break others. Currently single-process only.
|
||||
- **HealthMonitor** (`apps/api/src/services/health_monitor.py`) — Background polling task that checks container/tunnel health and publishes `instance.health_changed` and `instance.error` events via the bus.
|
||||
- **Lifecycle Hooks** (`apps/api/src/services/lifecycle_hooks.py`) — `publish_lifecycle_event()` builds a standard payload, writes an audit row to the `instance_events` table, and publishes to the bus. Used extensively by the tool-instances API (`instance.created`, `instance.started`, `instance.stopped`, etc.).
|
||||
- **SSE Stream** (`apps/api/src/api/events.py`) — `GET /events/stream` subscribes to the wildcard `"*"` topic and pushes JSON payloads to **all** authenticated users. There is no per-user filtering. It enforces a 5-connection limit per user and drops oldest events when the queue is full.
|
||||
- **Audit Model** (`apps/api/src/models/instance_event.py`) — `InstanceEvent` persists event metadata, type, status, message, and `created_by` user ID. It is tied to `tool_instances.id` but is **not** a user-facing notification store.
|
||||
- **User / Preferences** (`apps/api/src/models/user.py`, `apps/api/src/models/user_config.py`) — `User` has a 1-to-1 `UserConfig` JSON blob (`config` column) used for theme, editor, git identity, etc. No notification-related keys exist yet.
|
||||
|
||||
### Frontend
|
||||
- **AppShell** (`apps/web/src/components/app-shell.tsx`) — Global layout with a top `shell-header`. The right side (`header-actions`) currently holds a user chip and a logout button. This is the natural mount point for a bell icon + notification center dropdown.
|
||||
- **Toast System** (`apps/web/src/state/toast.tsx`) — Global ephemeral toast context. Supports `info`, `success`, `warning`, `error` with configurable duration. Toasts are stored in React state and auto-dismiss.
|
||||
- **Event Bridge** (`apps/web/src/components/event-toast-bridge.tsx`, `apps/web/src/components/toast-rules.ts`) — Listens to the `EventContext`, deduplicates instance events (1-second window), and maps them to toasts (e.g., `instance.error` → red toast).
|
||||
- **EventProvider / useEvents** (`apps/web/src/state/events.tsx`, `apps/web/src/hooks/use-events.ts`) — Manages a single global SSE connection with exponential-backoff reconnect and 401/429 handling. Events are accumulated in a plain array in state.
|
||||
- **Icons** (`apps/web/src/utils/icons.ts`) — Uses `@phosphor-icons/react`. No `bell` icon is currently registered.
|
||||
- **Styling** (`apps/web/src/styles.css`) — Header uses flex layout with `backdrop-filter: blur`. Existing badge styles (`nav-badge`, `mobile-nav-badge`) can be reused or extended for an unread count.
|
||||
|
||||
## 2. Gaps Between Toast-Only and a Full Notification Center
|
||||
|
||||
| Gap | Impact |
|
||||
|-----|--------|
|
||||
| **No persistent notification store** | Missed events are lost forever if the user is offline or the toast expires. |
|
||||
| **No per-user event filtering** | SSE broadcasts all instance events to every user. Users may receive irrelevant toasts. |
|
||||
| **No read/unread/dismiss lifecycle** | Toasts are purely ephemeral; there is no concept of “mark as read” or “dismiss”. |
|
||||
| **No historical API** | Users cannot revisit past notifications. |
|
||||
| **No categorization / severity model** | Events are raw strings (`instance.error`). No structured category (system, container, security, etc.). |
|
||||
| **No user preferences** | Cannot mute specific notification types or choose toast vs. silent delivery. |
|
||||
| **No UI surface for a list** | No dropdown, popover, or panel component exists for listing notifications. |
|
||||
| **No mobile-specific notification UI** | Mobile header is absent (mobile uses bottom nav). Need to decide where the bell lives on small screens. |
|
||||
| **No non-instance notification sources** | Only container/health events are wired. System messages, build failures, or billing alerts have no pipeline. |
|
||||
|
||||
## 3. Key Files and Integration Points
|
||||
|
||||
### Backend — New / Modified
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `apps/api/src/models/notification.py` | New SQLAlchemy model: `Notification` (user-scoped, read/unread, dismissed, category, payload). |
|
||||
| `alembic/versions/…_add_notifications.py` | Migration for the new table + indexes on `(user_id, read_at)` and `(user_id, created_at)`. |
|
||||
| `apps/api/src/services/notification_service.py` | New service: subscribes to event-bus topics, fans out per-user `Notification` rows. |
|
||||
| `apps/api/src/api/notifications.py` | New FastAPI router: `GET /notifications`, `PATCH /notifications/{id}/read`, `POST /notifications/mark-all-read`, `DELETE /notifications/{id}`. |
|
||||
| `apps/api/src/main.py` | Register the new router and import the `Notification` model for Alembic discovery. |
|
||||
| `apps/api/src/api/events.py` | Decide whether to multiplex notification events into SSE or keep REST polling only. |
|
||||
| `apps/api/src/services/lifecycle_hooks.py` | Optionally shift from “publish raw event” to “publish raw event + call notification service”. |
|
||||
| `apps/api/src/services/health_monitor.py` | Health state changes should feed into notification service. |
|
||||
| `apps/api/src/models/user_config.py` | Extend JSON schema (or add new columns) for notification preferences (mute categories, disable toasts). |
|
||||
|
||||
### Frontend — New / Modified
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `apps/web/src/components/notification-center.tsx` | Bell icon + dropdown panel with notification list, empty state, and actions (mark read, dismiss). |
|
||||
| `apps/web/src/hooks/use-notifications.ts` | Fetch notifications, unread count, mark-read/dismiss mutations, optional optimistic updates. |
|
||||
| `apps/web/src/state/notifications.tsx` | React context/provider for notification list and unread count. Could poll or be driven by SSE. |
|
||||
| `apps/web/src/components/app-shell.tsx` | Mount `<NotificationCenter />` inside `header-actions`. Hide on mobile terminal view. |
|
||||
| `apps/web/src/utils/icons.ts` | Add `"bell"` (Phosphor `Bell`) to `IconName` / `iconRegistry`. |
|
||||
| `apps/web/src/styles.css` | Add dropdown/popover positioning, z-index layering, and notification-item hover states. |
|
||||
| `apps/web/src/components/event-toast-bridge.tsx` | Coordinate with notification system to avoid duplicate toast + notification for the same event. |
|
||||
| `apps/web/src/components/mobile-nav.tsx` | Consider adding a bell icon or a badge on the existing “Sessions” nav item on mobile. |
|
||||
|
||||
## 4. Risks and Unknowns
|
||||
|
||||
1. **SSE Scaling / Filtering**
|
||||
The current SSE endpoint broadcasts every event to every connected user. Adding per-user notification filtering inside the same SSE loop will require either:
|
||||
- A separate SSE stream for notifications with user-scoped queues, or
|
||||
- Client-side filtering (simple but wastes bandwidth and leaks data).
|
||||
**Recommendation:** Start with REST polling for the notification list (every 30 s + manual refresh) and keep the existing SSE for real-time instance events. A dedicated `notifications/stream` SSE can be a fast-follow.
|
||||
|
||||
2. **Single-Process Event Bus Limit**
|
||||
`InstanceEventBus` is an in-memory singleton. If the API is ever scaled to multiple workers, events published in one process will not be visible in another. The notification service should be architected so that it can later be backed by a persistent message queue (e.g., Redis pub/sub) without changing its interface.
|
||||
|
||||
3. **User Identification for Instance Events**
|
||||
Most instance events naturally map to `ToolInstance.owner_id`, but some actions (e.g., an admin stopping another user’s container) may need to notify a different user than the owner. The `publish_lifecycle_event` helper currently accepts `created_by`; the notification service should accept an explicit `target_user_id` parameter.
|
||||
|
||||
4. **Duplicate Surface (Toast vs. Center)**
|
||||
Users will be annoyed if every notification produces both a toast and a center entry simultaneously. We need a preference layer (“Show toasts for: all / errors only / none”) and a mechanism for the toast bridge to check whether a notification was already ingested into the center.
|
||||
|
||||
5. **Mobile Real Estate**
|
||||
The mobile layout does not have a top header. The notification center will need a home inside `MobileNav` (e.g., a bell icon that opens a bottom sheet) or inside the existing `ToolsBottomSheet`.
|
||||
|
||||
6. **Migration Safety**
|
||||
Adding a high-write table (`notifications`) to the same database used for health checks and events could introduce write contention under heavy load. Indexes on `(user_id, created_at)` and a partial index on `read_at IS NULL` are essential from day one.
|
||||
|
||||
7. **No Existing Dropdown Component**
|
||||
There is no reusable dropdown/popover in the design system. We will need to build one (or at least a positioned panel) and ensure it closes on outside click, handles focus, and works in both light and dark themes.
|
||||
|
||||
## 5. Recommended Architecture Approach
|
||||
|
||||
### Phase 1 — Core Backend (REST + DB)
|
||||
1. **Model** — Create `Notification` table:
|
||||
- `id` (UUID PK)
|
||||
- `user_id` (FK → users.id, indexed)
|
||||
- `category` (str: `instance`, `system`, `health`, `security`)
|
||||
- `severity` (str: `info`, `warning`, `error`, `success`)
|
||||
- `title`, `message` (text)
|
||||
- `source_id`, `source_type` (nullable, e.g., `tool_instances.id`)
|
||||
- `metadata` (JSON)
|
||||
- `read_at` (datetime, nullable, indexed)
|
||||
- `dismissed_at` (datetime, nullable)
|
||||
- `created_at` (timestamp)
|
||||
2. **Service** — `NotificationService` with methods:
|
||||
- `create_notification(user_id, category, severity, title, message, …)`
|
||||
- `get_unread_count(user_id)`
|
||||
- `list_notifications(user_id, limit, offset, unread_only)`
|
||||
- `mark_read(notification_id)`, `mark_all_read(user_id)`, `dismiss(notification_id)`
|
||||
3. **Bus Integration** — Subscribe `NotificationService` to relevant event types (or have `lifecycle_hooks` and `HealthMonitor` call it directly). Use `ToolInstance.owner_id` as the default `user_id`.
|
||||
4. **API** — New FastAPI router under `/notifications` with the CRUD endpoints above.
|
||||
5. **Preferences** — Extend `UserConfig` JSON with:
|
||||
- `notification_mute_categories: string[]`
|
||||
- `notification_toast_level: "all" | "errors" | "none"`
|
||||
|
||||
### Phase 2 — Frontend UI
|
||||
1. **Icon** — Add `bell` to the Phosphor icon registry.
|
||||
2. **Component** — `<NotificationCenter />`:
|
||||
- Bell icon with an unread count badge.
|
||||
- Click opens a dropdown panel (positioned under the bell, right-aligned).
|
||||
- Panel contains a scrollable list of recent notifications, grouped by date.
|
||||
- Each row shows severity icon, title, relative timestamp, and a “Mark read” / “Dismiss” action.
|
||||
- Footer with “Mark all as read”.
|
||||
3. **State** — `NotificationProvider` + `useNotifications()` hook:
|
||||
- Poll `GET /notifications` every 30 seconds.
|
||||
- Poll `GET /notifications/unread` every 15 seconds for the badge.
|
||||
- Optimistically update local state on mark-read/dismiss.
|
||||
4. **Integration** — Mount inside `AppShell` header-actions. Suppress bell on `isMobileTerminal`.
|
||||
5. **Toast Coordination** — Update `EventToastBridge` to respect `notification_toast_level` before showing a toast. Consider adding a `notification_id` to the toast metadata so clicking the toast could open the notification center.
|
||||
|
||||
### Phase 3 — Real-Time (Fast Follow)
|
||||
- Add a lightweight `notifications/stream` SSE endpoint that pushes only to the owning user.
|
||||
- Replace polling in `NotificationProvider` with SSE for instantaneous badge updates.
|
||||
|
||||
### Modularity Guidelines
|
||||
- **Sources are decoupled:** Any backend module can call `notification_service.create_notification(...)`. The event bus remains the transport for raw events; the notification service is the consumer that turns them into user-visible rows.
|
||||
- **Category extensibility:** New sources (e.g., future billing or team-mention system) only need to supply `category`, `severity`, and `target_user_id`.
|
||||
- **Frontend reusability:** The notification list item component should accept a generic `NotificationItem` interface so new categories can render custom icons or deep links without rewriting the list.
|
||||
|
||||
---
|
||||
|
||||
**Next Step:** Proceed to **SDD Specification** to lock down the exact API schema, component props, and database migration details.
|
||||
```
|
||||
|
||||
---
|
||||
@@ -0,0 +1,283 @@
|
||||
# SDD Proposal — Notification Center
|
||||
|
||||
**Change ID:** `notification-center`
|
||||
**Status:** Draft
|
||||
**Date:** 2026-05-29
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current notification surface is limited to ephemeral toasts driven by an unfiltered SSE stream. Users face three critical gaps:
|
||||
|
||||
1. **No persistence** — If a user is offline, reloads the page, or dismisses a toast, the event is gone forever. There is no way to review what happened while they were away.
|
||||
2. **No scoping** — The SSE endpoint broadcasts all instance events to every authenticated user. Users receive toasts for containers they do not own, creating noise and potential information leakage.
|
||||
3. **No lifecycle or control** — Toasts auto-dismiss with no read/unread state, no dismissal history, and no user preferences to mute categories or suppress toast pop-ups.
|
||||
|
||||
These gaps make the system unsuitable for any asynchronous, user-specific, or high-signal communication such as health alerts, system maintenance notices, or future billing events.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
| # | Goal | Success Measure |
|
||||
|---|------|-----------------|
|
||||
| G1 | **Persistent, per-user notification store** backed by a new database table. Notifications survive page reloads, browser restarts, and session changes. | 100 % of notifications created for a user are retrievable after a full browser close + reopen. |
|
||||
| G2 | **Per-user filtering** — Users only see notifications scoped to their user_id. | Zero cross-user notification leakage in API responses. |
|
||||
| G3 | **Read/unread/dismiss lifecycle** with REST endpoints and optimistic UI updates. | Users can mark individual or all notifications read, and dismiss unwanted entries; state persists on refresh. |
|
||||
| G4 | **Notification center UI** — Bell icon in the top-right AppShell header with a dropdown panel listing recent notifications. | Bell is visible on desktop; dropdown renders within 200 ms of click; accessible via keyboard. |
|
||||
| G5 | **Unread count badge** — Red badge on the bell icon reflecting the real-time unread count. | Badge count matches GET /notifications/unread within one polling interval. |
|
||||
| G6 | **Modular notification sources** — Any backend module can call a central NotificationService to create user-scoped notifications without touching instance events directly. | A new source (e.g., a future billing module) can emit notifications by adding a single service call. |
|
||||
| G7 | **Toast coordination** — The existing toast system respects user preferences and avoids duplicate surfacing when a notification is already in the center. | No user sees both a toast and a center entry for the same backend event unless they explicitly re-open the center. |
|
||||
| G8 | **User preferences** — Mute categories and toast-level settings stored in UserConfig. | Preference changes take effect immediately without a server restart. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Goals
|
||||
|
||||
| # | Non-Goal | Rationale |
|
||||
|---|----------|-----------|
|
||||
| NG1 | **Real-time SSE for notifications in Phase 1** | Will use REST polling (30 s list / 15 s unread) to ship faster. A dedicated notifications/stream SSE is a fast-follow (Phase 3). |
|
||||
| NG2 | **Push notifications / WebHooks / Email** | Out of scope for this change. The architecture must not block these later, but no transport work is included now. |
|
||||
| NG3 | **Multi-worker event-bus scaling** | InstanceEventBus remains an in-memory singleton. The NotificationService interface is designed so a future Redis-backed queue can slot in without consumer changes. |
|
||||
| NG4 | **Team / group-scoped notifications** | Notifications are 1-to-1 user_id only. Mentioning or broadcasting to teams is future work. |
|
||||
| NG5 | **Mobile-specific notification UI (bottom sheet)** | The bell will be hidden on isMobileTerminal. A mobile-native bottom-sheet variant is a future polish item. |
|
||||
| NG6 | **Rich-text or markdown bodies** | title and message are plain strings. No formatting engine is introduced. |
|
||||
|
||||
---
|
||||
|
||||
## 4. User Stories
|
||||
|
||||
| ID | Story | Acceptance Criteria |
|
||||
|----|-------|---------------------|
|
||||
| US-1 | **As a** user, **I want** to see a bell icon with an unread count in the header **so that** I know when something needs my attention. | Bell renders in header-actions; badge shows unread count; count updates on poll. |
|
||||
| US-2 | **As a** user, **I want** to click the bell and see a list of recent notifications **so that** I can catch up on events I missed. | Dropdown opens; lists last 20 notifications; shows title, relative time, severity icon; empty state when none exist. |
|
||||
| US-3 | **As a** user, **I want** to mark a notification as read **so that** the badge count decreases and the UI reflects my attention. | Clicking a row or its Mark read action updates read_at; badge decrements; row styling changes. |
|
||||
| US-4 | **As a** user, **I want** to dismiss a notification **so that** it no longer appears in my list. | Dismiss removes the row from the list and sets dismissed_at; does not affect other users. |
|
||||
| US-5 | **As a** user, **I want** to Mark all as read **so that** I can clear my inbox quickly. | Footer button marks all unread notifications read; badge resets to zero; list styling updates. |
|
||||
| US-6 | **As a** user, **I want** notification preferences (mute categories, toast level) **so that** I control noise. | Settings panel or modal exposes checkboxes / select for mute categories and toast level; saves to UserConfig. |
|
||||
| US-7 | **As a** backend developer, **I want** to emit a notification from any module with one function call **so that** I do not rebuild plumbing each time. | NotificationService.create_notification(...) is importable anywhere; auto-scopes to user_id. |
|
||||
| US-8 | **As a** user, **I want** container error events to appear as notifications **so that** I can review them later even if I missed the toast. | instance.error events from HealthMonitor / lifecycle_hooks generate a Notification row for the owner. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposed Solution
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
#### New Data Model
|
||||
|
||||
```python
|
||||
# apps/api/src/models/notification.py
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
metadata: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
```
|
||||
|
||||
**Indexes:**
|
||||
- (user_id, created_at DESC) — fast list queries
|
||||
- (user_id, read_at) WHERE read_at IS NULL — fast unread count (partial index)
|
||||
|
||||
#### New Service
|
||||
|
||||
```python
|
||||
# apps/api/src/services/notification_service.py
|
||||
class NotificationService:
|
||||
async def create_notification(
|
||||
self, user_id: UUID, category: str, severity: str,
|
||||
title: str, message: str | None = None,
|
||||
source_type: str | None = None, source_id: UUID | None = None,
|
||||
metadata: dict | None = None
|
||||
) -> Notification: ...
|
||||
|
||||
async def list_notifications(
|
||||
self, user_id: UUID, *, limit: int = 20, offset: int = 0,
|
||||
unread_only: bool = False
|
||||
) -> list[Notification]: ...
|
||||
|
||||
async def get_unread_count(self, user_id: UUID) -> int: ...
|
||||
async def mark_read(self, notification_id: UUID, user_id: UUID) -> Notification: ...
|
||||
async def mark_all_read(self, user_id: UUID) -> int: ...
|
||||
async def dismiss(self, notification_id: UUID, user_id: UUID) -> None: ...
|
||||
```
|
||||
|
||||
The service is instantiated as a module-level singleton and imported by event producers.
|
||||
|
||||
#### New API Router
|
||||
|
||||
- GET /notifications — list (paginated, supports ?unread_only=true)
|
||||
- GET /notifications/unread — returns { "count": int }
|
||||
- PATCH /notifications/{id}/read — mark single read
|
||||
- POST /notifications/mark-all-read — mark all read
|
||||
- DELETE /notifications/{id} — dismiss (soft-delete by setting dismissed_at)
|
||||
|
||||
All endpoints enforce user_id == current_user.id at the service layer.
|
||||
|
||||
#### Event-Bus Integration
|
||||
|
||||
- lifecycle_hooks.py and health_monitor.py call notification_service.create_notification(...) with user_id=tool_instance.owner_id after publishing the raw event.
|
||||
- No changes to InstanceEventBus itself; the notification service is a consumer, not bus middleware.
|
||||
|
||||
#### Preferences Extension
|
||||
|
||||
Extend UserConfig.config JSON schema with two new keys:
|
||||
|
||||
- notification_mute_categories: string[] — categories the user does not want to see at all.
|
||||
- notification_toast_level: "all" | "errors" | "none" — default is "all".
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
#### New / Modified Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| notification-center.tsx | Bell icon + dropdown panel. Manages open/close state, outside-click close, keyboard Escape. |
|
||||
| notification-item.tsx | Single row: severity icon, title, relative time, mark-read/dismiss actions. |
|
||||
| notification-provider.tsx | React context: holds list, unread count, polling logic (30 s / 15 s), mutations with optimistic updates. |
|
||||
| use-notifications.ts | Hook exposing notifications, unreadCount, markRead, markAllRead, dismiss, isLoading. |
|
||||
| app-shell.tsx | Mount NotificationCenter inside header-actions; hide when isMobileTerminal. |
|
||||
| event-toast-bridge.tsx | Read userConfig.notification_toast_level before emitting a toast. Skip toast if level is "none" or event severity is below threshold. |
|
||||
| icons.ts | Register "bell" pointing to PhosphorIcons.Bell. |
|
||||
| styles.css | Add .notification-dropdown, .notification-item, .notification-badge utilities. |
|
||||
|
||||
#### Toast Coordination Logic
|
||||
|
||||
1. Backend event triggers NotificationService.create_notification() (always happens).
|
||||
2. EventToastBridge receives the SSE event.
|
||||
3. Bridge checks userConfig.notification_toast_level:
|
||||
- If "none": never toast.
|
||||
- If "errors": only toast when severity is "error".
|
||||
- If "all": toast as before.
|
||||
4. Bridge also checks if the event category is in notification_mute_categories; if so, skip toast.
|
||||
5. The notification row is always created on the backend regardless of frontend preferences; filtering happens at read time and in the bridge.
|
||||
|
||||
#### Polling Strategy
|
||||
|
||||
- Notification list: GET /notifications every 30 seconds while the dropdown is closed; refresh immediately when opened.
|
||||
- Unread count: GET /notifications/unread every 15 seconds.
|
||||
- Intervals are configurable constants in the provider.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Soft-delete via dismissed_at instead of hard DELETE** | Preserves audit history and allows future features such as "Recently dismissed" or admin analytics. |
|
||||
| **Partial index on read_at IS NULL** | Unread count is queried frequently; a partial index keeps it small and fast even as the table grows. |
|
||||
| **Poll instead of SSE for Phase 1** | Avoids redesigning the SSE multiplexing logic and lets us ship the full UI and backend in one PR. SSE follow-up is isolated. |
|
||||
| **Plain-text title/message** | Avoids introducing a markdown parser or HTML sanitization dependency. Rich content can be a future enhancement. |
|
||||
| **UserConfig JSON blob for preferences** | Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. |
|
||||
| **No middleware in InstanceEventBus** | Producers (lifecycle_hooks, health_monitor) explicitly call the notification service. This makes the dependency visible and avoids hidden side effects in the bus. |
|
||||
| **Category + severity enums stored as strings** | Simple, human-readable, and extensible without Alembic migrations when a new source introduces a category. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| **High write volume on notifications table** | Medium | High | Add partial indexes from day one; monitor write throughput; shard or archive old rows (e.g., auto-dismiss after 90 days) if volume becomes problematic. |
|
||||
| **Cross-user data leakage in API** | Low | Critical | Enforce user_id filter in every service method; add integration tests that attempt to read another user’s notification and assert 404. |
|
||||
| **Polling overhead at scale** | Medium | Medium | Poll intervals are conservative; unread count endpoint is a single COUNT query with a partial index. SSE fast-follow eliminates polling. |
|
||||
| **Mobile layout absence** | Low | Low | Bell is hidden on isMobileTerminal. Mobile bottom-sheet is a future non-goal. |
|
||||
| **No reusable dropdown component** | Medium | Medium | Build a minimal positioned panel inside notification-center.tsx using a ref + useEffect for outside click; extract to a design-system component only after it stabilizes. |
|
||||
| **Notification service called before DB commit** | Medium | Medium | Ensure lifecycle_hooks commits the parent transaction (instance_events insert) before calling the notification service, or wrap both in the same unit of work. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Alembic migration creates the notifications table with correct columns, FK, and indexes.
|
||||
- [ ] GET /notifications returns only rows where user_id matches the authenticated user, ordered by created_at DESC.
|
||||
- [ ] GET /notifications/unread returns the exact count of rows where read_at IS NULL for the authenticated user.
|
||||
- [ ] PATCH /notifications/{id}/read sets read_at and returns the updated row; 404 if not owned by caller.
|
||||
- [ ] POST /notifications/mark-all-read sets read_at on all unread rows for the caller; returns count affected.
|
||||
- [ ] DELETE /notifications/{id} sets dismissed_at; row no longer appears in list queries.
|
||||
- [ ] HealthMonitor and lifecycle_hooks generate notifications scoped to the tool instance owner.
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Bell icon renders in AppShell header-actions on desktop.
|
||||
- [ ] Unread count badge updates within 15 seconds of a new notification.
|
||||
- [ ] Dropdown opens on bell click, closes on outside click or Escape.
|
||||
- [ ] Notification list shows title, relative time, severity icon; unread rows are visually distinct.
|
||||
- [ ] Mark read and Dismiss actions update UI optimistically and persist after refresh.
|
||||
- [ ] Mark all as read clears the badge and updates all visible rows.
|
||||
- [ ] Empty state message shown when no notifications exist.
|
||||
- [ ] Toast bridge respects notification_toast_level and notification_mute_categories.
|
||||
|
||||
### Integration
|
||||
|
||||
- [ ] End-to-end test: trigger an instance.error event → verify notification row created → verify badge increments → verify toast appears (or not) based on preference → mark read → verify badge clears.
|
||||
|
||||
---
|
||||
|
||||
## 9. Effort Estimate + PR Breakdown
|
||||
|
||||
### PR 1 — Backend Core (~2 days)
|
||||
**Scope:** Migration, model, service, API router, registration in main.py.
|
||||
**Files:**
|
||||
- alembic/versions/..._add_notifications.py
|
||||
- apps/api/src/models/notification.py
|
||||
- apps/api/src/services/notification_service.py
|
||||
- apps/api/src/api/notifications.py
|
||||
- apps/api/src/main.py
|
||||
**Tests:** Service unit tests, API integration tests (ownership, pagination, mark-all-read).
|
||||
|
||||
### PR 2 — Backend Integration (~1 day)
|
||||
**Scope:** Wire lifecycle_hooks and HealthMonitor to call NotificationService; add preferences to UserConfig schema.
|
||||
**Files:**
|
||||
- apps/api/src/services/lifecycle_hooks.py
|
||||
- apps/api/src/services/health_monitor.py
|
||||
- apps/api/src/models/user_config.py (schema docs / validation)
|
||||
**Tests:** End-to-end event-to-notification creation tests.
|
||||
|
||||
### PR 3 — Frontend Core (~2 days)
|
||||
**Scope:** Icon, provider, hook, notification-center component, item component, styles, app-shell integration.
|
||||
**Files:**
|
||||
- apps/web/src/utils/icons.ts
|
||||
- apps/web/src/state/notifications.tsx
|
||||
- apps/web/src/hooks/use-notifications.ts
|
||||
- apps/web/src/components/notification-center.tsx
|
||||
- apps/web/src/components/notification-item.tsx
|
||||
- apps/web/src/components/app-shell.tsx
|
||||
- apps/web/src/styles.css
|
||||
**Tests:** Component render tests, hook behavior tests, optimistic update tests.
|
||||
|
||||
### PR 4 — Toast Coordination + Preferences UI (~1 day)
|
||||
**Scope:** Update EventToastBridge; add preference controls (inside existing settings modal or new section); connect to UserConfig API.
|
||||
**Files:**
|
||||
- apps/web/src/components/event-toast-bridge.tsx
|
||||
- apps/web/src/components/toast-rules.ts (if toast level logic lives here)
|
||||
- Settings / preferences component (TBD based on existing UI)
|
||||
**Tests:** Bridge logic tests, preference persistence tests.
|
||||
|
||||
### Total Estimated Effort: ~6 engineering days
|
||||
|
||||
**Sequence:** PR 1 and PR 2 can be stacked (2 before 3). PR 3 depends on PR 1/2. PR 4 depends on PR 3.
|
||||
|
||||
---
|
||||
|
||||
## 10. Rollback Plan
|
||||
|
||||
1. **Database:** The migration is additive (new table + indexes). Rolling back requires a single Alembic downgrade that drops the notifications table. No existing tables are modified.
|
||||
2. **Frontend:** If the UI causes performance or layout issues, remove the NotificationCenter mount from app-shell.tsx. The rest of the codebase is unaffected.
|
||||
3. **Backend API:** If the router causes issues, unregister it in main.py. The underlying service and table can remain safely.
|
||||
4. **Event producers:** If notification creation causes errors, the explicit service call in lifecycle_hooks and health_monitor can be wrapped in a try/except log-and-continue block so that event publishing is never blocked.
|
||||
@@ -0,0 +1,416 @@
|
||||
# Notification Center Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Provide a persistent, per-user notification store with a REST API, a frontend notification center UI, and user-scoped preferences for category muting and toast suppression. Notifications are created by backend event producers (lifecycle hooks, health monitor) and surfaced to users through a bell icon dropdown, an unread count badge, and coordinated toast behavior.
|
||||
|
||||
> **Assumption:** This specification introduces the "Notification Center" as a new domain. No canonical spec exists for notifications; this is a full new domain spec.
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-1 | **Performance:** The `GET /notifications/unread` endpoint MUST respond in less than 10 milliseconds at p99 under normal load, backed by a partial index on `read_at IS NULL`. |
|
||||
| NFR-2 | **Security:** The API MUST enforce that every notification row is scoped to exactly one `user_id`; no endpoint MUST return or mutate a notification belonging to a different user. |
|
||||
| NFR-3 | **Scalability:** The `notifications` table MUST support high write volume from event producers without blocking reads; writes from `NotificationService.create_notification` MUST be independent of event producer transactions. |
|
||||
| NFR-4 | **Availability:** Notification creation failures in event producers MUST be caught, logged, and MUST NOT block the original event pipeline (lifecycle hooks, health monitor). |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: R1 — Notification data model
|
||||
|
||||
The system MUST provide a `Notification` SQLAlchemy model backed by a `notifications` table with the following columns:
|
||||
|
||||
- `id` — `UUID`, primary key, default `gen_random_uuid()`.
|
||||
- `user_id` — `UUID`, foreign key to `users.id`, `NOT NULL`, indexed.
|
||||
- `category` — `VARCHAR(32)`, `NOT NULL` (e.g., `instance`, `system`, `health`, `security`).
|
||||
- `severity` — `VARCHAR(16)`, `NOT NULL` (e.g., `info`, `warning`, `error`, `success`).
|
||||
- `title` — `VARCHAR(255)`, `NOT NULL`.
|
||||
- `message` — `TEXT`, nullable.
|
||||
- `source_type` — `VARCHAR(64)`, nullable (e.g., `tool_instances`).
|
||||
- `source_id` — `UUID`, nullable (e.g., the related tool instance UUID).
|
||||
- `metadata` — `JSONB`, `NOT NULL DEFAULT '{}'`, stores unstructured extra data.
|
||||
- `read_at` — `TIMESTAMPTZ`, nullable, indexed.
|
||||
- `dismissed_at` — `TIMESTAMPTZ`, nullable.
|
||||
- `created_at` — `TIMESTAMPTZ`, `NOT NULL DEFAULT now()`, indexed.
|
||||
|
||||
**Indexes:**
|
||||
- `idx_notifications_user_created_at` on `(user_id, created_at DESC)`.
|
||||
- `idx_notifications_user_unread` on `(user_id, read_at)` WHERE `read_at IS NULL` (partial index).
|
||||
|
||||
**Foreign key:** `user_id` references `users.id` with `ON DELETE CASCADE`.
|
||||
|
||||
**Migration:** `alembic/versions/YYYY_MM_DD_HHMMSS_add_notifications_table.py`.
|
||||
|
||||
#### Scenario: SC-DB-1 — Migration creates table and indexes
|
||||
|
||||
- GIVEN the Alembic migration runs successfully,
|
||||
- WHEN inspecting the database schema,
|
||||
- THEN the `notifications` table exists with all columns, the foreign key, and the two indexes including the partial index.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R2 — NotificationService
|
||||
|
||||
The system MUST provide a `NotificationService` class with the following methods:
|
||||
|
||||
- `create_notification(user_id, category, severity, title, message=None, source_type=None, source_id=None, metadata=None)` — inserts a row and returns the `Notification`.
|
||||
- `list_notifications(user_id, *, limit=20, offset=0, unread_only=False)` — returns notifications scoped to `user_id`, ordered by `created_at DESC`, excluding rows where `dismissed_at IS NOT NULL`.
|
||||
- `get_unread_count(user_id)` — returns the count of rows where `user_id` matches and `read_at IS NULL` and `dismissed_at IS NULL`.
|
||||
- `mark_read(notification_id, user_id)` — sets `read_at = now()` on the matching row; returns the updated `Notification`.
|
||||
- `mark_all_read(user_id)` — sets `read_at = now()` on all rows where `user_id` matches and `read_at IS NULL`; returns the number of rows updated.
|
||||
- `dismiss(notification_id, user_id)` — sets `dismissed_at = now()` on the matching row.
|
||||
|
||||
All methods MUST filter by `user_id` so that no user can access another user's notifications.
|
||||
|
||||
#### Scenario: SC-SVC-1 — Create notification
|
||||
|
||||
- GIVEN a valid `user_id` and notification payload,
|
||||
- WHEN `create_notification` is called,
|
||||
- THEN a row is inserted with all provided fields, `read_at` is `NULL`, `dismissed_at` is `NULL`, and the row is returned.
|
||||
|
||||
#### Scenario: SC-SVC-2 — List excludes dismissed
|
||||
|
||||
- GIVEN two notifications for the same user, one dismissed and one not,
|
||||
- WHEN `list_notifications` is called,
|
||||
- THEN only the non-dismissed notification is returned.
|
||||
|
||||
#### Scenario: SC-SVC-3 — Unread count query uses partial index
|
||||
|
||||
- GIVEN 100 notifications for a user, 30 unread,
|
||||
- WHEN `get_unread_count` is executed,
|
||||
- THEN the query plan MUST use the partial index `idx_notifications_user_unread`.
|
||||
|
||||
#### Scenario: SC-SVC-4 — Cross-user isolation
|
||||
|
||||
- GIVEN a notification owned by user A,
|
||||
- WHEN user B calls `mark_read`, `dismiss`, or `list_notifications`,
|
||||
- THEN user B MUST NOT see or affect user A's notification.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R3 — REST API endpoints
|
||||
|
||||
The system MUST expose a FastAPI router mounted at `/notifications` with the following endpoints. All endpoints require authentication and derive `current_user.id` from the auth dependency.
|
||||
|
||||
#### GET /notifications
|
||||
|
||||
Query parameters:
|
||||
- `limit` — integer, optional, default `20`, maximum `100`.
|
||||
- `offset` — integer, optional, default `0`.
|
||||
- `unread_only` — boolean, optional, default `false`.
|
||||
|
||||
Response `200 OK`:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"category": "string",
|
||||
"severity": "string",
|
||||
"title": "string",
|
||||
"message": "string | null",
|
||||
"source_type": "string | null",
|
||||
"source_id": "uuid | null",
|
||||
"metadata": {},
|
||||
"read_at": "iso-datetime | null",
|
||||
"dismissed_at": "iso-datetime | null",
|
||||
"created_at": "iso-datetime"
|
||||
}
|
||||
],
|
||||
"total": 0,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /notifications/unread
|
||||
|
||||
Response `200 OK`:
|
||||
```json
|
||||
{
|
||||
"count": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### PATCH /notifications/{id}/read
|
||||
|
||||
Path parameter: `id` — UUID.
|
||||
|
||||
Response `200 OK` — returns the updated notification object (same schema as list item).
|
||||
|
||||
#### POST /notifications/mark-all-read
|
||||
|
||||
Response `200 OK`:
|
||||
```json
|
||||
{
|
||||
"marked_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
#### DELETE /notifications/{id}
|
||||
|
||||
Path parameter: `id` — UUID.
|
||||
|
||||
Performs a soft delete by setting `dismissed_at`.
|
||||
|
||||
Response `204 No Content`.
|
||||
|
||||
#### Scenario: SC-API-1 — List with pagination and unread_only filter
|
||||
|
||||
- GIVEN 5 notifications, 2 unread, for the authenticated user,
|
||||
- WHEN `GET /notifications?unread_only=true&limit=2` is called,
|
||||
- THEN the response contains exactly the 2 unread notifications, ordered by `created_at DESC`.
|
||||
|
||||
#### Scenario: SC-API-2 — Mark single read updates read_at
|
||||
|
||||
- GIVEN an unread notification owned by the caller,
|
||||
- WHEN `PATCH /notifications/{id}/read` is called,
|
||||
- THEN the response has `read_at` set to a non-null ISO datetime.
|
||||
|
||||
#### Scenario: SC-API-3 — Mark all read affects only caller
|
||||
|
||||
- GIVEN user A has 3 unread notifications and user B has 2 unread notifications,
|
||||
- WHEN user A calls `POST /notifications/mark-all-read`,
|
||||
- THEN the response `marked_count` is `3`, and user B's notifications remain unread.
|
||||
|
||||
#### Scenario: SC-API-4 — Dismiss removes from list
|
||||
|
||||
- GIVEN an unread notification owned by the caller,
|
||||
- WHEN `DELETE /notifications/{id}` is called,
|
||||
- THEN the endpoint returns `204`, and a subsequent `GET /notifications` no longer includes the dismissed row.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R4 — Event producers create notifications
|
||||
|
||||
The system MUST ensure that `lifecycle_hooks.py` and `health_monitor.py` call `NotificationService.create_notification` after publishing the raw event, using `ToolInstance.owner_id` as the `user_id`.
|
||||
|
||||
The notification MUST be created regardless of frontend preferences; filtering happens at read time and in the toast bridge.
|
||||
|
||||
#### Scenario: SC-PROD-1 — Container error creates notification
|
||||
|
||||
- GIVEN a running container owned by user U,
|
||||
- WHEN the health monitor detects a crash and publishes `instance.error`,
|
||||
- THEN a notification row is created for user U with `category="instance"`, `severity="error"`, and `source_type="tool_instances"`.
|
||||
|
||||
#### Scenario: SC-PROD-2 — Lifecycle event creates notification
|
||||
|
||||
- GIVEN a tool instance owned by user U,
|
||||
- WHEN a lifecycle hook publishes `instance.started`,
|
||||
- THEN a notification row is created for user U with `category="instance"` and `severity="info"`.
|
||||
|
||||
#### Scenario: SC-PROD-3 — Notification failure does not block event pipeline
|
||||
|
||||
- GIVEN `NotificationService.create_notification` raises an exception,
|
||||
- WHEN a lifecycle hook or health monitor publishes an event,
|
||||
- THEN the exception is caught and logged, the original event is still published, and the health monitor poll loop continues.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R5 — Frontend notification center component
|
||||
|
||||
The system MUST provide a `<NotificationCenter />` component mounted inside the `AppShell` `header-actions` area on desktop (hidden when `isMobileTerminal` is true).
|
||||
|
||||
The component MUST:
|
||||
- Render a bell icon (Phosphor `Bell`).
|
||||
- Display an unread count badge when `unreadCount > 0`.
|
||||
- Open a dropdown panel on bell click.
|
||||
- Close the dropdown on outside click or `Escape` key press.
|
||||
- Render a scrollable list of recent notifications inside the panel.
|
||||
- Show an empty state when no notifications exist.
|
||||
- Provide a "Mark all as read" action in the panel footer.
|
||||
|
||||
Each notification row MUST display:
|
||||
- A severity icon mapped from `severity`.
|
||||
- The `title`.
|
||||
- A relative timestamp derived from `created_at`.
|
||||
- "Mark read" and "Dismiss" actions.
|
||||
|
||||
Unread rows MUST be visually distinct from read rows.
|
||||
|
||||
#### Scenario: SC-UI-1 — Bell renders with badge
|
||||
|
||||
- GIVEN the user has 3 unread notifications,
|
||||
- WHEN the AppShell header is rendered,
|
||||
- THEN the bell icon is visible and the badge displays `3`.
|
||||
|
||||
#### Scenario: SC-UI-2 — Dropdown opens and lists notifications
|
||||
|
||||
- GIVEN the user has notifications,
|
||||
- WHEN the user clicks the bell icon,
|
||||
- THEN the dropdown opens and lists up to the default limit of notifications with title, relative time, and severity icon.
|
||||
|
||||
#### Scenario: SC-UI-3 — Empty state
|
||||
|
||||
- GIVEN the user has zero notifications,
|
||||
- WHEN the dropdown opens,
|
||||
- THEN an empty state message is shown (e.g., "No notifications").
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R6 — Frontend polling
|
||||
|
||||
The system MUST poll the notification endpoints at the following intervals while the user is authenticated:
|
||||
- `GET /notifications/unread` every 15 seconds to update the badge count.
|
||||
- `GET /notifications` every 30 seconds to refresh the list.
|
||||
|
||||
When the dropdown is opened, the list MUST be refreshed immediately regardless of the polling timer.
|
||||
|
||||
#### Scenario: SC-POLL-1 — Badge updates on new notification
|
||||
|
||||
- GIVEN the badge shows `0`,
|
||||
- WHEN a new unread notification is created on the backend,
|
||||
- THEN the badge updates to `1` within 15 seconds (one polling interval).
|
||||
|
||||
#### Scenario: SC-POLL-2 — List refreshes on open
|
||||
|
||||
- GIVEN the dropdown is closed and a new notification arrives,
|
||||
- WHEN the user opens the dropdown,
|
||||
- THEN the list is fetched immediately and includes the new notification.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R7 — Toast coordination respecting user preferences
|
||||
|
||||
The system MUST update `EventToastBridge` to check user notification preferences before showing a toast for an SSE event.
|
||||
|
||||
The bridge MUST:
|
||||
- Skip the toast entirely if `notification_toast_level` is `"none"`.
|
||||
- Skip the toast if the event's mapped `severity` is below the threshold:
|
||||
- `"errors"` level: only show toasts for `severity="error"`.
|
||||
- Skip the toast if the event's `category` is present in `notification_mute_categories`.
|
||||
|
||||
The notification row on the backend is still created; the bridge only controls toast surfacing.
|
||||
|
||||
#### Scenario: SC-TOAST-1 — Toast level "none" suppresses all toasts
|
||||
|
||||
- GIVEN `notification_toast_level` is `"none"`,
|
||||
- WHEN an `instance.error` event arrives via SSE,
|
||||
- THEN no toast is shown.
|
||||
|
||||
#### Scenario: SC-TOAST-2 — Toast level "errors" suppresses info/warning
|
||||
|
||||
- GIVEN `notification_toast_level` is `"errors"`,
|
||||
- WHEN an `instance.started` event (severity `info`) arrives via SSE,
|
||||
- THEN no toast is shown; an `instance.error` event still produces a toast.
|
||||
|
||||
#### Scenario: SC-TOAST-3 — Muted category suppresses toast
|
||||
|
||||
- GIVEN `notification_mute_categories` contains `["instance"]` and `notification_toast_level` is `"all"`,
|
||||
- WHEN an `instance.error` event arrives via SSE,
|
||||
- THEN no toast is shown for that event.
|
||||
|
||||
---
|
||||
|
||||
### Requirement: R8 — User preferences in UserConfig
|
||||
|
||||
The system MUST extend the `UserConfig` JSON `config` blob with two new keys:
|
||||
|
||||
- `notification_mute_categories` — `string[]`, default `[]`. Categories listed here are excluded from `list_notifications` results and suppress toasts for matching events.
|
||||
- `notification_toast_level` — `"all" | "errors" | "none"`, default `"all"`.
|
||||
|
||||
The `list_notifications` service method MUST filter out rows whose `category` is in the caller's `notification_mute_categories`.
|
||||
|
||||
Preference changes MUST take effect immediately without a server restart.
|
||||
|
||||
#### Scenario: SC-PREF-1 — Muted category excluded from list
|
||||
|
||||
- GIVEN `notification_mute_categories` contains `["instance"]` and the user has instance and system notifications,
|
||||
- WHEN `GET /notifications` is called,
|
||||
- THEN the response contains only system notifications; instance notifications are omitted.
|
||||
|
||||
#### Scenario: SC-PREF-2 — Preference change is immediate
|
||||
|
||||
- GIVEN `notification_toast_level` is `"all"`,
|
||||
- WHEN the user changes it to `"none"` and saves the preference,
|
||||
- THEN the next SSE event does not produce a toast.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### EH-1: Notification not found or not owned
|
||||
|
||||
If a `PATCH /notifications/{id}/read` or `DELETE /notifications/{id}` request targets a notification that does not exist or is owned by a different user, the endpoint MUST return `404 Not Found`. The response body SHOULD include a detail message: `"Notification not found"`.
|
||||
|
||||
### EH-2: Invalid category or severity
|
||||
|
||||
If `NotificationService.create_notification` is called with a `category` or `severity` value that does not conform to the project's allowed set, the service SHOULD raise a validation error (e.g., `ValueError`), and the caller SHOULD log it without blocking the event pipeline.
|
||||
|
||||
### EH-3: Service exceptions in event producers
|
||||
|
||||
`lifecycle_hooks.py` and `health_monitor.py` MUST wrap `NotificationService.create_notification` calls in a `try/except` block. On exception, the error MUST be logged with `correlation_id`, and the original event publishing MUST continue.
|
||||
|
||||
### EH-4: Polling failure
|
||||
|
||||
If a polling request (`GET /notifications` or `GET /notifications/unread`) fails on the frontend, the error MUST be silently logged (not thrown as an unhandled exception), and the next polling cycle MUST proceed on schedule.
|
||||
|
||||
---
|
||||
|
||||
## Scenarios (Acceptance Criteria Summary)
|
||||
|
||||
| ID | Scenario |
|
||||
|----|----------|
|
||||
| SC-1 | **Container error → notification created for owner → badge increments.** A health monitor crash detection creates a notification for the instance owner; within one 15-second poll cycle, the frontend badge increments. |
|
||||
| SC-2 | **User clicks bell → dropdown opens → shows unread notifications.** Clicking the bell renders the dropdown panel with unread rows visually distinct. |
|
||||
| SC-3 | **User marks notification read → badge decrements → row styling changes.** Clicking "Mark read" or the row triggers `PATCH /notifications/{id}/read`; the badge count decreases by one; the row styling updates to the read state. |
|
||||
| SC-4 | **User dismisses notification → row removed → persists on refresh.** Clicking "Dismiss" triggers `DELETE /notifications/{id}`; the row is removed from the list; on page reload the row remains absent. |
|
||||
| SC-5 | **User clicks "mark all read" → badge resets to 0.** Clicking "Mark all as read" triggers `POST /notifications/mark-all-read`; the badge shows `0`; all visible rows transition to the read state. |
|
||||
| SC-6 | **User sets toast level to "none" → no toast shown for new events.** Changing `notification_toast_level` to `"none"` prevents the `EventToastBridge` from showing any toast for incoming SSE events. |
|
||||
| SC-7 | **User mutes "instance" category → no instance notifications in list.** Adding `"instance"` to `notification_mute_categories` removes instance notifications from `GET /notifications` and suppresses instance toasts. |
|
||||
|
||||
---
|
||||
|
||||
## API Contract Reference
|
||||
|
||||
### Request / Response Schemas
|
||||
|
||||
**NotificationItem:**
|
||||
| Field | Type | Nullable |
|
||||
|-------|------|----------|
|
||||
| id | UUID string | no |
|
||||
| user_id | UUID string | no |
|
||||
| category | string (max 32) | no |
|
||||
| severity | string (max 16) | no |
|
||||
| title | string (max 255) | no |
|
||||
| message | string | yes |
|
||||
| source_type | string (max 64) | yes |
|
||||
| source_id | UUID string | yes |
|
||||
| metadata | object | no (default `{}`) |
|
||||
| read_at | ISO 8601 datetime | yes |
|
||||
| dismissed_at | ISO 8601 datetime | yes |
|
||||
| created_at | ISO 8601 datetime | no |
|
||||
|
||||
**NotificationListResponse:**
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| items | NotificationItem[] |
|
||||
| total | integer |
|
||||
| limit | integer |
|
||||
| offset | integer |
|
||||
|
||||
**UnreadCountResponse:**
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| count | integer |
|
||||
|
||||
**MarkAllReadResponse:**
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| marked_count | integer |
|
||||
|
||||
### Endpoints Summary
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/notifications` | Required | List notifications with pagination and `unread_only` filter. |
|
||||
| GET | `/notifications/unread` | Required | Returns `{ count: int }` for the authenticated user. |
|
||||
| PATCH | `/notifications/{id}/read` | Required | Marks a single notification read. |
|
||||
| POST | `/notifications/mark-all-read` | Required | Marks all unread notifications read for the caller. |
|
||||
| DELETE | `/notifications/{id}` | Required | Soft-deletes (dismisses) a single notification. |
|
||||
@@ -0,0 +1,868 @@
|
||||
# SDD Tasks: Notification Center
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | ~1,800 total (PR-1 ~600; PR-2 ~250; PR-3 ~700; PR-4 ~250) |
|
||||
| 400-line budget risk | High |
|
||||
| Chained PRs recommended | Yes |
|
||||
| Suggested split | PR 1 (Backend Core) → PR 2 (Backend Integration) → PR 3 (Frontend Core) → PR 4 (Toast Coordination) |
|
||||
| Delivery strategy | auto-chain |
|
||||
| Chain strategy | stacked-to-main |
|
||||
|
||||
```
|
||||
Decision needed before apply: No
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: stacked-to-main
|
||||
400-line budget risk: High
|
||||
```
|
||||
|
||||
> **Note:** PR-1 (~600 lines) and PR-3 (~700 lines) exceed the 400-line review budget. PR-3 in particular carries High risk. Tasks within each PR are grouped into autonomous work units. If review fanout is available, PR-3 can be split into (a) Provider + Hook + Styles and (b) NotificationCenter + NotificationItem + AppShell integration. PR-1 can be split into (a) Migration + Model + Service and (b) Router + Registration + Tests.
|
||||
|
||||
---
|
||||
|
||||
## PR-1: Backend Core
|
||||
|
||||
**Goal:** Establish the persistent notification backend: database schema, SQLAlchemy model, NotificationService singleton, FastAPI router with Pydantic schemas, and comprehensive unit + integration tests.
|
||||
|
||||
**Estimated Lines:** ~600
|
||||
**Review Risk:** Medium
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-001: Create Alembic migration for notifications table
|
||||
|
||||
**Description:**
|
||||
Write an Alembic revision that creates the `notifications` table with all columns, constraints, indexes, and the foreign key to `users.id` as specified in the design.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Migration creates `notifications` table with columns: `id`, `user_id`, `category`, `severity`, `title`, `message`, `source_type`, `source_id`, `metadata`, `read_at`, `dismissed_at`, `created_at`.
|
||||
- [ ] Foreign key `user_id` references `users.id` with `ON DELETE CASCADE`.
|
||||
- [ ] Index `idx_notifications_user_created_at` on `(user_id, created_at DESC)`.
|
||||
- [ ] Partial index `idx_notifications_user_unread` on `(user_id, read_at)` where `read_at IS NULL`.
|
||||
- [ ] `upgrade()` and `downgrade()` are both implemented and pass `alembic upgrade head` / `alembic downgrade -1`.
|
||||
- [ ] Migration depends on current `head` revision.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-002: Create SQLAlchemy Notification model and export
|
||||
|
||||
**Description:**
|
||||
Add the `Notification` SQLAlchemy model following the existing `UUIDPrimaryKeyMixin` + `Base` pattern. Export it from `models/__init__.py` for Alembic autogenerate discovery.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/models/notification.py` *(new)*
|
||||
- `apps/api/src/models/__init__.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `Notification` model matches the design schema exactly with correct types (`UUID`, `String(32)`, `String(16)`, `String(255)`, `Text`, `JSONB`, `DateTime(timezone=True)`).
|
||||
- [ ] `user_id` has `ForeignKey("users.id", ondelete="CASCADE")`, `nullable=False`, `index=True`.
|
||||
- [ ] `read_at` and `created_at` are indexed.
|
||||
- [ ] `metadata` column defaults to `{}`.
|
||||
- [ ] Model is exported in `models/__init__.py`.
|
||||
- [ ] `alembic revision --autogenerate` produces no drift against the hand-written migration.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-001
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-003: [RED] Write NotificationService unit tests — basic CRUD
|
||||
|
||||
**Description:**
|
||||
Write failing pytest unit tests for `NotificationService` covering create, list, count, mark_read, mark_all_read, and dismiss happy paths.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/tests/unit/test_notification_service.py` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_create_notification`: assert row inserted with correct values, `read_at` NULL, `dismissed_at` NULL.
|
||||
- [ ] `test_list_notifications_orders_by_created_at_desc`: 3 rows inserted, newest first.
|
||||
- [ ] `test_list_notifications_excludes_dismissed`: dismissed row not returned.
|
||||
- [ ] `test_list_notifications_unread_only`: `unread_only=True` returns only unread.
|
||||
- [ ] `test_get_unread_count`: 5 rows, 2 unread → count is 2.
|
||||
- [ ] `test_mark_read_sets_read_at`: `read_at` is not NULL after call.
|
||||
- [ ] `test_mark_all_read_affects_all_unread`: all unread rows updated.
|
||||
- [ ] `test_dismiss_sets_dismissed_at`: `dismissed_at` is not NULL after call.
|
||||
- [ ] Tests use `db_session` fixture and create test `User` rows in session.
|
||||
|
||||
**Estimated effort:** Small (3–4 hours)
|
||||
**Dependencies:** NC-PR1-002
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-004: [GREEN] Implement NotificationService
|
||||
|
||||
**Description:**
|
||||
Implement the `NotificationService` singleton with all methods. The service accepts `AsyncSession` explicitly and filters all queries by `user_id`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/services/notification_service.py` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `create_notification(session, user_id, *, category, severity, title, ...)` inserts row and returns `Notification`.
|
||||
- [ ] `list_notifications(session, user_id, *, limit=20, offset=0, unread_only=False, mute_categories=None)` returns `(items, total)` tuple, excludes `dismissed_at IS NOT NULL`, orders by `created_at DESC`.
|
||||
- [ ] `get_unread_count(session, user_id)` counts rows where `read_at IS NULL` and `dismissed_at IS NULL`.
|
||||
- [ ] `mark_read(session, notification_id, user_id)` sets `read_at = now()`, returns updated row; raises 404-equivalent if not found or not owned.
|
||||
- [ ] `mark_all_read(session, user_id)` sets `read_at = now()` on all unread rows for user; returns count updated.
|
||||
- [ ] `dismiss(session, notification_id, user_id)` sets `dismissed_at = now()`; raises 404-equivalent if not found or not owned.
|
||||
- [ ] All methods filter by `user_id`.
|
||||
- [ ] `NC-PR1-003` tests pass.
|
||||
|
||||
**Estimated effort:** Medium (4–5 hours)
|
||||
**Dependencies:** NC-PR1-003
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-005: [TRIANGULATE] NotificationService edge-case and isolation tests
|
||||
|
||||
**Description:**
|
||||
Add unit tests for cross-user isolation, wrong-owner failures, mute category filtering, and partial index usage.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/tests/unit/test_notification_service.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_mark_read_wrong_owner_raises`: User A creates notification; User B calls `mark_read` → exception raised.
|
||||
- [ ] `test_dismiss_wrong_owner_raises`: User A creates notification; User B calls `dismiss` → exception raised.
|
||||
- [ ] `test_list_notifications_mute_categories`: pass `mute_categories=["instance"]`; instance rows excluded, system rows returned.
|
||||
- [ ] `test_get_unread_count_excludes_dismissed`: unread but dismissed row → count is 0.
|
||||
- [ ] `test_get_unread_count_query_uses_partial_index`: query plan uses `idx_notifications_user_unread` (verified via `EXPLAIN` or SQLite equivalent).
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-004
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-006: [RED] Write API integration tests — basic endpoints
|
||||
|
||||
**Description:**
|
||||
Write failing integration tests for the notifications API router covering list, unread count, mark read, mark all read, and dismiss.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/tests/integration/test_notifications_api.py` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_list_requires_auth`: `GET /notifications` without auth → `401`.
|
||||
- [ ] `test_list_returns_only_own_notifications`: create for user A; user B lists → not in response.
|
||||
- [ ] `test_unread_count_endpoint`: create 3 unread; `GET /notifications/unread` → `{count: 3}`.
|
||||
- [ ] `test_mark_read_endpoint`: create unread; `PATCH /notifications/{id}/read` → `200`, `read_at` set.
|
||||
- [ ] `test_mark_all_read_endpoint`: create 4 unread; `POST /notifications/mark-all-read` → `{marked_count: 4}`.
|
||||
- [ ] `test_dismiss_endpoint`: create notification; `DELETE /notifications/{id}` → `204`; subsequent list excludes it.
|
||||
- [ ] Uses `authenticated_client` and `db_session` fixtures.
|
||||
|
||||
**Estimated effort:** Small (3–4 hours)
|
||||
**Dependencies:** NC-PR1-004
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-007: [GREEN] Implement FastAPI notifications router and Pydantic schemas
|
||||
|
||||
**Description:**
|
||||
Create the FastAPI `APIRouter` for `/notifications` with all endpoints and Pydantic response models. Read `mute_categories` from `UserConfig` and pass to `list_notifications`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/api/notifications.py` *(new)*
|
||||
- `apps/api/src/api/__init__.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `GET /notifications` with `limit`, `offset`, `unread_only` query params; returns `NotificationListResponse`.
|
||||
- [ ] `GET /notifications/unread` returns `UnreadCountResponse`.
|
||||
- [ ] `PATCH /notifications/{id}/read` returns `NotificationItem`; `404` if not owned.
|
||||
- [ ] `POST /notifications/mark-all-read` returns `MarkAllReadResponse`.
|
||||
- [ ] `DELETE /notifications/{id}` returns `204 No Content`; `404` if not owned.
|
||||
- [ ] Router reads `notification_mute_categories` from user's `UserConfig.config` and passes to `list_notifications`.
|
||||
- [ ] `limit` capped at 100.
|
||||
- [ ] All endpoints use `get_current_user_id` / `get_db_session` dependencies.
|
||||
- [ ] Router exported from `api/__init__.py`.
|
||||
- [ ] `NC-PR1-006` tests pass.
|
||||
|
||||
**Estimated effort:** Medium (4–5 hours)
|
||||
**Dependencies:** NC-PR1-006
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-008: [TRIANGULATE] API edge-case and ownership tests
|
||||
|
||||
**Description:**
|
||||
Add integration tests for pagination, ownership enforcement, and mute categories filtering at the API layer.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/tests/integration/test_notifications_api.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_list_pagination`: create 25 notifications; `limit=10&offset=10` → items length 10, total 25.
|
||||
- [ ] `test_mark_read_404_for_other_user`: create for user A; user B PATCH → `404`.
|
||||
- [ ] `test_dismiss_404_for_other_user`: user B DELETE user A's notification → `404`.
|
||||
- [ ] `test_mute_categories_filter_in_list`: set user config `mute_categories=["instance"]`, create instance + system notifications; `GET /notifications` returns only system.
|
||||
- [ ] `test_mark_all_read_affects_only_caller`: user A has 3 unread, user B has 2; A calls mark-all-read → A=0, B=2.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-007
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-009: Register router in main.py and import model for Alembic
|
||||
|
||||
**Description:**
|
||||
Import and include the notifications router in the FastAPI app. Import the `Notification` model in `main.py` for Alembic autogenerate discovery.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/main.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `notifications_router` imported and included with `app.include_router(...)`.
|
||||
- [ ] `Notification` model imported in `main.py` (F401 noqa comment if unused).
|
||||
- [ ] App boots without import cycles.
|
||||
- [ ] `GET /health` still returns `200`.
|
||||
- [ ] `GET /notifications` returns `401` when unauthenticated (smoke test).
|
||||
|
||||
**Estimated effort:** Small (1 hour)
|
||||
**Dependencies:** NC-PR1-007
|
||||
|
||||
---
|
||||
|
||||
### NC-PR1-010: [REFACTOR] Backend code quality and type safety pass
|
||||
|
||||
**Description:**
|
||||
Run `ruff check .`, `mypy .`, and `pytest` on the new code. Fix any lint errors, type annotations, or docstring gaps. Ensure no `print` statements or debug logs remain.
|
||||
|
||||
**Files to modify:**
|
||||
- Any of the above files with lint/type issues.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `ruff check .` passes with zero errors on new files.
|
||||
- [ ] `mypy .` passes with zero type errors on new files.
|
||||
- [ ] `pytest tests/unit/test_notification_service.py tests/integration/test_notifications_api.py` passes.
|
||||
- [ ] All public methods have docstrings.
|
||||
- [ ] No `print()` or leftover `logger.debug` from development.
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR1-008, NC-PR1-009
|
||||
|
||||
---
|
||||
|
||||
## PR-2: Backend Integration
|
||||
|
||||
**Goal:** Wire lifecycle hooks and health monitor to create notifications, extend UserConfig for preferences, and validate the end-to-end event producer flow.
|
||||
|
||||
**Estimated Lines:** ~250
|
||||
**Review Risk:** Low
|
||||
|
||||
---
|
||||
|
||||
### NC-PR2-001: Wire lifecycle_hooks.py to call NotificationService
|
||||
|
||||
**Description:**
|
||||
After `publish_lifecycle_event()` publishes the raw event to `InstanceEventBus`, call `NotificationService.create_notification()` with `user_id=tool_instance.owner_id`. Wrap in `try/except` so event pipeline is never blocked.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/services/lifecycle_hooks.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `publish_lifecycle_event` calls `notification_service.create_notification(...)` after bus publish.
|
||||
- [ ] `user_id` is set to `instance.owner_id`.
|
||||
- [ ] `category="instance"`.
|
||||
- [ ] `severity` mapped: `info` for created/started/stopped/restarted/deleted; `error` for error.
|
||||
- [ ] `title` derived from event type (e.g., "Container started").
|
||||
- [ ] `source_type="tool_instances"`, `source_id=instance.id`.
|
||||
- [ ] Service call wrapped in `try/except`; on failure, error is logged with `correlation_id` and execution continues.
|
||||
- [ ] Original event bus publish and audit row insert are unaffected by notification failure.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-010
|
||||
|
||||
---
|
||||
|
||||
### NC-PR2-002: Wire health_monitor.py to call NotificationService
|
||||
|
||||
**Description:**
|
||||
After `HealthMonitor` detects a state change and publishes the event, call `NotificationService.create_notification()` with `user_id=instance.owner_id`. Wrap in `try/except`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/services/health_monitor.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `_handle_state_change` calls `notification_service.create_notification(...)` after bus publish.
|
||||
- [ ] `user_id` is set to `instance.owner_id`.
|
||||
- [ ] `category="health"` for health changes; `"instance"` for errors.
|
||||
- [ ] `severity` mapped: `error` for crash, `warning` for unhealthy, `info` for recovery.
|
||||
- [ ] `source_type="tool_instances"`, `source_id=instance.id`.
|
||||
- [ ] Service call wrapped in `try/except`; on failure, error is logged with `correlation_id` and loop continues.
|
||||
- [ ] Original event bus publish and health check insert are unaffected.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-010
|
||||
|
||||
---
|
||||
|
||||
### NC-PR2-003: Extend UserConfig schema for notification preferences
|
||||
|
||||
**Description:**
|
||||
Add `notification_mute_categories` and `notification_toast_level` to the `UserConfigResponse` and `UserConfigUpdate` Pydantic models. Apply `mute_categories` filtering in `list_notifications`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/src/api/user_config.py`
|
||||
- `apps/api/src/services/notification_service.py`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `UserConfigResponse` includes `notification_mute_categories: list[str] | None = None` and `notification_toast_level: str | None = None`.
|
||||
- [ ] `UserConfigUpdate` includes the same optional fields.
|
||||
- [ ] `list_notifications` in `NotificationService` accepts `mute_categories` and filters with `Notification.category.not_in(mute_categories)`.
|
||||
- [ ] `GET /users/me/config` returns new keys when present in JSON blob.
|
||||
- [ ] `PATCH /users/me/config` persists new keys into the JSON blob.
|
||||
- [ ] Existing config keys are unaffected.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR1-010
|
||||
|
||||
---
|
||||
|
||||
### NC-PR2-004: [RED] Write event producer integration tests
|
||||
|
||||
**Description:**
|
||||
Write integration tests that exercise real lifecycle and health monitor endpoints and assert notification rows are created for the instance owner.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/api/tests/integration/test_notification_producers.py` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_lifecycle_event_creates_notification`: trigger `instance.started` via lifecycle hook; assert notification row exists with `category="instance"`, `severity="info"`, `user_id=owner_id`.
|
||||
- [ ] `test_health_monitor_error_creates_notification`: simulate health monitor detecting crash; assert notification row with `severity="error"`.
|
||||
- [ ] `test_notification_failure_does_not_block_event_pipeline`: mock `create_notification` to raise; assert event is still published and no exception escapes.
|
||||
- [ ] `test_notification_ownership_matches_instance_owner`: create instance for user A; trigger event; assert notification `user_id` is A's ID, not the calling user's.
|
||||
- [ ] Uses `authenticated_client`, `db_session`, and `test_project_and_repo` fixtures.
|
||||
|
||||
**Estimated effort:** Medium (3–4 hours)
|
||||
**Dependencies:** NC-PR2-001, NC-PR2-002, NC-PR2-003
|
||||
|
||||
---
|
||||
|
||||
### NC-PR2-005: [GREEN / REFACTOR] Verify producer tests pass and clean up
|
||||
|
||||
**Description:**
|
||||
Run the producer integration tests, fix any failures, and do a final lint/type check on all modified files.
|
||||
|
||||
**Files to modify:**
|
||||
- Any files with issues found during test runs.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `pytest tests/integration/test_notification_producers.py` passes.
|
||||
- [ ] `ruff check .` passes on modified files.
|
||||
- [ ] `mypy .` passes on modified files.
|
||||
- [ ] No regressions in existing `pytest` suite.
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR2-004
|
||||
|
||||
---
|
||||
|
||||
## PR-3: Frontend Core
|
||||
|
||||
**Goal:** Build the frontend notification surface: icon registry, React context with polling, hook, notification list components, styles, and AppShell integration.
|
||||
|
||||
**Estimated Lines:** ~700
|
||||
**Review Risk:** High
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-001: Add bell icon to icon registry
|
||||
|
||||
**Description:**
|
||||
Register the Phosphor `Bell` icon in the frontend icon registry under the name `"bell"`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/utils/icons.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `"bell"` added to `IconName` union type.
|
||||
- [ ] `bell: Bell` added to `iconRegistry` map.
|
||||
- [ ] `Bell` imported from `@phosphor-icons/react`.
|
||||
- [ ] `<Icon name="bell" />` renders without error in a quick manual check.
|
||||
|
||||
**Estimated effort:** Small (30 minutes)
|
||||
**Dependencies:** None (can be prepared before PR-2 merges)
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-002: [RED] Write useNotifications hook tests
|
||||
|
||||
**Description:**
|
||||
Write failing tests for the `useNotifications` hook covering state exposure, optimistic updates, and revert behavior.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/hooks/use-notifications.test.ts` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_returns_notifications_and_unreadCount_from_context`: mock provider value; assert hook returns same array and count.
|
||||
- [ ] `test_optimistically_updates_on_markRead`: call `markRead`; assert local `read_at` set and `unreadCount` decremented before API resolves.
|
||||
- [ ] `test_reverts_optimistic_update_on_markRead_failure`: mock API rejection; assert state reverted.
|
||||
- [ ] `test_optimistically_updates_on_dismiss`: call `dismiss`; assert item removed and count decremented.
|
||||
- [ ] `test_reverts_optimistic_update_on_dismiss_failure`: mock API rejection; assert item restored.
|
||||
- [ ] `test_calls_refreshList_when_invoked`: assert `GET /notifications` called.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR3-001
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-003: [GREEN] Implement NotificationProvider context with polling
|
||||
|
||||
**Description:**
|
||||
Create the `NotificationProvider` React context that polls the backend endpoints, manages notification list and unread count, and handles tab visibility pause/resume.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/state/notifications.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Context maintains `notifications: NotificationItem[]` and `unreadCount: number`.
|
||||
- [ ] Polls `GET /notifications/unread` every 15 seconds.
|
||||
- [ ] Polls `GET /notifications` every 30 seconds when dropdown is closed.
|
||||
- [ ] Pauses all polling when `document.hidden` is true; resumes on visible.
|
||||
- [ ] On dropdown open: immediately fetches list, pauses 30s list poll.
|
||||
- [ ] On dropdown close: restarts 30s list poll.
|
||||
- [ ] On logout: stops polling and clears state.
|
||||
- [ ] Polling errors are silently logged; next cycle proceeds.
|
||||
- [ ] On `401` response: stops all polling.
|
||||
|
||||
**Estimated effort:** Medium (4–5 hours)
|
||||
**Dependencies:** NC-PR3-002
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-004: [GREEN] Implement useNotifications hook
|
||||
|
||||
**Description:**
|
||||
Create the `useNotifications()` consumer hook that exposes state and mutation callbacks with optimistic updates.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/hooks/use-notifications.ts` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook returns `notifications`, `unreadCount`, `isLoading`, `error`, `markRead`, `markAllRead`, `dismiss`, `refreshList`.
|
||||
- [ ] `markRead(id)`: optimistically sets `read_at` and decrements `unreadCount`; calls `PATCH /notifications/{id}/read`; reverts on failure.
|
||||
- [ ] `markAllRead()`: optimistically sets `read_at` on all items and `unreadCount=0`; calls `POST /notifications/mark-all-read`; reverts on failure.
|
||||
- [ ] `dismiss(id)`: optimistically removes item and decrements `unreadCount` if unread; calls `DELETE /notifications/{id}`; reverts on failure.
|
||||
- [ ] `refreshList()`: calls `GET /notifications` and updates state.
|
||||
- [ ] Errors are surfaced as `error` state but not thrown.
|
||||
- [ ] `NC-PR3-002` tests pass.
|
||||
|
||||
**Estimated effort:** Medium (3–4 hours)
|
||||
**Dependencies:** NC-PR3-003
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-005: [TRIANGULATE] Hook edge-case and error handling tests
|
||||
|
||||
**Description:**
|
||||
Add tests for 401 handling, polling pause, and multiple rapid mutations.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/hooks/use-notifications.test.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_stops_polling_on_401`: simulate 401; assert polling intervals cleared.
|
||||
- [ ] `test_pauses_polling_when_document_hidden`: simulate `visibilitychange` to hidden; assert `clearInterval` called.
|
||||
- [ ] `test_resumes_polling_when_document_visible`: simulate hidden then visible; assert intervals restarted and immediate fetches fired.
|
||||
- [ ] `test_multiple_markRead_calls_decrement_correctly`: mark 3 items read rapidly; assert `unreadCount` decrements by 3.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR3-004
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-006: [RED] Write NotificationItem component tests
|
||||
|
||||
**Description:**
|
||||
Write failing render tests for the `NotificationItem` presentational component.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/notification-item.test.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_displays_title_and_relative_time`: render with sample data; assert title and relative time visible.
|
||||
- [ ] `test_applies_unread_styling_when_read_at_is_null`: assert unread CSS class present.
|
||||
- [ ] `test_applies_read_styling_when_read_at_is_set`: assert read CSS class present.
|
||||
- [ ] `test_calls_onMarkRead_when_mark_read_clicked`: simulate click; assert callback with correct id.
|
||||
- [ ] `test_calls_onDismiss_when_dismiss_clicked`: simulate click; assert callback with correct id.
|
||||
- [ ] `test_displays_severity_icon`: assert severity icon element present.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR3-001
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-007: [GREEN] Implement NotificationItem component
|
||||
|
||||
**Description:**
|
||||
Build the presentational row component for a single notification.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/notification-item.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Accepts `notification: NotificationItem`, `onMarkRead: (id: string) => void`, `onDismiss: (id: string) => void`.
|
||||
- [ ] Displays severity icon mapped from `severity` to Phosphor icon (`Info`, `Warning`, `XCircle`, `CheckCircle`).
|
||||
- [ ] Displays `title` and relative timestamp (e.g., "2m ago").
|
||||
- [ ] Unread rows have `.notification-item--unread` class (bolder text, accent border, background tint).
|
||||
- [ ] Read rows have `.notification-item--read` class (reduced opacity).
|
||||
- [ ] Renders "Mark read" and "Dismiss" action buttons.
|
||||
- [ ] `NC-PR3-006` tests pass.
|
||||
|
||||
**Estimated effort:** Small (3–4 hours)
|
||||
**Dependencies:** NC-PR3-006
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-008: [RED] Write NotificationCenter component tests
|
||||
|
||||
**Description:**
|
||||
Write failing render and interaction tests for the `NotificationCenter` component.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/notification-center.test.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_renders_bell_icon`: assert bell icon visible.
|
||||
- [ ] `test_shows_badge_when_unread_count_gt_0`: provider state `unreadCount=3`; assert badge text is "3".
|
||||
- [ ] `test_hides_badge_when_unread_count_is_0`: assert badge not in document.
|
||||
- [ ] `test_opens_dropdown_on_bell_click`: simulate click; assert dropdown panel visible.
|
||||
- [ ] `test_closes_dropdown_on_outside_click`: open dropdown; click outside; assert panel not visible.
|
||||
- [ ] `test_closes_dropdown_on_escape`: open dropdown; fire `Escape` key; assert panel not visible.
|
||||
- [ ] `test_renders_empty_state_when_no_notifications`: assert empty state text visible.
|
||||
- [ ] `test_renders_notification_items`: list has 2 items; assert 2 `NotificationItem` components rendered.
|
||||
- [ ] `test_calls_markAllRead_on_footer_button_click`: simulate click; assert mock called.
|
||||
- [ ] `test_refreshes_list_immediately_on_open`: open dropdown; assert `refreshList` mock called.
|
||||
|
||||
**Estimated effort:** Small (3–4 hours)
|
||||
**Dependencies:** NC-PR3-007
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-009: [GREEN] Implement NotificationCenter component
|
||||
|
||||
**Description:**
|
||||
Build the `NotificationCenter` component: bell icon with badge, dropdown panel with list, empty state, footer actions, outside-click/Escape close, and mobile terminal hiding.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/notification-center.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Renders bell icon (`<Icon name="bell" />`).
|
||||
- [ ] Shows unread count badge when `unreadCount > 0`; caps display at "99+".
|
||||
- [ ] Badge uses existing `nav-badge` CSS class.
|
||||
- [ ] Dropdown opens on bell click, closes on outside click or `Escape`.
|
||||
- [ ] Dropdown is a positioned panel below the bell, right-aligned.
|
||||
- [ ] Contains scrollable list of `NotificationItem` components.
|
||||
- [ ] Shows empty state message when list is empty (e.g., "No notifications").
|
||||
- [ ] Footer has "Mark all as read" button calling `markAllRead()`.
|
||||
- [ ] Calls `refreshList()` immediately when opening.
|
||||
- [ ] Hidden when `isMobileTerminal` is true.
|
||||
- [ ] Uses `useNotifications()` hook.
|
||||
- [ ] `NC-PR3-008` tests pass.
|
||||
|
||||
**Estimated effort:** Medium (4–5 hours)
|
||||
**Dependencies:** NC-PR3-008
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-010: Add notification CSS styles
|
||||
|
||||
**Description:**
|
||||
Add utility classes for the notification dropdown, items, badge, and empty state to `styles.css`.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/styles.css`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `.notification-dropdown` has absolute positioning, `z-index` above header, `max-height`, scroll, shadow, and matches light/dark theme variables.
|
||||
- [ ] `.notification-item` has padding, border-bottom, hover state.
|
||||
- [ ] `.notification-item--unread` has distinct styling (accent left border, slightly different background).
|
||||
- [ ] `.notification-item--read` has reduced opacity.
|
||||
- [ ] `.notification-badge` reuses or extends existing `nav-badge` styles.
|
||||
- [ ] `.notification-empty` has centered text and muted color.
|
||||
- [ ] Styles work in both light and dark themes.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR3-009
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-011: Integrate NotificationCenter into AppShell
|
||||
|
||||
**Description:**
|
||||
Mount `<NotificationCenter />` inside the `AppShell` `header-actions` area. Wrap with `NotificationProvider` at the appropriate level.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/app-shell.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `<NotificationProvider>` wraps the authenticated app layout (inside or alongside `EventProvider`).
|
||||
- [ ] `<NotificationCenter />` rendered inside `header-actions` div, before the user chip.
|
||||
- [ ] Component is hidden when `isMobileTerminal` is true.
|
||||
- [ ] No visual regressions in existing header layout.
|
||||
- [ ] Existing tests for `AppShell` still pass (or are updated if needed).
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR3-009, NC-PR3-010
|
||||
|
||||
---
|
||||
|
||||
### NC-PR3-012: [REFACTOR] Frontend code quality and type check pass
|
||||
|
||||
**Description:**
|
||||
Run `npm run typecheck`, `npm run lint`, and frontend tests. Fix any errors. Verify accessibility (keyboard navigation, ARIA labels).
|
||||
|
||||
**Files to modify:**
|
||||
- Any files with type/lint issues.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `npm run typecheck` passes with zero errors.
|
||||
- [ ] `npm run lint` passes with zero errors.
|
||||
- [ ] `npm test` (or `vitest run`) passes for all new test files.
|
||||
- [ ] Bell icon has `aria-label="Notifications"`.
|
||||
- [ ] Dropdown panel has `role="menu"` or `role="dialog"` and appropriate `aria-*` attributes.
|
||||
- [ ] Mark read / dismiss buttons have accessible labels.
|
||||
- [ ] No `console.log` left from development.
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR3-011
|
||||
|
||||
---
|
||||
|
||||
## PR-4: Toast Coordination
|
||||
|
||||
**Goal:** Update the toast bridge to respect notification preferences, extend settings UI for preference controls, and verify coordination end-to-end.
|
||||
|
||||
**Estimated Lines:** ~250
|
||||
**Review Risk:** Low
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-001: Extend toast-rules.ts with category/severity mapping
|
||||
|
||||
**Description:**
|
||||
Add `mapEventToCategory` and `mapEventToSeverity` functions to `toast-rules.ts` so the bridge can evaluate events against user preferences.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/toast-rules.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `mapEventToCategory(event)` returns `"instance"` for `instance.*` events, `"health"` for `health.*`, `"system"` otherwise.
|
||||
- [ ] `mapEventToSeverity(event)` returns `"error"` for `instance.error` / `health.error`; `"warning"` for unhealthy health changes; `"info"` for created/started/stopped/restarted/deleted; `"success"` for recovery to running.
|
||||
- [ ] Functions are pure and exported.
|
||||
- [ ] Existing toast mapping behavior is preserved (no regressions in current tests).
|
||||
- [ ] New functions have unit tests.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** PR-3 merged (frontend types available)
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-002: [RED] Write EventToastBridge preference check tests
|
||||
|
||||
**Description:**
|
||||
Write failing tests for the updated `EventToastBridge` that verify preference-based toast suppression.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/event-toast-bridge.test.tsx` *(new)*
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_shows_toast_when_level_is_all_and_category_not_muted`: assert toast shown.
|
||||
- [ ] `test_suppresses_toast_when_level_is_none`: assert no toast.
|
||||
- [ ] `test_suppresses_info_toast_when_level_is_errors`: event severity `info`; assert no toast.
|
||||
- [ ] `test_shows_error_toast_when_level_is_errors`: event severity `error`; assert toast shown.
|
||||
- [ ] `test_suppresses_toast_when_category_is_muted`: config mute contains event category; assert no toast.
|
||||
- [ ] Tests mock `useEventContext`, user config context, and `toast-rules.ts` as needed.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR4-001
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-003: [GREEN] Update EventToastBridge with preference checks
|
||||
|
||||
**Description:**
|
||||
Modify `EventToastBridge` to read `notification_toast_level` and `notification_mute_categories` from user config and skip toasts based on the preference hierarchy.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/event-toast-bridge.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Bridge reads user config (from existing settings API / context).
|
||||
- [ ] Evaluation order: mute categories first, then toast level.
|
||||
- [ ] If `notification_toast_level === "none"`: no toasts shown.
|
||||
- [ ] If `notification_toast_level === "errors"`: only toasts for `severity === "error"`.
|
||||
- [ ] If `notification_toast_level === "all"`: toasts shown as before.
|
||||
- [ ] If event category is in `notification_mute_categories`: toast suppressed.
|
||||
- [ ] Backend notification creation is unaffected; bridge only controls toast surfacing.
|
||||
- [ ] `NC-PR4-002` tests pass.
|
||||
|
||||
**Estimated effort:** Small (2–3 hours)
|
||||
**Dependencies:** NC-PR4-002
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-004: [TRIANGULATE] Bridge edge-case and integration tests
|
||||
|
||||
**Description:**
|
||||
Add tests for preference changes taking effect immediately, mixed mute + level constraints, and no regressions in existing deduplication.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/components/event-toast-bridge.test.tsx`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `test_preference_change_is_immediate`: config changes from `"all"` to `"none"`; next event suppressed.
|
||||
- [ ] `test_muted_category_overrides_all_level`: level `"all"` but category muted; toast suppressed.
|
||||
- [ ] `test_deduplication_still_works_with_preferences`: two identical allowed events within 1s → one toast.
|
||||
- [ ] `test_unmapped_event_defaults_to_info`: unknown event type → category `"system"`, severity `"info"`.
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR4-003
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-005: Extend settings UI with notification preferences
|
||||
|
||||
**Description:**
|
||||
Add notification preference controls to the existing General settings tab: a multi-select/checkbox group for mute categories and a select for toast level.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/src/pages/settings.tsx`
|
||||
- `apps/web/src/api/settings.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `UserConfig` interface in `api/settings.ts` includes `notification_mute_categories?: string[]` and `notification_toast_level?: "all" | "errors" | "none"`.
|
||||
- [ ] `UserConfigUpdate` interface includes the same optional fields.
|
||||
- [ ] General settings tab has a "Notifications" section.
|
||||
- [ ] Toast level select with options: "All", "Errors only", "None".
|
||||
- [ ] Mute categories checkboxes for known categories: `instance`, `system`, `health`, `security`.
|
||||
- [ ] Preferences save via existing `updateUserConfig` API.
|
||||
- [ ] Saved preferences persist after page reload.
|
||||
- [ ] Default values: `notification_toast_level="all"`, `notification_mute_categories=[]`.
|
||||
|
||||
**Estimated effort:** Small (3–4 hours)
|
||||
**Dependencies:** NC-PR4-003
|
||||
|
||||
---
|
||||
|
||||
### NC-PR4-006: [REFACTOR] Final quality pass and verification
|
||||
|
||||
**Description:**
|
||||
Run full frontend type check, lint, and test suite. Do a manual smoke test of the notification center + toast coordination.
|
||||
|
||||
**Files to modify:**
|
||||
- Any files with issues found.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `npm run typecheck` passes.
|
||||
- [ ] `npm run lint` passes.
|
||||
- [ ] `npm test` passes for all new and modified test files.
|
||||
- [ ] Manual smoke test: trigger an `instance.error` event → notification appears in dropdown → toast appears (if level="all") → mark read → badge clears.
|
||||
- [ ] Manual smoke test: set toast level to "none" → trigger event → no toast appears, but notification still created.
|
||||
- [ ] No regressions in existing settings page functionality.
|
||||
|
||||
**Estimated effort:** Small (1–2 hours)
|
||||
**Dependencies:** NC-PR4-004, NC-PR4-005
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph (PR Level)
|
||||
|
||||
```
|
||||
PR-1: Backend Core
|
||||
│
|
||||
├─► NC-PR1-001 ──► NC-PR1-002
|
||||
│
|
||||
├─► NC-PR1-003 ──► NC-PR1-004 ──► NC-PR1-005
|
||||
│
|
||||
├─► NC-PR1-006 ──► NC-PR1-007 ──► NC-PR1-008
|
||||
│
|
||||
├─► NC-PR1-009
|
||||
│
|
||||
└─► NC-PR1-010
|
||||
|
||||
PR-2: Backend Integration (depends on PR-1 merged)
|
||||
│
|
||||
├─► NC-PR2-001
|
||||
│
|
||||
├─► NC-PR2-002
|
||||
│
|
||||
├─► NC-PR2-003
|
||||
│
|
||||
├─► NC-PR2-004
|
||||
│
|
||||
└─► NC-PR2-005
|
||||
|
||||
PR-3: Frontend Core (depends on PR-1/PR-2 merged)
|
||||
│
|
||||
├─► NC-PR3-001
|
||||
│
|
||||
├─► NC-PR3-002 ──► NC-PR3-003 ──► NC-PR3-004 ──► NC-PR3-005
|
||||
│
|
||||
├─► NC-PR3-006 ──► NC-PR3-007
|
||||
│
|
||||
├─► NC-PR3-008 ──► NC-PR3-009 ──► NC-PR3-010 ──► NC-PR3-011
|
||||
│
|
||||
└─► NC-PR3-012
|
||||
|
||||
PR-4: Toast Coordination (depends on PR-3 merged)
|
||||
│
|
||||
├─► NC-PR4-001
|
||||
│
|
||||
├─► NC-PR4-002 ──► NC-PR4-003 ──► NC-PR4-004
|
||||
│
|
||||
├─► NC-PR4-005
|
||||
│
|
||||
└─► NC-PR4-006
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Summary
|
||||
|
||||
| PR | Task ID | Description | TDD Phase | Effort |
|
||||
|----|---------|-------------|-----------|--------|
|
||||
| 1 | NC-PR1-001 | Alembic migration for notifications table | — | S |
|
||||
| 1 | NC-PR1-002 | SQLAlchemy Notification model and export | — | S |
|
||||
| 1 | NC-PR1-003 | Service unit tests — basic CRUD | RED | S |
|
||||
| 1 | NC-PR1-004 | Implement NotificationService | GREEN | M |
|
||||
| 1 | NC-PR1-005 | Service edge-case and isolation tests | TRIANGULATE | S |
|
||||
| 1 | NC-PR1-006 | API integration tests — basic endpoints | RED | S |
|
||||
| 1 | NC-PR1-007 | Implement FastAPI router and Pydantic schemas | GREEN | M |
|
||||
| 1 | NC-PR1-008 | API edge-case and ownership tests | TRIANGULATE | S |
|
||||
| 1 | NC-PR1-009 | Register router in main.py | — | S |
|
||||
| 1 | NC-PR1-010 | Backend code quality and type safety pass | REFACTOR | S |
|
||||
| 2 | NC-PR2-001 | Wire lifecycle_hooks.py to NotificationService | — | S |
|
||||
| 2 | NC-PR2-002 | Wire health_monitor.py to NotificationService | — | S |
|
||||
| 2 | NC-PR2-003 | Extend UserConfig schema for preferences | — | S |
|
||||
| 2 | NC-PR2-004 | Event producer integration tests | RED | M |
|
||||
| 2 | NC-PR2-005 | Verify producer tests and clean up | GREEN / REFACTOR | S |
|
||||
| 3 | NC-PR3-001 | Add bell icon to icon registry | — | S |
|
||||
| 3 | NC-PR3-002 | useNotifications hook tests | RED | S |
|
||||
| 3 | NC-PR3-003 | Implement NotificationProvider context | GREEN | M |
|
||||
| 3 | NC-PR3-004 | Implement useNotifications hook | GREEN | M |
|
||||
| 3 | NC-PR3-005 | Hook edge-case and error handling tests | TRIANGULATE | S |
|
||||
| 3 | NC-PR3-006 | NotificationItem component tests | RED | S |
|
||||
| 3 | NC-PR3-007 | Implement NotificationItem component | GREEN | S |
|
||||
| 3 | NC-PR3-008 | NotificationCenter component tests | RED | S |
|
||||
| 3 | NC-PR3-009 | Implement NotificationCenter component | GREEN | M |
|
||||
| 3 | NC-PR3-010 | Add notification CSS styles | — | S |
|
||||
| 3 | NC-PR3-011 | Integrate NotificationCenter into AppShell | — | S |
|
||||
| 3 | NC-PR3-012 | Frontend code quality and type check pass | REFACTOR | S |
|
||||
| 4 | NC-PR4-001 | Extend toast-rules.ts with mapping | — | S |
|
||||
| 4 | NC-PR4-002 | EventToastBridge preference check tests | RED | S |
|
||||
| 4 | NC-PR4-003 | Update EventToastBridge with preference checks | GREEN | S |
|
||||
| 4 | NC-PR4-004 | Bridge edge-case and integration tests | TRIANGULATE | S |
|
||||
| 4 | NC-PR4-005 | Extend settings UI with notification preferences | — | S |
|
||||
| 4 | NC-PR4-006 | Final quality pass and verification | REFACTOR | S |
|
||||
|
||||
**Total tasks:** 31
|
||||
**Total estimated effort:** ~100 hours (backend ~40h, frontend ~45h, integration ~15h)
|
||||
Reference in New Issue
Block a user