Files
headquarter/openspec/changes/notification-center/proposal.md
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

17 KiB
Raw Permalink Blame History

SDD Proposal — Notification Center

Change ID: notification-center
Status: Draft
Date: 2026-05-29


1. Problem Statement

The current notification surface is limited to ephemeral toasts driven by an unfiltered SSE stream. Users face three critical gaps:

  1. No persistence — If a user is offline, reloads the page, or dismisses a toast, the event is gone forever. There is no way to review what happened while they were away.
  2. No scoping — The SSE endpoint broadcasts all instance events to every authenticated user. Users receive toasts for containers they do not own, creating noise and potential information leakage.
  3. No lifecycle or control — Toasts auto-dismiss with no read/unread state, no dismissal history, and no user preferences to mute categories or suppress toast pop-ups.

These gaps make the system unsuitable for any asynchronous, user-specific, or high-signal communication such as health alerts, system maintenance notices, or future billing events.


2. Goals

# Goal Success Measure
G1 Persistent, per-user notification store backed by a new database table. Notifications survive page reloads, browser restarts, and session changes. 100 % of notifications created for a user are retrievable after a full browser close + reopen.
G2 Per-user filtering — Users only see notifications scoped to their user_id. Zero cross-user notification leakage in API responses.
G3 Read/unread/dismiss lifecycle with REST endpoints and optimistic UI updates. Users can mark individual or all notifications read, and dismiss unwanted entries; state persists on refresh.
G4 Notification center UI — Bell icon in the top-right AppShell header with a dropdown panel listing recent notifications. Bell is visible on desktop; dropdown renders within 200 ms of click; accessible via keyboard.
G5 Unread count badge — Red badge on the bell icon reflecting the real-time unread count. Badge count matches GET /notifications/unread within one polling interval.
G6 Modular notification sources — Any backend module can call a central NotificationService to create user-scoped notifications without touching instance events directly. A new source (e.g., a future billing module) can emit notifications by adding a single service call.
G7 Toast coordination — The existing toast system respects user preferences and avoids duplicate surfacing when a notification is already in the center. No user sees both a toast and a center entry for the same backend event unless they explicitly re-open the center.
G8 User preferences — Mute categories and toast-level settings stored in UserConfig. Preference changes take effect immediately without a server restart.

3. Non-Goals

# Non-Goal Rationale
NG1 Real-time SSE for notifications in Phase 1 Will use REST polling (30 s list / 15 s unread) to ship faster. A dedicated notifications/stream SSE is a fast-follow (Phase 3).
NG2 Push notifications / WebHooks / Email Out of scope for this change. The architecture must not block these later, but no transport work is included now.
NG3 Multi-worker event-bus scaling InstanceEventBus remains an in-memory singleton. The NotificationService interface is designed so a future Redis-backed queue can slot in without consumer changes.
NG4 Team / group-scoped notifications Notifications are 1-to-1 user_id only. Mentioning or broadcasting to teams is future work.
NG5 Mobile-specific notification UI (bottom sheet) The bell will be hidden on isMobileTerminal. A mobile-native bottom-sheet variant is a future polish item.
NG6 Rich-text or markdown bodies title and message are plain strings. No formatting engine is introduced.

4. User Stories

ID Story Acceptance Criteria
US-1 As a user, I want to see a bell icon with an unread count in the header so that I know when something needs my attention. Bell renders in header-actions; badge shows unread count; count updates on poll.
US-2 As a user, I want to click the bell and see a list of recent notifications so that I can catch up on events I missed. Dropdown opens; lists last 20 notifications; shows title, relative time, severity icon; empty state when none exist.
US-3 As a user, I want to mark a notification as read so that the badge count decreases and the UI reflects my attention. Clicking a row or its Mark read action updates read_at; badge decrements; row styling changes.
US-4 As a user, I want to dismiss a notification so that it no longer appears in my list. Dismiss removes the row from the list and sets dismissed_at; does not affect other users.
US-5 As a user, I want to Mark all as read so that I can clear my inbox quickly. Footer button marks all unread notifications read; badge resets to zero; list styling updates.
US-6 As a user, I want notification preferences (mute categories, toast level) so that I control noise. Settings panel or modal exposes checkboxes / select for mute categories and toast level; saves to UserConfig.
US-7 As a backend developer, I want to emit a notification from any module with one function call so that I do not rebuild plumbing each time. NotificationService.create_notification(...) is importable anywhere; auto-scopes to user_id.
US-8 As a user, I want container error events to appear as notifications so that I can review them later even if I missed the toast. instance.error events from HealthMonitor / lifecycle_hooks generate a Notification row for the owner.

5. Proposed Solution

5.1 Backend

New Data Model

