Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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,
|
|
})),
|
|
})
|
|
}
|