Files
headquarter/openspec/changes/notification-center/design.md
T
alex cbaebcf649 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
2026-05-29 12:09:14 +02:00

1020 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<NotificationCenter />` 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<string, unknown>;
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<void>;
markAllRead: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
refreshList: () => Promise<void>;
}
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 (310s); 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: <generated>
Revises: <current_head>
Create Date: 2026-05-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "<generated>"
down_revision = "<current_head>"
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 | 310 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. |