cbaebcf649
- 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
417 lines
18 KiB
Markdown
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. |
|