Skip to content

feat: Add global Notifications management (admin endpoints + Svelte UI)#2256

Open
niemyjski wants to merge 6 commits into
mainfrom
niemyjski/add-svelte-notifications-management
Open

feat: Add global Notifications management (admin endpoints + Svelte UI)#2256
niemyjski wants to merge 6 commits into
mainfrom
niemyjski/add-svelte-notifications-management

Conversation

@niemyjski
Copy link
Copy Markdown
Member

@niemyjski niemyjski commented May 26, 2026

Summary

Adds system notifications management: persistent banners, release announcements, and force-client-refresh — all via new admin endpoints on StatusController and a Svelte 5 admin UI.

What changed

Backend

  • NotificationService (new): cache-backed system notification storage + WebSocket publishing
  • StatusController — 2 new endpoints + publish flag on existing:
    • GET /api/v2/notifications/settings (new) — active notification + env fallback config
    • POST /api/v2/notifications/force-refresh (new) — force-reload all connected clients
    • POST/DELETE /api/v2/notifications/system?publish=true — existing + publish flag (default true, backward compat)
  • NotificationSettingsResponse (new record): typed DTO for settings endpoint

Frontend (Svelte 5)

  • system-notification-banner.svelte (new): 3-tier message resolution (realtime WebSocket → persisted API → env fallback); added to (app)/+layout.svelte
  • /system/notifications page (new): admin UI with Set/Clear/Send Release/Force Refresh dialogs
  • api.svelte.ts and models.ts: TanStack Query wrappers for all notification endpoints
  • routes.svelte.ts: Bell icon + Notifications entry under System

Tests

  • 19 backend integration tests (StatusControllerTests.cs)
  • 9 frontend unit tests covering 3-tier message resolution
  • Controller manifest snapshot updated for 2 new endpoints

Breaking changes

None. All existing endpoints behave identically. The publish parameter defaults to true.

OpenSpec

Designed in openspec/changes/add-svelte-notifications-management/.

niemyjski and others added 3 commits May 26, 2026 16:15
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NotificationService and refactor StatusController to delegate, adding notifications/settings, notifications/force-refresh, and a publish query param.
  • Add Svelte system-notification-banner (mounted in (app)/+layout.svelte) and a /system/notifications admin 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>
@niemyjski niemyjski requested a review from Copilot May 27, 2026 12:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 4 comments.

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>
@github-actions
Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Web 73% 62% 3923
Exceptionless.AppHost 18% 9% 82
Exceptionless.Insulation 25% 23% 203
Exceptionless.Core 69% 63% 7786
Summary 68% (13501 / 19813) 62% (7097 / 11536) 11994

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants