Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions openspec/changes/add-svelte-notifications-management/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Design: Add Svelte Notifications Management

## Backend

### NotificationService extraction

Extract shared notification logic from `StatusController` into a service to avoid duplication:

```csharp
// src/Exceptionless.Core/Services/NotificationService.cs
public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider)
{
public Task<SystemNotification?> GetSystemNotificationAsync();
public Task<SystemNotification> SetSystemNotificationAsync(string message, bool publish = true);
public Task ClearSystemNotificationAsync(bool publish = true);
public Task<ReleaseNotification> SendReleaseNotificationAsync(string? message, bool critical);
}
```

- Cache key: `system-notification` (no TTL — persists until explicitly cleared)
- Publish: via `IMessagePublisher` (unchanged)
- `StatusController` delegates to this service (behavior unchanged for existing endpoints)

### New StatusController endpoints

Two new endpoints added to `StatusController` (all notification routes live under `/api/v2/notifications/`):

| Method | Route | Auth | Body | Response |
|--------|-------|------|------|----------|
| GET | `notifications/settings` | GlobalAdminPolicy | — | `NotificationSettingsResponse` |
| POST | `notifications/force-refresh` | GlobalAdminPolicy | optional `{ value: string }` | `ReleaseNotification` |

Existing endpoints updated with `bool publish = true` query parameter:

| Method | Route | Change |
|--------|-------|--------|
| POST | `notifications/system` | Added `?publish=true/false` |
| DELETE | `notifications/system` | Added `?publish=true/false` |

### DTO

`NotificationSettingsResponse` (in `src/Exceptionless.Core/Models/Messaging/`):

```csharp
public record NotificationSettingsResponse
{
public string? ConfiguredSystemNotificationMessage { get; init; }
public SystemNotification? SystemNotification { get; init; }
}
```

Serialized as snake_case via the project's `LowerCaseUnderscoreNamingPolicy`:
- `configured_system_notification_message`
- `system_notification`

### Message length validation

All notification message inputs are capped at **1000 characters** via `MaxNotificationMessageLength` constant in `StatusController`. Applies to `PostSystemNotificationAsync`, `PostReleaseNotificationAsync`, and `ForceRefreshAsync`.

### Authorization

- `GET notifications/system` — UserPolicy (banner shown to all logged-in users)
- All other notification endpoints — GlobalAdminPolicy

## Frontend (Svelte)

### Feature module

`src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/`

- `models.ts` — TypeScript DTOs matching snake_case API serialization
- `api.svelte.ts` — TanStack Query wrappers using `useFetchClient`
- `resolve-message.ts` — Pure function for three-tier message resolution
- `force-refresh-coordinator.ts` — Module-level flag to handle admin self-reload race
- `components/system-notification-banner.svelte` — Banner rendering component

### Three-tier message resolution

Implemented in `resolve-message.ts`:

```
Priority (highest to lowest):
1. realtimeMessage — from WebSocket (undefined = not yet received, null = explicitly cleared)
2. persistedMessage — from GET /notifications/system on mount
3. fallbackMessage — from PUBLIC_SYSTEM_NOTIFICATION_MESSAGE env var
```

When `realtimeMessage` is `null` (explicit WebSocket clear), the persisted value is **skipped** — only the env fallback can show. This is intentional: the WebSocket clear signals "clear the dynamic notification" not "suppress all messages including env".

### Force-refresh self-reload coordinator

A module-level flag (`force-refresh-coordinator.ts`) prevents the initiating admin tab from reloading before the success toast renders:

- `flagSelfInitiatedForceRefresh()` — called by the admin page before the API request
- `consumeSelfInitiatedFlag()` — called by the banner on critical `ReleaseNotification`
- If flag was set: delay reload by **1500ms** (toast-visible window) then reload
- If flag was not set (other clients): reload **immediately**

### Notification banners

Rendered in `src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte`.

Behavior:
1. On mount: fetch `GET /api/v2/notifications/system` (30s staleTime; WebSocket is primary realtime path)
2. Fallback: `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` env var
3. Listen for `SystemNotification` and `ReleaseNotification` DOM CustomEvents
4. System notification → destructive Alert with `role="alert"` and `aria-live="assertive"`
5. Release notification → info Alert with `role="status"` and `aria-live="polite"`
6. Critical release → page reload (immediate for non-initiators, 1500ms delayed for initiating admin)

