# SDD Design — Notification Center **Change ID:** `notification-center` **Phase:** design **Date:** 2026-05-29 **Owner:** Gentle AI **Scope:** Cross-cutting (backend + frontend) **Est. Lines:** ~1,800 (recommend 4 chained PRs) **Depends on:** `container-monitoring-notifications` (event bus, SSE, lifecycle hooks) --- ## 1. Component Architecture ### 1.1 NotificationService — Backend Singleton **Pattern:** Module-level singleton, instantiated once at import time and shared across request handlers and event producers. **Responsibilities:** - Insert per-user `Notification` rows into the database. - Filter all queries by `user_id`; enforce strict ownership isolation. - Exclude `dismissed_at IS NOT NULL` rows from list queries. - Return paginated results ordered by `created_at DESC`. - Apply `notification_mute_categories` filtering at the service layer so muted categories are never returned. **Class:** ```python # apps/api/src/services/notification_service.py class NotificationService: """Singleton notification persistence service.""" 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 | None = None, ) -> 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]: ... async def get_unread_count( self, session: AsyncSession, user_id: uuid.UUID, ) -> int: ... async def mark_read( self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID, ) -> Notification: ... async def mark_all_read( self, session: AsyncSession, user_id: uuid.UUID, ) -> int: ... async def dismiss( self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID, ) -> None: ... ``` **Why pass `AsyncSession` explicitly:** - Event producers (lifecycle hooks, health monitor) may hold their own DB session. The service performs a single atomic INSERT/UPDATE inside that session rather than opening a nested transaction. The caller is responsible for commit. --- ### 1.2 Notification API Router — FastAPI **Pattern:** Standard FastAPI `APIRouter`, mounted in `main.py` under `/notifications`. **Responsibilities:** - Derive `current_user` from the existing auth dependency. - Validate query parameters (`limit` capped at 100). - Delegate to `NotificationService`; return Pydantic response models. - Return `404` when a notification does not exist or is owned by a different user. - Read `notification_mute_categories` from `UserConfig` and pass it to `list_notifications`. **Router:** ```python # apps/api/src/api/notifications.py router = APIRouter(prefix="/notifications", tags=["notifications"]) @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_async_session), ) -> NotificationListResponse: ... @router.get("/unread", response_model=UnreadCountResponse) async def get_unread_count( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_async_session), ) -> UnreadCountResponse: ... @router.patch("/{id}/read", response_model=NotificationItem) async def mark_notification_read( id: uuid.UUID, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_async_session), ) -> NotificationItem: ... @router.post("/mark-all-read", response_model=MarkAllReadResponse) async def mark_all_read( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_async_session), ) -> MarkAllReadResponse: ... @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) async def dismiss_notification( id: uuid.UUID, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_async_session), ) -> None: ... ``` --- ### 1.3 Event Producer Integration **Lifecycle Hooks** (`apps/api/src/services/lifecycle_hooks.py`): - After `publish_lifecycle_event()` publishes the raw event to `InstanceEventBus`, call `notification_service.create_notification(...)` with: - `user_id = tool_instance.owner_id` - `category = "instance"` - `severity` mapped from event type (`info` for created/started/stopped/restarted/deleted, `error` for error) - `title` derived from event type (e.g., "Container started") - `message` from payload message - `source_type = "tool_instances"`, `source_id = tool_instance.id` - Wrap the service call in `try/except`; on failure log with `correlation_id` and continue. **Health Monitor** (`apps/api/src/services/health_monitor.py`): - After detecting a state change and publishing `instance.health_changed` or `instance.error`, call `notification_service.create_notification(...)` with: - `user_id = tool_instance.owner_id` - `category = "health"` for health changes, `"instance"` for errors - `severity` mapped from new status (`error` for crash, `warning` for unhealthy, `info` for recovery) - Wrap the service call in `try/except`; on failure log with `correlation_id` and continue. **Important:** Notification creation is **fire-and-forget** from the event producer perspective. The event pipeline (bus publish, audit row insert) must never be blocked by a notification service failure. --- ### 1.4 Frontend Components #### NotificationProvider **Pattern:** React Context + Provider, modeled after `EventProvider` (`apps/web/src/state/events.tsx`). **Responsibilities:** - Maintain `notifications: NotificationItem[]` and `unreadCount: number` in state. - Run two independent polling intervals: - Unread count: every 15 seconds. - Notification list: every 30 seconds (suppressed when dropdown is open to avoid double-fetch). - Provide mutation functions (`markRead`, `markAllRead`, `dismiss`) with optimistic updates. - Pause all polling when the document is hidden (`document.hidden`). - Immediately refresh the list when the dropdown opens. **Location:** `apps/web/src/state/notifications.tsx` #### useNotifications Hook **Pattern:** Consumer hook, modeled after `useEvents()` (`apps/web/src/hooks/use-events.ts`). **Responsibilities:** - Expose `notifications`, `unreadCount`, `isLoading`, `error`. - Expose `markRead(id)`, `markAllRead()`, `dismiss(id)`, `refreshList()`. - Handle optimistic state updates before API call resolves. - Surface errors silently (log to console) without throwing. **Location:** `apps/web/src/hooks/use-notifications.ts` #### NotificationCenter Component **Pattern:** Controlled dropdown panel mounted in `AppShell` header-actions. **Responsibilities:** - Render a bell icon (Phosphor `Bell`) with an unread count badge. - Manage `isOpen` state; open on bell click, close on outside click or `Escape` key. - Render a scrollable dropdown panel positioned below the bell, right-aligned. - Render a list of `NotificationItem` components. - Show empty state when the list is empty. - Render a footer with "Mark all as read" action. - Call `refreshList()` immediately when opening. - Hide entirely when `isMobileTerminal` is true. **Location:** `apps/web/src/components/notification-center.tsx` #### NotificationItem Component **Pattern:** Presentational row component. **Responsibilities:** - Display severity icon (mapped from `severity` to Phosphor icon). - Display `title` and relative timestamp (e.g., "2m ago"). - Render "Mark read" and "Dismiss" action buttons. - Apply visual distinction for unread rows (e.g., bolder text, accent border, background tint). - Apply reduced opacity for read rows. **Props interface:** ```typescript interface NotificationItemProps { notification: NotificationItem; onMarkRead: (id: string) => void; onDismiss: (id: string) => void; } ``` **Location:** `apps/web/src/components/notification-item.tsx` --- ### 1.5 Toast Coordination — EventToastBridge **Pattern:** Modify existing `EventToastBridge` (`apps/web/src/components/event-toast-bridge.tsx`) to consult notification preferences before emitting toasts. **Responsibilities:** - Read `userConfig.notification_toast_level` and `userConfig.notification_mute_categories` from the existing user config context. - Before showing a toast for an SSE event: 1. Skip if `toastLevel === "none"`. 2. Skip if `toastLevel === "errors"` and event severity is not `"error"`. 3. Skip if event category is in `muteCategories`. - The toast bridge **does not** suppress the backend notification creation; it only controls frontend toast surfacing. - To avoid duplicate perception: if a toast is shown for an event that also generated a notification, the user sees the toast (ephemeral) and later sees the same item in the center (persistent). This is acceptable because the toast provides immediate attention while the center provides history. **Location:** `apps/web/src/components/event-toast-bridge.tsx` --- ## 2. File Structure ### New Files | File | Purpose | |------|---------| | `apps/api/src/models/notification.py` | SQLAlchemy `Notification` model with indexes and FK. | | `apps/api/src/services/notification_service.py` | `NotificationService` singleton with create/list/count/read/dismiss methods. | | `apps/api/src/api/notifications.py` | FastAPI router: `GET /notifications`, `GET /notifications/unread`, `PATCH /{id}/read`, `POST /mark-all-read`, `DELETE /{id}`. | | `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` | Alembic migration creating `notifications` table, FK, and two indexes. | | `apps/web/src/components/notification-center.tsx` | Bell icon + dropdown panel with list, empty state, footer actions, outside-click/Escape close. | | `apps/web/src/components/notification-item.tsx` | Single notification row: severity icon, title, relative time, mark-read/dismiss actions. | | `apps/web/src/state/notifications.tsx` | `NotificationProvider`: polling logic (15s unread / 30s list), optimistic mutations, visibility pause. | | `apps/web/src/hooks/use-notifications.ts` | `useNotifications()` hook exposing state and mutation callbacks. | | `apps/web/src/utils/icons.ts` | Add `"bell"` to `IconName` union and `iconRegistry` mapping to `PhosphorIcons.Bell`. | ### Modified Files | File | Purpose | |------|---------| | `apps/api/src/main.py` | Import and register `notifications_router`; import `Notification` model for Alembic autogenerate. | | `apps/api/src/models/__init__.py` | Export `Notification` for Alembic autogenerate. | | `apps/api/src/api/__init__.py` | Export `notifications_router` (if barrel file exists). | | `apps/api/src/services/lifecycle_hooks.py` | After publishing raw event, call `NotificationService.create_notification` with `owner_id` as `user_id`. Wrap in try/except. | | `apps/api/src/services/health_monitor.py` | After state change detection, call `NotificationService.create_notification` with `owner_id` as `user_id`. Wrap in try/except. | | `apps/web/src/components/app-shell.tsx` | Mount `` inside `header-actions`; hide when `isMobileTerminal` is true. | | `apps/web/src/components/event-toast-bridge.tsx` | Read `notification_toast_level` and `notification_mute_categories` from user config; skip toasts based on preference hierarchy. | | `apps/web/src/styles.css` | Add `.notification-dropdown`, `.notification-item`, `.notification-item--unread`, `.notification-badge`, `.notification-empty` utility classes. | --- ## 3. Interface Design ### 3.1 NotificationService Method Signatures ```python # apps/api/src/services/notification_service.py from sqlalchemy.ext.asyncio import AsyncSession import uuid from datetime import datetime from typing import Any class NotificationService: 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: ... 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]: """Returns (items, total_count).""" ... async def get_unread_count( self, session: AsyncSession, user_id: uuid.UUID, ) -> int: ... async def mark_read( self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID, ) -> Notification: ... async def mark_all_read( self, session: AsyncSession, user_id: uuid.UUID, ) -> int: """Returns number of rows updated.""" ... async def dismiss( self, session: AsyncSession, notification_id: uuid.UUID, user_id: uuid.UUID, ) -> None: ... ``` ### 3.2 API Endpoint Request/Response Schemas (Pydantic) ```python # apps/api/src/api/notifications.py from pydantic import BaseModel, Field import uuid from datetime import datetime class NotificationItem(BaseModel): 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 metadata: dict read_at: datetime | None dismissed_at: datetime | None created_at: datetime class Config: from_attributes = True class NotificationListResponse(BaseModel): items: list[NotificationItem] total: int limit: int offset: int class UnreadCountResponse(BaseModel): count: int class MarkAllReadResponse(BaseModel): marked_count: int ``` ### 3.3 Frontend: useNotifications() Return Type ```typescript // apps/web/src/hooks/use-notifications.ts export interface NotificationItem { id: string; user_id: string; category: string; severity: "info" | "warning" | "error" | "success"; title: string; message: string | null; source_type: string | null; source_id: string | null; metadata: Record; read_at: string | null; dismissed_at: string | null; created_at: string; } export interface UseNotificationsReturn { notifications: NotificationItem[]; unreadCount: number; isLoading: boolean; error: Error | null; markRead: (id: string) => Promise; markAllRead: () => Promise; dismiss: (id: string) => Promise; refreshList: () => Promise; } export function useNotifications(): UseNotificationsReturn { // Consumes NotificationContext } ``` ### 3.4 NotificationItem Props ```typescript // apps/web/src/components/notification-item.tsx export interface NotificationItemProps { notification: NotificationItem; onMarkRead: (id: string) => void; onDismiss: (id: string) => void; } ``` ### 3.5 Toast Coordination: Preference Checks Before Showing Toast ```typescript // apps/web/src/components/event-toast-bridge.tsx interface UserNotificationConfig { notification_toast_level: "all" | "errors" | "none"; notification_mute_categories: string[]; } function shouldShowToast( event: InstanceEventPayload, config: UserNotificationConfig, ): boolean { if (config.notification_toast_level === "none") return false; const severity = mapEventToSeverity(event); if (config.notification_toast_level === "errors" && severity !== "error") { return false; } const category = mapEventToCategory(event); if (config.notification_mute_categories.includes(category)) { return false; } return true; } ``` --- ## 4. Data Flow ### 4.1 Event Producer → NotificationService → DB → REST API → Frontend Polling ``` HealthMonitor / LifecycleHook │ ├──► Publish raw event to InstanceEventBus │ │ │ ▼ │ SSE Stream ──► Frontend (toast bridge) │ └──► NotificationService.create_notification(user_id=owner_id, ...) │ ▼ DB INSERT INTO notifications │ ▼ Frontend polling (every 15s unread / 30s list) │ ├──► GET /notifications/unread ──► Badge count update │ └──► GET /notifications ──► Dropdown list update ``` **Key invariants:** - The notification row is created **independent** of the SSE stream. A user who is offline will still have notifications waiting when they return. - The `NotificationService` call happens **after** the raw event is published, so event bus delivery is never blocked by DB write latency. - Event producers wrap the service call in `try/except`; a notification insert failure is logged but does not break the event pipeline. ### 4.2 User Action → PATCH/POST/DELETE → Optimistic UI Update → API Call → State Refresh ``` User clicks "Mark read" on a notification row │ ▼ NotificationItem calls onMarkRead(id) │ ▼ useNotifications.markRead(id) │ ├──► Optimistic: update local state → set read_at, decrement unreadCount │ ├──► API: PATCH /notifications/{id}/read │ │ │ ├──► 200 OK → no further state change (already optimistic) │ │ │ └──► 404/500 → rollback optimistic update, log error │ └──► Next polling cycle refreshes full list as reconciliation ``` **Optimistic update rules:** - `markRead`: Immediately set `read_at = now()` on the local item and decrement `unreadCount` by 1. - `markAllRead`: Immediately set `read_at = now()` on all items and set `unreadCount = 0`. - `dismiss`: Immediately remove the item from the local array; if it was unread, decrement `unreadCount`. - On API failure, revert to the pre-mutation state. ### 4.3 Toast Flow: SSE Event → Preference Check → Toast Decision ``` InstanceEventBus publishes event │ ▼ EventContext receives payload │ ▼ EventToastBridge.onEvent(payload) │ ├──► Map event type → category, severity │ ├──► Read userConfig.notification_toast_level │ │ │ ├──► "none" → skip │ ├──► "errors" and severity != "error" → skip │ └──► "all" → continue │ ├──► Read userConfig.notification_mute_categories │ │ │ ├──► category in mute list → skip │ └──► not muted → continue │ └──► Show toast via toast system │ ▼ Notification row already exists (created independently) ``` **Duplicate avoidance:** - The toast and the notification center are **intentionally independent surfaces**. A user may see a toast for an event and also see the same event in the center later. - The toast is ephemeral (3–10s); the center is persistent. This is by design, not a bug. - No deduplication between toast and center is required because they serve different purposes (alert vs. history). --- ## 5. State Machine ### 5.1 Notification Lifecycle ``` +------------------+ │ unread │ │ (read_at IS NULL) +--------+---------+ │ ┌─────────────┼─────────────┐ │ │ │ ▼ ▼ ▼ +-----------+ +-----------+ +-----------+ │ mark read │ │ mark all │ │ dismiss │ │ (PATCH) │ │ read │ │ (DELETE) │ +-----+-----+ │ (POST) │ +-----+-----+ │ +-----+-----+ │ │ │ │ ▼ ▼ ▼ +-----------+ +-----------+ +-----------+ │ read │ │ read │ │ dismissed │ │(read_at │ │(read_at │ │(dismissed_│ │ set) │ │ set) │ │ at set) │ +-----------+ +-----------+ +-----------+ │ │ │ │ │ │ └──────────────┴──────────────┘ │ ▼ +-----------+ │ absent │ │ from list │ │ queries │ +-----------+ ``` **Transitions:** | From | Action | To | DB Change | |------|--------|----|-----------| | `unread` | Mark read | `read` | `read_at = now()` | | `unread` | Mark all read | `read` | `read_at = now()` on all unread rows | | `unread` | Dismiss | `dismissed` | `dismissed_at = now()` | | `read` | Dismiss | `dismissed` | `dismissed_at = now()` | | `dismissed` | — | `absent` | Excluded from all list queries | ### 5.2 Badge State The badge state is a **derived value** from `unreadCount`, which is polled every 15 seconds. ``` Badge state │ ├──► unreadCount === 0 → badge hidden │ └──► unreadCount > 0 → badge visible, text = unreadCount (cap display at "99+" if count > 99) ``` **Badge color:** Use the existing `nav-badge` CSS class (red background, white text) for consistency with the session-card health badges. --- ## 6. Database Schema ### 6.1 Table: `notifications` ```sql CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, category VARCHAR(32) NOT NULL, severity VARCHAR(16) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT, source_type VARCHAR(64), source_id UUID, metadata JSONB NOT NULL DEFAULT '{}', read_at TIMESTAMPTZ, dismissed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_notifications_user_created_at ON notifications(user_id, created_at DESC); CREATE INDEX idx_notifications_user_unread ON notifications(user_id, read_at) WHERE read_at IS NULL; ``` **SQLAlchemy model:** ```python # apps/api/src/models/notification.py import uuid from datetime import datetime from typing import Any from sqlalchemy import ForeignKey, String, Text, DateTime, JSON from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func from .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 ) metadata: Mapped[dict[str, Any]] = mapped_column( JSONB, 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 ) ``` ### 6.2 Migration **File:** `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` **Dependency:** Depends on the current Alembic `head` at time of creation. ```python """Add notifications table Revision ID: Revises: Create Date: 2026-05-29 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "" down_revision = "" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "notifications", sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), sa.Column("user_id", postgresql.UUID(as_uuid=True), 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", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'"), nullable=False), 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.text("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") ``` --- ## 7. Polling Strategy ### 7.1 Intervals | Endpoint | Interval | Condition | |----------|----------|-----------| | `GET /notifications/unread` | 15 seconds | Always while authenticated | | `GET /notifications` | 30 seconds | Only while dropdown is **closed** | | `GET /notifications` | Immediate | When dropdown is **opened** | ### 7.2 Tab Visibility Handling When `document.visibilityState === "hidden"` (user switches tabs or minimizes): - Pause all polling intervals using `clearInterval`. - Resume polling when `visibilityState === "visible"` using `document.addEventListener("visibilitychange", ...)`. - On resume, immediately fire both polls to catch up, then restart intervals. **Rationale:** Prevents unnecessary server load from background tabs and reduces battery usage on mobile. ### 7.3 Authentication State - When the user logs out, stop all polling and clear notification state. - When the user logs in, start polling immediately (do not wait for the first interval). ### 7.4 Dropdown Open Behavior ``` User clicks bell │ ├──► clearInterval(listPollInterval) │ ├──► set isOpen = true │ ├──► immediate GET /notifications │ │ │ └──► update state │ └──► on dropdown close: restart 30s list poll, immediate GET /notifications/unread ``` ### 7.5 Error Resilience - If a poll request fails (network error, 5xx), log to console and schedule the next poll normally. - Do not display error UI for polling failures; the user should not be interrupted. - If a poll returns 401, stop all polling (user session expired). --- ## 8. Toast Coordination ### 8.1 Preference Hierarchy Preferences are stored in `UserConfig.config` JSON blob: ```json { "notification_mute_categories": ["instance"], "notification_toast_level": "all" } ``` **Evaluation order (first match wins):** 1. **Mute categories:** If the event's mapped `category` is in `notification_mute_categories`, suppress the toast entirely. 2. **Toast level:** - `"none"` → suppress all toasts. - `"errors"` → only show toasts when `severity === "error"`. - `"all"` → show all toasts (default). ### 8.2 Event-to-Category/Severity Mapping ```typescript // apps/web/src/components/toast-rules.ts (extended) function mapEventToCategory(event: InstanceEventPayload): string { if (event.event.startsWith("instance.")) return "instance"; if (event.event.startsWith("health.")) return "health"; return "system"; } function mapEventToSeverity(event: InstanceEventPayload): "info" | "warning" | "error" | "success" { switch (event.event) { case "instance.error": case "health.error": return "error"; case "instance.health_changed": return event.status === "unhealthy" ? "warning" : "success"; case "instance.created": case "instance.started": case "instance.stopped": case "instance.restarted": case "instance.deleted": return "info"; default: return "info"; } } ``` ### 8.3 Avoiding Duplicate Toast + Center Entry **There is no deduplication requirement** between toast and center entry. The two surfaces serve different purposes: | Surface | Purpose | Lifetime | |---------|---------|----------| | Toast | Immediate attention grabber | 3–10 seconds | | Center | Persistent history and review | Until dismissed | Both can show the same event. This is acceptable because: - The toast auto-dismisses quickly. - The user may miss the toast and rely on the center. - The user may see the toast and later want to reference details in the center. **Future enhancement (Phase 3):** Add `notification_id` to toast metadata so clicking the toast can open the center and scroll to the corresponding row. --- ## 9. Testing Strategy ### 9.1 Backend: Service Unit Tests **File:** `tests/unit/test_notification_service.py` | Test | Description | |------|-------------| | `test_create_notification` | Call `create_notification` with all fields; assert row inserted with correct values, `read_at` NULL, `dismissed_at` NULL. | | `test_list_notifications_orders_by_created_at_desc` | Insert 3 rows; assert list returns newest first. | | `test_list_notifications_excludes_dismissed` | Insert 2 rows, dismiss 1; assert only 1 returned. | | `test_list_notifications_unread_only` | Insert 1 read, 1 unread; assert `unread_only=True` returns 1. | | `test_list_notifications_mute_categories` | Insert rows with categories `instance` and `system`; pass `mute_categories=["instance"]`; assert only `system` returned. | | `test_get_unread_count` | Insert 5 rows, 2 unread; assert count is 2. | | `test_get_unread_count_excludes_dismissed` | Insert 1 unread dismissed; assert count is 0. | | `test_mark_read_sets_read_at` | Call `mark_read`; assert `read_at` is not NULL. | | `test_mark_read_wrong_owner_raises` | User A creates notification; User B calls `mark_read`; assert 404-equivalent exception. | | `test_mark_all_read_affects_only_caller` | User A has 3 unread, User B has 2; mark all for A; assert A=0 unread, B=2 unread. | | `test_dismiss_sets_dismissed_at` | Call `dismiss`; assert `dismissed_at` is not NULL. | | `test_dismiss_wrong_owner_raises` | User A creates notification; User B calls `dismiss`; assert 404-equivalent exception. | **Fixtures needed:** - `notification_service`: fresh `NotificationService()` instance. - `db_session`: async SQLAlchemy session with rollback after each test. - `user_a`, `user_b`: test user rows inserted in session. ### 9.2 Backend: API Integration Tests **File:** `tests/integration/test_notifications_api.py` | Test | Description | |------|-------------| | `test_list_requires_auth` | `GET /notifications` without auth → `401`. | | `test_list_returns_only_own_notifications` | Create notification for user A; user B requests list → not in response. | | `test_list_pagination` | Create 25 notifications; `limit=10&offset=10` → items length 10, total 25. | | `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_read_404_for_other_user` | Create notification for user A; user B PATCH → `404`. | | `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. | | `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. | ### 9.3 Frontend: Component Tests **File:** `apps/web/src/components/notification-center.test.tsx` | Test | Description | |------|-------------| | `renders bell icon` | Mount with provider; assert bell icon visible. | | `shows badge when unread count > 0` | Provider state has `unreadCount=3`; assert badge text is "3". | | `hides badge when unread count is 0` | Provider state has `unreadCount=0`; assert badge not in document. | | `opens dropdown on bell click` | Simulate click; assert dropdown panel visible. | | `closes dropdown on outside click` | Open dropdown; click outside; assert panel not visible. | | `closes dropdown on Escape` | Open dropdown; fire `Escape` key; assert panel not visible. | | `renders empty state when no notifications` | List empty; assert empty state text visible. | | `renders notification items` | List has 2 items; assert 2 `NotificationItem` components rendered. | | `calls markAllRead on footer button click` | Simulate "Mark all as read" click; assert mock called. | | `refreshes list immediately on open` | Open dropdown; assert `refreshList` mock called. | **File:** `apps/web/src/components/notification-item.test.tsx` | Test | Description | |------|-------------| | `displays title and relative time` | Render with sample data; assert title and time visible. | | `applies unread styling when read_at is null` | `read_at=null`; assert unread CSS class present. | | `applies read styling when read_at is set` | `read_at=iso-string`; assert read CSS class present. | | `calls onMarkRead when mark read clicked` | Simulate click; assert callback with correct id. | | `calls onDismiss when dismiss clicked` | Simulate click; assert callback with correct id. | ### 9.4 Frontend: Hook Tests **File:** `apps/web/src/hooks/use-notifications.test.ts` | Test | Description | |------|-------------| | `returns notifications from context` | Mock provider value; assert hook returns same array. | | `returns unreadCount from context` | Mock provider value; assert hook returns correct count. | | `optimistically updates on markRead` | Call `markRead`; assert local state updated before API resolves. | | `reverts optimistic update on markRead failure` | Mock API rejection; assert state reverted to original. | | `optimistically updates on dismiss` | Call `dismiss`; assert item removed and count decremented. | | `reverts optimistic update on dismiss failure` | Mock API rejection; assert item restored and count restored. | | `calls refreshList when invoked` | Mock API; assert `GET /notifications` called. | ### 9.5 Frontend: Toast Bridge Tests **File:** `apps/web/src/components/event-toast-bridge.test.tsx` | Test | Description | |------|-------------| | `shows toast when level is all and category not muted` | Config `{level:"all",mute:[]}`; assert toast shown. | | `suppresses toast when level is none` | Config `{level:"none"}`; assert no toast. | | `suppresses info toast when level is errors` | Config `{level:"errors"}`; event severity `info`; assert no toast. | | `shows error toast when level is errors` | Config `{level:"errors"}`; event severity `error`; assert toast shown. | | `suppresses toast when category is muted` | Config `{level:"all",mute:["instance"]}`; category `instance`; assert no toast. | ### 9.6 End-to-End Test **File:** `tests/e2e/notification-center.spec.ts` (Playwright) | Test | Description | |------|-------------| | `container error creates notification and increments badge` | Trigger `instance.error` via API; within 15s assert badge shows `1`; open dropdown; assert error notification visible. | | `mark read clears badge` | Open dropdown; click mark read; assert badge hidden. | | `dismiss removes from list` | Open dropdown; click dismiss; assert notification not in list; refresh page; assert still absent. | | `mark all read clears all` | Create 3 unread; click "Mark all as read"; assert badge hidden; assert all rows styled as read. | | `toast respects preferences` | Set toast level to `"none"`; trigger event; assert no toast appears. | --- ## 10. Rollout Plan | PR | Contents | Estimated Lines | Review Risk | |----|----------|-----------------|-------------| | **PR 1: Backend core** | Migration, model, `NotificationService`, API router, registration in `main.py`, unit + integration tests | ~600 | Medium | | **PR 2: Backend integration** | Wire `lifecycle_hooks.py` and `health_monitor.py`; extend `UserConfig` schema docs; event producer tests | ~250 | Low | | **PR 3: Frontend core** | Icon, `NotificationProvider`, `useNotifications`, `NotificationCenter`, `NotificationItem`, styles, `AppShell` integration, component + hook tests | ~700 | Medium | | **PR 4: Toast coordination** | Update `EventToastBridge` + `toast-rules.ts`; preference UI wiring; bridge + preference tests | ~250 | Low | **Dependency order:** PR 1 → PR 2 → PR 3 → PR 4. PR 3 depends on PR 1/2 backend APIs. PR 4 depends on PR 3 UI. --- ## 11. Decisions | ID | Decision | Rationale | |----|----------|-----------| | D1 | `NotificationService` accepts `AsyncSession` instead of managing its own | Keeps service lightweight and avoids nested transaction issues when called from event producers that already hold a session. | | D2 | Soft-delete via `dismissed_at` | Preserves audit history; allows future "recently dismissed" or admin analytics features. | | D3 | Partial index on `read_at IS NULL` | Unread count is queried every 15 seconds per active user; partial index keeps query small and fast. | | D4 | Poll instead of SSE for Phase 1 | Avoids redesigning SSE multiplexing for per-user streams. REST polling is simpler, cache-friendly, and sufficient for MVP. | | D5 | No deduplication between toast and center | The surfaces serve different purposes (alert vs. history). Both showing the same event is acceptable. | | D6 | `UserConfig` JSON blob for preferences | Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. | | D7 | Pause polling on `document.hidden` | Reduces server load from background tabs and improves battery life on mobile. | | D8 | `category` and `severity` stored as strings, not enums | Extensible without Alembic migrations when new sources introduce categories. | | D9 | Event producers call `NotificationService` directly, not via EventBus | Makes the dependency visible and avoids hidden side effects. The bus remains transport for raw events only. | | D10 | Cap badge display at "99+" | Prevents layout shift if a user accumulates an extreme number of unread notifications. |