feat: Add global Notifications management (admin endpoints + Svelte UI)#2256
Open
niemyjski wants to merge 6 commits into
Open
feat: Add global Notifications management (admin endpoints + Svelte UI)#2256niemyjski wants to merge 6 commits into
niemyjski wants to merge 6 commits into
Conversation
Extract NotificationService from StatusController to centralize notification logic (system notifications, release notifications, force refresh) while maintaining full backward compatibility with existing StatusController endpoints. Backend: - NotificationService with get/set/clear system notification and send release - 5 new admin endpoints under /admin/notifications (GET settings, PUT/DELETE system, POST release, POST force-refresh) - Admin DTOs for request/response models - DI registration in Bootstrapper Frontend: - Notification feature module with models and TanStack Query API layer - NotificationBanners component in app layout for system/release messages - Admin page at /system/notifications with dialog-based actions - Nav entry in system routes Tests: - 13 backend integration tests covering auth, validation, and legacy compat - 6 frontend unit tests for notification event logic - HTTP test samples for all admin endpoints Co-authored-by: Claude <noreply@anthropic.com>
[Consumes("application/json")] on an endpoint with optional body causes
ASP.NET Core to return 404 when called without Content-Type header.
Since the request body is nullable/optional, remove the constraint.
Added test: ForceRefresh_WithNoBody_UsesDefaultMessage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…system-notifications Architectural cleanup: - Removed duplicate admin endpoints from AdminController — StatusController already had these endpoints with proper auth - Added GET /notifications/settings and POST /notifications/force-refresh to StatusController where they belong - Added 'publish' query param to POST/DELETE system notification (backward compatible, defaults to true) - Deleted NotificationModels.cs — use existing ValueFromBody + query param patterns instead of custom DTOs - Renamed frontend feature from 'notifications' to 'system-notifications' - Renamed component from 'notification-banners' to 'system-notification-banner' - Moved tests from AdminNotificationTests to StatusControllerTests (with AAA) - Deleted admin-notifications.http, added samples to status.http - Updated OpenSpec tasks to reflect final architecture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a global system-notifications management feature: backend NotificationService plus two new StatusController endpoints (GET notifications/settings, POST notifications/force-refresh) and a publish flag on the existing set/clear endpoints, a Svelte 5 admin page at /system/notifications, a global banner component that resolves messages from realtime WebSocket → persisted API → env fallback, and accompanying tests and OpenSpec docs.
Changes:
- Extract notification logic into
NotificationServiceand refactorStatusControllerto delegate, addingnotifications/settings,notifications/force-refresh, and apublishquery param. - Add Svelte
system-notification-banner(mounted in(app)/+layout.svelte) and a/system/notificationsadmin page with TanStack Query API wrappers and models. - Add backend integration tests, frontend unit tests, HTTP samples, and an OpenSpec proposal/design/spec/tasks set.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Exceptionless.Core/Services/NotificationService.cs | New service centralizing cache + message-bus notification operations. |
| src/Exceptionless.Core/Bootstrapper.cs | Registers NotificationService as a singleton. |
| src/Exceptionless.Web/Controllers/StatusController.cs | Delegates to NotificationService; adds settings, force-refresh, and publish flag. |
| tests/Exceptionless.Tests/Controllers/StatusControllerTests.cs | Adds tests for new endpoints and publish=false. |
| tests/http/status.http | HTTP samples for force-refresh and settings. |
| src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/models.ts | Frontend types for settings response. |
| src/Exceptionless.Web/ClientApp/src/lib/features/system-notifications/api.svelte.ts | TanStack Query wrappers for notification endpoints. |
| .../components/system-notification-banner.svelte | Global banner with realtime/persisted/env-fallback resolution. |
| .../components/system-notification-banner.test.ts | Unit tests (currently only exercise CustomEvent semantics). |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte | Mounts SystemNotificationBanner in the app layout. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/system/notifications/+page.svelte | New admin management page with action dialogs. |
| src/Exceptionless.Web/ClientApp/src/routes/(app)/system/routes.svelte.ts | Adds Bell-icon nav entry for global admins. |
| openspec/changes/add-svelte-notifications-management/* | Proposal, design, spec, tasks for the change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+3
to
+67
| ## ADDED: Admin notification management endpoints | ||
|
|
||
| ### Requirement: Admin can read notification settings | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `GET /api/v2/admin/notifications` | ||
| Then the response is 200 with: | ||
| - `configured_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 `/api/v2/admin/notifications/*` | ||
| Then the response is 403 Forbidden. | ||
|
|
||
| ### Requirement: Admin can set a system notification | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `PUT /api/v2/admin/notifications/system` with body `{ "message": "Maintenance tonight" }` | ||
| Then: | ||
| - The system notification is stored in cache key `system-notification` | ||
| - A `SystemNotification` message is published to the message bus | ||
| - The response is 200 with the `SystemNotification` object | ||
|
|
||
| #### Scenario: Empty message is rejected | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `PUT /api/v2/admin/notifications/system` with body `{ "message": "" }` | ||
| Then the response is 400 Bad Request. | ||
|
|
||
| ### Requirement: Admin can clear a system notification | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `DELETE /api/v2/admin/notifications/system` | ||
| Then: | ||
| - The `system-notification` cache key is removed | ||
| - A blank `SystemNotification` is published to the message bus | ||
| - The response is 204 No Content | ||
|
|
||
| #### Scenario: Clear without publish | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `DELETE /api/v2/admin/notifications/system?publish=false` | ||
| Then: | ||
| - The `system-notification` cache key is removed | ||
| - No message is published to the message bus | ||
| - The response is 204 No Content | ||
|
|
||
| ### Requirement: Admin can send a release notification | ||
|
|
||
| Given an authenticated global admin user | ||
| When they send `POST /api/v2/admin/notifications/release` with body `{ "message": "v8.0 released", "critical": false }` | ||
| Then: | ||
| - A `ReleaseNotification` is published to the message bus with `critical=false` | ||
| - 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/admin/notifications/force-refresh` | ||
| Then: | ||
| - A `ReleaseNotification` is published with `critical=true` and no message | ||
| - The response is 200 with the `ReleaseNotification` object | ||
|
|
Comment on lines
+237
to
+252
| [Fact] | ||
| public async Task PostSystemNotificationAsync_WithPublishFalse_DoesNotPublish() | ||
| { | ||
| // Arrange & Act | ||
| var notification = await SendRequestAsAsync<SystemNotification>(r => r | ||
| .Post() | ||
| .AsGlobalAdminUser() | ||
| .AppendPath("notifications/system") | ||
| .QueryString("publish", "false") | ||
| .Content(new ValueFromBody<string>("Silent notification")) | ||
| .StatusCodeShouldBeOk()); | ||
|
|
||
| // Assert | ||
| Assert.NotNull(notification); | ||
| Assert.Equal("Silent notification", notification.Message); | ||
| } |
Comment on lines
+212
to
+225
| [Fact] | ||
| public async Task ForceRefresh_WithNoBody_UsesDefaultMessage() | ||
| { | ||
| // Act | ||
| var notification = await SendRequestAsAsync<ReleaseNotification>(r => r | ||
| .Post() | ||
| .AsGlobalAdminUser() | ||
| .AppendPath("notifications/force-refresh") | ||
| .StatusCodeShouldBeOk()); | ||
|
|
||
| // Assert | ||
| Assert.NotNull(notification); | ||
| Assert.True(notification.Critical); | ||
| } |
Comment on lines
+1
to
+70
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| // NOTE: Testing the notification-banners component directly is impractical because: | ||
| // 1. It imports from '$env/dynamic/public' which requires SvelteKit runtime | ||
| // 2. The component relies on DOM CustomEvent listeners set up via $effect | ||
| // 3. Mocking the env module requires SvelteKit-specific test setup | ||
| // | ||
| // Instead, we test the core logic that the component depends on: | ||
| // - Event handling behavior | ||
| // - Message resolution logic | ||
|
|
||
| describe('notification-banners logic', () => { | ||
| it('SystemNotification event with message provides the message', () => { | ||
| const event = new CustomEvent('SystemNotification', { | ||
| bubbles: true, | ||
| detail: { date: new Date().toISOString(), message: 'Maintenance tonight' } | ||
| }); | ||
|
|
||
| expect(event.detail.message).toBe('Maintenance tonight'); | ||
| }); | ||
|
|
||
| it('SystemNotification event with no message resets to null', () => { | ||
| const event = new CustomEvent('SystemNotification', { | ||
| bubbles: true, | ||
| detail: { date: new Date().toISOString(), message: undefined } | ||
| }); | ||
|
|
||
| const message = event.detail.message || null; | ||
| expect(message).toBeNull(); | ||
| }); | ||
|
|
||
| it('ReleaseNotification with critical=true indicates reload needed', () => { | ||
| const event = new CustomEvent('ReleaseNotification', { | ||
| bubbles: true, | ||
| detail: { critical: true, date: new Date().toISOString(), message: 'Breaking change' } | ||
| }); | ||
|
|
||
| // The component calls window.location.reload() when critical is true. | ||
| // Here we just verify the critical flag is correctly detected. | ||
| expect(event.detail.critical).toBe(true); | ||
| }); | ||
|
|
||
| it('ReleaseNotification with critical=false extracts message', () => { | ||
| const event = new CustomEvent('ReleaseNotification', { | ||
| bubbles: true, | ||
| detail: { critical: false, date: new Date().toISOString(), message: 'New version available' } | ||
| }); | ||
|
|
||
| let releaseMessage: null | string = null; | ||
| if (!event.detail.critical) { | ||
| releaseMessage = event.detail.message || null; | ||
| } | ||
|
|
||
| expect(releaseMessage).toBe('New version available'); | ||
| }); | ||
|
|
||
| it('fallback message used when no persisted system message', () => { | ||
| const systemMessage: null | string = null; | ||
| const fallbackMessage = 'Scheduled maintenance'; | ||
|
|
||
| const displayMessage = systemMessage ?? fallbackMessage; | ||
| expect(displayMessage).toBe('Scheduled maintenance'); | ||
| }); | ||
|
|
||
| it('persisted system message takes precedence over fallback', () => { | ||
| const systemMessage = 'Active outage'; | ||
| const fallbackMessage = 'Scheduled maintenance'; | ||
|
|
||
| const displayMessage = systemMessage ?? fallbackMessage; | ||
| expect(displayMessage).toBe('Active outage'); |
Comment on lines
+150
to
+160
| [HttpGet("notifications/settings")] | ||
| [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] | ||
| public async Task<IActionResult> GetNotificationSettingsAsync() | ||
| { | ||
| var notification = await _notificationService.GetSystemNotificationAsync(); | ||
| return Ok(new | ||
| { | ||
| ConfiguredSystemNotificationMessage = _appOptions.NotificationMessage, | ||
| SystemNotification = notification | ||
| }); | ||
| } |
- Regenerate controller-manifest.json with new endpoints (CI RCA) - Add typed NotificationSettingsResponse record (replaces anonymous object) - Fix validation: empty message returns 400 BadRequest (was 404 NotFound) - Rename misleading test names to match actual behavior - Strengthen publish=false test to verify cache persistence - Extract resolveDisplayMessage() into testable utility - Rewrite frontend tests to actually cover resolution logic - Update OpenSpec spec to reflect StatusController architecture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines
+6
to
+7
| configured_system_notification_message?: null | string; | ||
| system_notification?: null | SystemNotification; |
Comment on lines
+167
to
+171
| public async Task<ActionResult<ReleaseNotification>> ForceRefreshAsync(ValueFromBody<string?>? message) | ||
| { | ||
| var notification = await _notificationService.SendReleaseNotificationAsync(message?.Value, critical: true); | ||
| return Ok(notification); | ||
| } |
Comment on lines
+13
to
+20
| export function clearSystemNotificationMutation() { | ||
| const queryClient = useQueryClient(); | ||
| return createMutation<void, ProblemDetails, { publish?: boolean }>(() => ({ | ||
| mutationFn: async (params: { publish?: boolean }) => { | ||
| const client = useFetchClient(); | ||
| const publish = params.publish !== false; | ||
| await client.delete(`notifications/system?publish=${publish}`); | ||
| }, |
Comment on lines
+28
to
+38
| export function forceRefreshClientsMutation() { | ||
| return createMutation<ReleaseNotification, ProblemDetails, undefined | { message?: string }>(() => ({ | ||
| mutationFn: async (params?: { message?: string }) => { | ||
| const client = useFetchClient(); | ||
| const response = await client.postJSON<ReleaseNotification>('notifications/force-refresh', { | ||
| value: params?.message ?? null | ||
| }); | ||
| return response.data!; | ||
| } | ||
| })); | ||
| } |
Comment on lines
+109
to
+119
| {settingsQuery.data.configured_system_notification_message || '(none)'} | ||
| </span> | ||
| </div> | ||
| <div> | ||
| <span class="text-sm font-medium">Active System Notification:</span> | ||
| {#if settingsQuery.data.system_notification?.message} | ||
| <span class="ml-2 text-sm text-red-600 dark:text-red-400"> | ||
| {settingsQuery.data.system_notification.message} | ||
| </span> | ||
| <span class="text-muted-foreground ml-2 text-xs"> | ||
| (set {new Date(settingsQuery.data.system_notification.date).toLocaleString()}) |
Comment on lines
+11
to
+21
| export function resolveDisplayMessage( | ||
| realtimeMessage: null | string | undefined, | ||
| persistedMessage: null | string, | ||
| fallbackMessage: null | string | ||
| ): null | string { | ||
| if (realtimeMessage !== undefined) { | ||
| return realtimeMessage || fallbackMessage; | ||
| } | ||
|
|
||
| return persistedMessage || fallbackMessage; | ||
| } |
…esh with no message - Replace boolean string interpolation in query strings with URLSearchParams - forceRefreshClientsMutation: omit body entirely when no message is provided, so the no-body backend code path is exercised from the UI (not just tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment on lines
+24
to
+39
| ### Admin endpoints | ||
|
|
||
| Added to `AdminController` (already `[Authorize(Policy = GlobalAdminPolicy)]`): | ||
|
|
||
| | Method | Route | Body | Response | | ||
| |--------|-------|------|----------| | ||
| | GET | `admin/notifications` | — | `{ configured_message, system_notification }` | | ||
| | PUT | `admin/notifications/system` | `{ message }` | `SystemNotification` | | ||
| | DELETE | `admin/notifications/system?publish=true` | — | 204 | | ||
| | POST | `admin/notifications/release` | `{ message, critical }` | `ReleaseNotification` | | ||
| | POST | `admin/notifications/force-refresh` | — | `ReleaseNotification` | | ||
|
|
||
| DTOs in `src/Exceptionless.Web/Models/Admin/`: | ||
| - `NotificationSettingsResponse` — configured fallback + current persisted notification | ||
| - `SetSystemNotificationRequest` — message string | ||
| - `SendReleaseNotificationRequest` — message + critical flag |
Comment on lines
+76
to
+85
| async function handleForceRefresh() { | ||
| try { | ||
| await forceRefresh.mutateAsync(forceRefreshMessage ? { message: forceRefreshMessage } : undefined); | ||
| toast.success('Force refresh sent to all clients.'); | ||
| showForceRefreshDialog = false; | ||
| forceRefreshMessage = ''; | ||
| } catch { | ||
| toast.error('Failed to send force refresh.'); | ||
| } | ||
| } |
Comment on lines
+1
to
+53
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { resolveDisplayMessage } from '../resolve-message'; | ||
|
|
||
| describe('resolveDisplayMessage', () => { | ||
| describe('when realtime message has not been received (undefined)', () => { | ||
| it('returns persisted message over fallback', () => { | ||
| expect(resolveDisplayMessage(undefined, 'Active outage', 'Scheduled maintenance')).toBe('Active outage'); | ||
| }); | ||
|
|
||
| it('returns fallback when no persisted message', () => { | ||
| expect(resolveDisplayMessage(undefined, null, 'Scheduled maintenance')).toBe('Scheduled maintenance'); | ||
| }); | ||
|
|
||
| it('returns null when no persisted or fallback message', () => { | ||
| expect(resolveDisplayMessage(undefined, null, null)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('when realtime message is received (string)', () => { | ||
| it('returns the realtime message', () => { | ||
| expect(resolveDisplayMessage('Realtime alert', 'Old persisted', 'Fallback')).toBe('Realtime alert'); | ||
| }); | ||
|
|
||
| it('ignores persisted message', () => { | ||
| expect(resolveDisplayMessage('New message', 'Stale persisted', null)).toBe('New message'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('when realtime message is cleared (null)', () => { | ||
| it('falls back to fallback message', () => { | ||
| expect(resolveDisplayMessage(null, 'Persisted', 'Fallback')).toBe('Fallback'); | ||
| }); | ||
|
|
||
| it('returns null when no fallback configured', () => { | ||
| expect(resolveDisplayMessage(null, 'Persisted', null)).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('edge cases', () => { | ||
| it('empty string realtime falls through to fallback', () => { | ||
| expect(resolveDisplayMessage('', null, 'Fallback')).toBe('Fallback'); | ||
| }); | ||
|
|
||
| it('empty string persisted falls through to fallback', () => { | ||
| expect(resolveDisplayMessage(undefined, '', 'Fallback')).toBe('Fallback'); | ||
| }); | ||
|
|
||
| it('all empty/null returns null', () => { | ||
| expect(resolveDisplayMessage(undefined, null, null)).toBeNull(); | ||
| }); | ||
| }); | ||
| }); |
| return response.data ?? null; | ||
| }, | ||
| queryKey: queryKeys.current, | ||
| staleTime: 30_000 |
…/prefer-svelte-reactivity lint rule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds system notifications management: persistent banners, release announcements, and force-client-refresh — all via new admin endpoints on
StatusControllerand a Svelte 5 admin UI.What changed
Backend
NotificationService(new): cache-backed system notification storage + WebSocket publishingStatusController— 2 new endpoints +publishflag on existing:GET /api/v2/notifications/settings(new) — active notification + env fallback configPOST /api/v2/notifications/force-refresh(new) — force-reload all connected clientsPOST/DELETE /api/v2/notifications/system?publish=true— existing +publishflag (defaulttrue, backward compat)NotificationSettingsResponse(new record): typed DTO for settings endpointFrontend (Svelte 5)
system-notification-banner.svelte(new): 3-tier message resolution (realtime WebSocket → persisted API → env fallback); added to(app)/+layout.svelte/system/notificationspage (new): admin UI with Set/Clear/Send Release/Force Refresh dialogsapi.svelte.tsandmodels.ts: TanStack Query wrappers for all notification endpointsroutes.svelte.ts: Bell icon + Notifications entry under SystemTests
StatusControllerTests.cs)Breaking changes
None. All existing endpoints behave identically. The
publishparameter defaults totrue.OpenSpec
Designed in
openspec/changes/add-svelte-notifications-management/.