# Notification Center Specification ## Purpose Provide a persistent, per-user notification store with a REST API, a frontend notification center UI, and user-scoped preferences for category muting and toast suppression. Notifications are created by backend event producers (lifecycle hooks, health monitor) and surfaced to users through a bell icon dropdown, an unread count badge, and coordinated toast behavior. > **Assumption:** This specification introduces the "Notification Center" as a new domain. No canonical spec exists for notifications; this is a full new domain spec. --- ## Non-Functional Requirements | ID | Requirement | |----|-------------| | NFR-1 | **Performance:** The `GET /notifications/unread` endpoint MUST respond in less than 10 milliseconds at p99 under normal load, backed by a partial index on `read_at IS NULL`. | | NFR-2 | **Security:** The API MUST enforce that every notification row is scoped to exactly one `user_id`; no endpoint MUST return or mutate a notification belonging to a different user. | | NFR-3 | **Scalability:** The `notifications` table MUST support high write volume from event producers without blocking reads; writes from `NotificationService.create_notification` MUST be independent of event producer transactions. | | NFR-4 | **Availability:** Notification creation failures in event producers MUST be caught, logged, and MUST NOT block the original event pipeline (lifecycle hooks, health monitor). | --- ## Requirements ### Requirement: R1 — Notification data model The system MUST provide a `Notification` SQLAlchemy model backed by a `notifications` table with the following columns: - `id` — `UUID`, primary key, default `gen_random_uuid()`. - `user_id` — `UUID`, foreign key to `users.id`, `NOT NULL`, indexed. - `category` — `VARCHAR(32)`, `NOT NULL` (e.g., `instance`, `system`, `health`, `security`). - `severity` — `VARCHAR(16)`, `NOT NULL` (e.g., `info`, `warning`, `error`, `success`). - `title` — `VARCHAR(255)`, `NOT NULL`. - `message` — `TEXT`, nullable. - `source_type` — `VARCHAR(64)`, nullable (e.g., `tool_instances`). - `source_id` — `UUID`, nullable (e.g., the related tool instance UUID). - `metadata` — `JSONB`, `NOT NULL DEFAULT '{}'`, stores unstructured extra data. - `read_at` — `TIMESTAMPTZ`, nullable, indexed. - `dismissed_at` — `TIMESTAMPTZ`, nullable. - `created_at` — `TIMESTAMPTZ`, `NOT NULL DEFAULT now()`, indexed. **Indexes:** - `idx_notifications_user_created_at` on `(user_id, created_at DESC)`. - `idx_notifications_user_unread` on `(user_id, read_at)` WHERE `read_at IS NULL` (partial index). **Foreign key:** `user_id` references `users.id` with `ON DELETE CASCADE`. **Migration:** `alembic/versions/YYYY_MM_DD_HHMMSS_add_notifications_table.py`. #### Scenario: SC-DB-1 — Migration creates table and indexes - GIVEN the Alembic migration runs successfully, - WHEN inspecting the database schema, - THEN the `notifications` table exists with all columns, the foreign key, and the two indexes including the partial index. --- ### Requirement: R2 — NotificationService The system MUST provide a `NotificationService` class with the following methods: - `create_notification(user_id, category, severity, title, message=None, source_type=None, source_id=None, metadata=None)` — inserts a row and returns the `Notification`. - `list_notifications(user_id, *, limit=20, offset=0, unread_only=False)` — returns notifications scoped to `user_id`, ordered by `created_at DESC`, excluding rows where `dismissed_at IS NOT NULL`. - `get_unread_count(user_id)` — returns the count of rows where `user_id` matches and `read_at IS NULL` and `dismissed_at IS NULL`. - `mark_read(notification_id, user_id)` — sets `read_at = now()` on the matching row; returns the updated `Notification`. - `mark_all_read(user_id)` — sets `read_at = now()` on all rows where `user_id` matches and `read_at IS NULL`; returns the number of rows updated. - `dismiss(notification_id, user_id)` — sets `dismissed_at = now()` on the matching row. All methods MUST filter by `user_id` so that no user can access another user's notifications. #### Scenario: SC-SVC-1 — Create notification - GIVEN a valid `user_id` and notification payload, - WHEN `create_notification` is called, - THEN a row is inserted with all provided fields, `read_at` is `NULL`, `dismissed_at` is `NULL`, and the row is returned. #### Scenario: SC-SVC-2 — List excludes dismissed - GIVEN two notifications for the same user, one dismissed and one not, - WHEN `list_notifications` is called, - THEN only the non-dismissed notification is returned. #### Scenario: SC-SVC-3 — Unread count query uses partial index - GIVEN 100 notifications for a user, 30 unread, - WHEN `get_unread_count` is executed, - THEN the query plan MUST use the partial index `idx_notifications_user_unread`. #### Scenario: SC-SVC-4 — Cross-user isolation - GIVEN a notification owned by user A, - WHEN user B calls `mark_read`, `dismiss`, or `list_notifications`, - THEN user B MUST NOT see or affect user A's notification. --- ### Requirement: R3 — REST API endpoints The system MUST expose a FastAPI router mounted at `/notifications` with the following endpoints. All endpoints require authentication and derive `current_user.id` from the auth dependency. #### GET /notifications Query parameters: - `limit` — integer, optional, default `20`, maximum `100`. - `offset` — integer, optional, default `0`. - `unread_only` — boolean, optional, default `false`. Response `200 OK`: ```json { "items": [ { "id": "uuid", "user_id": "uuid", "category": "string", "severity": "string", "title": "string", "message": "string | null", "source_type": "string | null", "source_id": "uuid | null", "metadata": {}, "read_at": "iso-datetime | null", "dismissed_at": "iso-datetime | null", "created_at": "iso-datetime" } ], "total": 0, "limit": 20, "offset": 0 } ``` #### GET /notifications/unread Response `200 OK`: ```json { "count": 0 } ``` #### PATCH /notifications/{id}/read Path parameter: `id` — UUID. Response `200 OK` — returns the updated notification object (same schema as list item). #### POST /notifications/mark-all-read Response `200 OK`: ```json { "marked_count": 0 } ``` #### DELETE /notifications/{id} Path parameter: `id` — UUID. Performs a soft delete by setting `dismissed_at`. Response `204 No Content`. #### Scenario: SC-API-1 — List with pagination and unread_only filter - GIVEN 5 notifications, 2 unread, for the authenticated user, - WHEN `GET /notifications?unread_only=true&limit=2` is called, - THEN the response contains exactly the 2 unread notifications, ordered by `created_at DESC`. #### Scenario: SC-API-2 — Mark single read updates read_at - GIVEN an unread notification owned by the caller, - WHEN `PATCH /notifications/{id}/read` is called, - THEN the response has `read_at` set to a non-null ISO datetime. #### Scenario: SC-API-3 — Mark all read affects only caller - GIVEN user A has 3 unread notifications and user B has 2 unread notifications, - WHEN user A calls `POST /notifications/mark-all-read`, - THEN the response `marked_count` is `3`, and user B's notifications remain unread. #### Scenario: SC-API-4 — Dismiss removes from list - GIVEN an unread notification owned by the caller, - WHEN `DELETE /notifications/{id}` is called, - THEN the endpoint returns `204`, and a subsequent `GET /notifications` no longer includes the dismissed row. --- ### Requirement: R4 — Event producers create notifications The system MUST ensure that `lifecycle_hooks.py` and `health_monitor.py` call `NotificationService.create_notification` after publishing the raw event, using `ToolInstance.owner_id` as the `user_id`. The notification MUST be created regardless of frontend preferences; filtering happens at read time and in the toast bridge. #### Scenario: SC-PROD-1 — Container error creates notification - GIVEN a running container owned by user U, - WHEN the health monitor detects a crash and publishes `instance.error`, - THEN a notification row is created for user U with `category="instance"`, `severity="error"`, and `source_type="tool_instances"`. #### Scenario: SC-PROD-2 — Lifecycle event creates notification - GIVEN a tool instance owned by user U, - WHEN a lifecycle hook publishes `instance.started`, - THEN a notification row is created for user U with `category="instance"` and `severity="info"`. #### Scenario: SC-PROD-3 — Notification failure does not block event pipeline - GIVEN `NotificationService.create_notification` raises an exception, - WHEN a lifecycle hook or health monitor publishes an event, - THEN the exception is caught and logged, the original event is still published, and the health monitor poll loop continues. --- ### Requirement: R5 — Frontend notification center component The system MUST provide a `` component mounted inside the `AppShell` `header-actions` area on desktop (hidden when `isMobileTerminal` is true). The component MUST: - Render a bell icon (Phosphor `Bell`). - Display an unread count badge when `unreadCount > 0`. - Open a dropdown panel on bell click. - Close the dropdown on outside click or `Escape` key press. - Render a scrollable list of recent notifications inside the panel. - Show an empty state when no notifications exist. - Provide a "Mark all as read" action in the panel footer. Each notification row MUST display: - A severity icon mapped from `severity`. - The `title`. - A relative timestamp derived from `created_at`. - "Mark read" and "Dismiss" actions. Unread rows MUST be visually distinct from read rows. #### Scenario: SC-UI-1 — Bell renders with badge - GIVEN the user has 3 unread notifications, - WHEN the AppShell header is rendered, - THEN the bell icon is visible and the badge displays `3`. #### Scenario: SC-UI-2 — Dropdown opens and lists notifications - GIVEN the user has notifications, - WHEN the user clicks the bell icon, - THEN the dropdown opens and lists up to the default limit of notifications with title, relative time, and severity icon. #### Scenario: SC-UI-3 — Empty state - GIVEN the user has zero notifications, - WHEN the dropdown opens, - THEN an empty state message is shown (e.g., "No notifications"). --- ### Requirement: R6 — Frontend polling The system MUST poll the notification endpoints at the following intervals while the user is authenticated: - `GET /notifications/unread` every 15 seconds to update the badge count. - `GET /notifications` every 30 seconds to refresh the list. When the dropdown is opened, the list MUST be refreshed immediately regardless of the polling timer. #### Scenario: SC-POLL-1 — Badge updates on new notification - GIVEN the badge shows `0`, - WHEN a new unread notification is created on the backend, - THEN the badge updates to `1` within 15 seconds (one polling interval). #### Scenario: SC-POLL-2 — List refreshes on open - GIVEN the dropdown is closed and a new notification arrives, - WHEN the user opens the dropdown, - THEN the list is fetched immediately and includes the new notification. --- ### Requirement: R7 — Toast coordination respecting user preferences The system MUST update `EventToastBridge` to check user notification preferences before showing a toast for an SSE event. The bridge MUST: - Skip the toast entirely if `notification_toast_level` is `"none"`. - Skip the toast if the event's mapped `severity` is below the threshold: - `"errors"` level: only show toasts for `severity="error"`. - Skip the toast if the event's `category` is present in `notification_mute_categories`. The notification row on the backend is still created; the bridge only controls toast surfacing. #### Scenario: SC-TOAST-1 — Toast level "none" suppresses all toasts - GIVEN `notification_toast_level` is `"none"`, - WHEN an `instance.error` event arrives via SSE, - THEN no toast is shown. #### Scenario: SC-TOAST-2 — Toast level "errors" suppresses info/warning - GIVEN `notification_toast_level` is `"errors"`, - WHEN an `instance.started` event (severity `info`) arrives via SSE, - THEN no toast is shown; an `instance.error` event still produces a toast. #### Scenario: SC-TOAST-3 — Muted category suppresses toast - GIVEN `notification_mute_categories` contains `["instance"]` and `notification_toast_level` is `"all"`, - WHEN an `instance.error` event arrives via SSE, - THEN no toast is shown for that event. --- ### Requirement: R8 — User preferences in UserConfig The system MUST extend the `UserConfig` JSON `config` blob with two new keys: - `notification_mute_categories` — `string[]`, default `[]`. Categories listed here are excluded from `list_notifications` results and suppress toasts for matching events. - `notification_toast_level` — `"all" | "errors" | "none"`, default `"all"`. The `list_notifications` service method MUST filter out rows whose `category` is in the caller's `notification_mute_categories`. Preference changes MUST take effect immediately without a server restart. #### Scenario: SC-PREF-1 — Muted category excluded from list - GIVEN `notification_mute_categories` contains `["instance"]` and the user has instance and system notifications, - WHEN `GET /notifications` is called, - THEN the response contains only system notifications; instance notifications are omitted. #### Scenario: SC-PREF-2 — Preference change is immediate - GIVEN `notification_toast_level` is `"all"`, - WHEN the user changes it to `"none"` and saves the preference, - THEN the next SSE event does not produce a toast. --- ## Error Handling ### EH-1: Notification not found or not owned If a `PATCH /notifications/{id}/read` or `DELETE /notifications/{id}` request targets a notification that does not exist or is owned by a different user, the endpoint MUST return `404 Not Found`. The response body SHOULD include a detail message: `"Notification not found"`. ### EH-2: Invalid category or severity If `NotificationService.create_notification` is called with a `category` or `severity` value that does not conform to the project's allowed set, the service SHOULD raise a validation error (e.g., `ValueError`), and the caller SHOULD log it without blocking the event pipeline. ### EH-3: Service exceptions in event producers `lifecycle_hooks.py` and `health_monitor.py` MUST wrap `NotificationService.create_notification` calls in a `try/except` block. On exception, the error MUST be logged with `correlation_id`, and the original event publishing MUST continue. ### EH-4: Polling failure If a polling request (`GET /notifications` or `GET /notifications/unread`) fails on the frontend, the error MUST be silently logged (not thrown as an unhandled exception), and the next polling cycle MUST proceed on schedule. --- ## Scenarios (Acceptance Criteria Summary) | ID | Scenario | |----|----------| | SC-1 | **Container error → notification created for owner → badge increments.** A health monitor crash detection creates a notification for the instance owner; within one 15-second poll cycle, the frontend badge increments. | | SC-2 | **User clicks bell → dropdown opens → shows unread notifications.** Clicking the bell renders the dropdown panel with unread rows visually distinct. | | SC-3 | **User marks notification read → badge decrements → row styling changes.** Clicking "Mark read" or the row triggers `PATCH /notifications/{id}/read`; the badge count decreases by one; the row styling updates to the read state. | | SC-4 | **User dismisses notification → row removed → persists on refresh.** Clicking "Dismiss" triggers `DELETE /notifications/{id}`; the row is removed from the list; on page reload the row remains absent. | | SC-5 | **User clicks "mark all read" → badge resets to 0.** Clicking "Mark all as read" triggers `POST /notifications/mark-all-read`; the badge shows `0`; all visible rows transition to the read state. | | SC-6 | **User sets toast level to "none" → no toast shown for new events.** Changing `notification_toast_level` to `"none"` prevents the `EventToastBridge` from showing any toast for incoming SSE events. | | SC-7 | **User mutes "instance" category → no instance notifications in list.** Adding `"instance"` to `notification_mute_categories` removes instance notifications from `GET /notifications` and suppresses instance toasts. | --- ## API Contract Reference ### Request / Response Schemas **NotificationItem:** | Field | Type | Nullable | |-------|------|----------| | id | UUID string | no | | user_id | UUID string | no | | category | string (max 32) | no | | severity | string (max 16) | no | | title | string (max 255) | no | | message | string | yes | | source_type | string (max 64) | yes | | source_id | UUID string | yes | | metadata | object | no (default `{}`) | | read_at | ISO 8601 datetime | yes | | dismissed_at | ISO 8601 datetime | yes | | created_at | ISO 8601 datetime | no | **NotificationListResponse:** | Field | Type | |-------|------| | items | NotificationItem[] | | total | integer | | limit | integer | | offset | integer | **UnreadCountResponse:** | Field | Type | |-------|------| | count | integer | **MarkAllReadResponse:** | Field | Type | |-------|------| | marked_count | integer | ### Endpoints Summary | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/notifications` | Required | List notifications with pagination and `unread_only` filter. | | GET | `/notifications/unread` | Required | Returns `{ count: int }` for the authenticated user. | | PATCH | `/notifications/{id}/read` | Required | Marks a single notification read. | | POST | `/notifications/mark-all-read` | Required | Marks all unread notifications read for the caller. | | DELETE | `/notifications/{id}` | Required | Soft-deletes (dismisses) a single notification. |