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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
186 lines
6.0 KiB
TypeScript
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,
|
|
})),
|
|
})
|
|
}
|