From fbcfc2a599da5d1e1e7c46baf77ca406b73505a3 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 25 Sep 2026 17:02:29 +0530 Subject: [PATCH 1/2] feat(admin): operator notifications dashboard UI Add Notifications admin view for targets, event toggles, save/reload, test delivery, and delivery history on top of the notifications API. --- .changeset/admin-notifications-ui.md | 10 + resources/admin/assets/dashboard.css | 42 +++ resources/admin/assets/dashboard.js | 5 + resources/admin/assets/notifications.js | 422 ++++++++++++++++++++++++ resources/admin/dashboard.html | 116 +++++++ 5 files changed, 595 insertions(+) create mode 100644 .changeset/admin-notifications-ui.md create mode 100644 resources/admin/assets/notifications.js diff --git a/.changeset/admin-notifications-ui.md b/.changeset/admin-notifications-ui.md new file mode 100644 index 00000000..1930c629 --- /dev/null +++ b/.changeset/admin-notifications-ui.md @@ -0,0 +1,10 @@ +--- +"nostream": minor +--- + +feat(admin): add notifications console page for operator alerts + +Adds a Notifications view to the admin dashboard for configuring targets and +event toggles, testing delivery, and browsing the delivery log via the admin API. + +Closes #761 diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css index 33efdd16..0a22b138 100644 --- a/resources/admin/assets/dashboard.css +++ b/resources/admin/assets/dashboard.css @@ -819,6 +819,48 @@ margin-bottom: 0; } +.notifications-target-card .notifications-target-type { + max-width: 10rem; +} + +.notifications-log-table { + color: var(--text); + font-size: 0.75rem; +} + +.notifications-log-table thead th { + border-bottom-color: var(--panel-border); + color: var(--label); + font-size: 0.65rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.notifications-log-table td, +.notifications-log-table th { + border-color: var(--panel-border); + vertical-align: top; +} + +.notifications-status-ok { + color: var(--ok); + font-weight: 600; +} + +.notifications-status-failed { + color: var(--danger); + font-weight: 600; +} + +.notifications-advanced summary { + cursor: pointer; + list-style: none; +} + +.notifications-advanced summary::-webkit-details-marker { + display: none; +} + @media (max-width: 768px) { .admin-dashboard .metric-value { font-size: 1rem; diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 8420d266..039793fa 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -1285,6 +1285,11 @@ return } + if (viewId === 'notifications-view') { + window.__ADMIN_NOTIFICATIONS__?.load?.() + return + } + if (viewId === 'metrics-view') { startMetricsStream() } diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js new file mode 100644 index 00000000..aaff89eb --- /dev/null +++ b/resources/admin/assets/notifications.js @@ -0,0 +1,422 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const adminBase = `${config.pathPrefix || ''}/admin` + + const REDACTED = '***' + + const elements = { + enabled: document.getElementById('notifications-enabled'), + eventsFieldset: document.getElementById('notifications-events-fieldset'), + saveButton: document.getElementById('notifications-save-button'), + reloadButton: document.getElementById('notifications-reload-button'), + addTargetButton: document.getElementById('notifications-add-target-button'), + targetsRoot: document.getElementById('notifications-targets'), + error: document.getElementById('notifications-error'), + success: document.getElementById('notifications-success'), + retryMax: document.getElementById('notifications-retry-max'), + retryDelay: document.getElementById('notifications-retry-delay'), + logRetention: document.getElementById('notifications-log-retention'), + logStatus: document.getElementById('notifications-log-status'), + logEvent: document.getElementById('notifications-log-event'), + logRefresh: document.getElementById('notifications-log-refresh'), + logMore: document.getElementById('notifications-log-more'), + logBody: document.getElementById('notifications-log-body'), + } + + if (!elements.targetsRoot) { + return + } + + let notificationsConfig = null + let notificationsLoaded = false + let logLimit = 25 + + const eventInputs = () => document.querySelectorAll('#notifications-events-fieldset [data-event-key]') + + const hideAlerts = () => { + elements.error?.classList.add('d-none') + elements.success?.classList.add('d-none') + } + + const showError = (message) => { + if (!elements.error) { + return + } + elements.error.textContent = message + elements.error.classList.remove('d-none') + } + + const showSuccess = (message) => { + if (!elements.success) { + return + } + elements.success.textContent = message + elements.success.classList.remove('d-none') + } + + const defaultEvents = () => ({ + 'admission.invoice.created': true, + 'admission.invoice.paid': true, + 'admission.invoice.failed': true, + 'settings.changed': true, + 'relay.restarted': false, + }) + + const createEmptyTarget = () => ({ + id: crypto.randomUUID(), + type: 'http', + enabled: true, + url: '', + botToken: '', + chatId: '', + }) + + const readEventsFromForm = () => { + const events = { ...defaultEvents(), ...(notificationsConfig?.events ?? {}) } + eventInputs().forEach((input) => { + const key = input.dataset.eventKey + if (key) { + events[key] = input.checked + } + }) + return events + } + + const readTargetsFromDom = () => { + const cards = elements.targetsRoot.querySelectorAll('[data-target-id]') + return [...cards].map((card) => { + const id = card.dataset.targetId + const type = card.querySelector('[data-target-field="type"]')?.value ?? 'http' + const enabled = card.querySelector('[data-target-field="enabled"]')?.checked ?? true + const urlInput = card.querySelector('[data-target-field="url"]') + const botTokenInput = card.querySelector('[data-target-field="botToken"]') + const chatIdInput = card.querySelector('[data-target-field="chatId"]') + + const target = { id, type, enabled } + + const url = urlInput?.value?.trim() ?? '' + if (url && url !== REDACTED) { + target.url = url + } else if (url === REDACTED) { + target.url = REDACTED + } + + const botToken = botTokenInput?.value?.trim() ?? '' + if (botToken && botToken !== REDACTED) { + target.botToken = botToken + } else if (botToken === REDACTED) { + target.botToken = REDACTED + } + + const chatId = chatIdInput?.value?.trim() ?? '' + if (chatId) { + target.chatId = chatId + } + + return target + }) + } + + const buildPatchBody = () => ({ + enabled: elements.enabled?.checked ?? false, + events: readEventsFromForm(), + targets: readTargetsFromDom(), + retry: { + maxAttempts: Number(elements.retryMax?.value ?? 5), + baseDelayMs: Number(elements.retryDelay?.value ?? 1000), + }, + deliveryLogRetentionDays: Number(elements.logRetention?.value ?? 30), + }) + + const renderTargetCard = (target) => { + const card = document.createElement('article') + card.className = 'panel-card notifications-target-card mb-2' + card.dataset.targetId = target.id + + const urlValue = target.url ?? '' + const botTokenValue = target.botToken ?? '' + + card.innerHTML = ` +
+
+ TGT + + +
+ + +
+
+
+ + +
+
+

