# 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. |