Security: Plain text only. No `{@html}`.

### Admin page

Route: `/system/notifications`
- Added to system nav in `routes.svelte.ts`, visible to global admins only
- Card layout showing current state (configured fallback + active notification)
- Dialog-based actions: set notification, clear, send release, force refresh
- All textareas enforce `maxlength={1000}` matching backend validation
- Uses shadcn-svelte Alert, Button, Dialog, Textarea, Card
- Mutations use svelte-sonner for success/error toasts
- Invalidates queries on mutation success

### Query keys

```typescript
export const queryKeys = {
current: ['notifications', 'system'] as const,
settings: ['notifications', 'settings'] as const
};
```

## Security

- Admin endpoints behind GlobalAdminPolicy
- No HTML injection: plain text rendering only
- Message length capped at 1000 chars (both frontend maxlength and backend validation)
- No secrets exposed in admin response (only configured message text)

## Accessibility

- Banners use `role="alert"` / `role="status"` with appropriate `aria-live`
- Color is not sole indicator (icon + text in banners)
29 changes: 29 additions & 0 deletions openspec/changes/add-svelte-notifications-management/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Proposal: Add Svelte Notifications Management

## Summary

Implement global notification support in the Svelte 5 UI matching the legacy Angular behavior: system notification banners, release notification banners, critical release force-refresh, configuration fallback messages, and a global-admin notification management page.

New endpoints are added to `StatusController` (rather than a new AdminController) to keep all notification routes under the existing `/api/v2/notifications/` prefix and avoid proliferating controllers.

## Classification

- **Type:** Feature + UI migration
- **Affected areas:** Backend/API, Svelte UI, Redis (cache key `system-notification`), WebSocket messages, tests
- **OpenSpec justification:** New API endpoints, WebSocket message consumption in new UI, admin authorization, cross-cutting UI/API contract, Angular-to-Svelte behavior migration

## Compatibility Risks

| Risk | Mitigation |
|------|-----------|
| Existing `StatusController` notification endpoints | Preserved unchanged; new endpoints and query params are additive |
| WebSocket message format (`SystemNotification`, `ReleaseNotification`) | Consumed as-is; no format changes |
| Redis cache key `system-notification` | Shared via extracted `NotificationService` |
| Config key `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` | Read-only consumption; no changes to how it's set |
| SDK/client expectations | No SDK changes; WebSocket messages unchanged |

## Rollback Plan

- New endpoints are additive; removing them has no impact on existing clients.
- Frontend notification banners degrade gracefully (no banner shown if fetch fails).
- Admin page is behind global-admin nav guard; removing it is safe.
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Spec: Notifications

## ADDED: StatusController notification management endpoints

### Requirement: Admin can read notification settings

Given an authenticated global admin user
When they send `GET /api/v2/notifications/settings`
Then the response is 200 with a `NotificationSettingsResponse`:
- `configured_system_notification_message`: the `AppOptions.NotificationMessage` value (may be null)
- `system_notification`: the currently cached `SystemNotification` (may be null)

### Requirement: Non-admin cannot access admin notification endpoints

Given an authenticated non-admin user
When they send any request to `POST/DELETE /api/v2/notifications/*` or `GET /api/v2/notifications/settings`
Then the response is 403 Forbidden.

### Requirement: Admin can set a system notification

Given an authenticated global admin user
When they send `POST /api/v2/notifications/system` with body `{ "value": "Maintenance tonight" }`
Then:
- The system notification is stored in cache key `system-notification`
- A `SystemNotification` message is published to the message bus (when `publish=true`, the default)
- The response is 200 with the `SystemNotification` object

#### Scenario: Empty message is rejected

Given an authenticated global admin user
When they send `POST /api/v2/notifications/system` with body `{ "value": "" }`
Then the response is 400 Bad Request.

#### Scenario: Publish suppression

Given an authenticated global admin user
When they send `POST /api/v2/notifications/system?publish=false` with body `{ "value": "Silent" }`
Then:
- The system notification is stored in cache
- No message is published to the message bus
- The response is 200 with the `SystemNotification` object

### Requirement: Admin can clear a system notification

