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
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
# 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
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
```python
|
||||
# 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 user’s 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.
|
||||
Reference in New Issue
Block a user