cms.c2sgmbh/src/lib/alerting/alert-service.ts
Martin Porwoll 6bbbea52fc feat: implement monitoring & alerting system
- Add AuditLogs collection for tracking critical system actions
  - User changes (create, update, delete)
  - Tenant changes with sensitive data masking
  - Login events tracking

- Add Alert Service with multi-channel support
  - Email, Slack, Discord, Console channels
  - Configurable alert levels (info, warning, error, critical)
  - Environment-based configuration

- Add Email failure alerting
  - Automatic alerts on repeated failed emails
  - Per-tenant failure counting with hourly reset

- Add Email-Logs API endpoints
  - GET /api/email-logs/export (CSV/JSON export)
  - GET /api/email-logs/stats (statistics with filters)

- Add audit hooks for Users and Tenants collections
- Update TODO.md with completed monitoring tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:58:20 +00:00

344 lines
9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Alert Service
*
* Zentraler Service für System-Benachrichtigungen.
* Unterstützt verschiedene Kanäle: E-Mail, Slack, Discord, etc.
*/
import type { Payload } from 'payload'
export type AlertChannel = 'email' | 'slack' | 'discord' | 'console'
export type AlertLevel = 'info' | 'warning' | 'error' | 'critical'
export interface AlertConfig {
channels: AlertChannel[]
recipients?: string[] // E-Mail-Adressen
slackWebhook?: string
discordWebhook?: string
minLevel: AlertLevel
}
interface AlertInput {
level: AlertLevel
title: string
message: string
details?: Record<string, unknown>
tenantId?: number
tenantName?: string
}
// Alert-Konfiguration aus Environment
const alertConfig: AlertConfig = {
channels: (process.env.ALERT_CHANNELS?.split(',') as AlertChannel[]) || ['console'],
recipients: process.env.ALERT_EMAIL_RECIPIENTS?.split(','),
slackWebhook: process.env.ALERT_SLACK_WEBHOOK,
discordWebhook: process.env.ALERT_DISCORD_WEBHOOK,
minLevel: (process.env.ALERT_MIN_LEVEL as AlertLevel) || 'warning',
}
// Level-Hierarchie für Filterung
const levelPriority: Record<AlertLevel, number> = {
info: 0,
warning: 1,
error: 2,
critical: 3,
}
/**
* Prüft ob ein Alert basierend auf dem Level gesendet werden soll
*/
function shouldSendAlert(level: AlertLevel): boolean {
return levelPriority[level] >= levelPriority[alertConfig.minLevel]
}
/**
* Formatiert die Alert-Nachricht für verschiedene Kanäle
*/
function formatAlertMessage(alert: AlertInput, format: 'text' | 'html' | 'markdown'): string {
const timestamp = new Date().toLocaleString('de-DE')
const levelEmoji: Record<AlertLevel, string> = {
info: '',
warning: '⚠️',
error: '❌',
critical: '🚨',
}
if (format === 'html') {
return `
<div style="font-family: Arial, sans-serif; padding: 20px; border-left: 4px solid ${
alert.level === 'critical'
? '#dc2626'
: alert.level === 'error'
? '#f97316'
: alert.level === 'warning'
? '#eab308'
: '#3b82f6'
};">
<h2 style="margin: 0 0 10px;">${levelEmoji[alert.level]} ${alert.title}</h2>
<p style="color: #666; margin: 0 0 15px;">${timestamp}</p>
${alert.tenantName ? `<p><strong>Tenant:</strong> ${alert.tenantName}</p>` : ''}
<p>${alert.message}</p>
${
alert.details
? `
<details>
<summary>Details</summary>
<pre style="background: #f5f5f5; padding: 10px; overflow: auto;">${JSON.stringify(alert.details, null, 2)}</pre>
</details>
`
: ''
}
</div>
`
}
if (format === 'markdown') {
let md = `${levelEmoji[alert.level]} **${alert.title}**\n\n`
md += `*${timestamp}*\n\n`
if (alert.tenantName) md += `**Tenant:** ${alert.tenantName}\n\n`
md += `${alert.message}\n`
if (alert.details) {
md += `\n\`\`\`json\n${JSON.stringify(alert.details, null, 2)}\n\`\`\``
}
return md
}
// Plain text
let text = `${levelEmoji[alert.level]} ${alert.title}\n`
text += `${timestamp}\n\n`
if (alert.tenantName) text += `Tenant: ${alert.tenantName}\n`
text += `${alert.message}\n`
if (alert.details) {
text += `\nDetails: ${JSON.stringify(alert.details)}`
}
return text
}
/**
* Sendet Alert via E-Mail
*/
async function sendEmailAlert(payload: Payload, alert: AlertInput): Promise<void> {
if (!alertConfig.recipients?.length) {
console.warn('[AlertService] No email recipients configured')
return
}
const levelLabels: Record<AlertLevel, string> = {
info: 'Info',
warning: 'Warnung',
error: 'Fehler',
critical: 'KRITISCH',
}
try {
await payload.sendEmail({
to: alertConfig.recipients.join(','),
subject: `[${levelLabels[alert.level]}] ${alert.title}`,
html: formatAlertMessage(alert, 'html'),
text: formatAlertMessage(alert, 'text'),
})
console.log(`[AlertService] Email alert sent to ${alertConfig.recipients.join(', ')}`)
} catch (error) {
console.error('[AlertService] Failed to send email alert:', error)
}
}
/**
* Sendet Alert via Slack Webhook
*/
async function sendSlackAlert(alert: AlertInput): Promise<void> {
if (!alertConfig.slackWebhook) {
console.warn('[AlertService] No Slack webhook configured')
return
}
const colors: Record<AlertLevel, string> = {
info: '#3b82f6',
warning: '#eab308',
error: '#f97316',
critical: '#dc2626',
}
try {
const response = await fetch(alertConfig.slackWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
attachments: [
{
color: colors[alert.level],
title: alert.title,
text: alert.message,
fields: [
...(alert.tenantName
? [
{
title: 'Tenant',
value: alert.tenantName,
short: true,
},
]
: []),
{
title: 'Level',
value: alert.level.toUpperCase(),
short: true,
},
],
footer: 'Payload CMS Alert',
ts: Math.floor(Date.now() / 1000),
},
],
}),
})
if (!response.ok) {
throw new Error(`Slack responded with ${response.status}`)
}
console.log('[AlertService] Slack alert sent')
} catch (error) {
console.error('[AlertService] Failed to send Slack alert:', error)
}
}
/**
* Sendet Alert via Discord Webhook
*/
async function sendDiscordAlert(alert: AlertInput): Promise<void> {
if (!alertConfig.discordWebhook) {
console.warn('[AlertService] No Discord webhook configured')
return
}
const colors: Record<AlertLevel, number> = {
info: 0x3b82f6,
warning: 0xeab308,
error: 0xf97316,
critical: 0xdc2626,
}
try {
const response = await fetch(alertConfig.discordWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
{
title: alert.title,
description: alert.message,
color: colors[alert.level],
fields: [
...(alert.tenantName
? [
{
name: 'Tenant',
value: alert.tenantName,
inline: true,
},
]
: []),
{
name: 'Level',
value: alert.level.toUpperCase(),
inline: true,
},
],
footer: {
text: 'Payload CMS Alert',
},
timestamp: new Date().toISOString(),
},
],
}),
})
if (!response.ok) {
throw new Error(`Discord responded with ${response.status}`)
}
console.log('[AlertService] Discord alert sent')
} catch (error) {
console.error('[AlertService] Failed to send Discord alert:', error)
}
}
/**
* Sendet Alert an alle konfigurierten Kanäle
*/
export async function sendAlert(payload: Payload, alert: AlertInput): Promise<void> {
if (!shouldSendAlert(alert.level)) {
return
}
const promises: Promise<void>[] = []
for (const channel of alertConfig.channels) {
switch (channel) {
case 'email':
promises.push(sendEmailAlert(payload, alert))
break
case 'slack':
promises.push(sendSlackAlert(alert))
break
case 'discord':
promises.push(sendDiscordAlert(alert))
break
case 'console':
console.log(`[Alert:${alert.level.toUpperCase()}]`, formatAlertMessage(alert, 'text'))
break
}
}
await Promise.allSettled(promises)
}
/**
* Email-Fehler Alert
*/
export async function alertEmailFailed(
payload: Payload,
tenantId: number,
tenantName: string,
to: string,
subject: string,
error: string,
failedCount: number,
): Promise<void> {
await sendAlert(payload, {
level: failedCount >= 5 ? 'critical' : 'error',
title: `E-Mail-Versand fehlgeschlagen${failedCount >= 5 ? ' (wiederholt)' : ''}`,
message: `E-Mail an "${to}" mit Betreff "${subject}" konnte nicht gesendet werden.`,
tenantId,
tenantName,
details: {
to,
subject,
error: error.substring(0, 200), // Gekürzt um Secrets zu vermeiden
failedCount,
recommendation:
failedCount >= 5
? 'SMTP-Konfiguration prüfen! Mehrere Fehler in Folge.'
: 'Einzelner Fehler. Weiter beobachten.',
},
})
}
/**
* Rate-Limit Alert
*/
export async function alertRateLimitReached(
payload: Payload,
endpoint: string,
userId?: number,
userEmail?: string,
): Promise<void> {
await sendAlert(payload, {
level: 'warning',
title: 'Rate-Limit erreicht',
message: `Rate-Limit für Endpoint "${endpoint}" wurde erreicht.`,
details: {
endpoint,
userId,
userEmail,
},
})
}