diff --git a/openspec/changes/add-svelte-notifications-management/design.md b/openspec/changes/add-svelte-notifications-management/design.md new file mode 100644 index 0000000000..b42a193714 --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/design.md @@ -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 GetSystemNotificationAsync(); + public Task SetSystemNotificationAsync(string message, bool publish = true); + public Task ClearSystemNotificationAsync(bool publish = true); + public Task 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) diff --git a/openspec/changes/add-svelte-notifications-management/proposal.md b/openspec/changes/add-svelte-notifications-management/proposal.md new file mode 100644 index 0000000000..059c31b120 --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/proposal.md @@ -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. diff --git a/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md b/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md new file mode 100644 index 0000000000..19ce3b5d3b --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/specs/notifications/spec.md @@ -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 + diff --git a/openspec/changes/add-svelte-notifications-management/tasks.md b/openspec/changes/add-svelte-notifications-management/tasks.md new file mode 100644 index 0000000000..acef849c75 --- /dev/null +++ b/openspec/changes/add-svelte-notifications-management/tasks.md @@ -0,0 +1,71 @@ +# Tasks: Add Svelte Notifications Management + +## Backend + +- [x] **Task 1: Extract NotificationService** + - Created `src/Exceptionless.Core/Services/NotificationService.cs` + - Methods: `GetSystemNotificationAsync`, `SetSystemNotificationAsync`, `ClearSystemNotificationAsync`, `SendReleaseNotificationAsync` + - Registered as singleton in DI (`Bootstrapper.cs`) + - Refactored `StatusController` to delegate to `NotificationService` (no behavior change) + +- [x] **Task 2: Add new StatusController endpoints** + - `GET notifications/settings` — returns configured fallback + current notification (admin only) + - `POST notifications/force-refresh` — force reload all clients via critical release notification (admin only) + - Added `bool publish = true` query param to POST and DELETE system notification endpoints + - All endpoints use existing `ValueFromBody` pattern for backward compatibility + +- [x] **Task 3: Update HTTP test samples** + - Updated `tests/http/status.http` with force-refresh and settings endpoint samples + +- [x] **Task 4: Add backend integration tests** + - Added tests to existing `tests/Exceptionless.Tests/Controllers/StatusControllerTests.cs` + - Tests: settings (admin/non-admin), force-refresh (with message/without/non-admin), publish flag, message length limits + - All tests follow AAA (Arrange/Act/Assert) pattern + +## Frontend — System Notification Banner + +- [x] **Task 5: Create system-notifications feature module** + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts` + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts` + - All API calls target StatusController routes (`notifications/*`) + +- [x] **Task 6: Create system-notification-banner component** + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/components/system-notification-banner.svelte` + - Three-tier message resolution: realtime WebSocket → persisted API → env fallback (implemented in `resolve-message.ts`) + - System = destructive Alert with `role="alert"` `aria-live="assertive"` + - Release = info Alert with `role="status"` `aria-live="polite"` + - Critical release = `window.location.reload()` (immediate for all clients; 1500ms delayed on initiating admin tab via `force-refresh-coordinator.ts`) + +- [x] **Task 6b: Add force-refresh coordinator** + - Created `src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/force-refresh-coordinator.ts` + - Prevents admin self-reload race: flags are set before API call, consumed in banner's critical handler + +- [x] **Task 7: Integrate banner into app layout** + - Added `` to `(app)/+layout.svelte` + +- [x] **Task 8: Add unit tests** + - `resolve-message.test.ts` — 9 tests for `resolveDisplayMessage` three-tier resolution logic + - `force-refresh-coordinator.test.ts` — 4 tests for the self-initiated reload flag + +## Frontend — Admin Page + +- [x] **Task 9: Add System → Notifications route and nav entry** + - Created `src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte` + - Added Bell icon + Notifications nav entry in `routes.svelte.ts`, visible only to global admins + +- [x] **Task 10: Implement notifications admin page** + - Displays current state (configured fallback, persisted notification) + - Actions via dialogs: Set system notification, Clear/reset, Send release notification, Force refresh + - Uses shadcn-svelte components, svelte-sonner toasts, TanStack Query mutations + - Invalidates notification queries on success + +## Final Validation + +- [x] **Task 11: Full build and test validation** + - `dotnet build` ✓ + - `dotnet test -- --filter-class StatusControllerTests` → 19/19 pass ✓ + - `npm run check` (svelte-check) → 0 errors ✓ + - `npm run lint` → clean ✓ + - `npm run build` → success ✓ + - `npm run test:unit` → 249/249 pass ✓ + - UI dogfood via browser → all flows verified ✓ diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index bfd443ef16..751d8e0880 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -198,6 +198,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddStartupAction(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Services/NotificationService.cs b/src/Exceptionless.Core/Services/NotificationService.cs new file mode 100644 index 0000000000..10b0f093b6 --- /dev/null +++ b/src/Exceptionless.Core/Services/NotificationService.cs @@ -0,0 +1,40 @@ +using Exceptionless.Core.Messaging.Models; +using Foundatio.Caching; +using Foundatio.Messaging; + +namespace Exceptionless.Core.Services; + +public class NotificationService(ICacheClient cacheClient, IMessagePublisher messagePublisher, TimeProvider timeProvider) +{ + private const string SystemNotificationCacheKey = "system-notification"; + + public async Task GetSystemNotificationAsync() + { + var result = await cacheClient.GetAsync(SystemNotificationCacheKey); + return result.HasValue ? result.Value : null; + } + + public async Task SetSystemNotificationAsync(string message, bool publish = true) + { + var notification = new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime, Message = message }; + await cacheClient.SetAsync(SystemNotificationCacheKey, notification); + if (publish) + await messagePublisher.PublishAsync(notification); + return notification; + } + + public async Task ClearSystemNotificationAsync(bool publish = true) + { + await cacheClient.RemoveAsync(SystemNotificationCacheKey); + if (publish) + await messagePublisher.PublishAsync(new SystemNotification { Date = timeProvider.GetUtcNow().UtcDateTime }); + } + + public async Task SendReleaseNotificationAsync(string? message, bool critical, bool publish = true) + { + var notification = new ReleaseNotification { Critical = critical, Date = timeProvider.GetUtcNow().UtcDateTime, Message = message }; + if (publish) + await messagePublisher.PublishAsync(notification); + return notification; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts new file mode 100644 index 0000000000..60f64e9fc4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts @@ -0,0 +1,67 @@ +import type { ReleaseNotification, SystemNotification } from '$features/websockets/models'; + +import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; + +export const queryKeys = { + current: ['notifications', 'system'] as const +}; + +export function clearSystemNotificationMutation() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (params: { publish?: boolean }) => { + const client = useFetchClient(); + const publish = params.publish !== false; + await client.delete(`notifications/system?publish=${publish}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.current }); + } + })); +} + +export function getCurrentSystemNotificationQuery() { + return createQuery(() => ({ + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('notifications/system', { signal }); + + return response.data ?? null; + }, + queryKey: queryKeys.current, + staleTime: 30_000 + })); +} + +export function sendReleaseNotificationMutation() { + return createMutation(() => ({ + mutationFn: async (params: { critical?: boolean; message?: string }) => { + const client = useFetchClient(); + const critical = params.critical ?? false; + const response = await client.postJSON(`notifications/release?critical=${critical}`, { + value: params.message ?? null + }); + + return response.data!; + } + })); +} + +export function setSystemNotificationMutation() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (params: { message: string; publish?: boolean }) => { + const client = useFetchClient(); + const publish = params.publish !== false; + const response = await client.postJSON(`notifications/system?publish=${publish}`, { + value: params.message + }); + + return response.data!; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.current }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte new file mode 100644 index 0000000000..7b037da9dd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte @@ -0,0 +1,51 @@ + + +{#if displayMessage} + + {#snippet icon()} + + {/snippet} + {displayMessage} + +{/if} + +{#if releaseMessage} + + {#snippet icon()} + + {/snippet} + {releaseMessage} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte index a029fd7ea9..c00c62c076 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte @@ -38,9 +38,9 @@