Files
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

417 lines
18 KiB
Markdown

# 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 `<NotificationCenter />` 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. |