feat(notifications): add per-tenant notification preferences
This commit is contained in:
19
backend/db/migrations/0079_notification_matrix.sql
Normal file
19
backend/db/migrations/0079_notification_matrix.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
|
||||
('communication.message.direct', 'Direktnachrichten', 'Neue persönliche Chatnachrichten.', 'communication', '["inapp","email","push"]'),
|
||||
('communication.message.mentioned', 'Erwähnungen', 'Du wirst in einem Chatraum oder Projektchat persönlich erwähnt.', 'communication', '["inapp","email","push"]'),
|
||||
('communication.message.room', 'Raum- und Projektnachrichten', 'Neue Nachrichten in deinen Chaträumen, ohne persönliche Erwähnung.', 'communication', '["inapp","email","push"]')
|
||||
ON CONFLICT (event_key) DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE notifications_event_types SET allowed_channels = '["inapp","email","push"]', display_name = 'Neuer Chatraum', description = 'Du wirst einem neu angelegten Chatraum hinzugefügt.' WHERE event_key = 'communication.room.invited';
|
||||
--> statement-breakpoint
|
||||
INSERT INTO notifications_preferences (tenant_id, user_id, event_type, channel, enabled)
|
||||
SELECT tenant_id, user_id, replacement.key, channel, enabled FROM notifications_preferences
|
||||
CROSS JOIN (VALUES ('communication.message.direct'), ('communication.message.mentioned')) AS replacement(key)
|
||||
WHERE event_type = 'communication.message.new' ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO notifications_preferences_defaults (tenant_id, event_key, channel, enabled)
|
||||
SELECT tenant_id, replacement.key, channel, enabled FROM notifications_preferences_defaults
|
||||
CROSS JOIN (VALUES ('communication.message.direct'), ('communication.message.mentioned')) AS replacement(key)
|
||||
WHERE event_key = 'communication.message.new' ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE notifications_event_types SET is_active = false WHERE event_key IN ('communication.message.new', 'communication.call.started', 'communication.call.missed');
|
||||
@@ -533,6 +533,13 @@
|
||||
"when": 1790985600000,
|
||||
"tag": "0078_chat_visibility",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1791072000000,
|
||||
"tag": "0079_notification_matrix",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
backend/tests/notificationPreferences.test.ts
Normal file
59
backend/tests/notificationPreferences.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import Fastify from 'fastify'
|
||||
import routes from '../src/routes/notifications'
|
||||
import { NotificationService } from '../src/modules/notification.service'
|
||||
import { notificationsEventTypes, notificationsPreferences } from '../db/schema'
|
||||
|
||||
function fixture(prefs: any[] = [], defaults: any[] = []) {
|
||||
const writes: any[] = []
|
||||
const event = { eventKey: 'communication.message.direct', displayName: 'Direktnachrichten', category: 'communication', allowedChannels: ['inapp', 'email', 'push'], isActive: true }
|
||||
const db: any = {
|
||||
select: () => ({ from: (table: any) => ({ where: async () => table === notificationsEventTypes ? [event] : table === notificationsPreferences ? prefs : defaults }) }),
|
||||
transaction: async (callback: any) => callback(db),
|
||||
insert: () => ({ values: (value: any) => ({ onConflictDoUpdate: async () => { writes.push(value) } }) }),
|
||||
}
|
||||
return { service: new NotificationService({ db } as any, async () => null), db, writes }
|
||||
}
|
||||
|
||||
test('defaults are quiet for email, user overrides win over tenant defaults', async () => {
|
||||
assert.deepEqual((await fixture().service.preferences(1, 'user')).events[0].channels, { email: false, push: true })
|
||||
const { service } = fixture([{ channel: 'push', enabled: false }], [{ channel: 'push', enabled: true }, { channel: 'email', enabled: true }])
|
||||
assert.deepEqual((await service.preferences(1, 'user')).events[0].channels, { email: true, push: false })
|
||||
})
|
||||
|
||||
test('invalid batches cause no writes, including duplicates and unsupported channels', async () => {
|
||||
for (const bad of [null, {}, [{ eventType: 'unknown', channel: 'push', enabled: true }],
|
||||
[{ eventType: 'communication.message.direct', channel: 'sms', enabled: true }],
|
||||
[{ eventType: 'communication.message.direct', channel: 'push', enabled: 'false' }],
|
||||
Array(2).fill({ eventType: 'communication.message.direct', channel: 'push', enabled: true })]) {
|
||||
const { service, writes } = fixture()
|
||||
await assert.rejects(service.savePreferences(1, 'user', bad), { statusCode: 400 })
|
||||
assert.equal(writes.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('API ignores supplied tenant/user identifiers and saves in the authenticated context', async () => {
|
||||
const { db, writes } = fixture()
|
||||
const app = Fastify()
|
||||
app.decorate('db', db)
|
||||
app.addHook('preHandler', async (req: any) => { req.user = { tenant_id: 7, user_id: 'current-user' } })
|
||||
await app.register(routes)
|
||||
try {
|
||||
const response = await app.inject({ method: 'PUT', url: '/notifications/preferences', payload: [
|
||||
{ tenantId: 999, userId: 'other', eventType: 'communication.message.direct', channel: 'push', enabled: false },
|
||||
] })
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.deepEqual(writes, [{ tenantId: 7, userId: 'current-user', eventType: 'communication.message.direct', channel: 'push', enabled: false }])
|
||||
const invalid = await app.inject({ method: 'PUT', url: '/notifications/preferences', payload: [{ eventType: 'system.test_push', channel: 'push', enabled: false }] })
|
||||
assert.equal(invalid.statusCode, 400)
|
||||
} finally { await app.close() }
|
||||
})
|
||||
|
||||
test('room notifications stay opt-in while direct messages remain enabled', async () => {
|
||||
const { service } = fixture()
|
||||
const resolve = (eventType: string) => (service as any).resolveEnabledChannels({ tenantId: 1, userId: 'user', eventType, channels: ['email', 'push'] })
|
||||
assert.deepEqual(await resolve('communication.message.room'), [])
|
||||
assert.deepEqual(await resolve('communication.room.invited'), [])
|
||||
assert.deepEqual(await resolve('communication.message.direct'), ['push'])
|
||||
})
|
||||
Reference in New Issue
Block a user