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

39 KiB
Raw Blame History

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:

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

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

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

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

# 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

// 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

// 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

// 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

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:

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

"""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:

{
  "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

// 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}/read200, 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.