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