Fachdokumente nach docs/, Entscheidungen festgehalten
CLAUDE.md und README.md verweisen auf docs/, die Dateien lagen aber im Wurzelverzeichnis. Verschoben. docs/13-entscheidungen.md hält fest, wo die Dokumente sich widersprechen und wie es aufgelöst wurde. Der wichtigste Punkt: 04-datenmodell.md verlangt eine Regel, die jedes UPDATE auf CaseEntryVersion verwirft, 05-akten-und-vorlagen.md verlangt, dass eine Umklassifizierung rückwirkend auf alle Versionen wirkt. Beides zusammen geht nicht. Aufgelöst über einen Trigger, der die Tabelle anhängend hält, aber das Anheben von minClearance zulässt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Produkt und Domäne
|
||||
|
||||
## Kontext
|
||||
|
||||
Die AfiS ist im Rollenspiel die interne Ermittlungsbehörde einer SCP-Foundation-Einrichtung.
|
||||
Sie untersucht Korruption, Dienstvergehen und Sicherheitsvorfälle bei eigenem Personal.
|
||||
Bisher organisiert sie sich über ein Discord-Forum, was für verknüpfte Akten und abgestufte
|
||||
Sichtbarkeit nicht taugt.
|
||||
|
||||
Das System ersetzt das Forum, nicht Discord selbst. Discord bleibt der Ort für Identität,
|
||||
Rollen und Benachrichtigungen.
|
||||
|
||||
## Nutzergruppen
|
||||
|
||||
| Gruppe | Typisches Verhalten | Wichtigste Ansicht |
|
||||
|---|---|---|
|
||||
| Ermittler | Legt Fälle an, schreibt Vermerke, lädt Belege hoch | Fallakte, eigene Fälle |
|
||||
| Abteilungsleiter | Prüft, schließt Fälle ab, vergibt Rechte, exportiert | Dashboard, Rollenverwaltung |
|
||||
| Beobachter mit niedriger Stufe | Liest mit, kommentiert im Chat | Fallliste, Chat |
|
||||
| Administrator (du) | Betrieb, Vorlagen, Notfallzugriff | Adminbereich |
|
||||
|
||||
Der Abteilungsleiter ist ausdrücklich **nicht technisch**. Alles, was er regelmäßig braucht,
|
||||
muss über die Oberfläche gehen. Keine Konfigurationsdateien, keine Datenbankeingriffe.
|
||||
|
||||
## Glossar
|
||||
|
||||
Verbindliche Zuordnung zwischen Fachbegriff und Code-Bezeichner.
|
||||
|
||||
| Deutsch (UI) | Code | Bedeutung |
|
||||
|---|---|---|
|
||||
| Fallakte | `Case` | Eine Ermittlung |
|
||||
| Aktenzeichen | `caseNumber` | `AfiS-<Jahr>-<4-stellig>`, z. B. `AfiS-2026-0042` |
|
||||
| Abschnitt | `CaseSection` | Aus der Vorlage erzeugtes, versioniertes Feld |
|
||||
| Vermerk / Verlaufseintrag | `CaseEntry` | Chronologischer Eintrag, nur anhängend |
|
||||
| Verlauf | `timeline` | Die Folge aller `CaseEntry` |
|
||||
| Vorlage | `CaseTemplate` | Definiert Abschnitte einer Fallart |
|
||||
| Fallart | `caseType` | Korruptionsverdacht, Dienstvergehen, Sicherheitsvorfall, Routineprüfung |
|
||||
| Freigabestufe | `clearanceLevel` | 0 bis 5 |
|
||||
| Klassifizierung | `classification` | Die Stufe eines Objekts |
|
||||
| Schwärzung | `Redaction` | Für niedrigere Stufen unkenntlicher Bereich |
|
||||
| Personalakte | `PersonnelFile` | Akte zu einer Person |
|
||||
| Beteiligter | `CaseParticipant` | Ermittler, Zeuge oder Betroffener eines Falls |
|
||||
| Aktenchat | `CaseChat` | Informelle Nachrichten zu einer Akte |
|
||||
| Wiedervorlage | `dueAt` | Termin, zu dem der Fall wieder anzufassen ist |
|
||||
| Zugriffsprotokoll | `AuditLog` | Wer hat wann was gelesen oder geändert |
|
||||
|
||||
## Freigabestufen
|
||||
|
||||
| Stufe | Bezeichnung | Existenz für niedrigere Stufen |
|
||||
|---|---|---|
|
||||
| 0 | Offen | sichtbar |
|
||||
| 1 | Intern | sichtbar |
|
||||
| 2 | Vertraulich | sichtbar, Inhalt gesperrt |
|
||||
| 3 | Geheim | unsichtbar |
|
||||
| 4 | Streng geheim | unsichtbar |
|
||||
| 5 | Leitungssache | unsichtbar |
|
||||
|
||||
Die Spalte ganz rechts ist konfigurierbar (`ClearanceLevelConfig.hiddenBelow`), die Werte
|
||||
oben sind der Auslieferungszustand.
|
||||
|
||||
## Nicht im Umfang
|
||||
|
||||
- Keine öffentliche Registrierung, kein Passwort-Login, keine E-Mail-Zustellung
|
||||
- Kein mobiles Nativ-App-Ziel, aber die Weboberfläche muss auf dem Handy gut lesbar sein
|
||||
- Keine Echtzeit-Zusammenarbeit im selben Textfeld (nur Konflikterkennung)
|
||||
- Keine Volltextsuche in Anhängen (OCR) in Version 1
|
||||
@@ -0,0 +1,90 @@
|
||||
# Architektur
|
||||
|
||||
## Dienste
|
||||
|
||||
```
|
||||
Browser
|
||||
└─ cloudflared (Tunnel, TLS und Kompression liegen bei Cloudflare)
|
||||
└─ web Next.js 15, App Router, Server Actions, SSE-Endpunkt
|
||||
├─ pgbouncer ─ db PostgreSQL 16
|
||||
├─ files Volume, getrennt in quarantine/ und store/
|
||||
├─ clamav clamd, angesprochen über Unix-Socket
|
||||
└─ pdf Playwright-Container für PDF-Erzeugung
|
||||
|
||||
worker discord.js, hängt am Gateway, schreibt Rollen in db
|
||||
db-backup Dauerläufer mit Cron, nächtlicher Dump
|
||||
offsite-backup Dauerläufer mit Cron, restic auf zweites Ziel
|
||||
```
|
||||
|
||||
Alles liegt in **einem** Dockge-Stack. Kein Reverse Proxy im Stack: der Tunnel terminiert TLS,
|
||||
Cloudflare komprimiert, und Next.js liefert seine statischen Dateien selbst mit korrekten
|
||||
Cache-Headern aus. Am Router wird kein Port geöffnet.
|
||||
|
||||
Migrationen laufen im Startkommando von `web`, nicht in einem Init-Container. Begründung und
|
||||
die vollständige `compose.yaml` in `docs/11-betrieb.md`.
|
||||
|
||||
## Warum ein eigener Worker
|
||||
|
||||
Die Web-App fragt nie live bei Discord an. Der Worker hält `GuildMember` in Postgres aktuell:
|
||||
|
||||
- Gateway-Ereignisse `guildMemberUpdate`, `guildMemberRemove`, `guildMemberAdd`, `roleUpdate`
|
||||
- Voller Abgleich alle 5 Minuten als Sicherheitsnetz
|
||||
- Bei jeder Änderung: `pg_notify('member_changed', userId)`, damit die Web-App laufende
|
||||
Sessions invalidieren kann
|
||||
|
||||
Dadurch ist eine Rechteänderung in Discord in unter einer Sekunde in der App wirksam,
|
||||
ohne dass ein einziger Request in der App auf die Discord-API warten muss.
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
.
|
||||
├─ app/
|
||||
│ ├─ (auth)/ Login, Callback, Fehlerseiten
|
||||
│ ├─ (app)/
|
||||
│ │ ├─ dashboard/
|
||||
│ │ ├─ faelle/
|
||||
│ │ │ ├─ page.tsx Liste
|
||||
│ │ │ ├─ loading.tsx Skelett
|
||||
│ │ │ ├─ neu/
|
||||
│ │ │ └─ [caseNumber]/
|
||||
│ │ │ ├─ page.tsx Akte
|
||||
│ │ │ ├─ loading.tsx
|
||||
│ │ │ └─ @chat/ Paralleler Slot, lädt unabhängig
|
||||
│ │ ├─ personal/
|
||||
│ │ ├─ suche/
|
||||
│ │ └─ admin/ Rollen-Mapping, Vorlagen, Protokoll
|
||||
│ └─ api/
|
||||
│ ├─ auth/[...nextauth]/
|
||||
│ ├─ files/[id]/ Ausliefernder Endpunkt mit Rechteprüfung
|
||||
│ ├─ upload/
|
||||
│ └─ stream/ SSE
|
||||
├─ lib/
|
||||
│ ├─ auth/ Session, Rechteauflösung
|
||||
│ ├─ access/ Zugriffsprüfung als Prisma-Where-Fragmente
|
||||
│ ├─ db.ts
|
||||
│ ├─ audit.ts
|
||||
│ ├─ i18n/de.ts
|
||||
│ └─ realtime/
|
||||
├─ components/
|
||||
├─ worker/ Eigenes Paket, discord.js
|
||||
├─ prisma/
|
||||
├─ docs/
|
||||
└─ docker/
|
||||
```
|
||||
|
||||
## Datenflüsse, die man kennen muss
|
||||
|
||||
**Seitenaufruf einer Akte.** Server Component lädt Kopfdaten und Abschnitte in einer Abfrage.
|
||||
Verlauf und Chat hängen in eigenen `<Suspense>`-Grenzen und streamen nach. Die Seite ist
|
||||
sichtbar, bevor der Verlauf da ist.
|
||||
|
||||
**Neuer Vermerk.** Server Action schreibt `CaseEntry`, danach `revalidateTag('case:<id>')`
|
||||
und `pg_notify` für alle offenen SSE-Verbindungen dieser Akte. Der Client hat den Eintrag
|
||||
schon optimistisch angezeigt und ersetzt ihn beim Eintreffen anhand der Client-ID.
|
||||
|
||||
**Upload.** Datei geht per XHR mit Fortschritt an `/api/upload`, landet in `quarantine/`,
|
||||
Prüfkette läuft asynchron, Statuswechsel kommt über SSE zurück.
|
||||
|
||||
**Rechteänderung in Discord.** Worker schreibt, `pg_notify`, Web-App verwirft den Cache der
|
||||
betroffenen Session, nächster Request rechnet die Stufe neu.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Authentifizierung und Berechtigungen
|
||||
|
||||
## Grundsatz
|
||||
|
||||
**OAuth klärt, wer jemand ist. Der Bot klärt, was er darf.** Das Discord-Zugangstoken des
|
||||
Nutzers wird nach dem Callback verworfen und nie wieder benutzt.
|
||||
|
||||
## Login-Ablauf
|
||||
|
||||
1. Nutzer klickt „Mit Discord anmelden". Auth.js startet OAuth2 mit Scope `identify`,
|
||||
dazu `state` und PKCE.
|
||||
2. Im Callback liest die App nur `id`, `username`, `global_name`, `avatar`.
|
||||
3. Die App fragt **serverseitig mit dem Bot-Token** `GET /guilds/{GUILD_ID}/members/{userId}`.
|
||||
- 404 → Login abgelehnt, Seite „Du bist nicht auf dem AfiS-Discord". Kein Konto anlegen.
|
||||
- 200 → Rollen werden übernommen, `User` wird angelegt oder aktualisiert.
|
||||
4. Eigene Session in der Datenbank, Cookie mit `HttpOnly`, `Secure`, `SameSite=Lax`,
|
||||
Laufzeit 30 Tage, gleitende Verlängerung.
|
||||
5. In der Session steht nur `userId` und `sessionId`. Rechte werden pro Request aufgelöst.
|
||||
|
||||
Im Developer Portal muss das **Server Members Intent** aktiviert sein, sonst liefert der Bot
|
||||
keine Rollen.
|
||||
|
||||
## Rechteauflösung
|
||||
|
||||
```ts
|
||||
type EffectivePermissions = {
|
||||
userId: string
|
||||
clearanceLevel: number // höchste Stufe aller Rollen
|
||||
permissions: Set<Permission> // Vereinigung aller Rollen
|
||||
isAdmin: boolean // aus BOOTSTRAP_ADMIN_IDS oder Recht 'admin'
|
||||
}
|
||||
```
|
||||
|
||||
Auflösung aus `UserRole` × `RoleMapping`. Ergebnis wird pro Request einmal berechnet und in
|
||||
einem prozesslokalen LRU-Cache (60 s TTL) gehalten. Der Cache wird bei `pg_notify`
|
||||
`member_changed` oder `role_mapping_changed` sofort verworfen.
|
||||
|
||||
Rechte-Enum:
|
||||
|
||||
```
|
||||
case.read case.create case.edit case.close case.reopen case.delete
|
||||
case.edit_closed # Änderungen an abgeschlossenen Fällen
|
||||
entry.create entry.correct
|
||||
entry.edit_own # eigene Einträge nachträglich ändern
|
||||
entry.edit_any # fremde Einträge ändern, für die Leitung
|
||||
entry.retract # Eintrag zurückziehen (kein Löschen)
|
||||
personnel.read personnel.edit
|
||||
template.manage roles.manage audit.read export.approve
|
||||
admin
|
||||
|
||||
`entry.edit_own`, `entry.edit_any` und `entry.retract` sind im Auslieferungszustand **keiner
|
||||
Rolle zugeordnet**. Der Leiter schaltet sie bewusst frei, wenn er sie will.
|
||||
```
|
||||
|
||||
## Notfallzugang
|
||||
|
||||
`BOOTSTRAP_ADMIN_IDS` in der `.env` enthält eine oder zwei Discord-IDs. Diese Nutzer haben
|
||||
immer Stufe 5 und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim
|
||||
ersten Fehler in der Rollenverwaltung selbst aus.
|
||||
|
||||
## Zugriffsprüfung auf Akten
|
||||
|
||||
Reihenfolge, erste zutreffende Regel gewinnt:
|
||||
|
||||
1. Nutzer steht in `CaseExclusion` → **kein Zugriff**, egal welche Stufe.
|
||||
Deckt den Fall ab, dass gegen einen hochrangigen Mitarbeiter ermittelt wird.
|
||||
2. Nutzer ist Admin → Zugriff, wird protokolliert.
|
||||
3. Nutzer ist Beteiligter (`CaseParticipant`) → Zugriff.
|
||||
4. `user.clearanceLevel >= case.classification` → Zugriff.
|
||||
5. Sonst kein Zugriff.
|
||||
|
||||
Kein Zugriff bedeutet:
|
||||
- Ist die Stufe als `hiddenBelow` markiert: **404**, die Akte existiert für den Nutzer nicht.
|
||||
- Sonst: Eintrag in Listen sichtbar, Titel und Inhalt gesperrt, Detailseite zeigt Sperrhinweis.
|
||||
|
||||
## Umsetzung in Prisma
|
||||
|
||||
Die Prüfung wird nie in JavaScript nachgelagert. `lib/access/caseFilter.ts` liefert ein
|
||||
wiederverwendbares Where-Fragment:
|
||||
|
||||
```ts
|
||||
export function visibleCaseWhere(p: EffectivePermissions): Prisma.CaseWhereInput {
|
||||
if (p.isAdmin) return {}
|
||||
return {
|
||||
AND: [
|
||||
{ exclusions: { none: { userId: p.userId } } },
|
||||
{
|
||||
OR: [
|
||||
{ participants: { some: { userId: p.userId } } },
|
||||
{ classification: { lte: p.clearanceLevel } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dasselbe Muster für `CaseEntry`, `Attachment`, `PersonnelFile` und die Suche. Trefferzahlen
|
||||
und Paginierung laufen über dieselbe Abfrage, damit sich aus Lücken nichts ableiten lässt.
|
||||
|
||||
Für Abschnitte und Schwärzungen gibt es eine zweite Ebene: `CaseSection.minClearance`.
|
||||
Felder oberhalb der Nutzerstufe werden serverseitig durch einen Platzhalter ersetzt,
|
||||
**bevor** die Daten den Server verlassen. Nie im Client ausblenden.
|
||||
|
||||
## Sessions beenden
|
||||
|
||||
Der Worker meldet `guildMemberRemove` oder den Verlust einer AfiS-Rolle. Die App löscht dann
|
||||
alle Sessions des Nutzers und markiert `User.isActive = false`. Der Datensatz bleibt, damit
|
||||
Autorenangaben in alten Akten gültig bleiben.
|
||||
|
||||
## Tests, die es geben muss
|
||||
|
||||
- Nutzer ohne Guild-Mitgliedschaft kann sich nicht anmelden
|
||||
- Nutzer mit Stufe 2 bekommt für eine Stufe-4-Akte 404, nicht 403
|
||||
- Ausgeschlossener Nutzer mit Stufe 5 bekommt 404
|
||||
- Suche mit Stufe 1 liefert für eine Stufe-3-Akte keinen Treffer und keine veränderte Trefferzahl
|
||||
- Rollenentzug in Discord beendet laufende Sessions innerhalb von 5 Sekunden
|
||||
- Nutzer ohne `entry.edit_any` kann einen fremden Eintrag nicht ändern, auch nicht über
|
||||
einen direkten Aufruf der Server Action
|
||||
- Nutzer ohne `entry.edit_own` kann nach Ablauf der Karenzzeit den eigenen Eintrag nicht ändern
|
||||
- Ein Eintrag, dessen `minClearance` erhöht wurde, ist auch in der Versionshistorie für
|
||||
niedrigere Stufen nicht lesbar
|
||||
- Ein `DELETE` auf `CaseEntry` verändert nichts, weder über Prisma noch über rohes SQL
|
||||
- Autor und `createdAt` bleiben nach einer Änderung unverändert
|
||||
@@ -0,0 +1,321 @@
|
||||
# Datenmodell
|
||||
|
||||
Prisma mit PostgreSQL. Zeitstempel immer `DateTime` in UTC, Anzeige in Europe/Berlin.
|
||||
IDs sind UUIDv7, damit sie zeitlich sortierbar und clientseitig erzeugbar sind
|
||||
(wichtig für optimistische Updates, siehe `docs/09-performance-und-ux.md`).
|
||||
|
||||
## Schema-Entwurf
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
id String @id // Discord-Snowflake
|
||||
username String
|
||||
displayName String
|
||||
avatarHash String?
|
||||
isActive Boolean @default(true)
|
||||
firstSeenAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
@@index([isActive, displayName])
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String
|
||||
discordRoleId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@id([userId, discordRoleId])
|
||||
}
|
||||
|
||||
model RoleMapping {
|
||||
discordRoleId String @id
|
||||
label String // Anzeigename, aus Discord gespiegelt
|
||||
clearanceLevel Int @default(0)
|
||||
permissions String[]
|
||||
sortOrder Int @default(0)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ClearanceLevelConfig {
|
||||
level Int @id // 0..5
|
||||
name String // "Vertraulich"
|
||||
hiddenBelow Boolean @default(false) // Existenz verbergen?
|
||||
colorToken String // siehe Design-System
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
userAgent String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model CaseTemplate {
|
||||
id String @id @default(uuid())
|
||||
key String // "korruptionsverdacht"
|
||||
version Int
|
||||
name String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
fields Json // TemplateField[]
|
||||
createdAt DateTime @default(now())
|
||||
@@unique([key, version])
|
||||
}
|
||||
|
||||
model Case {
|
||||
id String @id
|
||||
caseNumber String @unique // AfiS-2026-0042
|
||||
title String
|
||||
status CaseStatus @default(DRAFT)
|
||||
classification Int @default(1)
|
||||
templateId String
|
||||
templateKey String
|
||||
dueAt DateTime?
|
||||
closedAt DateTime?
|
||||
outcome String?
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
sections CaseSection[]
|
||||
entries CaseEntry[]
|
||||
participants CaseParticipant[]
|
||||
exclusions CaseExclusion[]
|
||||
links CaseLink[] @relation("from")
|
||||
chat ChatMessage[]
|
||||
|
||||
@@index([status, updatedAt(sort: Desc)])
|
||||
@@index([classification])
|
||||
@@index([dueAt])
|
||||
}
|
||||
|
||||
enum CaseStatus { DRAFT OPEN DORMANT REVIEW CLOSED ARCHIVED SEALED }
|
||||
|
||||
model CaseSection {
|
||||
id String @id @default(uuid())
|
||||
caseId String
|
||||
fieldKey String // Schlüssel aus der Vorlage
|
||||
minClearance Int @default(0)
|
||||
currentVersion Int @default(1)
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedById String
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
versions CaseSectionVersion[]
|
||||
@@unique([caseId, fieldKey])
|
||||
}
|
||||
|
||||
model CaseSectionVersion {
|
||||
id String @id @default(uuid())
|
||||
sectionId String
|
||||
version Int
|
||||
value Json
|
||||
authorId String
|
||||
createdAt DateTime @default(now())
|
||||
section CaseSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
@@unique([sectionId, version])
|
||||
}
|
||||
|
||||
model CaseEntry {
|
||||
id String @id // clientseitig erzeugt, UUIDv7
|
||||
caseId String
|
||||
type EntryType
|
||||
body String
|
||||
minClearance Int @default(0)
|
||||
authorId String // unveränderlich
|
||||
createdAt DateTime @default(now()) // unveränderlich
|
||||
correctsId String? // Korrektur eines früheren Eintrags
|
||||
|
||||
version Int @default(1)
|
||||
editedAt DateTime?
|
||||
editedById String?
|
||||
editVisible Boolean @default(false) // false = innerhalb der Karenzzeit geändert
|
||||
|
||||
retractedAt DateTime?
|
||||
retractedById String?
|
||||
retractionReason String?
|
||||
|
||||
versions CaseEntryVersion[]
|
||||
attachments Attachment[]
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
@@index([caseId, createdAt(sort: Desc)])
|
||||
@@index([caseId, editedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// Strikt anhängend. Enthält jeweils den VORHERIGEN Stand. Wird nie gelöscht,
|
||||
// auch nicht von Admins. Kein onDelete: Cascade nach oben durchreichen, das
|
||||
// Löschen eines Eintrags gibt es ohnehin nicht.
|
||||
model CaseEntryVersion {
|
||||
id String @id @default(uuid())
|
||||
entryId String
|
||||
version Int
|
||||
body String
|
||||
minClearance Int // erbt Erhöhungen des Eintrags
|
||||
changeReason String?
|
||||
editedById String
|
||||
editedAt DateTime @default(now())
|
||||
entry CaseEntry @relation(fields: [entryId], references: [id])
|
||||
@@unique([entryId, version])
|
||||
@@index([entryId, version(sort: Desc)])
|
||||
}
|
||||
|
||||
// Einstellungen, die der Leiter im Adminbereich pflegt. Einzeiler-Tabelle.
|
||||
model AppSetting {
|
||||
id Int @id @default(1)
|
||||
entryEditPolicy EditPolicy @default(GRACE_WINDOW)
|
||||
entryEditGraceMinutes Int @default(15)
|
||||
entryEditMarkGrace Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedById String?
|
||||
}
|
||||
|
||||
enum EditPolicy { APPEND_ONLY GRACE_WINDOW OPEN }
|
||||
|
||||
enum EntryType { NOTE INTERROGATION EVIDENCE STATUS_CHANGE CORRECTION SYSTEM }
|
||||
|
||||
model CaseParticipant {
|
||||
caseId String
|
||||
userId String?
|
||||
personnelFileId String?
|
||||
role ParticipantRole
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
@@id([caseId, role, userId, personnelFileId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum ParticipantRole { LEAD INVESTIGATOR WITNESS SUBJECT OBSERVER }
|
||||
|
||||
model CaseExclusion {
|
||||
caseId String
|
||||
userId String
|
||||
reason String?
|
||||
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
@@id([caseId, userId])
|
||||
}
|
||||
|
||||
model CaseLink {
|
||||
fromId String
|
||||
toId String
|
||||
kind LinkKind
|
||||
from Case @relation("from", fields: [fromId], references: [id], onDelete: Cascade)
|
||||
@@id([fromId, toId, kind])
|
||||
}
|
||||
|
||||
enum LinkKind { RELATED SUCCESSOR MERGED_INTO }
|
||||
|
||||
model PersonnelFile {
|
||||
id String @id @default(uuid())
|
||||
userId String? // null bei NPCs
|
||||
displayName String
|
||||
serviceNumber String? @unique
|
||||
rank String?
|
||||
department String?
|
||||
status String?
|
||||
classification Int @default(1)
|
||||
fields Json
|
||||
searchVector Unsupported("tsvector")?
|
||||
@@index([displayName])
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(uuid())
|
||||
entryId String?
|
||||
caseId String?
|
||||
originalName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
sha256 String
|
||||
scanStatus ScanStatus @default(PENDING)
|
||||
scanMessage String?
|
||||
storageKey String // UUID-Pfad in store/
|
||||
thumbKey String?
|
||||
blurhash String?
|
||||
uploadedById String
|
||||
createdAt DateTime @default(now())
|
||||
entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||||
@@index([sha256])
|
||||
@@index([scanStatus])
|
||||
}
|
||||
|
||||
enum ScanStatus { PENDING CLEAN BLOCKED FAILED }
|
||||
|
||||
model ChatMessage {
|
||||
id String @id // clientseitig, UUIDv7
|
||||
caseId String?
|
||||
channelId String?
|
||||
authorId String
|
||||
body String
|
||||
replyToId String?
|
||||
editedAt DateTime?
|
||||
withdrawnAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
@@index([caseId, createdAt(sort: Desc)])
|
||||
@@index([channelId, createdAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model ReadMarker {
|
||||
userId String
|
||||
scopeType String // "case" | "channel"
|
||||
scopeId String
|
||||
lastReadAt DateTime
|
||||
@@id([userId, scopeType, scopeId])
|
||||
}
|
||||
|
||||
model Watch {
|
||||
userId String
|
||||
caseId String
|
||||
@@id([userId, caseId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
at DateTime @default(now())
|
||||
actorId String
|
||||
action String // "case.read", "export.pdf", ...
|
||||
targetType String
|
||||
targetId String
|
||||
clearanceAtTime Int
|
||||
meta Json?
|
||||
@@index([targetType, targetId, at(sort: Desc)])
|
||||
@@index([actorId, at(sort: Desc)])
|
||||
}
|
||||
```
|
||||
|
||||
## Zusätzliche Migrationen von Hand
|
||||
|
||||
Prisma kann das nicht ausdrücken, also als reines SQL in die Migration:
|
||||
|
||||
```sql
|
||||
-- Volltextsuche
|
||||
CREATE INDEX case_search_idx ON "Case" USING GIN (search_vector);
|
||||
CREATE INDEX entry_body_idx ON "CaseEntry" USING GIN (to_tsvector('german', body));
|
||||
|
||||
-- Trigger, der search_vector aus Titel, Aktenzeichen und Abschnittswerten füllt
|
||||
-- (Gewichtung: A = Aktenzeichen/Titel, B = Abschnitte, C = Verlaufseinträge)
|
||||
|
||||
-- Verlaufseinträge dürfen nur angehängt und aktualisiert werden, nie gelöscht.
|
||||
-- Zweite Sicherung neben der Anwendungslogik, falls jemand direkt an der DB hantiert.
|
||||
CREATE RULE entry_no_delete AS ON DELETE TO "CaseEntry" DO INSTEAD NOTHING;
|
||||
CREATE RULE entry_version_no_delete AS ON DELETE TO "CaseEntryVersion" DO INSTEAD NOTHING;
|
||||
CREATE RULE entry_version_no_update AS ON UPDATE TO "CaseEntryVersion" DO INSTEAD NOTHING;
|
||||
|
||||
-- search_vector muss auch bei UPDATE auf CaseEntry neu berechnet werden,
|
||||
-- sonst findet die Suche nach einer nachträglichen Änderung den alten Text.
|
||||
|
||||
-- Nur offene Fälle, häufigste Abfrage der Startseite
|
||||
CREATE INDEX case_open_idx ON "Case" (updated_at DESC) WHERE status IN ('OPEN','REVIEW');
|
||||
|
||||
-- Aktenzeichen-Vergabe kollisionsfrei
|
||||
CREATE SEQUENCE case_number_2026 START 1;
|
||||
```
|
||||
|
||||
Die Nummernvergabe läuft über eine Tabelle `CaseCounter(year, lastNumber)` mit
|
||||
`SELECT ... FOR UPDATE` in derselben Transaktion wie das Anlegen, damit bei parallelem
|
||||
Anlegen keine Lücken oder Doppelungen entstehen.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Akten, Vorlagen und Versionierung
|
||||
|
||||
## Die vier Ebenen einer Akte
|
||||
|
||||
| Ebene | Änderbar | Versioniert | Zweck |
|
||||
|---|---|---|---|
|
||||
| Kopfdaten | ja | nein, aber im Audit-Log | Status, Klassifizierung, Frist, Beteiligte |
|
||||
| Abschnitte | ja | ja, pro Feld | Aktueller Sachstand |
|
||||
| Verlauf | ja, mit Version und Grabstein | ja, pro Eintrag | Wie es dazu kam |
|
||||
| Chat | Bearbeiten mit Vermerk | nein | Informelle Absprache, kein Aktenbestandteil |
|
||||
|
||||
Die Trennung zwischen Abschnitt und Verlauf ist nicht verhandelbar. Ein Abschnitt beantwortet
|
||||
„was wissen wir jetzt", der Verlauf „wie sind wir dahin gekommen".
|
||||
|
||||
Der Verlauf ist nachträglich änderbar, aber jede Änderung ist sichtbar und nachvollziehbar.
|
||||
Siehe „Nachträgliche Änderungen" weiter unten.
|
||||
|
||||
## Vorlagen
|
||||
|
||||
```ts
|
||||
type TemplateField = {
|
||||
key: string // stabil, nie ändern
|
||||
label: string
|
||||
type: 'longtext' | 'shorttext' | 'select' | 'multiselect'
|
||||
| 'date' | 'person' | 'number' | 'checklist' | 'table'
|
||||
options?: string[] // für select/multiselect
|
||||
required?: boolean
|
||||
requiredToClose?: boolean // blockiert den Abschluss des Falls
|
||||
minClearance?: number // Feld erst ab dieser Stufe sichtbar
|
||||
help?: string
|
||||
}
|
||||
```
|
||||
|
||||
Vorlagen sind versioniert. Eine Akte merkt sich `templateId` und `templateKey`. Änderungen an
|
||||
einer Vorlage erzeugen eine neue Version und lassen bestehende Akten unberührt. Im
|
||||
Vorlagen-Editor gibt es dafür eine Vorschau „So sieht eine neue Akte damit aus".
|
||||
|
||||
Startvorlagen im Seed: Korruptionsverdacht, Dienstvergehen, Sicherheitsvorfall, Routineprüfung.
|
||||
|
||||
## Feldebene-Klassifizierung
|
||||
|
||||
`minClearance` pro Feld ist das, was das Stufensystem lebendig macht. Für Nutzer unterhalb der
|
||||
Stufe wird der Wert serverseitig durch
|
||||
|
||||
```ts
|
||||
{ redacted: true, requiredLevel: 4 }
|
||||
```
|
||||
|
||||
ersetzt. Die Oberfläche zeigt einen Schwärzungsbalken mit „Geschwärzt, ab Stufe 4 sichtbar".
|
||||
Der echte Wert verlässt den Server nicht.
|
||||
|
||||
## Schwärzung im Freitext
|
||||
|
||||
Im Editor markiert man einen Bereich und wählt eine Stufe. Gespeichert wird als Auszeichnung:
|
||||
|
||||
```
|
||||
Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.
|
||||
```
|
||||
|
||||
Der Server ersetzt den Inhalt beim Rendern für niedrigere Stufen durch einen Balken gleicher
|
||||
Länge (nicht gleicher Text, sonst lässt sich die Länge auswerten: immer auf ein festes Raster
|
||||
runden). Der Parser gehört in `lib/redaction.ts` und wird sowohl für die Ansicht als auch für
|
||||
den PDF-Export benutzt.
|
||||
|
||||
## Versionierung der Abschnitte
|
||||
|
||||
Jede Änderung schreibt `CaseSectionVersion` und erhöht `currentVersion`. Die UI zeigt
|
||||
`v4 · Vogel` neben der Überschrift, Klick öffnet einen Diff (Zeilen- oder Wortdiff, `diff`-Paket).
|
||||
|
||||
Beim Speichern schickt der Client die Version mit, die er geladen hat. Weicht sie ab, kommt
|
||||
409 zurück und die UI zeigt beide Fassungen nebeneinander mit „Meine übernehmen" oder
|
||||
„Fremde übernehmen". Kein stilles Überschreiben.
|
||||
|
||||
## Verlauf
|
||||
|
||||
Statusänderungen und Klassifizierungsänderungen erzeugen automatisch einen `SYSTEM`-Eintrag.
|
||||
`SYSTEM`-Einträge sind von jeder Änderung ausgenommen.
|
||||
|
||||
Neben der nachträglichen Änderung bleibt der Korrektureintrag bestehen: ein neuer Eintrag mit
|
||||
`type = CORRECTION` und `correctsId`. Das ist der richtige Weg, wenn sich der **Sachverhalt**
|
||||
geändert hat. Nachträgliches Ändern ist der richtige Weg, wenn sich die **Darstellung**
|
||||
geändert hat, also Tippfehler, vergessener Zusatz, falsche Uhrzeit im eigenen Vermerk.
|
||||
|
||||
## Nachträgliche Änderungen
|
||||
|
||||
Verlaufseinträge dürfen geändert und zurückgezogen werden. Ein hartes Löschen gibt es nicht,
|
||||
weder über die Oberfläche noch über eine Server Action.
|
||||
|
||||
### Änderungsrichtlinie
|
||||
|
||||
Global einstellbar im Adminbereich, `AppSetting.entryEditPolicy`:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|---|---|
|
||||
| `APPEND_ONLY` | Keine Änderung möglich, nur Korrektureinträge |
|
||||
| `GRACE_WINDOW` | Eigene Einträge innerhalb von N Minuten frei änderbar, danach nur mit Recht **(Auslieferungszustand, N = 15)** |
|
||||
| `OPEN` | Jederzeit änderbar, sofern das Recht vorliegt |
|
||||
|
||||
Zusätzlich: `entryEditGraceMinutes` (Standard 15) und `entryEditMarkGrace` (Standard `false`).
|
||||
Steht letzteres auf `false`, bekommt eine Änderung innerhalb der Karenzzeit **keine sichtbare
|
||||
Markierung**, wird aber trotzdem versioniert und protokolliert. Ein Tippfehler, der zwei
|
||||
Minuten nach dem Absenden korrigiert wird, soll die Akte nicht mit „bearbeitet"-Marken
|
||||
zupflastern. Alles danach ist sichtbar markiert.
|
||||
|
||||
### Was geändert werden darf
|
||||
|
||||
| Feld | Änderbar |
|
||||
|---|---|
|
||||
| `body` | ja |
|
||||
| `minClearance` | ja, mit Recht `case.edit` |
|
||||
| `type` | nein |
|
||||
| `authorId` | **nie** |
|
||||
| `createdAt` | **nie** |
|
||||
| `caseId` | nein, ein Eintrag wandert nicht in eine andere Akte |
|
||||
|
||||
Autor und Entstehungszeit sind unantastbar. Wer das umgehen will, muss den Eintrag
|
||||
zurückziehen und einen neuen schreiben, und genau das ist dann auch sichtbar.
|
||||
|
||||
### Versionierung
|
||||
|
||||
Jede Änderung schreibt den **vorherigen** Stand nach `CaseEntryVersion` und erhöht
|
||||
`CaseEntry.version`. Die Versionstabelle ist strikt anhängend und wird auch von Admins nicht
|
||||
gelöscht.
|
||||
|
||||
Die UI zeigt am Eintrag `bearbeitet · v3`. Ein Klick öffnet die Historie mit Wortdiff,
|
||||
Zeitpunkt und bearbeitender Person. **Diese Historie sieht jeder, der den Eintrag sehen darf**,
|
||||
nicht nur die Leitung. Das ist der ganze Sinn der Sache: In einer Abteilung, die Korruption
|
||||
aufdeckt, muss nachprüfbar sein, dass niemand still etwas umgeschrieben hat.
|
||||
|
||||
Ab der zweiten Änderung oder bei Änderung eines fremden Eintrags ist eine **Begründung
|
||||
Pflicht** (`changeReason`, mindestens 10 Zeichen). Sie steht in der Historie.
|
||||
|
||||
### Zurückziehen statt Löschen
|
||||
|
||||
`retractedAt`, `retractedById` und `retractionReason` (Pflicht) werden gesetzt. Der Eintrag
|
||||
bleibt an seiner Stelle im Verlauf stehen und zeigt:
|
||||
|
||||
> Eintrag zurückgezogen am 31.08.2026 durch Ltr. Reinhardt · Begründung: Falscher Fall
|
||||
|
||||
Der ursprüngliche Text bleibt in der Datenbank und ist für Nutzer mit `audit.read` weiterhin
|
||||
lesbar, für alle anderen nicht. Zurückziehen lässt sich rückgängig machen (`entry.retract`),
|
||||
was wiederum protokolliert wird.
|
||||
|
||||
### Umklassifizierung wirkt rückwirkend
|
||||
|
||||
Wird `minClearance` eines Eintrags erhöht, **erben alle bestehenden Versionen den neuen Wert**.
|
||||
Sonst liest jemand mit niedrigerer Stufe den ursprünglichen Text einfach in der Historie.
|
||||
Das ist die Stelle, an der eine naive Umsetzung leckt.
|
||||
|
||||
### Gesperrte Zustände
|
||||
|
||||
- `CLOSED`: Änderungen nur mit `case.edit_closed`, erzeugen zusätzlich einen `SYSTEM`-Eintrag
|
||||
„Nachträgliche Änderung nach Abschluss"
|
||||
- `ARCHIVED` und `SEALED`: keine Änderungen, auch nicht für Admins
|
||||
|
||||
### Protokollierung
|
||||
|
||||
Jede Änderung schreibt einen `AuditLog`-Eintrag mit Aktion `entry.edit`, `entry.retract` oder
|
||||
`entry.restore`, der Eintrags-ID, der neuen Versionsnummer, der Begründung und einem
|
||||
kompakten Diff in `meta`. Der Audit-Log wird nie gelöscht und ist die zweite, unabhängige
|
||||
Spur neben der Versionstabelle.
|
||||
|
||||
### Änderungsübersicht je Akte
|
||||
|
||||
Eigener Reiter an der Akte: alle nachträglichen Änderungen an Abschnitten und Verlauf,
|
||||
chronologisch, mit Person, Zeitpunkt, Begründung und Diff. Für den Leiter ist das die Ansicht,
|
||||
mit der er im Zweifel prüft, ob an einer Akte nachträglich gedreht wurde. Sichtbar für alle,
|
||||
die die Akte lesen dürfen.
|
||||
|
||||
### Folgen für andere Teile
|
||||
|
||||
- **Export**: `updatedAt` ändert sich, der PDF-Cache verfällt automatisch. Auf dem Deckblatt
|
||||
steht ein `Stand:`-Zeitstempel, damit eine ältere ausgedruckte Fassung erkennbar ist.
|
||||
- **Suche**: Der Trigger auf `search_vector` muss auch bei `UPDATE` auf `CaseEntry` feuern.
|
||||
- **Ungelesen**: Eine Änderung setzt die Akte für andere wieder auf ungelesen und löst
|
||||
dieselbe Benachrichtigung aus wie ein neuer Eintrag, mit dem Zusatz „geändert".
|
||||
- **Echtzeit**: Ereignis `entry.updated` über SSE, Clients tauschen den Eintrag anhand der ID.
|
||||
|
||||
## Lebenszyklus
|
||||
|
||||
```
|
||||
DRAFT ──► OPEN ──► REVIEW ──► CLOSED ──► ARCHIVED
|
||||
│ ▲ │
|
||||
▼ │ └──► OPEN (Rückgabe zur Nachbesserung)
|
||||
DORMANT
|
||||
|
||||
SEALED: aus jedem Zustand erreichbar, nur mit Recht 'admin' oder Stufe 5 lesbar
|
||||
```
|
||||
|
||||
Regeln:
|
||||
- `OPEN → REVIEW` braucht `case.edit`
|
||||
- `REVIEW → CLOSED` braucht `case.close` und alle `requiredToClose`-Felder gefüllt
|
||||
- `CLOSED → OPEN` braucht `case.reopen` und erzeugt einen Pflicht-Vermerk mit Begründung
|
||||
- `ARCHIVED` ist schreibgeschützt, nur noch lesbar
|
||||
|
||||
## Rückverweise
|
||||
|
||||
Auf einer Personalakte stehen automatisch die Fälle, in denen die Person vorkommt, getrennt
|
||||
nach Rolle. **Diese Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.** Ein
|
||||
Nutzer, der einen Fall nicht sehen darf, sieht ihn hier auch nicht und die Zählung enthält ihn
|
||||
nicht. Das ist der häufigste Ort, an dem ein Aktensystem Informationen leaken lässt.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Chat und Echtzeit
|
||||
|
||||
## Zwei Ebenen
|
||||
|
||||
- **Aktenchat**: hängt an einer Akte, formlos, ausdrücklich kein Aktenbestandteil.
|
||||
Optisch klar vom Verlauf abgesetzt (siehe Design-System).
|
||||
- **Kanäle**: frei angelegte Räume für allgemeine Absprachen, mit eigener Mindeststufe.
|
||||
|
||||
## Kernfunktion: „Als Vermerk übernehmen"
|
||||
|
||||
Eine Chatnachricht wird per Klick zu einem `CaseEntry` mit `type = NOTE`. Der erzeugte
|
||||
Eintrag trägt einen Hinweis auf den Ursprung (Autor, Zeitpunkt, Nachrichten-ID). Die
|
||||
Chatnachricht bleibt stehen und bekommt eine Markierung „übernommen".
|
||||
|
||||
Das ist die Brücke zwischen Discord-Gewohnheit und sauberem Aktenbestand und sollte
|
||||
prominent sein, nicht in einem Kontextmenü versteckt.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- Erwähnungen `@` mit Autovervollständigung aus `User`
|
||||
- Antworten auf einzelne Nachrichten (`replyToId`), zitierte Vorschau
|
||||
- Reaktionen (kleiner fester Satz, keine freie Emoji-Auswahl)
|
||||
- Bearbeiten mit sichtbarem `editedAt`
|
||||
- Löschen nur als Zurückziehen: `withdrawnAt` gesetzt, Platzhalter „Nachricht zurückgezogen"
|
||||
- Ungelesen-Markierung über `ReadMarker`, Trennlinie „Neu seit deinem letzten Besuch"
|
||||
|
||||
## Aktenzeichen-Erkennung
|
||||
|
||||
Text wie `AfiS-2026-0038` wird beim Rendern zu einer Vorschaukarte. **Die Karte wird
|
||||
serverseitig aufgelöst und respektiert die Stufe des Lesers.** Wer die Akte nicht sehen darf,
|
||||
bekommt eine neutrale Karte ohne Titel und ohne Status. Nie den Titel mitschicken und im
|
||||
Client ausblenden.
|
||||
|
||||
## Echtzeit über SSE
|
||||
|
||||
Kein WebSocket, kein Redis. Postgres `LISTEN/NOTIFY` plus ein SSE-Endpunkt reicht bei dieser
|
||||
Nutzerzahl vollständig.
|
||||
|
||||
```
|
||||
GET /api/stream?topics=case:<id>,notifications
|
||||
```
|
||||
|
||||
- Ein einziger `pg`-Client hält `LISTEN` offen und verteilt an die Verbindungen im Prozess
|
||||
- Server sendet alle 25 Sekunden einen Kommentar-Heartbeat gegen Proxy-Timeouts
|
||||
- Client nutzt `EventSource` mit `Last-Event-ID`, bei Reconnect werden verpasste Nachrichten
|
||||
über `?since=<id>` nachgeladen
|
||||
- Reconnect mit exponentiellem Backoff, gedeckelt bei 30 Sekunden
|
||||
- Beim Wechsel des Tabs in den Hintergrund wird die Verbindung nach 5 Minuten geschlossen und
|
||||
bei Rückkehr neu aufgebaut
|
||||
|
||||
Ereignistypen: `chat.new`, `chat.edit`, `entry.new`, `entry.updated`, `entry.retracted`,
|
||||
`case.update`, `attachment.scanned`, `presence`.
|
||||
|
||||
`entry.updated` und `entry.retracted` tauschen den Eintrag im offenen Verlauf anhand der ID
|
||||
aus. Wer die Akte gerade offen hat, sieht die Änderung sofort und nicht erst beim Neuladen.
|
||||
|
||||
## Dedupe mit Client-IDs
|
||||
|
||||
Der Client erzeugt die Nachrichten-ID (UUIDv7) selbst und zeigt die Nachricht sofort an.
|
||||
Kommt dieselbe ID über SSE zurück, wird der optimistische Eintrag ersetzt statt verdoppelt.
|
||||
Die ID ist gleichzeitig der Idempotenzschlüssel: ein wiederholtes Absenden nach Netzfehler
|
||||
erzeugt keine zweite Nachricht (`INSERT ... ON CONFLICT DO NOTHING`).
|
||||
|
||||
## Discord-Benachrichtigungen
|
||||
|
||||
Der Worker postet in einen konfigurierbaren Channel und optional als DM an Beteiligte:
|
||||
|
||||
> Neuer Eintrag in **AfiS-2026-0042** · Insp. Vogel · https://afis.example.de/faelle/AfiS-2026-0042
|
||||
|
||||
**Niemals Inhalte, niemals Titel bei Akten ab Stufe 3.** Discord kennt eure Klassifizierung
|
||||
nicht. Der Link führt zur App, die dann selbst prüft.
|
||||
|
||||
Konfigurierbar pro Nutzer: alles / nur meine Fälle / nur Erwähnungen / aus.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Dateien und Sicherheit
|
||||
|
||||
## Bedrohungsmodell, ehrlich
|
||||
|
||||
Geschlossene Gruppe, alle kennen sich. Das realistische Risiko ist nicht die geschmuggelte
|
||||
Ransomware, sondern eine hochgeladene SVG- oder HTML-Datei, die im Browser eines Kollegen
|
||||
Skriptcode ausführt und dessen Session übernimmt. Deshalb die Maßnahmen nach Wirkung sortiert
|
||||
und nicht nach Auffälligkeit.
|
||||
|
||||
## Prüfkette
|
||||
|
||||
```
|
||||
Upload → quarantine/<uuid> → Typprüfung → ClamAV → Neu kodieren → store/<uuid>
|
||||
│ │
|
||||
└─────────────┴──► ScanStatus = BLOCKED
|
||||
```
|
||||
|
||||
### 1. Annahme
|
||||
|
||||
- Ziel ist `quarantine/`, Dateiname wird durch die Attachment-UUID ersetzt
|
||||
- Originalname nur in der Datenbank, beim Ausliefern wieder gesetzt
|
||||
- Limits: 25 MB pro Datei, 500 MB Kontingent pro Nutzer, 20 Uploads pro Minute
|
||||
- Der Datensatz wird sofort mit `scanStatus = PENDING` angelegt, damit die UI ihn zeigen kann
|
||||
|
||||
### 2. Typprüfung, Whitelist über Magic Bytes
|
||||
|
||||
Erlaubt: `image/png`, `image/jpeg`, `image/webp`, `application/pdf`, `text/plain`.
|
||||
|
||||
Geprüft wird mit `file-type` anhand des Inhalts, **nie anhand der Endung**. Stimmt der
|
||||
erkannte Typ nicht mit dem gemeldeten überein, ist die Datei blockiert.
|
||||
|
||||
Ausdrücklich verboten, auch wenn es bequem wäre: SVG (führt Skripte aus), HTML, ZIP und
|
||||
andere Archive, Office-Dokumente mit Makros, alles Ausführbare.
|
||||
|
||||
### 3. ClamAV
|
||||
|
||||
Eigener Container `clamav/clamav:stable`, angebunden über den clamd-Unix-Socket, geteiltes
|
||||
Volume mit `quarantine/`. `freshclam` läuft im Container. Timeout 30 Sekunden, bei Timeout
|
||||
`FAILED` und manuelle Freigabe durch einen Admin möglich.
|
||||
|
||||
### 4. Neu kodieren
|
||||
|
||||
- Bilder: einmal durch `sharp` (`.rotate().toFormat(...)`). Entfernt EXIF, zerstört angehängte
|
||||
Nutzlast, erzeugt gleich das Vorschaubild (max. 480 px) und einen Blurhash für das
|
||||
Platzhalterbild.
|
||||
- PDF: `qpdf --decrypt --remove-page-labels` und Entfernen eingebetteter Dateien und
|
||||
JavaScript. Erste Seite wird als Vorschaubild gerendert.
|
||||
- Text: Zeichensatz nach UTF-8 normalisieren, Größe prüfen.
|
||||
|
||||
### 5. Freigabe
|
||||
|
||||
SHA-256 berechnen, in `store/` verschieben, `scanStatus = CLEAN`, SSE-Ereignis
|
||||
`attachment.scanned` an alle offenen Ansichten der Akte.
|
||||
|
||||
Gleicher Hash bereits vorhanden: nur einen Verweis anlegen statt erneut zu speichern.
|
||||
|
||||
## Auslieferung
|
||||
|
||||
Alles über `GET /api/files/[id]`, niemals als statische Datei.
|
||||
|
||||
```
|
||||
Content-Disposition: attachment; filename="protokoll_karlsen.pdf"
|
||||
X-Content-Type-Options: nosniff
|
||||
Cache-Control: private, max-age=31536000, immutable
|
||||
Content-Security-Policy: default-src 'none'; sandbox
|
||||
```
|
||||
|
||||
Der Endpunkt prüft vorher die Sichtbarkeit der zugehörigen Akte und protokolliert den Zugriff
|
||||
ab Stufe 3. Vorschaubilder laufen über denselben Endpunkt mit `?variant=thumb` und dürfen
|
||||
inline ausgeliefert werden, weil sie serverseitig neu kodiert wurden.
|
||||
|
||||
Wenn möglich: eigene Subdomain `files.afis.example.de` ohne Session-Cookie, mit signierter,
|
||||
kurzlebiger URL. Dann kann selbst eine durchgerutschte Datei keine Session stehlen.
|
||||
|
||||
## Weitere Härtung
|
||||
|
||||
- Strikte CSP auf der App: `script-src 'self'`, kein `unsafe-inline` (Next.js Nonce nutzen)
|
||||
- Rate Limits auf Login, Upload, Suche und Export
|
||||
- Alle Server Actions prüfen die Berechtigung erneut, egal was das UI vorher versteckt hat
|
||||
- Kein `dangerouslySetInnerHTML` für Nutzerinhalte. Markdown über `remark` mit
|
||||
`rehype-sanitize`, feste Allowlist.
|
||||
- Datenbank-Nutzer der App ohne `SUPERUSER`, Migrationen über einen zweiten Nutzer
|
||||
@@ -0,0 +1,61 @@
|
||||
# Suche und Export
|
||||
|
||||
## Suche
|
||||
|
||||
Postgres Full-Text-Search, deutsche Konfiguration. Kein Elasticsearch.
|
||||
|
||||
- `search_vector` auf `Case` per Trigger aus Aktenzeichen und Titel (Gewicht A),
|
||||
Abschnittswerten (B) und Verlaufseinträgen (C)
|
||||
- GIN-Index, `websearch_to_tsquery('german', $1)`
|
||||
- Zusätzlich Trigram-Index (`pg_trgm`) auf `caseNumber` und `displayName` für Tippfehler
|
||||
|
||||
Filter: Status, Fallart, Zeitraum, Beteiligte, Klassifizierung, Tags, nur meine Fälle.
|
||||
|
||||
**Die Sichtbarkeitsprüfung gehört in dieselbe Abfrage**, nicht als Nachfilter. Sonst stimmen
|
||||
Trefferzahl und Seitenlänge nicht und aus den Lücken lässt sich auf verborgene Akten
|
||||
schließen. Gleiches gilt für Facettenzähler.
|
||||
|
||||
Gespeicherte Ansichten, die es ab Werk geben sollte: meine Fälle, Wiedervorlage diese Woche,
|
||||
seit meinem letzten Besuch geändert, unbearbeitet seit 14 Tagen, zuletzt geöffnet.
|
||||
|
||||
## Export
|
||||
|
||||
### Einzelakte als PDF
|
||||
|
||||
Aufbau: Deckblatt mit Aktenzeichen, Klassifizierungsvermerk und `Stand:`-Zeitstempel,
|
||||
Kopfdatentabelle, Abschnitte in Vorlagenreihenfolge, chronologischer Verlauf,
|
||||
Anhangsverzeichnis mit Hashes.
|
||||
|
||||
Nachträglich geänderte Verlaufseinträge werden im Export als solche ausgewiesen
|
||||
(`bearbeitet, v3, 31.08.2026`), zurückgezogene erscheinen als Grabstein mit Begründung.
|
||||
Ein Export darf nie sauberer aussehen als die Akte ist. Der `Stand:`-Zeitstempel macht
|
||||
erkennbar, dass ein älterer Ausdruck überholt sein kann.
|
||||
|
||||
Drei Regeln:
|
||||
|
||||
1. **Der Export respektiert die Stufe des Abrufers.** Gesperrte Felder werden als Schwärzung
|
||||
ausgegeben, nicht weggelassen, mit dem Vermerk „Geschwärzt, Stufe 4". Sonst merkt der
|
||||
Empfänger nicht, dass er eine gekürzte Fassung hat.
|
||||
2. **Wasserzeichen** auf jeder Seite: Name des Abrufers, Zeitstempel, Freigabestufe.
|
||||
3. **Jeder Export wird protokolliert.** Ab Stufe 3 zusätzlich Freigabe durch einen Nutzer mit
|
||||
Recht `export.approve`, also Vier-Augen-Prinzip. Die Anfrage erscheint beim Leiter als
|
||||
Aufgabe im Dashboard.
|
||||
|
||||
### Technik
|
||||
|
||||
Playwright-Container rendert eine dedizierte Druckansicht `/faelle/[caseNumber]/druck`
|
||||
mit `@media print`-Styles. Kein PDF-Bibliotheks-Layout von Hand.
|
||||
|
||||
Erzeugte PDFs werden gecacht unter dem Schlüssel
|
||||
`sha256(caseId + updatedAt + clearanceLevel + templateVersion)`. Auf einem Homeserver kostet
|
||||
das Rendern spürbar CPU, und dieselbe Akte wird oft mehrfach exportiert.
|
||||
|
||||
Der Export läuft als Hintergrundauftrag: Klick erzeugt einen Job, die UI zeigt einen
|
||||
Fortschrittsbalken und der Download startet automatisch, sobald der Job fertig ist. Bei über
|
||||
3 Sekunden bekommt der Nutzer zusätzlich eine Benachrichtigung, damit er wegklicken kann.
|
||||
|
||||
### Weitere Wege
|
||||
|
||||
- ZIP mit PDFs und Anhängen für Sammelexporte, nur mit Recht `admin`
|
||||
- Vollständiger JSON-Export der Datenbank als Umzugs- und Notfallformat, ohne
|
||||
Stufenfilterung, nur für `admin`, immer protokolliert
|
||||
@@ -0,0 +1,175 @@
|
||||
# Performance und Bedienungsgefühl
|
||||
|
||||
Dieses Dokument ist verbindlich. Die Anwendung läuft auf einem Homeserver hinter einem
|
||||
Tunnel, also mit spürbarer Grundlatenz. Das Gefühl von Geschwindigkeit entsteht deshalb nicht
|
||||
durch schnelle Server, sondern durch die richtige Reaktion im Client.
|
||||
|
||||
## Latenzbudgets
|
||||
|
||||
| Aktion | Sichtbare Reaktion nach | Fertig nach |
|
||||
|---|---|---|
|
||||
| Klick auf einen Knopf | 100 ms (Zustandswechsel) | egal |
|
||||
| Navigation zu einer Akte | 200 ms (Skelett oder Inhalt) | 1 s |
|
||||
| Chatnachricht senden | 0 ms (optimistisch) | Hintergrund |
|
||||
| Vermerk speichern | 0 ms (optimistisch) | Hintergrund |
|
||||
| Suche tippen | 150 ms (alte Treffer gedimmt) | 600 ms |
|
||||
| Datei-Upload | 0 ms (Vorschau lokal) | Hintergrund mit Balken |
|
||||
| PDF-Export | 200 ms (Fortschritt) | Benachrichtigung |
|
||||
|
||||
Wenn eine Aktion diese Budgets reißt, ist nicht der Server das Problem, sondern die
|
||||
fehlende Zwischenreaktion.
|
||||
|
||||
## Grundhaltung
|
||||
|
||||
1. **Nie eine leere Seite mit Spinner.** Jede Route hat eine `loading.tsx` mit einem Skelett,
|
||||
das die spätere Struktur exakt nachbildet (gleiche Höhen, gleiche Spaltenbreiten). Damit
|
||||
springt nichts, wenn die Daten kommen.
|
||||
2. **Spinner nur für Vorgänge unbekannter Länge über 400 ms.** Vorher gar nichts anzeigen,
|
||||
sonst blitzt es bei schnellen Antworten unangenehm auf.
|
||||
3. **Server Components sind der Standard.** `'use client'` nur dort, wo wirklich Interaktion
|
||||
stattfindet: Editoren, Chat, Uploads, Filterleisten.
|
||||
4. **Streamen statt sammeln.** Kopfdaten sofort, Verlauf und Chat in eigenen `<Suspense>`-
|
||||
Grenzen. Der Nutzer liest schon, während der Rest lädt.
|
||||
|
||||
## Optimistische Updates
|
||||
|
||||
React 19 `useOptimistic` plus Server Actions. Die Regel: **Was der Nutzer selbst auslöst und
|
||||
was fast immer klappt, wird sofort angezeigt.**
|
||||
|
||||
Optimistisch behandeln:
|
||||
|
||||
| Aktion | Optimistische Darstellung | Bei Fehler |
|
||||
|---|---|---|
|
||||
| Chatnachricht senden | Nachricht erscheint, leicht gedimmt bis bestätigt | Roter Rahmen, „Erneut senden" |
|
||||
| Reaktion setzen | Sofort umschalten | Zurücksetzen, dezenter Hinweis |
|
||||
| Vermerk anlegen | Eintrag oben im Verlauf | Eintrag bleibt, markiert als „nicht gespeichert" |
|
||||
| Eigenen Eintrag in der Karenzzeit ändern | Text sofort ersetzt | Alter Text zurück plus Toast |
|
||||
| Anhang löschen | Verschwindet sofort | Kommt zurück plus Toast |
|
||||
| Wiedervorlage ändern | Neues Datum sofort | Zurücksetzen |
|
||||
| Beobachten an/aus | Sofort umschalten | Zurücksetzen |
|
||||
| Als gelesen markieren | Sofort | Still ignorieren, unkritisch |
|
||||
|
||||
**Nicht** optimistisch behandeln, weil folgenreich oder nicht umkehrbar:
|
||||
|
||||
- Ändern eines fremden Eintrags oder eines Eintrags nach Ablauf der Karenzzeit.
|
||||
Hier gehört die Begründung erst abgeschickt und bestätigt, bevor der neue Text steht.
|
||||
- Zurückziehen eines Eintrags
|
||||
- Statuswechsel eines Falls, besonders `CLOSED`
|
||||
- Änderung der Klassifizierung
|
||||
- Änderung des Rollen-Mappings
|
||||
- Freigabe eines Exports
|
||||
- Alles im Adminbereich
|
||||
|
||||
Diese bekommen stattdessen einen sofortigen Pending-Zustand am Knopf (`useTransition`,
|
||||
Beschriftung wechselt, Knopf bleibt klickbar-aussehend, aber gesperrt) und eine klare
|
||||
Bestätigung danach.
|
||||
|
||||
### Rückabwicklung sauber machen
|
||||
|
||||
```ts
|
||||
const [optimistic, addOptimistic] = useOptimistic(entries, applyEntry)
|
||||
|
||||
async function submit(text: string) {
|
||||
const id = uuidv7()
|
||||
addOptimistic({ id, body: text, pending: true })
|
||||
const res = await createEntry({ id, caseId, body: text })
|
||||
if (!res.ok) toast.error('Vermerk nicht gespeichert', { action: retry(id) })
|
||||
}
|
||||
```
|
||||
|
||||
Die clientseitig erzeugte `id` ist zugleich Idempotenzschlüssel. Ein Wiederholungsversuch nach
|
||||
Netzfehler erzeugt keinen zweiten Eintrag.
|
||||
|
||||
## Löschen: rückgängig statt nachfragen
|
||||
|
||||
Für umkehrbare Löschungen (Anhang, Chatnachricht, Verknüpfung) kein Bestätigungsdialog.
|
||||
Stattdessen: Element verschwindet sofort, Toast „Gelöscht · Rückgängig" für 6 Sekunden, erst
|
||||
danach geht der endgültige Aufruf raus. Das ist schneller **und** sicherer als ein Dialog,
|
||||
den ohnehin niemand liest.
|
||||
|
||||
Bestätigungsdialoge bleiben für: Fall schließen, Fall versiegeln, Rollen-Mapping ändern,
|
||||
Sammelexport.
|
||||
|
||||
## Navigation
|
||||
|
||||
- `<Link prefetch>` auf allen Falllisten-Zeilen, zusätzlich `router.prefetch` bei `mouseenter`
|
||||
mit 80 ms Verzögerung, damit nicht beim bloßen Überfahren geladen wird
|
||||
- Route-Cache über `revalidateTag('case:<id>')` statt `router.refresh()`, damit nicht die
|
||||
ganze Seite neu berechnet wird
|
||||
- Oben ein dünner Fortschrittsbalken für Routenwechsel, aber **erst nach 200 ms** einblenden.
|
||||
Schnelle Wechsel sollen nicht flackern.
|
||||
- Zurück-Navigation nutzt den Browser-Cache, keine Neuabfrage
|
||||
|
||||
## Listen und Verlauf
|
||||
|
||||
- **Cursorbasierte Paginierung**, kein `OFFSET`. Sortierschlüssel ist `(createdAt, id)`.
|
||||
Stabil auch dann, wenn währenddessen neue Einträge dazukommen.
|
||||
- Beim Nachladen bleiben die alten Zeilen stehen (`keepPreviousData`), keine leere Tabelle.
|
||||
- Verlauf und Chat ab 200 Einträgen virtualisiert (`@tanstack/react-virtual`).
|
||||
- Chat lädt initial die letzten 50 Nachrichten und beim Hochscrollen weitere 50, mit
|
||||
Erhalt der Scrollposition (Höhe vor dem Einfügen merken, danach korrigieren).
|
||||
|
||||
## Suche
|
||||
|
||||
- Eingabe mit 250 ms Debounce
|
||||
- Laufende Anfrage bei neuer Eingabe per `AbortController` abbrechen
|
||||
- Während der Suche die alten Treffer auf 50 % Deckkraft setzen statt sie zu entfernen
|
||||
- Erste Filterung lokal auf bereits geladenen Fällen, damit bei kurzen Listen gar keine
|
||||
Anfrage nötig ist
|
||||
- Ergebnisse werden pro Suchbegriff kurz gecacht, damit Zurücknavigieren sofort geht
|
||||
|
||||
## Uploads
|
||||
|
||||
1. Datei wird sofort im Verlauf gezeigt, Vorschau aus lokalem `URL.createObjectURL`
|
||||
2. Fortschrittsbalken aus `XMLHttpRequest.upload.onprogress` (fetch kann das nicht)
|
||||
3. Nach dem Upload Status „Wird geprüft" mit dezenter Animation
|
||||
4. `attachment.scanned` über SSE tauscht den Zustand auf „Geprüft" oder „Blockiert"
|
||||
5. Mehrere Dateien laufen parallel, aber höchstens drei gleichzeitig
|
||||
6. Bilder werden vor dem Upload clientseitig auf max. 2560 px verkleinert, wenn sie größer sind
|
||||
|
||||
## Bilder
|
||||
|
||||
- `next/image` mit festen Dimensionen, damit nichts springt
|
||||
- Platzhalter aus dem gespeicherten Blurhash, nicht grau
|
||||
- Vorschaubilder serverseitig erzeugt, das Original wird nie in einer Liste geladen
|
||||
- Anhänge in der Akte laden erst beim Scrollen (`loading="lazy"`)
|
||||
|
||||
## Datenbank
|
||||
|
||||
- Indizes wie in `docs/04-datenmodell.md`, besonders `(caseId, createdAt DESC)`
|
||||
- Kein N+1: Beteiligte, Autoren und Anhänge immer per `include` mitladen
|
||||
- Autoren-Daten (Name, Avatar) in einer Map im Speicher halten, TTL 5 Minuten
|
||||
- Verbindungspool über pgbouncer im Transaktionsmodus, Prisma mit `pgbouncer=true`
|
||||
- Das Rollen-Mapping wird prozesslokal gecacht und nur bei `pg_notify` verworfen
|
||||
- **Der Audit-Eintrag darf die Antwort nicht blockieren.** Schreiben in eine Puffertabelle
|
||||
und alle 2 Sekunden im Bündel wegschreiben, aber innerhalb derselben Transaktion für
|
||||
schreibende Aktionen, damit nichts verloren geht.
|
||||
|
||||
## Auslieferung
|
||||
|
||||
- Brotli und HTTP/2 kommen von Cloudflare, im Stack ist dafür nichts zu tun
|
||||
- Schriften selbst gehostet, als `woff2` untergesetzt, `font-display: swap`
|
||||
- Statische Assets mit Hash im Namen und `immutable`-Cache
|
||||
- Keine Komponentenbibliothek im Bundle, das spart mehr als jede Optimierung danach
|
||||
- Bundle-Budget: erste Route unter 120 kB JavaScript nach Kompression.
|
||||
`pnpm analyze` gehört in die CI.
|
||||
|
||||
## Wahrnehmung schlägt Messung
|
||||
|
||||
Ein paar Kleinigkeiten mit unverhältnismäßig großer Wirkung:
|
||||
|
||||
- Der Fokus springt nach dem Absenden eines Vermerks sofort zurück ins leere Eingabefeld
|
||||
- Enter sendet, Shift+Enter macht einen Zeilenumbruch, überall gleich
|
||||
- Strg+K öffnet die Kommandopalette: Aktenzeichen eintippen und direkt hinspringen
|
||||
- Beim Öffnen einer Akte steht der Fokus auf dem Inhalt, nicht auf der Navigation
|
||||
- Entwürfe werden alle 2 Sekunden lokal gespeichert (`localStorage` pro Feld), damit ein
|
||||
versehentlicher Tab-Wechsel keinen Text kostet
|
||||
- Beim Verlassen mit ungespeicherten Änderungen eine Warnung, aber nur bei echten Änderungen
|
||||
- Offline erkannt: Eingaben werden gepuffert, Kopfzeile zeigt „Keine Verbindung, wird
|
||||
nachgesendet"
|
||||
|
||||
## Messen
|
||||
|
||||
`web-vitals` an einen internen Endpunkt, angezeigt im Adminbereich. Zielwerte:
|
||||
LCP unter 1,5 s, INP unter 200 ms, CLS unter 0,05. Dazu die Serverzeiten der fünf
|
||||
häufigsten Abfragen aus `pg_stat_statements`.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Design-System
|
||||
|
||||
## Gestaltungsidee
|
||||
|
||||
Der Bezugspunkt ist nicht „Dashboard" und nicht „Hacker-Terminal", sondern **eine
|
||||
Registratur im Nachtdienst**: lackierter Stahl, Aktenpapier, Stempelfarbe. Ruhige, kühle
|
||||
Flächen, in denen die einzige Farbe von der Klassifizierung kommt.
|
||||
|
||||
Das ist eine bewusste Absage an drei naheliegende Richtungen:
|
||||
|
||||
- Schwarz mit giftgrünem Akzent. Wirkt nach Filmrequisite, ermüdet beim Lesen langer Texte.
|
||||
- Warmes Cremeweiß mit Serifen-Überschriften. Zu behaglich für eine Ermittlungsbehörde.
|
||||
- Der übliche Karten-Baukasten mit gleichen Radien und weichen Schatten. Eine Akte ist kein
|
||||
Produktkatalog, und identische Kacheln nehmen der Hierarchie jede Aussage.
|
||||
|
||||
## Farben
|
||||
|
||||
Dunkel ist der Auslieferungszustand, ein helles Thema ist verpflichtend mitzuliefern.
|
||||
|
||||
```css
|
||||
--ink-900: #15181A; /* Grundfläche, kühles Anthrazit, leicht grünstichig */
|
||||
--ink-800: #1D2124; /* Panel */
|
||||
--ink-700: #262B2F; /* erhöhte Fläche, Eingabefelder */
|
||||
--ink-500: #3A4147; /* Linien und Rahmen */
|
||||
--paper: #E4E7E4; /* Primärtext, papierfarbenes Off-White */
|
||||
--paper-dim:#939C99; /* Sekundärtext */
|
||||
--steel: #7A97A6; /* interaktiv: Links, Fokus, aktive Zustände */
|
||||
```
|
||||
|
||||
Klassifizierungsfarben sind das einzige Buntsystem und werden nirgends sonst benutzt:
|
||||
|
||||
| Stufe | Token | Hex | Anmutung |
|
||||
|---|---|---|---|
|
||||
| 0 Offen | `--stamp-0` | `#8A918D` | Grau |
|
||||
| 1 Intern | `--stamp-1` | `#6D8A9C` | Stahlblau |
|
||||
| 2 Vertraulich | `--stamp-2` | `#4E8F72` | Grünspan |
|
||||
| 3 Geheim | `--stamp-3` | `#B08637` | Messing |
|
||||
| 4 Streng geheim | `--stamp-4` | `#B4543C` | Oxidrot |
|
||||
| 5 Leitungssache | `--stamp-5` | `#7E5E96` | Tiefviolett |
|
||||
|
||||
Status (offen, ruhend, abgeschlossen) wird **nicht** über Farbe transportiert, sondern über
|
||||
Position und Beschriftung. Sonst konkurrieren zwei Farbsysteme um dieselbe Aufmerksamkeit.
|
||||
|
||||
## Typografie
|
||||
|
||||
IBM Plex, selbst gehostet, als `woff2` untergesetzt. Die Wahl ist inhaltlich begründet: Plex
|
||||
wurde als Hausschrift einer Institution entworfen, hat einen schmalen Schnitt für
|
||||
Tabellenköpfe und eine passende Mono für Kennzeichen. Drei Rollen aus einer Familie halten das
|
||||
Bild geschlossen, ohne beliebig zu wirken.
|
||||
|
||||
| Rolle | Schnitt | Einsatz |
|
||||
|---|---|---|
|
||||
| Fließtext | Plex Sans 400 / 500 | Abschnitte, Vermerke, Chat |
|
||||
| Struktur | Plex Sans Condensed 500 | Spaltenköpfe, Stempel, Reiter |
|
||||
| Daten | Plex Mono 400 | Aktenzeichen, Zeitstempel, Hashes, Dienstnummern |
|
||||
|
||||
Skala: 12 / 13 / 15 / 18 / 24 px. Zeilenhöhe 1,6 im Fließtext. Zeilenlänge höchstens
|
||||
72 Zeichen, bei breiten Fenstern durch `max-width` begrenzt statt die Spalte zu dehnen.
|
||||
|
||||
Kein Versalsatz für Beschriftungen. Der Stempelcharakter kommt aus dem schmalen Schnitt, der
|
||||
Rahmenlinie und der Farbe, nicht aus Großbuchstaben. Auch keine hervorgehobenen Einzelwörter
|
||||
in Überschriften.
|
||||
|
||||
## Struktur
|
||||
|
||||
- Eckenradius 2 px, überall gleich. Akten sind nicht abgerundet.
|
||||
- Rahmen 1 px in `--ink-500`. Keine Schatten außer dem Fokusring.
|
||||
- Trennung durch Linien und Weißraum, nicht durch gestapelte Karten.
|
||||
- Die Klassifizierung erscheint als 3 px breite Kante links am Objekt plus einer
|
||||
Stempelmarke oben rechts. Beides in derselben Stufenfarbe.
|
||||
- Der Verlauf hat eine durchgehende senkrechte Linie als Zeitachse. Der Chat hat keine,
|
||||
sondern nur eine Trennlinie zum Hauptbereich. Der Unterschied muss ohne Beschriftung
|
||||
erkennbar sein.
|
||||
|
||||
## Der eine mutige Moment
|
||||
|
||||
Die Schwärzung. Ein massiver Balken in `--ink-900` mit feiner Rahmenlinie, der beim ersten
|
||||
Erscheinen einmal von links nach rechts durchwischt (150 ms, `prefers-reduced-motion`
|
||||
respektiert). Daneben in Mono die Stufe, ab der gelesen werden darf. Sonst bewegt sich in der
|
||||
Oberfläche nichts von selbst.
|
||||
|
||||
Alle übrige Bewegung ist Antwort auf eine Handlung: Aufklappen, Bestätigen, Einfügen.
|
||||
Keine Einblend-Animationen beim Scrollen, keine Hover-Effekte auf jeder Fläche.
|
||||
|
||||
## Schreibweise in der Oberfläche
|
||||
|
||||
- Aktive Verben, die sagen, was passiert: „Vermerk speichern", nicht „Absenden"
|
||||
- Dieselbe Bezeichnung durch den ganzen Ablauf: Knopf „Fall schließen" → Meldung
|
||||
„Fall geschlossen"
|
||||
- Fehler nennen Ursache und nächsten Schritt, ohne Entschuldigung:
|
||||
„Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen."
|
||||
- Leere Zustände sind eine Aufforderung: „Noch kein Vermerk. Ersten Vermerk anlegen."
|
||||
Nicht „Keine Daten vorhanden."
|
||||
- Keine Ausrufezeichen, kein „bitte", kein „erfolgreich"
|
||||
|
||||
## Barrierefreiheit als Untergrenze
|
||||
|
||||
Sichtbarer Fokusring in `--steel`, Kontrast mindestens 4,5:1 im Fließtext, vollständige
|
||||
Tastaturbedienung inklusive Kommandopalette, `prefers-reduced-motion` respektiert, alle
|
||||
Zustandsangaben zusätzlich als Text und nicht nur als Farbe.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Betrieb mit Dockge
|
||||
|
||||
Der Stack wird über [Dockge](https://github.com/louislam/dockge) verwaltet, nicht über die
|
||||
Kommandozeile. Das bestimmt ein paar Entscheidungen, die sonst anders aussähen.
|
||||
|
||||
## Was daraus folgt
|
||||
|
||||
1. **Ein einziger Stack, eine `compose.yaml`.** Dockge verwaltet Stacks getrennt und legt für
|
||||
jeden ein eigenes Netzwerk an. Mehrere Stacks müsste man von Hand zusammenstecken, also
|
||||
kommt alles in einen.
|
||||
2. **Keine Einmal-Container.** Dockge zeigt beendete Container nicht dauerhaft an. Ein
|
||||
Migrations- oder Backup-Job als `restart: no` wäre unsichtbar, wenn er seit Wochen
|
||||
fehlschlägt. Migrationen laufen deshalb im Startkommando von `web`, Backups in
|
||||
Dauerläufern mit eigenem Cron.
|
||||
3. **Healthchecks sind die Statusanzeige.** Dockge zeigt sie direkt in der Übersicht. Jeder
|
||||
Dienst braucht einen, sonst siehst du nur „läuft" und weißt nichts.
|
||||
4. **Named Volumes statt Bind Mounts.** Überstehen Stack-Updates zuverlässig und tauchen in
|
||||
Dockge sauber auf. Ausnahme ist das Backup-Ziel, das gehört auf eine echte Platte.
|
||||
5. **Kein Caddy.** Der Cloudflare Tunnel übernimmt TLS und Kompression, und Next.js liefert
|
||||
seine statischen Dateien selbst mit korrekten Cache-Headern aus. Ein zusätzlicher Proxy
|
||||
wäre nur ein weiterer Container, der kaputtgehen kann.
|
||||
|
||||
## Ablage
|
||||
|
||||
```
|
||||
/opt/stacks/afis/
|
||||
├── compose.yaml im Dockge-Editor bearbeitbar
|
||||
└── .env eigener Reiter in Dockge
|
||||
```
|
||||
|
||||
Beides wird über die Weboberfläche gepflegt. `.env` gehört nicht ins Git-Repository, die
|
||||
Vorlage dafür ist `.env.example`.
|
||||
|
||||
## compose.yaml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/DEIN-USER/afis-web:latest
|
||||
restart: unless-stopped
|
||||
command: sh -c "npx prisma migrate deploy && node server.js"
|
||||
env_file: .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
volumes:
|
||||
- files:/data/files
|
||||
- clamav_socket:/var/run/clamav
|
||||
ports:
|
||||
- "3000:3000" # nur im LAN, von außen kommt der Tunnel
|
||||
depends_on:
|
||||
pgbouncer: { condition: service_healthy }
|
||||
clamav: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
worker:
|
||||
image: ghcr.io/DEIN-USER/afis-worker:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
depends_on:
|
||||
pgbouncer: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"] # prüft Gateway-Verbindung
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: afis
|
||||
POSTGRES_USER: afis
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
TZ: ${TZ}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U afis -d afis"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgbouncer:
|
||||
image: edoburu/pgbouncer:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_USER: afis
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: afis
|
||||
POOL_MODE: transaction
|
||||
MAX_CLIENT_CONN: "100"
|
||||
DEFAULT_POOL_SIZE: "20"
|
||||
AUTH_TYPE: scram-sha-256
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U afis"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:stable
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLAMAV_NO_MILTERD: "true"
|
||||
FRESHCLAM_CHECKS: "2"
|
||||
volumes:
|
||||
- clamav_db:/var/lib/clamav
|
||||
- clamav_socket:/var/run/clamav
|
||||
- files:/data/files
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "clamdscan --ping 1 || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 15s
|
||||
retries: 3
|
||||
start_period: 300s # erster Signaturdownload dauert
|
||||
|
||||
pdf:
|
||||
image: ghcr.io/DEIN-USER/afis-pdf:latest # Playwright-Basis
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
restart: unless-stopped
|
||||
command: tunnel --no-autoupdate run
|
||||
environment:
|
||||
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
depends_on:
|
||||
web: { condition: service_healthy }
|
||||
|
||||
db-backup:
|
||||
image: prodrigestivill/postgres-backup-local:16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_DB: afis
|
||||
POSTGRES_USER: afis
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_EXTRA_OPTS: "-Fc"
|
||||
SCHEDULE: "0 15 4 * * *" # täglich 04:15
|
||||
BACKUP_KEEP_DAYS: "7"
|
||||
BACKUP_KEEP_WEEKS: "4"
|
||||
BACKUP_KEEP_MONTHS: "6"
|
||||
HEALTHCHECK_PORT: "8080"
|
||||
TZ: ${TZ}
|
||||
volumes:
|
||||
- /mnt/backup/afis/db:/backups
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
|
||||
offsite-backup:
|
||||
image: mazzolino/restic:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
RUN_ON_STARTUP: "false"
|
||||
BACKUP_CRON: "0 30 4 * * *" # 15 Minuten nach dem Dump
|
||||
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY}
|
||||
RESTIC_PASSWORD: ${RESTIC_PASSWORD}
|
||||
RESTIC_BACKUP_SOURCES: /data/db /data/files
|
||||
RESTIC_FORGET_ARGS: "--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune"
|
||||
TZ: ${TZ}
|
||||
volumes:
|
||||
- /mnt/backup/afis/db:/data/db:ro
|
||||
- files:/data/files:ro
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
files:
|
||||
clamav_db:
|
||||
clamav_socket:
|
||||
```
|
||||
|
||||
`DEIN-USER` durch deinen GitHub-Namen ersetzen. Die Images baut ein GitHub-Actions-Workflow
|
||||
und schiebt sie in die GitHub Container Registry. Dockge zieht sie beim Update.
|
||||
|
||||
## Erste Einrichtung
|
||||
|
||||
1. In Dockge einen neuen Stack `afis` anlegen
|
||||
2. `compose.yaml` einfügen, `DEIN-USER` ersetzen
|
||||
3. Im Reiter `.env` die Werte aus `.env.example` eintragen
|
||||
4. Deploy. Beim ersten Start dauert `clamav` mehrere Minuten, bis die Signaturen da sind.
|
||||
Solange bleibt der Container gelb, das ist normal.
|
||||
5. Prüfen, dass `web` grün wird. Dann `https://afis.example.de` aufrufen.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
Dockge: Stack öffnen, „Update", fertig. Das zieht neue Images und startet neu.
|
||||
Die Migration läuft automatisch im Startkommando von `web`.
|
||||
|
||||
**Vor jedem Update ein Dump.** Der läuft zwar nachts, aber ein Update mittags nach einer
|
||||
kaputten Migration ohne frisches Backup ist genau der Fall, den man einmal erlebt.
|
||||
In der Dockge-Konsole des `db-backup`-Containers:
|
||||
|
||||
```
|
||||
/backup.sh
|
||||
```
|
||||
|
||||
## Wiederherstellung
|
||||
|
||||
Aus der Dockge-Konsole des `db`-Containers:
|
||||
|
||||
```bash
|
||||
pg_restore -U afis -d afis --clean --if-exists /backups/daily/afis-<datum>.sql.gz
|
||||
```
|
||||
|
||||
Dateien liegen im Volume `files`. Aus dem restic-Repository:
|
||||
|
||||
```bash
|
||||
restic restore latest --target /restore --include /data/files
|
||||
```
|
||||
|
||||
**Einmal im Monat wirklich durchspielen**, in eine Wegwerf-Datenbank. Ein ungetestetes Backup
|
||||
ist keins, und das ist der Punkt, an dem Homeserver-Projekte reihenweise scheitern.
|
||||
|
||||
## Was nicht nach außen darf
|
||||
|
||||
Nur `web` hat eine Portfreigabe, und die geht ins LAN. `db`, `pgbouncer`, `clamav` und `pdf`
|
||||
sind ausschließlich im Stack-Netz erreichbar. Am Router wird kein Port geöffnet, der Tunnel
|
||||
verbindet sich ausgehend.
|
||||
|
||||
Der Dockge-Zugang selbst (Port 5001) gehört ebenfalls nicht ins Internet. Wenn du von
|
||||
unterwegs dran willst: über Tailscale oder einen zweiten, per Cloudflare Access geschützten
|
||||
Tunnel, nie offen.
|
||||
|
||||
## Beobachtung
|
||||
|
||||
- Dockge-Übersicht als erste Anlaufstelle: alle Healthchecks auf einen Blick
|
||||
- `/api/health` prüft Datenbank, Discord-Gateway und ClamAV und wird vom Healthcheck genutzt
|
||||
- Der Worker meldet Start, Verbindungsabbruch und fehlgeschlagenen Voll-Abgleich in den
|
||||
Admin-Discord-Kanal. Das ist deine eigentliche Alarmierung, weil du Discord ohnehin offen hast.
|
||||
- Logs über die Dockge-Oberfläche. Zusätzlich im Compose begrenzen, sonst läuft die Platte voll:
|
||||
|
||||
```yaml
|
||||
x-logging: &logging
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
```
|
||||
|
||||
Als Anker `<<: *logging` bei jedem Dienst einhängen.
|
||||
|
||||
## Aufräumen
|
||||
|
||||
Tägliche Aufgabe in der Anwendung, kein eigener Container: abgelaufene Sessions löschen,
|
||||
Quarantäne-Dateien älter als 24 Stunden löschen, PDF-Cache älter als 30 Tage löschen.
|
||||
Der Audit-Log wird nie gelöscht.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Arbeiten mit Claude Code
|
||||
|
||||
## Grundregel
|
||||
|
||||
Eine Phase aus `TASKS.md` pro Sitzung, nicht mehr. Lange Sitzungen verlieren den Faden und
|
||||
produzieren Code, den niemand mehr prüft. Nach jeder Phase committen.
|
||||
|
||||
## Ablauf pro Phase
|
||||
|
||||
```
|
||||
Lies CLAUDE.md, docs/03-auth-und-rechte.md und TASKS.md.
|
||||
Wir machen Phase 1. Erstell mir zuerst einen Plan mit den Dateien, die du anlegen oder
|
||||
ändern willst, und den offenen Entscheidungen. Baue noch nichts.
|
||||
```
|
||||
|
||||
Plan durchsehen, dann:
|
||||
|
||||
```
|
||||
Der Plan passt. Setz ihn um. Nach jeder Teilaufgabe: Tests laufen lassen und mir sagen,
|
||||
was fertig ist.
|
||||
```
|
||||
|
||||
Am Ende:
|
||||
|
||||
```
|
||||
Hak die erledigten Punkte in TASKS.md ab und schreib mir eine kurze Zusammenfassung,
|
||||
was jetzt funktioniert und was noch offen ist.
|
||||
```
|
||||
|
||||
## Nützliche Einzelaufträge
|
||||
|
||||
**Sicherheitsdurchsicht nach einer Phase**
|
||||
|
||||
> Geh alle neuen Endpunkte und Server Actions durch. Prüf für jeden: findet die
|
||||
> Berechtigungsprüfung serverseitig statt, steckt sie in der Datenbankabfrage statt als
|
||||
> Nachfilter, und gibt es einen Test für den Fall ohne Berechtigung? Liste Lücken auf.
|
||||
|
||||
**Performance-Durchsicht**
|
||||
|
||||
> Prüf die neuen Ansichten gegen docs/09-performance-und-ux.md. Für jede Mutation:
|
||||
> optimistisch oder nicht, und passt das zur Tabelle im Dokument? Für jede Route:
|
||||
> gibt es eine loading.tsx mit passendem Skelett?
|
||||
|
||||
**Existenz-Leaks**
|
||||
|
||||
> Such nach Stellen, an denen die Existenz einer nicht sichtbaren Akte durchsickern kann:
|
||||
> Trefferzahlen, Facettenzähler, Rückverweise auf Personalakten, Fehlermeldungen,
|
||||
> Antwortzeiten. Schreib Tests für jede gefundene Stelle.
|
||||
|
||||
**Änderbarkeit prüfen**
|
||||
|
||||
> Geh alle Pfade durch, über die ein CaseEntry verändert werden kann. Prüf für jeden:
|
||||
> wird eine CaseEntryVersion geschrieben, bleiben authorId und createdAt unangetastet,
|
||||
> gibt es einen AuditLog-Eintrag, und wird die Karenzzeit serverseitig aus AppSetting
|
||||
> geprüft statt aus dem Request? Liste Lücken auf.
|
||||
|
||||
**Wenn etwas komisch aussieht**
|
||||
|
||||
> Vergleich die Umsetzung mit docs/10-design-system.md. Wo hast du Radien, Schatten oder
|
||||
> Farben benutzt, die dort nicht vorgesehen sind?
|
||||
|
||||
## Was du nicht delegieren solltest
|
||||
|
||||
- Discord-Application und Bot-Einladung: mach das selbst im Developer Portal
|
||||
- Die echten `.env`-Werte, die gehören in den Dockge-Reiter und nicht ins Repository
|
||||
- Das Anlegen des Stacks in Dockge und den Cloudflare-Tunnel
|
||||
- Die erste Wiederherstellung eines Backups
|
||||
- Die Entscheidung, welche Discord-Rolle welche Stufe bekommt
|
||||
|
||||
## Wenn Claude Code sich verrennt
|
||||
|
||||
Neue Sitzung starten und den Kontext neu setzen, statt in der alten weiterzudiskutieren:
|
||||
|
||||
> Lies CLAUDE.md und docs/<passendes Dokument>. Der aktuelle Stand von <Datei> macht <X>,
|
||||
> soll aber <Y> machen. Erklär mir zuerst, warum der jetzige Stand nicht passt,
|
||||
> bevor du ihn änderst.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Getroffene Entscheidungen
|
||||
|
||||
Stellen, an denen die Fachdokumente sich widersprechen, lückenhaft sind oder gegen eine
|
||||
Eigenschaft von Postgres laufen. Jede Zeile nennt den Konflikt, die Entscheidung und den Grund.
|
||||
Wo eine Entscheidung fachlich anders ausfallen kann, steht das ausdrücklich dabei.
|
||||
|
||||
## Struktur
|
||||
|
||||
| Konflikt | Entscheidung |
|
||||
|---|---|
|
||||
| `CLAUDE.md` und `README.md` verweisen auf `docs/`, die Dateien lagen im Wurzelverzeichnis | Nach `docs/` verschoben |
|
||||
| `02-architektur.md` zeigt `app/` und `lib/` im Wurzelverzeichnis, `TASKS.md` Phase 0 verlangt einen pnpm-Workspace mit `web/` und `worker/` | Workspace mit `web/`, `worker/`, `pdf/` und `packages/db/`. Die Struktur aus `02-architektur.md` gilt innerhalb von `web/` |
|
||||
| Der Worker braucht denselben Prisma-Client wie die Web-App | Eigenes Workspace-Paket `packages/db` (`@afis/db`), von `web` und `worker` benutzt. Migrationen und Seed liegen dort |
|
||||
|
||||
## Datenmodell
|
||||
|
||||
| Konflikt | Entscheidung |
|
||||
|---|---|
|
||||
| `CaseParticipant` hat den zusammengesetzten Primärschlüssel `[caseId, role, userId, personnelFileId]`, aber `userId` und `personnelFileId` sind beide optional. Postgres lässt NULL in einem Primärschlüssel nicht zu | Eigener Schlüssel `id`, Eindeutigkeit über zwei **partielle** Unique-Indizes plus eine CHECK-Bedingung, dass mindestens eines der beiden Felder gesetzt ist |
|
||||
| `04-datenmodell.md` nennt zugleich `CREATE SEQUENCE case_number_2026` und die Tabelle `CaseCounter(year, lastNumber)` mit `SELECT … FOR UPDATE` | `CaseCounter`. Eine Sequenz pro Jahr müsste jedes Jahr von Hand angelegt werden, und der Abteilungsleiter ist ausdrücklich nicht technisch |
|
||||
| Das rohe SQL benutzt `search_vector` und `updated_at` in Schlangenschrift, die Prisma-Modelle Höckerschrift | Prisma-Voreinstellung behalten: Tabellen in Höckerschrift-Pascal, Spalten in Höckerschrift. Das SQL quotet entsprechend (`"searchVector"`, `"updatedAt"`) |
|
||||
| Kein Modell für Kanäle, obwohl `ChatMessage.channelId` existiert und `06-chat-und-realtime.md` Kanäle mit eigener Mindeststufe verlangt | Modell `Channel` ergänzt, dazu `ChatReaction` für den festen Reaktionssatz |
|
||||
| `/api/health` soll den Discord-Gateway prüfen, aber die Web-App fragt nie live bei Discord an | Modell `ServiceHeartbeat`. Der Worker schreibt alle 30 Sekunden seinen Zustand, `web` liest ihn |
|
||||
|
||||
## Der wichtigste Widerspruch
|
||||
|
||||
`04-datenmodell.md` verlangt `CREATE RULE entry_version_no_update … DO INSTEAD NOTHING` auf
|
||||
`CaseEntryVersion`. `05-akten-und-vorlagen.md` verlangt unter „Umklassifizierung wirkt
|
||||
rückwirkend", dass beim Anheben von `minClearance` **alle bestehenden Versionen den neuen Wert
|
||||
erben**. Beides zusammen geht nicht: die Regel würde genau die Nachführung verhindern, die das
|
||||
Leck schließt.
|
||||
|
||||
Aufgelöst durch einen Trigger statt einer Regel. `CaseEntryVersion` bleibt anhängend, aber
|
||||
`minClearance` darf nachgeführt werden, und nur nach oben. Jede andere Änderung wirft einen
|
||||
Fehler. Das erhält beide Absichten: der Text ist unveränderlich, die Klassifizierung wirkt
|
||||
rückwirkend.
|
||||
|
||||
Ein zweiter Trigger schützt `CaseEntry.authorId`, `createdAt`, `caseId` und `type` auf
|
||||
Datenbankebene, nicht nur in der Anwendung.
|
||||
|
||||
## Suche
|
||||
|
||||
`08-suche-und-export.md` beschreibt einen `search_vector` je Akte aus Titel, Abschnitten und
|
||||
Verlaufseinträgen. Ungeklärt bleibt, was mit Inhalten oberhalb der Stufe des Suchenden
|
||||
passiert. Ein gemeinsamer Vektor über alle Abschnitte würde einen Nutzer mit Stufe 1 einen
|
||||
Begriff finden lassen, der nur in einem als `[[c4]]…[[/c4]]` geschwärzten Bereich steht.
|
||||
Das verstößt gegen harte Regel 6.
|
||||
|
||||
**Vorläufig aufgenommen wird nur, was jeder lesen darf, der die Akte überhaupt sehen darf:**
|
||||
Aktenzeichen und Titel (Gewicht A), Abschnitte und Verlaufseinträge mit `minClearance = 0`
|
||||
(B und C), Schwärzungs-Auszeichnungen vorher entfernt. Zurückgezogene Einträge fallen heraus.
|
||||
|
||||
Das ist sicher, aber für hohe Stufen zu sparsam: ein Nutzer mit Stufe 5 findet geheime Inhalte
|
||||
nicht über die Volltextsuche. **Phase 7 muss das auflösen**, sinnvollerweise über getrennte
|
||||
Vektorspalten je Stufe. Diese Entscheidung ist bewusst konservativ getroffen, weil ein Leck
|
||||
hier schwerer zu bemerken ist als eine Lücke.
|
||||
|
||||
## Prisma 7
|
||||
|
||||
Die Dokumente nennen nur „Prisma + PostgreSQL 16". Eingesetzt wird Prisma 7, die
|
||||
aktuelle Fassung. Sie unterscheidet sich in drei Punkten, die den Aufbau betreffen:
|
||||
|
||||
| Änderung | Folge hier |
|
||||
|---|---|
|
||||
| Verbindungsdaten stehen nicht mehr im Schema | `packages/db/prisma.config.ts`. Migrationen laufen über `DIRECT_DATABASE_URL`, also an pgbouncer vorbei |
|
||||
| Keine Rust-Query-Engine mehr, stattdessen Treiber-Adapter | `@prisma/adapter-pg` über `pg`. Der Treiber wird für `LISTEN/NOTIFY` ohnehin gebraucht, und die Images müssen keine Engine nachladen |
|
||||
| Neuer Generator `prisma-client` statt `prisma-client-js` | Der Client wird als ECMAScript-Module nach `packages/db/src/generated` erzeugt statt nach `node_modules/.prisma`. Nötig mit `importFileExtension = "js"`, sonst schreibt er endungslose Importe, die Node im ESM-Modus nicht auflöst |
|
||||
|
||||
Der erzeugte Client ist ein Bauartefakt und liegt nicht im Repository.
|
||||
`pnpm --filter @afis/db run build` erzeugt ihn, die CI tut das vor lint, typecheck und test.
|
||||
|
||||
Für `prisma migrate deploy` braucht es weiterhin eine Rust-Schema-Engine. Sie wird beim
|
||||
Bauen des Web-Images heruntergeladen und liegt unter `/app/migrator` im Image. Beim
|
||||
Containerstart wird nichts nachgeladen: ein Container, der nur startet, wenn gerade eine
|
||||
Registry erreichbar ist, wäre auf einem Homeserver die falsche Bauart.
|
||||
|
||||
## Betrieb
|
||||
|
||||
| Konflikt | Entscheidung |
|
||||
|---|---|
|
||||
| `11-betrieb.md` verlangt „Jeder Dienst braucht einen Healthcheck", die abgedruckte `compose.yaml` hat für `db-backup` und `offsite-backup` keinen | Ergänzt. `db-backup` über seinen HTTP-Port 8080, `offsite-backup` über Cron-Prozess und `restic version` |
|
||||
| Das Startkommando `npx prisma migrate deploy` würde im Container aus dem Netz nachladen | Auf die im Image unter `/app/migrator` mitgelieferte Prisma-CLI umgestellt, zusammen mit Schema, Migrationsordner und Schema-Engine |
|
||||
| `pnpm deploy` zieht die optionalen Peers von `@prisma/client` mit ins Image (CLI, Studio, TypeScript, rund 240 MB) | `--no-optional` beim Bündeln. Zur Laufzeit wird nichts davon gebraucht |
|
||||
|
||||
## Protokollierung
|
||||
|
||||
`09-performance-und-ux.md` verlangt eine Puffertabelle für Audit-Einträge, die alle zwei
|
||||
Sekunden gebündelt geschrieben wird, zugleich aber, dass schreibende Aktionen ihren Eintrag
|
||||
**in derselben Transaktion** erzeugen. Umgesetzt wird beides getrennt: schreibende Aktionen
|
||||
protokollieren synchron in der Transaktion, Lesezugriffe ab Stufe 3 laufen über einen Puffer.
|
||||
Der Puffer liegt im Prozessspeicher, nicht in einer Tabelle — eine Puffertabelle hätte
|
||||
dieselbe Verlustgefahr bei einem Absturz, aber zusätzliche Schreiblast. Kommt in Phase 2.
|
||||
|
||||
## Vorlagen im Seed
|
||||
|
||||
`CLAUDE.md` beschreibt `pnpm db:seed` als „Testdaten inkl. Vorlagen", `TASKS.md` ordnet die
|
||||
vier Startvorlagen aber Phase 3 zu. Der Seed legt vorerst nur an, was die Anwendung zum Laufen
|
||||
braucht: die sechs Freigabestufen, die Änderungsrichtlinie und den Aktenzeichen-Zähler.
|
||||
Die Vorlagen kommen mit dem Vorlagen-Editor in Phase 3.
|
||||
Reference in New Issue
Block a user