import { db } from '@/lib/db' import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit' import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { resolvePermissions } from '@/lib/auth/effective' import type { Prisma } from '@afis/db' /** * Benachrichtigungen in den Postausgang legen. * * **Die Web-App entscheidet, wer was erfahren darf; der Worker liefert nur * aus.** Das ist die wichtigste Trennung hier: die Sichtbarkeitsprüfung steckt * in dieser Anwendung, und sie im Worker ein zweites Mal zu bauen hiesse, sie * zweimal richtig haben zu müssen. * * Deshalb steht in `meta` nur, was **stehen darf** — fertig entschieden. Ein * Titel landet dort nur, wenn er auch nach Discord darf. Der Worker setzt * daraus einen Satz zusammen und hat keine Entscheidung mehr zu treffen. * Siehe docs/06-chat-und-realtime.md. */ /** Ab dieser Stufe geht nicht einmal der Titel nach Discord. */ const TITLE_HIDDEN_FROM = READ_AUDIT_FROM_LEVEL export type OutboxKind = 'entry.new' | 'chat.new' | 'chat.mention' | 'case.status' /** * Die Empfänger einer Meldung zu einer Akte. * * Wer die Akte nicht sehen darf, bekommt nichts — auch keinen Hinweis, dass es * sie gibt. Geprüft wird mit **derselben** Abfrage wie beim Lesen: * `visibleCaseWhere`, je Empfänger. Das ist eine Abfrage pro Kandidat und bei * dieser Nutzerzahl billiger als der Fehler, die Regel ein zweites Mal zu * bauen und eine der beiden Fassungen später zu vergessen. */ async function empfaenger( caseId: string, actorId: string, nurDiese?: string[], ): Promise { const [beteiligt, beobachtend, kandidaten] = await Promise.all([ db.caseParticipant.findMany({ where: { caseId, userId: { not: null } }, select: { userId: true }, }), db.watch.findMany({ where: { caseId }, select: { userId: true } }), db.user.findMany({ where: { isActive: true, notifyMode: { not: 'OFF' }, id: { not: actorId, ...(nurDiese ? { in: nurDiese } : {}) }, }, select: { id: true, notifyMode: true }, }), ]) const nah = new Set( [...beteiligt.map((b) => b.userId!), ...beobachtend.map((w) => w.userId)].filter(Boolean), ) const ziele: string[] = [] for (const nutzer of kandidaten) { // Ausdrücklich gemeint zu sein hebt die Einstellung nicht auf, aber // "MENTIONS" heisst eben genau: nur dann. if (nutzer.notifyMode === 'MENTIONS' && nurDiese === undefined) continue if (nutzer.notifyMode === 'OWN_CASES' && !nah.has(nutzer.id) && nurDiese === undefined) { continue } const rechte = await resolvePermissions(nutzer.id) const sichtbar = await db.case.findFirst({ where: { AND: [visibleCaseWhere(rechte), { id: caseId }] }, select: { id: true }, }) if (sichtbar) ziele.push(nutzer.id) } return ziele } /** * Legt die Zeilen an: eine je Empfänger für die Direktnachricht, plus eine * ohne Empfänger für den allgemeinen Kanal. */ export async function enqueueCaseNotification( tx: Prisma.TransactionClient, input: { kind: OutboxKind caseId: string caseNumber: string classification: number actorId: string actorName: string /** Nur gesetzt, wenn ausdrücklich bestimmte Leute gemeint sind. */ onlyFor?: string[] meta?: Record title?: string }, ): Promise { // Ab Stufe 3 geht nicht einmal der Titel raus. Discord kennt unsere // Klassifizierung nicht, der Link führt zur App, die selbst prüft. const titel = input.classification >= TITLE_HIDDEN_FROM ? null : (input.title ?? null) const gemeinsam = { kind: input.kind, caseId: input.caseId, actorId: input.actorId, meta: { caseNumber: input.caseNumber, actorName: input.actorName, title: titel, ...input.meta, } as Prisma.InputJsonValue, } const ziele = await empfaenger(input.caseId, input.actorId, input.onlyFor) await tx.notificationOutbox.createMany({ data: [ // In den allgemeinen Kanal, ohne Empfänger. gemeinsam, ...ziele.map((ziel) => ({ ...gemeinsam, recipientId: ziel })), ], }) } /** * Die Meldung aus dem Chat einer Personalakte. * * Zwei Unterschiede zur Fallakte, beide bewusst: * * 1. **Nur an ausdrücklich Erwähnte**, nie in den allgemeinen Kanal. Dass es * zu einer bestimmten Person eine Akte gibt, ist selbst eine Auskunft, und * die gehört nicht in einen Raum, in dem alle mitlesen. * 2. **Kein Name in der Meldung.** Weder der der Person noch der Text. Der * Link führt in die Anwendung, und die prüft selbst. * * Geprüft wird je Empfänger mit derselben Abfrage wie beim Lesen, plus dem * Recht `personnel.read` — ohne das gibt es die Akte für ihn nicht. */ export async function enqueuePersonnelNotification( tx: Prisma.TransactionClient, input: { personnelFileId: string actorId: string actorName: string mentioned: string[] meta?: Record }, ): Promise { if (input.mentioned.length === 0) return const kandidaten = await db.user.findMany({ where: { isActive: true, notifyMode: { not: 'OFF' }, id: { in: input.mentioned, not: input.actorId }, }, select: { id: true }, }) const ziele: string[] = [] for (const nutzer of kandidaten) { const rechte = await resolvePermissions(nutzer.id) if (!rechte.isAdmin && !rechte.permissions.has('personnel.read')) continue const sichtbar = await db.personnelFile.findFirst({ where: { AND: [visiblePersonnelWhere(rechte), { id: input.personnelFileId }] }, select: { id: true }, }) if (sichtbar) ziele.push(nutzer.id) } if (ziele.length === 0) return await tx.notificationOutbox.createMany({ data: ziele.map((ziel) => ({ kind: 'chat.mention', personnelFileId: input.personnelFileId, actorId: input.actorId, recipientId: ziel, meta: { actorName: input.actorName, ...input.meta } as Prisma.InputJsonValue, })), }) }