mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14:12 +00:00
- 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>
344 lines
9 KiB
TypeScript
344 lines
9 KiB
TypeScript
/**
|
||
* 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,
|
||
},
|
||
})
|
||
}
|