-
Notifications
You must be signed in to change notification settings - Fork 233
feat(admin): add GET/PATCH /admin/notifications API #784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "nostream": minor | ||
| --- | ||
|
|
||
| feat(admin): add GET/PATCH /admin/notifications API for operator alert config | ||
|
|
||
| Closes #760. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { Request, Response } from 'express' | ||
|
|
||
| import { IController } from '../../@types/controllers' | ||
| import { getRedactedAdminNotifications } from '../../utils/admin-notifications-settings' | ||
|
|
||
| export class GetAdminNotificationsController implements IController { | ||
| public async handleRequest(_request: Request, response: Response): Promise<void> { | ||
| response.status(200).setHeader('content-type', 'application/json').send({ | ||
| notifications: getRedactedAdminNotifications(), | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { Request, Response } from 'express' | ||
| import { mergeDeepRight } from 'ramda' | ||
|
|
||
| import { IController } from '../../@types/controllers' | ||
| import { INotificationOutboxRepository } from '../../@types/repositories' | ||
| import { Settings } from '../../@types/settings' | ||
| import { OperatorNotificationEventType } from '../../@types/operator-notifications' | ||
| import { createLogger } from '../../factories/logger-factory' | ||
| import { adminNotificationsPatchBodySchema } from '../../schemas/admin-notifications-schema' | ||
| import { | ||
| getMergedAdminNotifications, | ||
| getRedactedAdminNotifications, | ||
| mergeAdminNotificationsPatch, | ||
| } from '../../utils/admin-notifications-settings' | ||
| import { | ||
| appendSettingsAuditLog, | ||
| loadDefaults, | ||
| loadMergedSettings, | ||
| loadUserSettings, | ||
| saveSettings, | ||
| validateSettings, | ||
| } from '../../utils/settings-config' | ||
| import { validateSchema } from '../../utils/validation' | ||
|
|
||
| const logger = createLogger('patch-admin-notifications-controller') | ||
|
|
||
| export class PatchAdminNotificationsController implements IController { | ||
| public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} | ||
|
|
||
| public async handleRequest(request: Request, response: Response): Promise<void> { | ||
| const validation = validateSchema(adminNotificationsPatchBodySchema)(request.body) | ||
| if (validation.error) { | ||
| response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) | ||
| return | ||
| } | ||
|
|
||
| const merged = loadMergedSettings() | ||
| const current = getMergedAdminNotifications(merged) | ||
| const nextNotifications = mergeAdminNotificationsPatch(current, validation.value) | ||
|
|
||
| const userSettings = loadUserSettings() as Settings | ||
| const nextUserSettings = mergeDeepRight(userSettings, { | ||
| admin: { | ||
| ...userSettings.admin, | ||
| notifications: nextNotifications, | ||
| }, | ||
| }) as Settings | ||
|
|
||
| const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings | ||
|
|
||
| const issues = validateSettings(mergedNext) | ||
| if (issues.length > 0) { | ||
| response.status(400).setHeader('content-type', 'application/json').send({ error: 'Validation failed', issues }) | ||
| return | ||
| } | ||
|
|
||
| saveSettings(nextUserSettings) | ||
| appendSettingsAuditLog({ | ||
| action: 'settings.updated', | ||
| changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], | ||
| remoteAddress: request.ip, | ||
| }) | ||
|
|
||
| try { | ||
| await this.notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { | ||
| changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], | ||
| remoteAddress: request.ip, | ||
| }) | ||
| } catch (error) { | ||
| logger.error('Unable to enqueue notifications settings outbox event', error) | ||
| } | ||
|
|
||
| response.status(200).setHeader('content-type', 'application/json').send({ | ||
| ok: true, | ||
| notifications: getRedactedAdminNotifications(), | ||
| }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { GetAdminNotificationsController } from '../../controllers/admin/get-notifications-controller' | ||
| import { IController } from '../../@types/controllers' | ||
|
|
||
| export const createGetAdminNotificationsController = (): IController => { | ||
| return new GetAdminNotificationsController() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { PatchAdminNotificationsController } from '../../controllers/admin/patch-notifications-controller' | ||
| import { IController } from '../../@types/controllers' | ||
| import { getMasterDbClient } from '../../database/client' | ||
| import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' | ||
|
|
||
| export const createPatchAdminNotificationsController = (): IController => { | ||
| const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) | ||
| return new PatchAdminNotificationsController(notificationOutboxRepository) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| const targetSchema = z | ||
| .object({ | ||
| id: z.string().min(1), | ||
| type: z.enum(['http', 'discord', 'slack', 'telegram']), | ||
| enabled: z.boolean(), | ||
| url: z.string().optional(), | ||
| botToken: z.string().optional(), | ||
| chatId: z.string().optional(), | ||
| }) | ||
| .strict() | ||
|
|
||
| export const adminNotificationsPatchBodySchema = z | ||
| .object({ | ||
| enabled: z.boolean().optional(), | ||
| targets: z.array(targetSchema).optional(), | ||
| events: z | ||
| .object({ | ||
| 'admission.invoice.created': z.boolean().optional(), | ||
| 'admission.invoice.paid': z.boolean().optional(), | ||
| 'admission.invoice.failed': z.boolean().optional(), | ||
| 'settings.changed': z.boolean().optional(), | ||
| 'relay.restarted': z.boolean().optional(), | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| retry: z | ||
| .object({ | ||
| maxAttempts: z.number().int().min(1).optional(), | ||
| baseDelayMs: z.number().int().min(0).optional(), | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| deliveryLogRetentionDays: z.number().int().min(1).optional(), | ||
| }) | ||
| .strict() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { AdminNotificationsSettings, OperatorNotificationTarget } from '../@types/operator-notifications' | ||
| import { Settings } from '../@types/settings' | ||
| import { loadDefaults, loadMergedSettings } from './settings-config' | ||
| import { redactSettingsSecrets } from './settings-redaction' | ||
|
|
||
| const REDACTED_SECRET = '***' | ||
|
|
||
| export const getMergedAdminNotifications = (settings: Settings = loadMergedSettings()): AdminNotificationsSettings => { | ||
| const defaults = loadDefaults().admin?.notifications | ||
| const configured = settings.admin?.notifications | ||
|
|
||
| return { | ||
| enabled: configured?.enabled ?? defaults?.enabled ?? false, | ||
| targets: configured?.targets ?? defaults?.targets ?? [], | ||
| events: { ...defaults?.events, ...configured?.events }, | ||
| retry: { | ||
| maxAttempts: configured?.retry?.maxAttempts ?? defaults?.retry?.maxAttempts ?? 5, | ||
| baseDelayMs: configured?.retry?.baseDelayMs ?? defaults?.retry?.baseDelayMs ?? 1000, | ||
| }, | ||
| deliveryLogRetentionDays: | ||
| configured?.deliveryLogRetentionDays ?? defaults?.deliveryLogRetentionDays ?? 30, | ||
| } | ||
| } | ||
|
|
||
| export const getRedactedAdminNotifications = (): AdminNotificationsSettings => { | ||
| const merged = loadMergedSettings() | ||
| const notifications = getMergedAdminNotifications(merged) | ||
| const redacted = redactSettingsSecrets({ admin: { notifications } }) as Settings | ||
|
|
||
| return getMergedAdminNotifications(redacted) | ||
| } | ||
|
|
||
| const isRedactedSecret = (value: unknown): boolean => value === REDACTED_SECRET | ||
|
|
||
| const mergeTargetSecrets = ( | ||
| incoming: OperatorNotificationTarget, | ||
| existing: OperatorNotificationTarget | undefined, | ||
| ): OperatorNotificationTarget => { | ||
| const merged: OperatorNotificationTarget = { ...incoming } | ||
|
|
||
| if (existing) { | ||
| if (isRedactedSecret(incoming.url) || (incoming.url === undefined && existing.url)) { | ||
| merged.url = existing.url | ||
| } | ||
| if (isRedactedSecret(incoming.botToken) || (incoming.botToken === undefined && existing.botToken)) { | ||
| merged.botToken = existing.botToken | ||
| } | ||
| } | ||
|
Comment on lines
+41
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a new or renamed Telegram target sends the redacted |
||
|
|
||
| return merged | ||
| } | ||
|
|
||
| export const mergeAdminNotificationsPatch = ( | ||
| current: AdminNotificationsSettings, | ||
| patch: Partial<AdminNotificationsSettings>, | ||
| ): AdminNotificationsSettings => { | ||
| const next: AdminNotificationsSettings = { | ||
| ...current, | ||
| ...patch, | ||
| events: patch.events ? { ...current.events, ...patch.events } : current.events, | ||
| retry: patch.retry ? { ...current.retry, ...patch.retry } : current.retry, | ||
| } | ||
|
|
||
| if (patch.targets) { | ||
| const existingById = new Map(current.targets.map((target) => [target.id, target])) | ||
| next.targets = patch.targets.map((target) => mergeTargetSecrets(target, existingById.get(target.id))) | ||
| } | ||
|
|
||
| return next | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import chai from 'chai' | ||
| import Sinon from 'sinon' | ||
|
|
||
| import { GetAdminNotificationsController } from '../../../../src/controllers/admin/get-notifications-controller' | ||
| import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' | ||
|
|
||
| const { expect } = chai | ||
|
|
||
| describe('GetAdminNotificationsController', () => { | ||
| let sandbox: Sinon.SinonSandbox | ||
| let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } | ||
|
|
||
| beforeEach(() => { | ||
| sandbox = Sinon.createSandbox() | ||
| response = { | ||
| status: sandbox.stub().returnsThis(), | ||
| setHeader: sandbox.stub().returnsThis(), | ||
| send: sandbox.stub().returnsThis(), | ||
| } | ||
| sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ | ||
| enabled: false, | ||
| targets: [], | ||
| events: {}, | ||
| retry: { maxAttempts: 5, baseDelayMs: 1000 }, | ||
| }) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| sandbox.restore() | ||
| }) | ||
|
|
||
| it('returns redacted notifications config', async () => { | ||
| const controller = new GetAdminNotificationsController() | ||
| await controller.handleRequest({} as any, response as any) | ||
|
|
||
| expect(response.status.calledOnceWithExactly(200)).to.equal(true) | ||
| expect( | ||
| response.send.calledOnceWith({ | ||
| notifications: { | ||
| enabled: false, | ||
| targets: [], | ||
| events: {}, | ||
| retry: { maxAttempts: 5, baseDelayMs: 1000 }, | ||
| }, | ||
| }), | ||
| ).to.equal(true) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new write endpoint has no controller-level tests for request validation, persistence, redacted-secret round trips, or outbox failures. The added merge test covers only an existing Discord target, so cases such as a new Telegram target receiving
***are not protected against regressions. As a non-blocking improvement, add PATCH controller tests for valid partial updates, invalid bodies, new and existing secret-bearing targets, and enqueue failures.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!