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

@@ -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');

View File

@@ -533,6 +533,13 @@
"when": 1790985600000, "when": 1790985600000,
"tag": "0078_chat_visibility", "tag": "0078_chat_visibility",
"breakpoints": true "breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1791072000000,
"tag": "0079_notification_matrix",
"breakpoints": true
} }
] ]
} }

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) { async listForUser(tenantId: number, userId: string, limit = 50) {
return await this.server.db return await this.server.db
.select() .select()
@@ -259,7 +295,7 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel) const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled if (defaultPref) return defaultPref.enabled
return true return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
}) })
} }

View File

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

View File

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

View 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'])
})

View File

@@ -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.

View File

@@ -0,0 +1,67 @@
<script setup>
const { $api } = useNuxtApp()
const auth = useAuthStore()
const events = ref([])
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const success = ref('')
let generation = 0
watch(() => auth.activeTenant, async () => {
const current = ++generation
events.value = []
loading.value = true
error.value = ''
success.value = ''
try {
const result = await $api('/api/notifications/preferences')
if (current === generation) events.value = result.events
} catch (err) {
if (current === generation) error.value = err?.data?.error || err.message || 'Einstellungen konnten nicht geladen werden.'
} finally {
if (current === generation) loading.value = false
}
}, { immediate: true })
async function save() {
const current = generation
saving.value = true
error.value = ''
success.value = ''
try {
await $api('/api/notifications/preferences', { method: 'PUT', body: events.value.flatMap(event =>
Object.entries(event.channels).map(([channel, enabled]) => ({ eventType: event.eventKey, channel, enabled }))) })
if (current === generation) success.value = 'Einstellungen gespeichert.'
} catch (err) {
if (current === generation) error.value = err?.data?.error || err.message || 'Speichern fehlgeschlagen.'
} finally { saving.value = false }
}
</script>
<template>
<section class="space-y-4">
<h2 class="text-lg font-semibold">Meine Benachrichtigungen</h2>
<p>Diese Einstellungen gelten nur für dein Profil im aktuellen Mandanten {{ auth.activeTenantData?.name || '' }}. Für andere Mandanten wechsle zuerst das Profil.</p>
<p class="text-sm text-gray-500">E-Mail geht an deine Konto-Adresse. Push erreicht deine registrierten Geräte und Browser. Testnachrichten bleiben unabhängig davon möglich.</p>
<p v-if="loading" role="status">Einstellungen werden geladen …</p>
<p v-if="error" role="alert" class="text-red-600">{{ error }}</p>
<p v-if="success" role="status" class="text-green-600">{{ success }}</p>
<template v-if="!loading && events.length">
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead><tr><th class="p-2">Auslöser</th><th class="p-2">E-Mail</th><th class="p-2">Push</th></tr></thead>
<tbody>
<tr v-for="event in events" :key="event.eventKey" class="border-t border-gray-200">
<td class="p-2"><strong>{{ event.displayName }}</strong><p class="text-sm text-gray-500">{{ event.description }}</p></td>
<td v-for="channel in ['email', 'push']" :key="channel" class="p-2">
<input v-if="channel in event.channels" v-model="event.channels[channel]" type="checkbox" :disabled="saving"
:aria-label="`${event.displayName}: ${channel === 'email' ? 'E-Mail' : 'Push'}`" class="h-5 w-5" />
<span v-else>–</span>
</td>
</tr>
</tbody>
</table>
</div>
<UButton :loading="saving" :disabled="saving" @click="save">Speichern</UButton>
</template>
</section>
</template>

View File

@@ -6,6 +6,8 @@ const router = useRouter()
const items = [{ const items = [{
label: 'Profil', label: 'Profil',
},{
label: 'Benachrichtigungen',
},{ },{
label: 'Projekte', label: 'Projekte',
content: 'This is the content shown for Tab1' content: 'This is the content shown for Tab1'
@@ -57,6 +59,7 @@ const isLight = computed({
</InputGroup> </InputGroup>
</div> </div>
<NotificationPreferences v-else-if="item.label === 'Benachrichtigungen'" />
<div v-else-if="item.label === 'Projekte'"> <div v-else-if="item.label === 'Projekte'">
<USeparator <USeparator
label="Phasenvorlagen" label="Phasenvorlagen"

View File

@@ -15,6 +15,8 @@ import {
} from '@/src/lib/server-config'; } from '@/src/lib/server-config';
import { useAuth } from '@/src/providers/auth-provider'; import { useAuth } from '@/src/providers/auth-provider';
import { NotificationPreferences } from '@/src/components/NotificationPreferences';
const PRIMARY = '#69c350'; const PRIMARY = '#69c350';
function isValidServerUrl(value: string): boolean { function isValidServerUrl(value: string): boolean {
@@ -153,6 +155,7 @@ export default function SettingsScreen() {
return ( return (
<ScrollView contentContainerStyle={styles.container}> <ScrollView contentContainerStyle={styles.container}>
{token && <NotificationPreferences key={String(activeTenantId)} token={token} />}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.title}>Server-Instanz</Text> <Text style={styles.title}>Server-Instanz</Text>
<Text style={styles.hint}> <Text style={styles.hint}>

View File

@@ -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<NotificationPreferenceEvent[]>([]);
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 <View style={{ gap: 12, padding: 16, backgroundColor: '#fff', borderRadius: 12 }}>
<Text style={{ fontSize: 18, fontWeight: '600' }}>Meine Benachrichtigungen</Text>
<Text>Gilt nur für dein Profil im aktuell ausgewählten Mandanten. E-Mail geht an deine Konto-Adresse, Push an deine registrierten Geräte.</Text>
{loading && <Text>Einstellungen werden geladen …</Text>}
{!!message && <Text accessibilityRole="alert">{message}</Text>}
{!loading && events.map(event => <View key={event.eventKey} style={{ gap: 4 }}>
<Text style={{ fontWeight: '600' }}>{event.displayName}</Text>
<Text>{event.description}</Text>
{(['email', 'push'] as const).filter(channel => channel in event.channels).map(channel => <View key={channel} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text>{channel === 'email' ? 'E-Mail' : 'Push'}</Text>
<Switch accessibilityLabel={`${event.displayName}: ${channel}`} disabled={saving} value={event.channels[channel] === true}
onValueChange={enabled => setEvents(rows => rows.map(row => row.eventKey === event.eventKey ? { ...row, channels: { ...row.channels, [channel]: enabled } } : row))} />
</View>)}
</View>)}
{!loading && events.length > 0 && <Pressable accessibilityRole="button" disabled={saving} onPress={save} style={{ padding: 12, backgroundColor: '#69c350', borderRadius: 8 }}>
<Text>{saving ? 'Speichert …' : 'Speichern'}</Text>
</Pressable>}
</View>;
}

View File

@@ -1301,3 +1301,17 @@ export async function uploadPlantFile(
return parsed as ProjectFile; return parsed as ProjectFile;
} }
export type NotificationPreferenceEvent = {
eventKey: string;
displayName: string;
description: string | null;
channels: Partial<Record<'email' | 'push', boolean>>;
};
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 }))) });
}