Files
FEDEO/backend/src/modules/notification.service.ts

432 lines
15 KiB
TypeScript

import type { FastifyInstance } from "fastify"
import webPush from "web-push"
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"
import {
authUsers,
notificationPushSubscriptions,
notificationsEventTypes,
notificationsItems,
notificationsPreferences,
notificationsPreferencesDefaults,
} from "../../db/schema"
import { secrets } from "../utils/secrets"
export type NotificationChannel = "inapp" | "email" | "push" | "webhook" | "sms"
export type NotificationStatus = "queued" | "sent" | "failed" | "read"
export interface TriggerInput {
tenantId: number
userId?: string
userIds?: string[]
eventType: string
title: string
message: string
payload?: Record<string, unknown>
channels?: NotificationChannel[]
}
export interface PushSubscriptionInput {
endpoint: string
keys: {
p256dh: string
auth: string
}
deviceLabel?: string
meta?: Record<string, unknown>
}
export interface UserDirectoryInfo {
email?: string
}
export type UserDirectory = (
server: FastifyInstance,
userId: string,
tenantId: number
) => Promise<UserDirectoryInfo | null>
const DEFAULT_CHANNELS: NotificationChannel[] = ["inapp"]
export class NotificationService {
constructor(
private server: FastifyInstance,
private getUser: UserDirectory
) {}
async trigger(input: TriggerInput) {
const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
if (!tenantId) throw new Error("tenantId fehlt")
if (!userIds.length) throw new Error("Keine Empfänger angegeben")
const eventType = await this.getActiveEventType(input.eventType)
const allowedChannels = this.normalizeChannels(eventType.allowedChannels)
const requestedChannels = input.channels?.length ? input.channels : allowedChannels
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length }
}
const results = []
for (const userId of userIds) {
const enabledChannels = await this.resolveEnabledChannels({
tenantId,
userId,
eventType: input.eventType,
channels,
})
for (const channel of enabledChannels) {
const itemRows = await this.server.db
.insert(notificationsItems)
.values({
tenantId,
userId,
eventType: input.eventType,
title: input.title,
message: input.message,
payload: input.payload ?? null,
channel,
status: "queued",
})
.returning()
const item = itemRows[0]
if (!item) continue
results.push(await this.deliver(item))
}
}
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db
.select()
.from(notificationsItems)
.where(and(
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId),
eq(notificationsItems.channel, "inapp")
))
.orderBy(desc(notificationsItems.createdAt))
.limit(Math.min(Math.max(limit, 1), 100))
}
async markRead(tenantId: number, userId: string, notificationId: string) {
const rows = await this.server.db
.update(notificationsItems)
.set({ readAt: new Date(), status: "read" })
.where(and(
eq(notificationsItems.id, notificationId),
eq(notificationsItems.tenantId, tenantId),
eq(notificationsItems.userId, userId)
))
.returning()
return rows[0] || null
}
async registerPushSubscription(
tenantId: number,
userId: string,
subscription: PushSubscriptionInput,
userAgent?: string
) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error("Push-Subscription ist unvollständig")
}
const rows = await this.server.db
.insert(notificationPushSubscriptions)
.values({
tenantId,
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
})
.onConflictDoUpdate({
target: notificationPushSubscriptions.endpoint,
set: {
tenantId,
userId,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent,
deviceLabel: subscription.deviceLabel,
meta: subscription.meta ?? null,
lastSeenAt: new Date(),
disabledAt: null,
},
})
.returning()
return rows[0]
}
async disablePushSubscription(tenantId: number, userId: string, endpoint: string) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(and(
eq(notificationPushSubscriptions.tenantId, tenantId),
eq(notificationPushSubscriptions.userId, userId),
eq(notificationPushSubscriptions.endpoint, endpoint)
))
return { success: true }
}
getPublicPushConfig() {
return {
configured: Boolean(secrets.WEB_PUSH_PUBLIC_KEY && secrets.WEB_PUSH_PRIVATE_KEY),
publicKey: secrets.WEB_PUSH_PUBLIC_KEY || "",
}
}
private async getActiveEventType(eventType: string) {
const rows = await this.server.db
.select()
.from(notificationsEventTypes)
.where(eq(notificationsEventTypes.eventKey, eventType))
.limit(1)
const row = rows[0]
if (!row || row.isActive !== true) {
throw new Error(`Unbekannter oder inaktiver Event-Typ: ${eventType}`)
}
return row
}
private normalizeChannels(value: unknown): NotificationChannel[] {
if (!Array.isArray(value)) return DEFAULT_CHANNELS
const valid = new Set(["inapp", "email", "push", "webhook", "sms"])
const channels = value.filter((channel): channel is NotificationChannel =>
typeof channel === "string" && valid.has(channel)
)
return channels.length ? channels : DEFAULT_CHANNELS
}
private async resolveEnabledChannels(input: {
tenantId: number
userId: string
eventType: string
channels: NotificationChannel[]
}) {
const prefs = await this.server.db
.select()
.from(notificationsPreferences)
.where(and(
eq(notificationsPreferences.tenantId, input.tenantId),
eq(notificationsPreferences.userId, input.userId),
eq(notificationsPreferences.eventType, input.eventType),
inArray(notificationsPreferences.channel, input.channels)
))
const defaults = await this.server.db
.select()
.from(notificationsPreferencesDefaults)
.where(and(
eq(notificationsPreferencesDefaults.tenantId, input.tenantId),
eq(notificationsPreferencesDefaults.eventKey, input.eventType),
inArray(notificationsPreferencesDefaults.channel, input.channels)
))
return input.channels.filter((channel) => {
const userPref = prefs.find((pref) => pref.channel === channel)
if (userPref) return userPref.enabled
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
})
}
private async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
}
if (item.channel === "push") {
return await this.deliverPush(item)
}
if (item.channel === "email") {
return await this.deliverEmail(item)
}
await this.markFailed(item.id, `Kein Zusteller für Kanal ${item.channel}`)
return { success: false, id: item.id, channel: item.channel }
}
private async deliverPush(item: typeof notificationsItems.$inferSelect) {
if (!secrets.WEB_PUSH_PUBLIC_KEY || !secrets.WEB_PUSH_PRIVATE_KEY) {
await this.markFailed(item.id, "Web Push ist nicht konfiguriert")
return { success: false, id: item.id, channel: item.channel }
}
webPush.setVapidDetails(
secrets.WEB_PUSH_SUBJECT || "mailto:admin@example.com",
secrets.WEB_PUSH_PUBLIC_KEY,
secrets.WEB_PUSH_PRIVATE_KEY
)
const subscriptions = await this.server.db
.select()
.from(notificationPushSubscriptions)
.where(and(
eq(notificationPushSubscriptions.tenantId, item.tenantId),
eq(notificationPushSubscriptions.userId, item.userId),
isNull(notificationPushSubscriptions.disabledAt)
))
if (!subscriptions.length) {
await this.markFailed(item.id, "Keine aktive Push-Subscription")
return { success: false, id: item.id, channel: item.channel }
}
const payload = JSON.stringify({
id: item.id,
title: item.title,
message: item.message,
payload: item.payload || {},
})
let delivered = 0
const errors: string[] = []
for (const subscription of subscriptions) {
try {
await webPush.sendNotification({
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}, payload)
delivered++
await this.server.db
.update(notificationPushSubscriptions)
.set({ lastSeenAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
} catch (error: any) {
errors.push(error?.message || String(error))
if (error?.statusCode === 404 || error?.statusCode === 410) {
await this.server.db
.update(notificationPushSubscriptions)
.set({ disabledAt: new Date() })
.where(eq(notificationPushSubscriptions.id, subscription.id))
}
}
}
if (delivered > 0) {
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel, delivered }
}
await this.markFailed(item.id, errors.join("; ") || "Push konnte nicht zugestellt werden")
return { success: false, id: item.id, channel: item.channel }
}
private async deliverEmail(item: typeof notificationsItems.$inferSelect) {
try {
const user = await this.getUser(this.server, item.userId, item.tenantId)
if (!user?.email) throw new Error(`Nutzer ${item.userId} hat keine E-Mail-Adresse`)
await this.sendEmail(user.email, item.title, item.message)
await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel }
} catch (error: any) {
await this.markFailed(item.id, error?.message || "E-Mail Versand fehlgeschlagen")
this.server.log.error({ err: error, notificationId: item.id }, "E-Mail Versand fehlgeschlagen")
return { success: false, id: item.id, channel: item.channel }
}
}
private async markSent(id: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "sent", sentAt: new Date() })
.where(eq(notificationsItems.id, id))
}
private async markFailed(id: string, error: string) {
await this.server.db
.update(notificationsItems)
.set({ status: "failed", error })
.where(eq(notificationsItems.id, id))
}
private async sendEmail(to: string, subject: string, message: string) {
const nodemailer = await import("nodemailer")
const transporter = nodemailer.createTransport({
host: secrets.MAILER_SMTP_HOST,
port: Number(secrets.MAILER_SMTP_PORT),
secure: secrets.MAILER_SMTP_SSL === "true",
auth: {
user: secrets.MAILER_SMTP_USER,
pass: secrets.MAILER_SMTP_PASS,
},
})
const html = this.renderFedeoHtml(subject, message)
await transporter.sendMail({
from: secrets.MAILER_FROM,
to,
subject,
text: message,
html,
})
}
private renderFedeoHtml(title: string, message: string) {
return `
<html><body style="font-family:sans-serif;color:#222">
<div style="border:1px solid #ddd;border-radius:8px;padding:16px;max-width:600px;margin:auto">
<h2 style="color:#0f62fe;margin:0 0 12px">FEDEO</h2>
<h3 style="margin:0 0 8px">${this.escapeHtml(title)}</h3>
<p>${this.nl2br(this.escapeHtml(message))}</p>
<hr style="margin:16px 0;border:none;border-top:1px solid #eee"/>
<p style="font-size:12px;color:#666">Automatisch generiert von FEDEO</p>
</div>
</body></html>
`
}
private escapeHtml(s: string) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private nl2br(s: string) {
return s.replace(/\n/g, "<br/>")
}
}