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

18 KiB

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:

  • idUUID, primary key, default gen_random_uuid().
  • user_idUUID, foreign key to users.id, NOT NULL, indexed.
  • categoryVARCHAR(32), NOT NULL (e.g., instance, system, health, security).
  • severityVARCHAR(16), NOT NULL (e.g., info, warning, error, success).
  • titleVARCHAR(255), NOT NULL.
  • messageTEXT, nullable.
  • source_typeVARCHAR(64), nullable (e.g., tool_instances).
  • source_idUUID, nullable (e.g., the related tool instance UUID).
  • metadataJSONB, NOT NULL DEFAULT '{}', stores unstructured extra data.
  • read_atTIMESTAMPTZ, nullable, indexed.
  • dismissed_atTIMESTAMPTZ, nullable.
  • created_atTIMESTAMPTZ, 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:

{
  "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:

{
  "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:

{
  "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_categoriesstring[], 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.