# apps/api/src/models/notification.py
class Notification(Base):
    __tablename__ = "notifications"

    id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4)
    user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
    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] = mapped_column(Text, nullable=True)
    source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
    source_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
    metadata: Mapped[dict] = mapped_column(JSON, 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, index=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
    )

Indexes:

  • (user_id, created_at DESC) — fast list queries
  • (user_id, read_at) WHERE read_at IS NULL — fast unread count (partial index)

New Service

# apps/api/src/services/notification_service.py
class NotificationService:
    async def create_notification(
        self, user_id: UUID, category: str, severity: str,
        title: str, message: str | None = None,
        source_type: str | None = None, source_id: UUID | None = None,
        metadata: dict | None = None
    ) -> Notification: ...

    async def list_notifications(
        self, user_id: UUID, *, limit: int = 20, offset: int = 0,
        unread_only: bool = False
    ) -> list[Notification]: ...

    async def get_unread_count(self, user_id: UUID) -> int: ...
    async def mark_read(self, notification_id: UUID, user_id: UUID) -> Notification: ...
    async def mark_all_read(self, user_id: UUID) -> int: ...
    async def dismiss(self, notification_id: UUID, user_id: UUID) -> None: ...

The service is instantiated as a module-level singleton and imported by event producers.

New API Router

  • GET /notifications — list (paginated, supports ?unread_only=true)
  • GET /notifications/unread — returns { "count": int }
  • PATCH /notifications/{id}/read — mark single read
  • POST /notifications/mark-all-read — mark all read
  • DELETE /notifications/{id} — dismiss (soft-delete by setting dismissed_at)

All endpoints enforce user_id == current_user.id at the service layer.

Event-Bus Integration

  • lifecycle_hooks.py and health_monitor.py call notification_service.create_notification(...) with user_id=tool_instance.owner_id after publishing the raw event.
  • No changes to InstanceEventBus itself; the notification service is a consumer, not bus middleware.

Preferences Extension

Extend UserConfig.config JSON schema with two new keys:

  • notification_mute_categories: string[] — categories the user does not want to see at all.
  • notification_toast_level: "all" | "errors" | "none" — default is "all".

5.2 Frontend

New / Modified Components

Component Purpose
notification-center.tsx Bell icon + dropdown panel. Manages open/close state, outside-click close, keyboard Escape.
notification-item.tsx Single row: severity icon, title, relative time, mark-read/dismiss actions.
notification-provider.tsx React context: holds list, unread count, polling logic (30 s / 15 s), mutations with optimistic updates.
use-notifications.ts Hook exposing notifications, unreadCount, markRead, markAllRead, dismiss, isLoading.
app-shell.tsx Mount NotificationCenter inside header-actions; hide when isMobileTerminal.
event-toast-bridge.tsx Read userConfig.notification_toast_level before emitting a toast. Skip toast if level is "none" or event severity is below threshold.
icons.ts Register "bell" pointing to PhosphorIcons.Bell.
styles.css Add .notification-dropdown, .notification-item, .notification-badge utilities.

Toast Coordination Logic

  1. Backend event triggers NotificationService.create_notification() (always happens).
  2. EventToastBridge receives the SSE event.
  3. Bridge checks userConfig.notification_toast_level:
    • If "none": never toast.
    • If "errors": only toast when severity is "error".
    • If "all": toast as before.
  4. Bridge also checks if the event category is in notification_mute_categories; if so, skip toast.
  5. The notification row is always created on the backend regardless of frontend preferences; filtering happens at read time and in the bridge.

Polling Strategy

  • Notification list: GET /notifications every 30 seconds while the dropdown is closed; refresh immediately when opened.
  • Unread count: GET /notifications/unread every 15 seconds.
  • Intervals are configurable constants in the provider.

6. Key Decisions

Decision Rationale
Soft-delete via dismissed_at instead of hard DELETE Preserves audit history and allows future features such as "Recently dismissed" or admin analytics.
Partial index on read_at IS NULL Unread count is queried frequently; a partial index keeps it small and fast even as the table grows.
Poll instead of SSE for Phase 1 Avoids redesigning the SSE multiplexing logic and lets us ship the full UI and backend in one PR. SSE follow-up is isolated.
Plain-text title/message Avoids introducing a markdown parser or HTML sanitization dependency. Rich content can be a future enhancement.
UserConfig JSON blob for preferences Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys.
No middleware in InstanceEventBus Producers (lifecycle_hooks, health_monitor) explicitly call the notification service. This makes the dependency visible and avoids hidden side effects in the bus.
Category + severity enums stored as strings Simple, human-readable, and extensible without Alembic migrations when a new source introduces a category.

7. Risks