Given an authenticated global admin user
When they send `DELETE /api/v2/notifications/system`
Then:
- The `system-notification` cache key is removed
- A blank `SystemNotification` is published to the message bus (when `publish=true`, the default)
- The response is 200

#### Scenario: Clear without publish

Given an authenticated global admin user
When they send `DELETE /api/v2/notifications/system?publish=false`
Then:
- The `system-notification` cache key is removed
- No message is published to the message bus
- The response is 200

### Requirement: Admin can send a release notification

Given an authenticated global admin user
When they send `POST /api/v2/notifications/release` with body `{ "value": "v8.0 released" }` and optional `?critical=false`
Then:
- A `ReleaseNotification` is published to the message bus
- The response is 200 with the `ReleaseNotification` object

### Requirement: Admin can force-refresh all clients

Given an authenticated global admin user
When they send `POST /api/v2/notifications/force-refresh` with optional body `{ "value": "reason" }`
Then:
- A `ReleaseNotification` is published with `critical=true` and optional message
- The response is 200 with the `ReleaseNotification` object

#### Scenario: Message exceeds max length

Given an authenticated global admin user
When they send `POST /api/v2/notifications/force-refresh` with a message body exceeding 1000 characters
Then the response is 400 Bad Request.

## ADDED: Svelte notification banners (Svelte UI only)

### Requirement: System notification banner displays on page load

Given a system notification is persisted in cache
When a user loads any authenticated page in the Svelte app
Then a destructive/danger banner is displayed at the top of the layout with the notification message.

#### Scenario: Fallback to configured message

Given no system notification is persisted in cache
And `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` is set to "Scheduled maintenance"
When a user loads any authenticated page
Then a destructive/danger banner displays "Scheduled maintenance".

#### Scenario: No notification and no fallback

Given no system notification is persisted in cache
And `PUBLIC_SYSTEM_NOTIFICATION_MESSAGE` is empty
When a user loads any authenticated page
Then no system notification banner is displayed.

### Requirement: Realtime system notification updates via WebSocket

Given a user has the Svelte app open
When a `SystemNotification` WebSocket message is received with a non-empty message
Then the system notification banner updates to show the new message.

#### Scenario: Clear notification via WebSocket

Given a user has the Svelte app open with a system notification banner visible
When a `SystemNotification` WebSocket message is received with an empty/null message
Then the system notification banner is hidden (falls back to env var if configured).

### Requirement: Release notification banner displays on WebSocket message

Given a user has the Svelte app open
When a `ReleaseNotification` WebSocket message is received with `critical=false`
Then an info banner is displayed with the release message.

### Requirement: Critical release notification triggers page reload

Given a user has the Svelte app open
When a `ReleaseNotification` WebSocket message is received with `critical=true`
Then `window.location.reload()` is called.

#### Scenario: Initiating admin tab gets a grace period

Given an admin has just triggered force-refresh from the admin page
When the resulting `critical=true` WebSocket event arrives on their own tab
Then `window.location.reload()` is delayed by 1500ms (so the success toast is visible).
All other connected clients still reload immediately.

### Requirement: Notification banners use accessible markup

Given a system notification banner is displayed
Then it has `role="alert"` and `aria-live="assertive"`.

Given a release notification banner is displayed
Then it has `role="status"` and `aria-live="polite"`.

### Requirement: No unsafe HTML rendering

Given any notification message content
When it is rendered in the Svelte UI
Then it is rendered as plain text (no innerHTML or {@html}).

## ADDED: Svelte admin notifications page (Svelte UI only)

### Requirement: System → Notifications page exists for global admins

Given an authenticated global admin user
When they navigate to `/system/notifications`
Then they see the notification management page with:
- Current configured fallback message (read-only)
- Current persisted system notification (if any)
- Actions: Set system notification, Clear, Send release notification, Force refresh

### Requirement: Notifications page is hidden from non-admins

Given an authenticated non-admin user
When they view the system navigation
Then the "Notifications" link is not visible.

## HTTP test file updates

### Requirement: tests/http files updated for notification endpoints

Given the notification endpoints exist in StatusController
Then `tests/http/status.http` includes sample requests for:
- GET notifications/settings
- POST notifications/system
- DELETE notifications/system
- POST notifications/release
- POST notifications/force-refresh

Loading
Loading