+
+
+ + +
+
+ + +
+
+ + +
+
+ ` + + card.querySelector('[data-target-field="type"]').value = target.type + card.querySelector('[data-target-field="enabled"]').checked = target.enabled !== false + card.querySelector('[data-target-field="url"]').value = urlValue + card.querySelector('[data-target-field="botToken"]').value = botTokenValue + card.querySelector('[data-target-field="chatId"]').value = target.chatId ?? '' + + card.querySelector('[data-action="remove-target"]')?.addEventListener('click', () => { + card.remove() + if (!elements.targetsRoot.querySelector('[data-target-id]')) { + elements.targetsRoot.innerHTML = '

No targets configured.

' + } + }) + + card.querySelector('[data-action="test-target"]')?.addEventListener('click', () => { + void testTarget(target.id, card) + }) + + return card + } + + const renderTargets = (targets) => { + elements.targetsRoot.replaceChildren() + if (!Array.isArray(targets) || targets.length === 0) { + elements.targetsRoot.innerHTML = '

No targets configured.

' + return + } + + targets.forEach((target) => { + elements.targetsRoot.appendChild(renderTargetCard(target)) + }) + } + + const applyConfigToForm = (config) => { + notificationsConfig = config + if (elements.enabled) { + elements.enabled.checked = config.enabled === true + } + + eventInputs().forEach((input) => { + const key = input.dataset.eventKey + input.checked = config.events?.[key] ?? defaultEvents()[key] ?? false + }) + + if (elements.retryMax) { + elements.retryMax.value = String(config.retry?.maxAttempts ?? 5) + } + if (elements.retryDelay) { + elements.retryDelay.value = String(config.retry?.baseDelayMs ?? 1000) + } + if (elements.logRetention) { + elements.logRetention.value = String(config.deliveryLogRetentionDays ?? 30) + } + + renderTargets(config.targets ?? []) + } + + const loadNotifications = async (force = false) => { + if (notificationsLoaded && !force) { + return + } + + hideAlerts() + + try { + const response = await fetch(`${adminBase}/notifications`, { credentials: 'include' }) + if (response.status === 401) { + return + } + if (!response.ok) { + showError('Unable to load notification settings.') + return + } + + const body = await response.json() + applyConfigToForm(body.notifications ?? {}) + notificationsLoaded = true + logLimit = 25 + await loadDeliveryLog(true) + } catch { + showError('Network error while loading notifications.') + } + } + + const saveNotifications = async () => { + hideAlerts() + elements.saveButton.disabled = true + + try { + const response = await fetch(`${adminBase}/notifications`, { + method: 'PATCH', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(buildPatchBody()), + }) + + const body = await response.json().catch(() => ({})) + + if (!response.ok) { + const issues = Array.isArray(body.issues) ? body.issues.map((i) => `${i.path}: ${i.message}`).join(' | ') : '' + showError(body.error ? `${body.error}${issues ? ` — ${issues}` : ''}` : 'Save failed.') + return + } + + applyConfigToForm(body.notifications ?? {}) + showSuccess('Notification settings saved.') + } catch { + showError('Network error while saving notifications.') + } finally { + elements.saveButton.disabled = false + } + } + + const testTarget = async (targetId, card) => { + const statusEl = card.querySelector('[data-target-status]') + if (statusEl) { + statusEl.textContent = 'Sending test…' + statusEl.classList.remove('text-danger', 'text-success') + } + + try { + const response = await fetch(`${adminBase}/notifications/test`, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ targetId }), + }) + + const body = await response.json().catch(() => ({})) + + if (!response.ok) { + if (statusEl) { + statusEl.textContent = body.error || 'Test delivery failed.' + statusEl.classList.add('text-danger') + } + return + } + + if (statusEl) { + statusEl.textContent = 'Test delivery succeeded.' + statusEl.classList.add('text-success') + } + + logLimit = 25 + await loadDeliveryLog(true) + } catch { + if (statusEl) { + statusEl.textContent = 'Network error during test delivery.' + statusEl.classList.add('text-danger') + } + } + } + + const formatLogTime = (iso) => { + if (!iso) { + return '—' + } + return iso.replace('T', ' ').replace(/\.\d{3}Z$/, '') + } + + const renderLogRows = (entries) => { + if (!elements.logBody) { + return + } + + if (!Array.isArray(entries) || entries.length === 0) { + elements.logBody.innerHTML = 'No delivery log entries.' + return + } + + elements.logBody.replaceChildren() + entries.forEach((entry) => { + const row = document.createElement('tr') + const statusClass = entry.status === 'success' ? 'notifications-status-ok' : 'notifications-status-failed' + row.innerHTML = ` + ${formatLogTime(entry.createdAt)} + ${entry.eventType ?? '—'} + ${entry.targetType ?? '—'} ${entry.targetId ?? ''} + ${entry.status ?? '—'} + ${entry.errorSnippet ?? '—'} + ` + elements.logBody.appendChild(row) + }) + } + + const loadDeliveryLog = async (reset = false) => { + if (reset) { + logLimit = 25 + } + + const params = new URLSearchParams({ limit: String(logLimit) }) + const status = elements.logStatus?.value?.trim() + const eventType = elements.logEvent?.value?.trim() + if (status) { + params.set('status', status) + } + if (eventType) { + params.set('eventType', eventType) + } + + try { + const response = await fetch(`${adminBase}/notifications/deliveries?${params.toString()}`, { + credentials: 'include', + }) + + if (!response.ok) { + showError('Unable to load delivery log.') + return + } + + const body = await response.json() + renderLogRows(body.entries ?? []) + } catch { + showError('Network error while loading delivery log.') + } + } + + elements.saveButton?.addEventListener('click', () => { + void saveNotifications() + }) + + elements.reloadButton?.addEventListener('click', () => { + notificationsLoaded = false + void loadNotifications(true) + }) + + elements.addTargetButton?.addEventListener('click', () => { + const placeholder = elements.targetsRoot.querySelector('.admin-muted') + if (placeholder && !elements.targetsRoot.querySelector('[data-target-id]')) { + elements.targetsRoot.replaceChildren() + } + elements.targetsRoot.appendChild(renderTargetCard(createEmptyTarget())) + }) + + elements.logRefresh?.addEventListener('click', () => { + void loadDeliveryLog(true) + }) + + elements.logMore?.addEventListener('click', () => { + logLimit = Math.min(logLimit + 25, 200) + void loadDeliveryLog(false) + }) + + window.__ADMIN_NOTIFICATIONS__ = { + load: loadNotifications, + } +})() diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 9779d6bc..095cbf65 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -59,6 +59,7 @@

Sign in

> Navigation

+
@@ -220,6 +221,120 @@

Charts

+
+
+

Operator notifications

+

Configure alert targets, event toggles, and review delivery history.

+ +
+ +
+
+ + +
+
+ Events +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+ + + +
+ +
+ +
+ Advanced (retry & retention) +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Delivery history

+
+
+ + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + +
Notification delivery log
Time (UTC)EventTargetStatusError
No entries loaded.
+
+
+
+
+

Configuration

@@ -264,5 +379,6 @@

Preview diff

} + From ff25a275a52b3624c9413d1b22928164a50b4f00 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sun, 27 Sep 2026 15:39:49 +0530 Subject: [PATCH 2/2] fix(admin): notifications UI greptile fixes for ids, test gating, and log safety --- resources/admin/assets/notifications.js | 228 +++++++++++++----- ...et-notification-delivery-log-controller.ts | 2 +- src/services/operator-notification-service.ts | 47 +++- .../operator-notification-service.spec.ts | 8 + 4 files changed, 210 insertions(+), 75 deletions(-) diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js index aaff89eb..48ab7fe2 100644 --- a/resources/admin/assets/notifications.js +++ b/resources/admin/assets/notifications.js @@ -62,8 +62,15 @@ 'relay.restarted': false, }) + const newTargetId = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID() + } + return `tgt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + } + const createEmptyTarget = () => ({ - id: crypto.randomUUID(), + id: newTargetId(), type: 'http', enabled: true, url: '', @@ -71,6 +78,19 @@ chatId: '', }) + const setTestButtonEnabled = (card, enabled) => { + const button = card.querySelector('[data-action="test-target"]') + if (!button) { + return + } + button.disabled = !enabled + if (enabled) { + button.removeAttribute('title') + } else { + button.title = 'Save notification settings before testing this target.' + } + } + const readEventsFromForm = () => { const events = { ...defaultEvents(), ...(notificationsConfig?.events ?? {}) } eventInputs().forEach((input) => { @@ -128,69 +148,130 @@ deliveryLogRetentionDays: Number(elements.logRetention?.value ?? 30), }) - const renderTargetCard = (target) => { + const appendOption = (select, value, label) => { + const option = document.createElement('option') + option.value = value + option.textContent = label + select.appendChild(option) + } + + const renderTargetCard = (target, { saved = true } = {}) => { const card = document.createElement('article') card.className = 'panel-card notifications-target-card mb-2' card.dataset.targetId = target.id - const urlValue = target.url ?? '' - const botTokenValue = target.botToken ?? '' - - card.innerHTML = ` -
-
- TGT - - -
- - -
-
-
- - -
-
-

-
-
- - -
-
- - -
-
- - -
-
- ` - - card.querySelector('[data-target-field="type"]').value = target.type - card.querySelector('[data-target-field="enabled"]').checked = target.enabled !== false - card.querySelector('[data-target-field="url"]').value = urlValue - card.querySelector('[data-target-field="botToken"]').value = botTokenValue - card.querySelector('[data-target-field="chatId"]').value = target.chatId ?? '' - - card.querySelector('[data-action="remove-target"]')?.addEventListener('click', () => { + const header = document.createElement('div') + header.className = 'd-flex flex-wrap justify-content-between align-items-center gap-2 mb-2' + + const headerLeft = document.createElement('div') + headerLeft.className = 'd-flex align-items-center gap-2 flex-wrap' + + const badge = document.createElement('span') + badge.className = 'module-code' + badge.textContent = 'TGT' + + const typeSelect = document.createElement('select') + typeSelect.dataset.targetField = 'type' + typeSelect.className = 'form-select form-select-sm console-input notifications-target-type' + appendOption(typeSelect, 'http', 'HTTP') + appendOption(typeSelect, 'discord', 'Discord') + appendOption(typeSelect, 'slack', 'Slack') + appendOption(typeSelect, 'telegram', 'Telegram') + typeSelect.value = target.type ?? 'http' + + const enabledWrap = document.createElement('div') + enabledWrap.className = 'form-check form-switch mb-0' + const enabledInput = document.createElement('input') + enabledInput.type = 'checkbox' + enabledInput.role = 'switch' + enabledInput.className = 'form-check-input' + enabledInput.dataset.targetField = 'enabled' + enabledInput.checked = target.enabled !== false + const enabledLabel = document.createElement('label') + enabledLabel.className = 'form-check-label small' + enabledLabel.textContent = 'Enabled' + enabledWrap.append(enabledInput, enabledLabel) + + headerLeft.append(badge, typeSelect, enabledWrap) + + const headerActions = document.createElement('div') + headerActions.className = 'd-flex gap-2' + const testButton = document.createElement('button') + testButton.type = 'button' + testButton.className = 'btn btn-console btn-sm' + testButton.dataset.action = 'test-target' + testButton.textContent = 'Test' + const removeButton = document.createElement('button') + removeButton.type = 'button' + removeButton.className = 'btn btn-console btn-sm' + removeButton.dataset.action = 'remove-target' + removeButton.textContent = 'Remove' + headerActions.append(testButton, removeButton) + header.append(headerLeft, headerActions) + + const statusEl = document.createElement('p') + statusEl.className = 'notifications-target-status small mb-2 admin-muted' + statusEl.dataset.targetStatus = '' + statusEl.setAttribute('aria-live', 'polite') + + const fieldsRow = document.createElement('div') + fieldsRow.className = 'row g-2' + + const urlCol = document.createElement('div') + urlCol.className = 'col-12 col-lg-6' + const urlLabel = document.createElement('label') + urlLabel.className = 'form-label field-label small mb-1' + urlLabel.textContent = 'Webhook URL' + const urlInput = document.createElement('input') + urlInput.type = 'url' + urlInput.autocomplete = 'off' + urlInput.className = 'form-control console-input form-control-sm' + urlInput.dataset.targetField = 'url' + urlInput.value = target.url ?? '' + urlCol.append(urlLabel, urlInput) + + const botCol = document.createElement('div') + botCol.className = 'col-12 col-md-6 col-lg-3' + const botLabel = document.createElement('label') + botLabel.className = 'form-label field-label small mb-1' + botLabel.textContent = 'Bot token' + const botInput = document.createElement('input') + botInput.type = 'password' + botInput.autocomplete = 'off' + botInput.className = 'form-control console-input form-control-sm' + botInput.dataset.targetField = 'botToken' + botInput.value = target.botToken ?? '' + botCol.append(botLabel, botInput) + + const chatCol = document.createElement('div') + chatCol.className = 'col-12 col-md-6 col-lg-3' + const chatLabel = document.createElement('label') + chatLabel.className = 'form-label field-label small mb-1' + chatLabel.textContent = 'Chat ID' + const chatInput = document.createElement('input') + chatInput.type = 'text' + chatInput.autocomplete = 'off' + chatInput.className = 'form-control console-input form-control-sm' + chatInput.dataset.targetField = 'chatId' + chatInput.value = target.chatId ?? '' + chatCol.append(chatLabel, chatInput) + + fieldsRow.append(urlCol, botCol, chatCol) + card.append(header, statusEl, fieldsRow) + + removeButton.addEventListener('click', () => { card.remove() if (!elements.targetsRoot.querySelector('[data-target-id]')) { elements.targetsRoot.innerHTML = '

No targets configured.

' } }) - card.querySelector('[data-action="test-target"]')?.addEventListener('click', () => { - void testTarget(target.id, card) + testButton.addEventListener('click', () => { + void testTarget(card.dataset.targetId, card) }) + setTestButtonEnabled(card, saved) + return card } @@ -202,7 +283,7 @@ } targets.forEach((target) => { - elements.targetsRoot.appendChild(renderTargetCard(target)) + elements.targetsRoot.appendChild(renderTargetCard(target, { saved: true })) }) } @@ -347,13 +428,36 @@ entries.forEach((entry) => { const row = document.createElement('tr') const statusClass = entry.status === 'success' ? 'notifications-status-ok' : 'notifications-status-failed' - row.innerHTML = ` - ${formatLogTime(entry.createdAt)} - ${entry.eventType ?? '—'} - ${entry.targetType ?? '—'} ${entry.targetId ?? ''} - ${entry.status ?? '—'} - ${entry.errorSnippet ?? '—'} - ` + + const timeCell = document.createElement('td') + timeCell.textContent = formatLogTime(entry.createdAt) + + const eventCell = document.createElement('td') + const eventCode = document.createElement('code') + eventCode.className = 'small' + eventCode.textContent = entry.eventType ?? '—' + eventCell.appendChild(eventCode) + + const targetCell = document.createElement('td') + const targetType = document.createElement('span') + targetType.className = 'small' + targetType.textContent = entry.targetType ?? '—' + const targetId = document.createElement('span') + targetId.className = 'admin-muted small' + targetId.textContent = entry.targetId ? ` ${entry.targetId}` : '' + targetCell.append(targetType, targetId) + + const statusCell = document.createElement('td') + const statusSpan = document.createElement('span') + statusSpan.className = statusClass + statusSpan.textContent = entry.status ?? '—' + statusCell.appendChild(statusSpan) + + const errorCell = document.createElement('td') + errorCell.className = 'small text-break' + errorCell.textContent = entry.errorSnippet ?? '—' + + row.append(timeCell, eventCell, targetCell, statusCell, errorCell) elements.logBody.appendChild(row) }) } @@ -404,7 +508,7 @@ if (placeholder && !elements.targetsRoot.querySelector('[data-target-id]')) { elements.targetsRoot.replaceChildren() } - elements.targetsRoot.appendChild(renderTargetCard(createEmptyTarget())) + elements.targetsRoot.appendChild(renderTargetCard(createEmptyTarget(), { saved: false })) }) elements.logRefresh?.addEventListener('click', () => { @@ -412,7 +516,7 @@ }) elements.logMore?.addEventListener('click', () => { - logLimit = Math.min(logLimit + 25, 200) + logLimit = Math.min(logLimit + 25, 500) void loadDeliveryLog(false) }) diff --git a/src/controllers/admin/get-notification-delivery-log-controller.ts b/src/controllers/admin/get-notification-delivery-log-controller.ts index 93209970..4dfc5436 100644 --- a/src/controllers/admin/get-notification-delivery-log-controller.ts +++ b/src/controllers/admin/get-notification-delivery-log-controller.ts @@ -17,7 +17,7 @@ export class GetAdminNotificationDeliveryLogController implements IController { }) return } - limit = Math.min(parsed, 200) + limit = Math.min(parsed, 500) } let status: NotificationDeliveryStatus | undefined diff --git a/src/services/operator-notification-service.ts b/src/services/operator-notification-service.ts index 65cc9dc0..c3d4dc69 100644 --- a/src/services/operator-notification-service.ts +++ b/src/services/operator-notification-service.ts @@ -62,7 +62,7 @@ export class OperatorNotificationService implements INotificationDispatcher { for (const target of targets) { try { await deliverToTarget(target, envelope) - await this.deliveryLogRepository.append({ + await this.appendDeliveryLog({ outboxId: context.outboxId ?? null, eventType, targetId: target.id, @@ -75,7 +75,7 @@ export class OperatorNotificationService implements INotificationDispatcher { const message = error instanceof Error ? error.message : String(error) logger.error('delivery failed for %s: %s', maskTargetForLog(target), message) failures.push(`${target.id}: ${message}`) - await this.deliveryLogRepository.append({ + await this.appendDeliveryLog({ outboxId: context.outboxId ?? null, eventType, targetId: target.id, @@ -109,19 +109,10 @@ export class OperatorNotificationService implements INotificationDispatcher { try { await deliverToTarget(target, envelope) - await this.deliveryLogRepository.append({ - outboxId: null, - eventType, - targetId: target.id, - targetType: target.type, - status: NotificationDeliveryStatus.SUCCESS, - attemptNumber: 1, - errorSnippet: null, - }) } catch (error) { const message = error instanceof Error ? error.message : String(error) logger.error('test delivery failed for %s: %s', maskTargetForLog(target), message) - await this.deliveryLogRepository.append({ + await this.appendDeliveryLog({ outboxId: null, eventType, targetId: target.id, @@ -132,6 +123,16 @@ export class OperatorNotificationService implements INotificationDispatcher { }) throw error } + + await this.appendDeliveryLog({ + outboxId: null, + eventType, + targetId: target.id, + targetType: target.type, + status: NotificationDeliveryStatus.SUCCESS, + attemptNumber: 1, + errorSnippet: null, + }) } public getMaxAttempts(): number { @@ -142,6 +143,28 @@ export class OperatorNotificationService implements INotificationDispatcher { return this.getNotificationsConfig().retry.baseDelayMs } + private async appendDeliveryLog(entry: { + outboxId: string | null + eventType: string + targetId: string + targetType: OperatorNotificationTarget['type'] + status: NotificationDeliveryStatus + attemptNumber: number + errorSnippet: string | null + }): Promise { + try { + await this.deliveryLogRepository.append(entry) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error( + 'delivery log append failed for target %s (%s): %s', + entry.targetId, + entry.status, + message, + ) + } + } + private getNotificationsConfig(): AdminNotificationsSettings { const defaults = loadDefaults().admin?.notifications const configured = this.settings().admin?.notifications diff --git a/test/unit/services/operator-notification-service.spec.ts b/test/unit/services/operator-notification-service.spec.ts index e48e4d00..7b4a9d53 100644 --- a/test/unit/services/operator-notification-service.spec.ts +++ b/test/unit/services/operator-notification-service.spec.ts @@ -91,6 +91,14 @@ describe('OperatorNotificationService', () => { expect(deliveryLogRepository.append).to.have.been.calledOnce }) + it('does not fail test delivery when success logging fails', async () => { + deliveryLogRepository.append.onFirstCall().rejects(new Error('db write failed')) + + await expect(service.dispatchTestTarget('discord-main')).to.be.fulfilled + + expect(axios.post).to.have.been.calledOnce + }) + it('logs failure and rethrows when a test delivery fails', async () => { ;(axios.post as Sinon.SinonStub).rejects(new Error('network down'))