Risk Likelihood Impact Mitigation
High write volume on notifications table Medium High Add partial indexes from day one; monitor write throughput; shard or archive old rows (e.g., auto-dismiss after 90 days) if volume becomes problematic.
Cross-user data leakage in API Low Critical Enforce user_id filter in every service method; add integration tests that attempt to read another users notification and assert 404.
Polling overhead at scale Medium Medium Poll intervals are conservative; unread count endpoint is a single COUNT query with a partial index. SSE fast-follow eliminates polling.
Mobile layout absence Low Low Bell is hidden on isMobileTerminal. Mobile bottom-sheet is a future non-goal.
No reusable dropdown component Medium Medium Build a minimal positioned panel inside notification-center.tsx using a ref + useEffect for outside click; extract to a design-system component only after it stabilizes.
Notification service called before DB commit Medium Medium Ensure lifecycle_hooks commits the parent transaction (instance_events insert) before calling the notification service, or wrap both in the same unit of work.

8. Acceptance Criteria

Backend

  • Alembic migration creates the notifications table with correct columns, FK, and indexes.
  • GET /notifications returns only rows where user_id matches the authenticated user, ordered by created_at DESC.
  • GET /notifications/unread returns the exact count of rows where read_at IS NULL for the authenticated user.
  • PATCH /notifications/{id}/read sets read_at and returns the updated row; 404 if not owned by caller.
  • POST /notifications/mark-all-read sets read_at on all unread rows for the caller; returns count affected.
  • DELETE /notifications/{id} sets dismissed_at; row no longer appears in list queries.
  • HealthMonitor and lifecycle_hooks generate notifications scoped to the tool instance owner.

Frontend

  • Bell icon renders in AppShell header-actions on desktop.
  • Unread count badge updates within 15 seconds of a new notification.
  • Dropdown opens on bell click, closes on outside click or Escape.
  • Notification list shows title, relative time, severity icon; unread rows are visually distinct.
  • Mark read and Dismiss actions update UI optimistically and persist after refresh.
  • Mark all as read clears the badge and updates all visible rows.
  • Empty state message shown when no notifications exist.
  • Toast bridge respects notification_toast_level and notification_mute_categories.

Integration

  • End-to-end test: trigger an instance.error event → verify notification row created → verify badge increments → verify toast appears (or not) based on preference → mark read → verify badge clears.

9. Effort Estimate + PR Breakdown

PR 1 — Backend Core (~2 days)

Scope: Migration, model, service, API router, registration in main.py. Files:

  • alembic/versions/..._add_notifications.py
  • apps/api/src/models/notification.py
  • apps/api/src/services/notification_service.py
  • apps/api/src/api/notifications.py
  • apps/api/src/main.py Tests: Service unit tests, API integration tests (ownership, pagination, mark-all-read).

PR 2 — Backend Integration (~1 day)

Scope: Wire lifecycle_hooks and HealthMonitor to call NotificationService; add preferences to UserConfig schema. Files:

  • apps/api/src/services/lifecycle_hooks.py
  • apps/api/src/services/health_monitor.py
  • apps/api/src/models/user_config.py (schema docs / validation) Tests: End-to-end event-to-notification creation tests.

PR 3 — Frontend Core (~2 days)

Scope: Icon, provider, hook, notification-center component, item component, styles, app-shell integration. Files:

  • apps/web/src/utils/icons.ts
  • apps/web/src/state/notifications.tsx
  • apps/web/src/hooks/use-notifications.ts
  • apps/web/src/components/notification-center.tsx
  • apps/web/src/components/notification-item.tsx
  • apps/web/src/components/app-shell.tsx
  • apps/web/src/styles.css Tests: Component render tests, hook behavior tests, optimistic update tests.

PR 4 — Toast Coordination + Preferences UI (~1 day)

Scope: Update EventToastBridge; add preference controls (inside existing settings modal or new section); connect to UserConfig API. Files:

  • apps/web/src/components/event-toast-bridge.tsx
  • apps/web/src/components/toast-rules.ts (if toast level logic lives here)
  • Settings / preferences component (TBD based on existing UI) Tests: Bridge logic tests, preference persistence tests.

Total Estimated Effort: ~6 engineering days

Sequence: PR 1 and PR 2 can be stacked (2 before 3). PR 3 depends on PR 1/2. PR 4 depends on PR 3.


10. Rollback Plan

  1. Database: The migration is additive (new table + indexes). Rolling back requires a single Alembic downgrade that drops the notifications table. No existing tables are modified.
  2. Frontend: If the UI causes performance or layout issues, remove the NotificationCenter mount from app-shell.tsx. The rest of the codebase is unaffected.
  3. Backend API: If the router causes issues, unregister it in main.py. The underlying service and table can remain safely.
  4. Event producers: If notification creation causes errors, the explicit service call in lifecycle_hooks and health_monitor can be wrapped in a try/except log-and-continue block so that event publishing is never blocked.