cbaebcf649
- 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
1020 lines
39 KiB
Markdown
1020 lines
39 KiB
Markdown
# 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 (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: <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 | 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. |
|