From 4c67ddd908ee06df75f3b5f98f2f1ae0da84e111 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sat, 3 Oct 2026 14:50:38 +0200 Subject: [PATCH] feat(notifications): add per-tenant notification preferences --- .../migrations/0079_notification_matrix.sql | 19 ++++++ backend/db/migrations/meta/_journal.json | 7 ++ backend/src/modules/notification.service.ts | 38 ++++++++++- backend/src/routes/communication.ts | 52 ++++++++------ backend/src/routes/notifications.ts | 6 ++ backend/tests/notificationPreferences.test.ts | 59 ++++++++++++++++ docs/notification-preferences.md | 33 +++++++++ .../components/NotificationPreferences.vue | 67 +++++++++++++++++++ frontend/pages/settings/index.vue | 3 + mobile/app/more/settings.tsx | 3 + .../components/NotificationPreferences.tsx | 43 ++++++++++++ mobile/src/lib/api.ts | 14 ++++ 12 files changed, 322 insertions(+), 22 deletions(-) create mode 100644 backend/db/migrations/0079_notification_matrix.sql create mode 100644 backend/tests/notificationPreferences.test.ts create mode 100644 docs/notification-preferences.md create mode 100644 frontend/components/NotificationPreferences.vue create mode 100644 mobile/src/components/NotificationPreferences.tsx diff --git a/backend/db/migrations/0079_notification_matrix.sql b/backend/db/migrations/0079_notification_matrix.sql new file mode 100644 index 00000000..b415c18f --- /dev/null +++ b/backend/db/migrations/0079_notification_matrix.sql @@ -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'); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 7d2076b4..4a9dc469 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -533,6 +533,13 @@ "when": 1790985600000, "tag": "0078_chat_visibility", "breakpoints": true + }, + { + "idx": 76, + "version": "7", + "when": 1791072000000, + "tag": "0079_notification_matrix", + "breakpoints": true } ] } diff --git a/backend/src/modules/notification.service.ts b/backend/src/modules/notification.service.ts index e5f2d40e..51c1c4cd 100644 --- a/backend/src/modules/notification.service.ts +++ b/backend/src/modules/notification.service.ts @@ -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() + 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" }) } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 202154dc..d1119c13 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -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 } }) diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 6b36bb06..c488c8b7 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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) diff --git a/backend/tests/notificationPreferences.test.ts b/backend/tests/notificationPreferences.test.ts new file mode 100644 index 00000000..73a02678 --- /dev/null +++ b/backend/tests/notificationPreferences.test.ts @@ -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']) +}) diff --git a/docs/notification-preferences.md b/docs/notification-preferences.md new file mode 100644 index 00000000..246c0f71 --- /dev/null +++ b/docs/notification-preferences.md @@ -0,0 +1,33 @@ +# Persönliche Benachrichtigungen + +Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil. + +## Ereignisse + +| Ereignis | Auslöser | E-Mail ohne Einstellung | Push ohne Einstellung | +| --- | --- | --- | --- | +| `communication.message.direct` | Persönliche Direktnachricht, ohne Absender | aus | an | +| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an | +| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus | +| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus | + +Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume; föderierte Zusammenarbeit wird hier noch nicht erfasst. + +Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten und Erwähnungen aktiv; die neuen optionalen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix. + +## API und Migration + +- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session. +- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht. +- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten. +- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus. + +## Sinnvolle nächste Auslöser + +- Aufgabe zugewiesen, Aufgabe fällig: Empfänger aus der Aufgabenverantwortung; Fälligkeit braucht einen geplanten Lauf und Schutz vor Mehrfachversand. +- Urlaubs-/Abwesenheitsantrag eingereicht und entschieden: zuständige Freigabeperson bzw. Antragsteller. +- Dokument zur Prüfung oder Freigabe zugewiesen: ausschließlich verantwortliche Benutzer. +- Projektzuordnung und wichtige Terminänderung: betroffene Projektmitglieder. +- Eingehender und verpasster Anruf: erst mit verlässlichen Anrufzuständen und Empfängerzuordnung anbinden. + +Diese Vorschläge werden noch nicht als schaltbare, aber wirkungslose Ereignisse angezeigt. Sicherheits- und Kontowiederherstellungs-E-Mails bleiben außerhalb der optionalen Matrix. diff --git a/frontend/components/NotificationPreferences.vue b/frontend/components/NotificationPreferences.vue new file mode 100644 index 00000000..c0d0c90e --- /dev/null +++ b/frontend/components/NotificationPreferences.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/pages/settings/index.vue b/frontend/pages/settings/index.vue index cc89d340..eba963dc 100644 --- a/frontend/pages/settings/index.vue +++ b/frontend/pages/settings/index.vue @@ -6,6 +6,8 @@ const router = useRouter() const items = [{ label: 'Profil', +},{ + label: 'Benachrichtigungen', },{ label: 'Projekte', content: 'This is the content shown for Tab1' @@ -57,6 +59,7 @@ const isLight = computed({ +
+ {token && } Server-Instanz diff --git a/mobile/src/components/NotificationPreferences.tsx b/mobile/src/components/NotificationPreferences.tsx new file mode 100644 index 00000000..99518ca1 --- /dev/null +++ b/mobile/src/components/NotificationPreferences.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { Pressable, Switch, Text, View } from 'react-native'; +import { fetchNotificationPreferences, saveNotificationPreferences, type NotificationPreferenceEvent } from '@/src/lib/api'; + +export function NotificationPreferences({ token }: { token: string }) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + useEffect(() => { + let active = true; + setLoading(true); + fetchNotificationPreferences(token).then(result => { if (active) setEvents(result.events); }) + .catch(error => { if (active) setMessage(error.message); }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [token]); + async function save() { + setSaving(true); + setMessage(''); + try { await saveNotificationPreferences(token, events); setMessage('Einstellungen gespeichert.'); } + catch (error) { setMessage(error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'); } + finally { setSaving(false); } + } + return + Meine Benachrichtigungen + Gilt nur für dein Profil im aktuell ausgewählten Mandanten. E-Mail geht an deine Konto-Adresse, Push an deine registrierten Geräte. + {loading && Einstellungen werden geladen …} + {!!message && {message}} + {!loading && events.map(event => + {event.displayName} + {event.description} + {(['email', 'push'] as const).filter(channel => channel in event.channels).map(channel => + {channel === 'email' ? 'E-Mail' : 'Push'} + setEvents(rows => rows.map(row => row.eventKey === event.eventKey ? { ...row, channels: { ...row.channels, [channel]: enabled } } : row))} /> + )} + )} + {!loading && events.length > 0 && + {saving ? 'Speichert …' : 'Speichern'} + } + ; +} diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 1f5ad587..a2d6ca72 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -1301,3 +1301,17 @@ export async function uploadPlantFile( return parsed as ProjectFile; } + +export type NotificationPreferenceEvent = { + eventKey: string; + displayName: string; + description: string | null; + channels: Partial>; +}; +export async function fetchNotificationPreferences(token: string) { + return apiRequest<{ tenantId: number; events: NotificationPreferenceEvent[] }>('/api/notifications/preferences', { token }); +} +export async function saveNotificationPreferences(token: string, events: NotificationPreferenceEvent[]) { + return apiRequest('/api/notifications/preferences', { token, method: 'PUT', body: events.flatMap(event => + Object.entries(event.channels).map(([channel, enabled]) => ({ eventType: event.eventKey, channel, enabled }))) }); +}