# 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. |
