Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

186 lines
6.0 KiB
TypeScript

import { db } from '@/lib/db'
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
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<string[]> {
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<string, unknown>
title?: string
},
): Promise<void> {
// 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<string, unknown>
},
): Promise<void> {
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,
})),
})
}