/ src / lib / server / runtime / alert-dispatch.ts
alert-dispatch.ts
 1  import { loadSettings } from '@/lib/server/settings/settings-repository'
 2  import type { AppNotification } from '@/types'
 3  import { errorMessage } from '@/lib/shared-utils'
 4  import { log } from '@/lib/server/logger'
 5  
 6  const TAG = 'alert-dispatch'
 7  
 8  /** In-memory rate limiter: dedupKey → last dispatch timestamp */
 9  const recentDispatches = new Map<string, number>()
10  const DEDUP_WINDOW_MS = 60_000
11  
12  export async function dispatchAlert(notification: AppNotification): Promise<void> {
13    const settings = loadSettings()
14    const url = typeof settings.alertWebhookUrl === 'string' ? settings.alertWebhookUrl.trim() : ''
15    if (!url) return
16  
17    const allowedEvents: string[] = Array.isArray(settings.alertWebhookEvents)
18      ? settings.alertWebhookEvents
19      : ['error']
20    if (!allowedEvents.includes(notification.type)) return
21  
22    // Rate limit by dedupKey (or notification id as fallback)
23    const dedupKey = notification.dedupKey || notification.id
24    const now = Date.now()
25    const lastSent = recentDispatches.get(dedupKey)
26    if (lastSent && now - lastSent < DEDUP_WINDOW_MS) return
27    recentDispatches.set(dedupKey, now)
28  
29    // Prune stale entries on every write to bound growth
30    for (const [key, ts] of recentDispatches) {
31      if (now - ts > DEDUP_WINDOW_MS) recentDispatches.delete(key)
32    }
33  
34    const webhookType = settings.alertWebhookType || 'custom'
35    let body: string
36  
37    if (webhookType === 'discord') {
38      body = JSON.stringify({
39        content: `⚠️ **${notification.title}**${notification.message ? `\n${notification.message}` : ''}`,
40      })
41    } else if (webhookType === 'slack') {
42      body = JSON.stringify({
43        text: `⚠️ *${notification.title}*${notification.message ? `\n${notification.message}` : ''}`,
44      })
45    } else {
46      body = JSON.stringify({
47        type: notification.type,
48        title: notification.title,
49        message: notification.message || null,
50        entityType: notification.entityType || null,
51        entityId: notification.entityId || null,
52        timestamp: notification.createdAt,
53      })
54    }
55  
56    try {
57      await fetch(url, {
58        method: 'POST',
59        headers: { 'Content-Type': 'application/json' },
60        body,
61        signal: AbortSignal.timeout(5000),
62      })
63    } catch (err: unknown) {
64      log.warn(TAG, 'Webhook delivery failed:', errorMessage(err))
65    }
66  }