feat(notifications): add per-tenant notification preferences

This commit is contained in:
2026-10-03 14:50:38 +02:00
parent efa6ea90a2
commit 4c67ddd908
12 changed files with 322 additions and 22 deletions

View File

@@ -111,6 +111,42 @@ export class NotificationService {
}
}
async preferences(tenantId: number, userId: string) {
const events = await this.server.db.select().from(notificationsEventTypes)
.where(eq(notificationsEventTypes.isActive, true))
const rows = []
for (const event of events.filter((event) => event.category !== "system")) {
const channels = this.normalizeChannels(event.allowedChannels).filter((channel) => channel === "email" || channel === "push")
const enabled = await this.resolveEnabledChannels({ tenantId, userId, eventType: event.eventKey, channels })
rows.push({ eventKey: event.eventKey, displayName: event.displayName, description: event.description,
channels: Object.fromEntries(channels.map((channel) => [channel, enabled.includes(channel)])) })
}
return { tenantId, events: rows }
}
async savePreferences(tenantId: number, userId: string, input: unknown) {
const invalid = () => Object.assign(new Error("Ungültige Benachrichtigungseinstellungen"), { statusCode: 400 })
if (!Array.isArray(input) || input.length > 200) throw invalid()
const available = await this.preferences(tenantId, userId)
const seen = new Set<string>()
const values = input.map((value) => {
if (!value || typeof value !== "object") throw invalid()
const { eventType, channel, enabled } = value
if (typeof eventType !== "string" || (channel !== "email" && channel !== "push")) throw invalid()
const event = available.events.find((event) => event.eventKey === eventType)
const key = `${eventType}:${channel}`
if (!event || !Object.prototype.hasOwnProperty.call(event.channels, channel) || typeof enabled !== "boolean" || seen.has(key)) throw invalid()
seen.add(key)
return { tenantId, userId, eventType: String(eventType), channel: channel as NotificationChannel, enabled }
})
await this.server.db.transaction(async (tx) => {
for (const value of values) await tx.insert(notificationsPreferences).values(value)
.onConflictDoUpdate({ target: [notificationsPreferences.tenantId, notificationsPreferences.userId,
notificationsPreferences.eventType, notificationsPreferences.channel], set: { enabled: value.enabled } })
})
return await this.preferences(tenantId, userId)
}
async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db
.select()
@@ -259,7 +295,7 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled
return true
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
})
}

View File

@@ -201,32 +201,34 @@ export default async function communicationRoutes(server: FastifyInstance) {
.from(communicationRoomMembers).where(eq(communicationRoomMembers.roomId, room.id))
const memberIds = new Set(memberRows.map((member) => member.userId))
const normalizedText = message.body.toLowerCase()
const recipients = users.filter((user) => {
if (user.userId === senderId || !memberIds.has(user.userId)) return false
if (room.type === "direct") return true
const recipients = users.filter((user) => user.userId !== senderId && memberIds.has(user.userId))
const isMentioned = (user: ChatUser) => {
const aliases = [displayName(user), user.fullName, user.firstName, user.email]
.filter(Boolean).map((value) => String(value).toLowerCase())
return aliases.some((alias) => normalizedText.includes(`@${alias}`))
})
}
if (!recipients.length) return
const senderName = sender ? displayName(sender) : "FEDEO"
await notifications.trigger({
tenantId,
userIds: recipients.map((recipient) => recipient.userId),
eventType: "communication.message.new",
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : `${senderName} hat dich erwähnt`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: room.type !== "direct",
direct: room.type === "direct",
},
channels: ["inapp", "push"],
})
for (const eventType of ["communication.message.direct", "communication.message.mentioned", "communication.message.room"]) {
const targets = recipients.filter((user) => (room.type === "direct" ? "communication.message.direct" : isMentioned(user) ? "communication.message.mentioned" : "communication.message.room") === eventType)
if (!targets.length) continue
await notifications.trigger({
tenantId,
userIds: targets.map((recipient) => recipient.userId),
eventType,
title: room.type === "direct" ? `Neue Direktnachricht von ${senderName}` : eventType === "communication.message.mentioned" ? `${senderName} hat dich erwähnt` : `Neue Nachricht in ${room.name}`,
message: message.body.length > 160 ? `${message.body.slice(0, 157)}...` : message.body,
payload: {
link: `/communication/chat?room=${encodeURIComponent(room.key)}`,
roomKey: room.key,
roomName: room.name,
roomType: room.type,
messageId: message.id,
mentioned: eventType === "communication.message.mentioned",
direct: room.type === "direct",
},
})
}
} catch (err) {
server.log.error({ err }, "Chat-Benachrichtigung konnte nicht ausgelöst werden")
}
@@ -307,6 +309,14 @@ export default async function communicationRoutes(server: FastifyInstance) {
return room
})
if (!created) return reply.code(409).send({ error: "Dieser Raumschlüssel ist bereits vergeben" })
const recipients = userIds.filter((id) => id !== req.user.user_id)
if (recipients.length) {
try {
await notifications.trigger({ tenantId, userIds: recipients, eventType: "communication.room.invited",
title: "Neuer Chatraum", message: `Du wurdest dem Raum „${created.name}“ hinzugefügt.`,
payload: { link: `/communication/chat?room=${encodeURIComponent(created.key)}` } })
} catch (err) { server.log.error({ err }, "Raum-Benachrichtigung fehlgeschlagen") }
}
return { ...roomToApi(created), alreadyExisted: false }
})

View File

@@ -23,6 +23,12 @@ const requireTenant = (tenantId: number | null) => {
export default async function notificationsRoutes(server: FastifyInstance) {
const svc = new NotificationService(server, getUserDirectory)
server.get("/notifications/preferences", async (req) =>
svc.preferences(requireTenant(req.user.tenant_id), req.user.user_id))
server.put("/notifications/preferences", async (req) =>
svc.savePreferences(requireTenant(req.user.tenant_id), req.user.user_id, req.body))
server.get("/notifications", async (req) => {
const limit = Number((req.query as { limit?: string })?.limit || 50)
return await svc.listForUser(requireTenant(req.user.tenant_id), req.user.user_id, limit)