Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/admin-notifications-api.md
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.
6 changes: 5 additions & 1 deletion src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export interface INotificationDeliveryLogRepository {
},
client?: DatabaseClient,
): Promise<void>
findRecent(limit?: number, client?: DatabaseClient): Promise<NotificationDeliveryLogEntry[]>
findRecent(
limit?: number,
filters?: { status?: NotificationDeliveryStatus; eventType?: string },
client?: DatabaseClient,
): Promise<NotificationDeliveryLogEntry[]>
findSuccessfulTargetIds(outboxId: string, client?: DatabaseClient): Promise<string[]>
deleteOlderThan(cutoff: Date, client?: DatabaseClient): Promise<number>
}
Expand Down
11 changes: 11 additions & 0 deletions src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
import { InvoiceStatus } from '../@types/invoice'
import { isExpiredInvoice } from '../utils/invoice'
import { Nip05Verification } from '../@types/nip05'
import { FSWatcher } from 'fs'

import { Settings } from '../@types/settings'
import { SettingsStatic } from '../utils/settings'
import { shutdownMetricsTelemetry } from '../telemetry/metrics'

const UPDATE_INVOICE_INTERVAL = 60000
Expand Down Expand Up @@ -79,6 +82,7 @@ export function applyReverificationOutcome(
export class MaintenanceWorker implements IRunnable {
private interval: NodeJS.Timeout | undefined
private isRunning = false
private watchers: FSWatcher[] | undefined
/**
* Where the next pass starts. Without it every pass re-reads the oldest ten, so
* ten invoices that never resolve starve everything behind them.
Expand Down Expand Up @@ -117,6 +121,8 @@ export class MaintenanceWorker implements IRunnable {
}

public run(): void {
this.watchers = SettingsStatic.watchSettings()

this.interval = setInterval(async () => {
if (this.isRunning) {
logger('skipping scheduled maintenance run because previous run is still in progress')
Expand Down Expand Up @@ -289,6 +295,11 @@ export class MaintenanceWorker implements IRunnable {
public close(callback?: () => void) {
logger('closing')
clearInterval(this.interval)
if (Array.isArray(this.watchers)) {
for (const watcher of this.watchers) {
watcher.close()
}
}
if (typeof callback === 'function') {
callback()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { NotificationDeliveryStatus } from '../../@types/operator-notifications'
import { INotificationDeliveryLogRepository } from '../../@types/repositories'

export class GetAdminNotificationDeliveryLogController implements IController {
Expand All @@ -19,7 +20,26 @@ export class GetAdminNotificationDeliveryLogController implements IController {
limit = Math.min(parsed, 200)
}

const entries = await this.deliveryLogRepository.findRecent(limit)
let status: NotificationDeliveryStatus | undefined
if (_request.query.status !== undefined) {
const value = String(_request.query.status)
if (value !== NotificationDeliveryStatus.SUCCESS && value !== NotificationDeliveryStatus.FAILED) {
response.status(400).setHeader('content-type', 'application/json').send({
error: 'status must be success or failed',
})
return
}
status = value
}

const eventType =
_request.query.eventType !== undefined ? String(_request.query.eventType).trim() : undefined
if (eventType !== undefined && !eventType) {
response.status(400).setHeader('content-type', 'application/json').send({ error: 'eventType must be non-empty' })
return
}

const entries = await this.deliveryLogRepository.findRecent(limit, { status, eventType })

response.status(200).setHeader('content-type', 'application/json').send({
entries: entries.map((entry) => ({
Expand Down
12 changes: 12 additions & 0 deletions src/controllers/admin/get-notifications-controller.ts
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(),
})
}
}
78 changes: 78 additions & 0 deletions src/controllers/admin/patch-notifications-controller.ts
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
}

Comment thread
Ferryx349 marked this conversation as resolved.
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)
}
19 changes: 16 additions & 3 deletions src/repositories/notification-delivery-log-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,24 @@ export class NotificationDeliveryLogRepository implements INotificationDeliveryL
.pluck('target_id')
}

public async findRecent(limit = 50, client: DatabaseClient = this.dbClient): Promise<NotificationDeliveryLogEntry[]> {
const rows = await client<DBNotificationDeliveryLogEntry>('notification_delivery_log')
public async findRecent(
limit = 50,
filters?: { status?: NotificationDeliveryStatus; eventType?: string },
client: DatabaseClient = this.dbClient,
): Promise<NotificationDeliveryLogEntry[]> {
let query = client<DBNotificationDeliveryLogEntry>('notification_delivery_log')
.orderBy('created_at', 'desc')
.limit(limit)
.select('*')

if (filters?.status) {
query = query.where('status', filters.status)
}

if (filters?.eventType) {
query = query.where('event_type', filters.eventType)
}

const rows = await query.select('*')

return rows.map(fromDB)
}
Expand Down
16 changes: 16 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import express, { json, Router } from 'express'

import { createGetAdminNotificationDeliveryLogController } from '../../factories/controllers/get-admin-notification-delivery-log-controller-factory'
import { createGetAdminNotificationsController } from '../../factories/controllers/get-admin-notifications-controller-factory'
import { createPatchAdminNotificationsController } from '../../factories/controllers/patch-admin-notifications-controller-factory'
import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory'
import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory'
import { createGetAdminNetworkHealthController } from '../../factories/controllers/get-admin-network-health-controller-factory'
Expand Down Expand Up @@ -106,6 +108,20 @@ router.post(
adminAuthMiddleware,
withAdminController(createPostAdminSettingsRestoreController),
)
router.get(
'/notifications',
adminRateLimitMiddleware,
adminAuthMiddleware,
withAdminController(createGetAdminNotificationsController),
)
router.patch(
'/notifications',
adminRateLimitMiddleware,
adminAuthGateMiddleware,
adminJsonBodyMiddleware,
adminAuthMiddleware,
withAdminController(createPatchAdminNotificationsController),
)
router.get(
'/notifications/deliveries',
adminRateLimitMiddleware,
Expand Down
37 changes: 37 additions & 0 deletions src/schemas/admin-notifications-schema.ts
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()
32 changes: 29 additions & 3 deletions src/services/operator-notification-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,38 @@ export class OperatorNotificationService implements INotificationDispatcher {
throw new Error(`Unknown notification target: ${targetId}`)
}

const envelope = this.buildEnvelope(OperatorNotificationEventType.RELAY_RESTARTED, {
const relayName = this.settings().info?.name?.trim() || this.settings().info.relay_url
const eventType = OperatorNotificationEventType.RELAY_RESTARTED
const envelope = this.buildEnvelope(eventType, {
test: true,
message: 'Operator notification test delivery',
message: `Test notification from ${relayName}`,
})

await deliverToTarget(target, envelope)
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({
outboxId: null,
eventType,
targetId: target.id,
targetType: target.type,
status: NotificationDeliveryStatus.FAILED,
attemptNumber: 1,
errorSnippet: message.slice(0, 2000),
})
throw error
}
}

public getMaxAttempts(): number {
Expand Down
Loading
Loading