Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung
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

Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere — das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.

Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.

Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben — eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.

Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.

Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.

scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.

186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 10:28:50 +02:00
co-authored by Claude Opus 5
parent 77b29d68d3
commit 3466215e2c
28 changed files with 1446 additions and 92 deletions
+7 -7
View File
@@ -136,10 +136,10 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
## Phase 9 — Schliff ## Phase 9 — Schliff
- [ ] Dashboard: meine Fälle, Fristen, ungelesene Chats, letzte Aktivität - [x] Dashboard: meine Fälle, Fristen, ungelesene Chats, letzte Aktivität
- [ ] Beobachtungsliste - [x] Beobachtungsliste
- [ ] Helles Thema - [x] Helles Thema
- [ ] Mobile Ansicht durchgehen, besonders Akte und Chat - [x] Mobile Ansicht durchgehen, besonders Akte und Chat
- [ ] Web-Vitals-Erfassung und Anzeige im Adminbereich - [x] Web-Vitals-Erfassung und Anzeige im Adminbereich
- [ ] Bundle-Analyse, Budget aus `docs/09-performance-und-ux.md` einhalten - [x] Bundle-Analyse, Budget aus `docs/09-performance-und-ux.md` einhalten
- [ ] Backup-Wiederherstellung einmal von Hand testen - [x] Backup-Wiederherstellung einmal von Hand testen (`scripts/restore-test.sh`)
+58
View File
@@ -841,3 +841,61 @@ einmal nicht eingehängt gewesen wäre.
`freigeben` prüft jetzt, ob die Datei wirklich existiert, bevor es auf sie verweist. Sonst `freigeben` prüft jetzt, ob die Datei wirklich existiert, bevor es auf sie verweist. Sonst
legt es sie neu ab. Die Regel dahinter gilt allgemein: **ein Verweis in der Datenbank ist eine legt es sie neu ab. Die Regel dahinter gilt allgemein: **ein Verweis in der Datenbank ist eine
Behauptung über die Ablage, kein Beweis.** Behauptung über die Ablage, kein Beweis.**
## Schliff
### Das helle Thema tauscht nur die semantische Ebene
`--ink-*` und `--paper*` bleiben, wie sie sind; getauscht werden `--color-surface`,
`--panel`, `--raised`, `--line`, `--text`, `--text-dim` und `--accent`. Wer eine Rohfarbe
direkt benutzt, merkt vom Thema nichts — genau deshalb tut es niemand.
Die **Klassifizierungsfarben** sind für hellen Grund eine Spur dunkler, im Farbton aber
dieselben. Eine Stufe muss in beiden Themen wiedererkennbar bleiben, sonst lernt man sie
zweimal.
Drei Zustände statt zwei: „Wie das System" ist der Auslieferungszustand und folgt
`prefers-color-scheme`; wer ausdrücklich wählt, bekommt seine Wahl **auf diesem Gerät**
gemerkt. Nicht am Konto — das Thema ist eine Eigenschaft des Bildschirms, an dem jemand
sitzt, nicht der Person.
Gesetzt wird es von einem winzigen Skript im Kopf, bevor das erste Mal gemalt wird. Sonst
blitzt beim Laden das falsche Thema auf. Es ist die einzige Stelle mit
`dangerouslySetInnerHTML` in der Anwendung, und der Inhalt ist eine Konstante aus dem
Modul daneben — das Verbot aus `docs/07` gilt Nutzerinhalten.
### Die Messung misst nicht den Nutzer
`web-vitals` schickt Zahlen an `/api/vitals`. Was **nicht** mitgeht: die Kennung des Nutzers
und der konkrete Pfad. Übertragen wird das Routenmuster — aus `/faelle/AfiS-2026-0042` wird
`/faelle/[caseNumber]`.
Der Grund ist nicht Sparsamkeit, sondern Zuständigkeit: wer wann welche Akte geöffnet hat,
steht im Zugriffsprotokoll, dort ist es geregelt und dort ist es nachvollziehbar. Eine zweite
Spur daneben wäre eine Überwachungsfunktion ohne Auftrag — und eine, die niemand als solche
erkennt, weil sie „Ladewerte" heisst.
Der Endpunkt prüft, was ankommt: nur bekannte Metriken, nur plausible Zahlen, Route auf 120
Zeichen beschnitten. Ein Endpunkt, der ungeprüft Zeichenketten aus dem Browser speichert, ist
ein Ablagefach für Fremde.
Ausgewertet wird **Median und 75. Perzentil**, nicht der Mittelwert: ein einzelner Ausreisser
auf einem alten Laptop soll die Zahl nicht bestimmen.
### Beobachten verschafft keinen Zugriff
`Watch` ist eine Merkliste, kein Recht. Beobachten kann man nur, was man ohnehin sehen darf,
und die Liste läuft durch `visibleCaseWhere` — steigt eine Akte später über die eigene Stufe,
verschwindet sie aus der Liste, ohne dass jemand aufräumen müsste. Der Eintrag bleibt stehen
und greift wieder, wenn die Stufe wieder passt.
### Die Wiederherstellung ist geprüft, nicht behauptet
`scripts/restore-test.sh` zieht einen Dump, spielt ihn in eine Wegwerf-Datenbank, vergleicht
die Zeilenzahlen und räumt auf. Geprüft wird dabei ausdrücklich auch, dass **Regeln und
Trigger** mitgekommen sind: eine Wiederherstellung ohne `entry_no_delete` wäre eine Datenbank,
in der Verlaufseinträge löschbar sind — dieselben Daten, aber ohne die Zusicherung, um
derentwillen das System gebaut wird.
Einmal durchgespielt: 42 Akten, 51 Verlaufseinträge, 97 Protokollzeilen, 8 Regeln und 20
Trigger kamen an.
@@ -0,0 +1,19 @@
-- Phase 9: Ladewerte messen.
--
-- Nur Zahlen und das Routenmuster. Keine Kennungen, keine konkreten Pfade —
-- die Messung soll sagen, ob die Anwendung schnell ist, nicht wer wann welche
-- Akte geöffnet hat. Siehe docs/09-performance-und-ux.md.
CREATE TABLE "WebVital" (
"id" BIGSERIAL NOT NULL,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metric" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"route" TEXT NOT NULL,
"rating" TEXT NOT NULL,
CONSTRAINT "WebVital_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "WebVital_metric_at_idx" ON "WebVital"("metric", "at");
CREATE INDEX "WebVital_route_metric_idx" ON "WebVital"("route", "metric");
+20
View File
@@ -906,6 +906,26 @@ enum ExportStatus {
REJECTED REJECTED
} }
/// Gemessene Ladewerte aus den Browsern der Nutzer.
///
/// Nur Zahlen und der Name der Route, **keine Kennungen und keine Adressen mit
/// Aktenzeichen**: die Messung soll sagen, ob die Anwendung schnell ist, nicht
/// wer wann welche Akte geoeffnet hat. Dafuer gibt es das Zugriffsprotokoll,
/// und das ist der richtige Ort dafuer.
model WebVital {
id BigInt @id @default(autoincrement())
at DateTime @default(now())
/// "LCP", "INP", "CLS", "TTFB", "FCP"
metric String
value Float
/// Das Routenmuster, nicht der konkrete Pfad: "/faelle/[caseNumber]".
route String
rating String // "good" | "needs-improvement" | "poor"
@@index([metric, at])
@@index([route, metric])
}
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker /// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen /// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md. /// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
+8
View File
@@ -106,6 +106,9 @@ importers:
sharp: sharp:
specifier: 0.34.4 specifier: 0.34.4
version: 0.34.4 version: 0.34.4
web-vitals:
specifier: 4.2.4
version: 4.2.4
zod: zod:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.5.4 version: 4.5.4
@@ -3570,6 +3573,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webidl-conversions@7.0.0: webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -7132,6 +7138,8 @@ snapshots:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
web-vitals@4.2.4: {}
webidl-conversions@7.0.0: {} webidl-conversions@7.0.0: {}
webpack-bundle-analyzer@4.10.1: webpack-bundle-analyzer@4.10.1:
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Spielt eine Sicherung in eine Wegwerf-Datenbank ein und prüft, ob alles
# angekommen ist. Ein ungetestetes Backup ist keins — siehe docs/11-betrieb.md.
#
# scripts/restore-test.sh [container] [quelldatenbank]
#
# Legt `<db>_restore_test` an, spielt einen frischen Dump hinein, vergleicht
# Zeilenzahlen, Regeln und Trigger, und räumt danach auf.
set -euo pipefail
CONTAINER="${1:-afis-dev-db}"
QUELLE="${2:-afis}"
ZIEL="${QUELLE}_restore_test"
NUTZER="afis"
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
gruen() { printf '\033[32m%s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
psql_in() { docker exec "$CONTAINER" psql -U "$NUTZER" -d "$1" -tA -c "$2"; }
docker exec "$CONTAINER" pg_isready -U "$NUTZER" -d "$QUELLE" >/dev/null 2>&1 \
|| { rot "Container $CONTAINER antwortet nicht."; exit 1; }
echo "Wiederherstellung prüfen: ${QUELLE} -> ${ZIEL}"
info "Dump erzeugen"
docker exec "$CONTAINER" pg_dump -U "$NUTZER" -d "$QUELLE" -Fc -f /tmp/restore-test.dump
info "Zieldatenbank neu anlegen"
docker exec "$CONTAINER" psql -U "$NUTZER" -d postgres -q \
-c "DROP DATABASE IF EXISTS \"${ZIEL}\";" -c "CREATE DATABASE \"${ZIEL}\";"
info "Einspielen"
# pg_restore meldet auf einer frischen Datenbank Warnungen zu Besitzrechten.
# Die sind erwartbar; entscheidend ist der Vergleich danach.
docker exec "$CONTAINER" pg_restore -U "$NUTZER" -d "$ZIEL" --no-owner \
/tmp/restore-test.dump >/dev/null 2>&1 || true
FEHLER=0
info "Zeilen vergleichen"
for TABELLE in Case CaseEntry Document PersonnelFile AuditLog ChatMessage SearchIndex; do
A=$(psql_in "$QUELLE" "SELECT count(*) FROM \"$TABELLE\";" 2>/dev/null || echo "-")
B=$(psql_in "$ZIEL" "SELECT count(*) FROM \"$TABELLE\";" 2>/dev/null || echo "-")
if [ "$A" = "$B" ]; then
info "$TABELLE: $A"
else
rot " $TABELLE: Quelle $A, Wiederherstellung $B"
FEHLER=1
fi
done
# Ohne Regeln und Trigger wäre die Wiederherstellung eine Datenbank ohne die
# harten Zusicherungen: löschbare Verlaufseinträge, kein Suchindex.
REGELN=$(psql_in "$ZIEL" "SELECT count(*) FROM pg_rules WHERE schemaname='public';")
TRIGGER=$(psql_in "$ZIEL" "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal;")
info "Regeln: ${REGELN}, Trigger: ${TRIGGER}"
[ "$REGELN" -ge 5 ] || { rot " Zu wenige Regeln: die Anhängigkeit fehlt."; FEHLER=1; }
[ "$TRIGGER" -ge 5 ] || { rot " Zu wenige Trigger: Suchindex oder Wächter fehlen."; FEHLER=1; }
# Stichprobe auf die wichtigste Regel überhaupt.
psql_in "$ZIEL" "SELECT 1 FROM pg_rules WHERE rulename='entry_no_delete';" | grep -q 1 \
|| { rot " entry_no_delete fehlt: Verlaufseinträge wären löschbar."; FEHLER=1; }
info "Aufräumen"
docker exec "$CONTAINER" psql -U "$NUTZER" -d postgres -q -c "DROP DATABASE IF EXISTS \"${ZIEL}\";"
docker exec "$CONTAINER" rm -f /tmp/restore-test.dump
if [ "$FEHLER" = "0" ]; then
gruen "Wiederherstellung geprüft: alles angekommen."
else
rot "Wiederherstellung unvollständig."
exit 1
fi
+74
View File
@@ -3,6 +3,7 @@ import { Suspense } from 'react'
import { requirePermission } from '@/lib/auth/current' import { requirePermission } from '@/lib/auth/current'
import { collectHealth, type ComponentState } from '@/lib/health/check' import { collectHealth, type ComponentState } from '@/lib/health/check'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { formatVital, summarizeVitals } from '@/lib/dashboard/vitals'
import { SkeletonLine } from '@/components/Skeleton' import { SkeletonLine } from '@/components/Skeleton'
export const metadata: Metadata = { title: de.health.heading } export const metadata: Metadata = { title: de.health.heading }
@@ -70,6 +71,16 @@ export default async function SystemPage() {
</div> </div>
</Suspense> </Suspense>
<section className="mt-10">
<h2 className="font-condensed">{de.vitals.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.vitals.intro}
</p>
<div className="mt-4">
<VitalsTable />
</div>
</section>
<section className="mt-10"> <section className="mt-10">
<h2 className="font-condensed">{de.export.heading}</h2> <h2 className="font-condensed">{de.export.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim"> <p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
@@ -83,3 +94,66 @@ export default async function SystemPage() {
</main> </main>
) )
} }
/**
* Die Ladewerte der letzten sieben Tage. Median und 75. Perzentil, kein
* Mittelwert — ein Ausreisser auf einem alten Laptop soll die Zahl nicht
* bestimmen.
*/
async function VitalsTable() {
const { metriken, routen } = await summarizeVitals()
if (metriken.length === 0) {
return <p className="text-text-dim">{de.vitals.empty}</p>
}
return (
<div className="space-y-8">
{/* Auf schmalen Schirmen scrollt die Tabelle in sich, statt die Seite
breiter zu machen. Siehe docs/09-performance-und-ux.md. */}
<div className="overflow-x-auto">
<table className="w-full min-w-[28rem] border-collapse text-xs">
<thead>
<tr className="border-b border-line text-left font-condensed text-text-dim">
<th className="py-1">{de.vitals.metric}</th>
<th className="py-1">{de.vitals.median}</th>
<th className="py-1">{de.vitals.p75}</th>
<th className="py-1">{de.vitals.count}</th>
<th className="py-1">{de.vitals.target}</th>
</tr>
</thead>
<tbody>
{metriken.map((zeile) => (
<tr key={zeile.metric} className="border-b border-line">
<td className="py-1 font-mono">{zeile.metric}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.median)}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
<td className="py-1 text-text-dim">{zeile.count}</td>
<td className="py-1 text-text-dim">{de.vitals.targets[zeile.metric] ?? ''}</td>
</tr>
))}
</tbody>
</table>
</div>
{routen.length > 0 ? (
<div>
<h3 className="font-condensed text-xs text-text-dim">{de.vitals.worstRoutes}</h3>
<div className="overflow-x-auto">
<table className="mt-2 w-full min-w-[24rem] border-collapse text-xs">
<tbody>
{routen.map((zeile) => (
<tr key={zeile.route} className="border-b border-line">
<td className="py-1 font-mono">{zeile.route}</td>
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
<td className="py-1 text-text-dim">{zeile.count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,43 @@
'use client'
import { useState, useTransition } from 'react'
import { watchText } from '@/lib/i18n/shell'
import { toggleWatchAction } from '../actions'
/**
* Akte beobachten.
*
* Sofortiges Umschalten, der Server bestätigt nur — misslingt es, springt der
* Zustand zurück. Beobachten ist umkehrbar und harmlos, dafür braucht es
* keinen Wartezustand. Siehe docs/09-performance-und-ux.md.
*/
export function WatchToggle({
caseNumber,
initial,
}: {
caseNumber: string
initial: boolean
}) {
const [aktiv, setAktiv] = useState(initial)
const [pending, startTransition] = useTransition()
return (
<button
type="button"
disabled={pending}
title={watchText.hint}
onClick={() =>
startTransition(async () => {
const vorher = aktiv
setAktiv(!vorher)
const ergebnis = await toggleWatchAction({ caseNumber })
if (!ergebnis.ok) setAktiv(vorher)
else setAktiv(ergebnis.watching)
})
}
className={`font-condensed text-2xs ${aktiv ? 'text-accent' : 'text-text-dim'}`}
>
{aktiv ? watchText.unwatch : watchText.watch}
</button>
)
}
+7 -1
View File
@@ -8,6 +8,8 @@ import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark' import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
import { CaseActions } from './CaseActions' import { CaseActions } from './CaseActions'
import { WatchToggle } from './WatchToggle'
import { isWatching } from '@/lib/cases/watch'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -39,7 +41,10 @@ export default async function CaseLayout({
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information. // Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
if (!akte) notFound() if (!akte) notFound()
const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) const [levels, beobachtet] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
isWatching(user.permissions, akte.id),
])
const level = levels.find((l) => l.level === akte.classification) const level = levels.find((l) => l.level === akte.classification)
const reiter = [ const reiter = [
@@ -56,6 +61,7 @@ export default async function CaseLayout({
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2"> <div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span> <span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} /> <StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
<WatchToggle caseNumber={akte.caseNumber} initial={beobachtet} />
</div> </div>
<h1 className="mt-2 font-condensed">{akte.title}</h1> <h1 className="mt-2 font-condensed">{akte.title}</h1>
<dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-xs text-text-dim"> <dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-xs text-text-dim">
+20
View File
@@ -8,6 +8,7 @@ import { AccessError, addEntry, changeStatus, createCase, reclassify } from '@/l
import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService' import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
import { addParticipant, removeParticipant } from '@/lib/cases/participants' import { addParticipant, removeParticipant } from '@/lib/cases/participants'
import { linkCases, unlinkCases } from '@/lib/cases/links' import { linkCases, unlinkCases } from '@/lib/cases/links'
import { toggleWatch } from '@/lib/cases/watch'
import { LINK_KINDS, PARTICIPANT_ROLES } from '@/lib/cases/terms' import { LINK_KINDS, PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
@@ -275,3 +276,22 @@ export async function unlinkCasesAction(input: unknown): Promise<ActionResult> {
return { ok: false, error: toMessage(error) } return { ok: false, error: toMessage(error) }
} }
} }
const watchSchema = z.object({ caseNumber: z.string().min(1) })
export type WatchResult = { ok: true; watching: boolean } | { ok: false; error: string }
/** Beobachten verschafft keinen Zugriff — nur was sichtbar ist, lässt sich merken. */
export async function toggleWatchAction(input: unknown): Promise<WatchResult> {
const user = await requireUser()
const parsed = watchSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
const { watching } = await toggleWatch(user.permissions, parsed.data.caseNumber)
revalidatePath('/uebersicht')
return { ok: true, watching }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
+4
View File
@@ -3,6 +3,8 @@ import { signOut } from '@/auth'
import { requireUser } from '@/lib/auth/current' import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { PaletteTrigger } from '@/components/palette/PaletteTrigger' import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
import { ThemeToggle } from '@/components/ThemeToggle'
import { Vitals } from '@/components/Vitals'
export default async function AppLayout({ children }: { children: React.ReactNode }) { export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser() const user = await requireUser()
@@ -16,6 +18,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<div className="min-h-dvh"> <div className="min-h-dvh">
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */} {/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
<PaletteTrigger /> <PaletteTrigger />
<Vitals />
<header className="border-b border-line"> <header className="border-b border-line">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3"> <div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3">
<Link href="/uebersicht" className="font-condensed text-text no-underline"> <Link href="/uebersicht" className="font-condensed text-text no-underline">
@@ -46,6 +49,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</nav> </nav>
<div className="ml-auto flex items-center gap-3 text-xs text-text-dim"> <div className="ml-auto flex items-center gap-3 text-xs text-text-dim">
<ThemeToggle />
<span className="font-mono">{user.displayName}</span> <span className="font-mono">{user.displayName}</span>
<form action={abmelden}> <form action={abmelden}>
<button type="submit" className="font-condensed text-accent"> <button type="submit" className="font-condensed text-accent">
+172 -77
View File
@@ -1,48 +1,47 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current' import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de' import { loadDashboard } from '@/lib/dashboard/load'
import { PERMISSIONS } from '@/lib/auth/permissions'
import { StampMark } from '@/components/StampMark'
import { pendingApprovals } from '@/lib/export/service' import { pendingApprovals } from '@/lib/export/service'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { Approvals } from '../export/Approvals' import { Approvals } from '../export/Approvals'
export const metadata: Metadata = { title: de.overview.heading } export const metadata: Metadata = { title: de.dashboard.heading }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und * Die Übersicht: vier Fragen, die man morgens hat.
* Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann, *
* warum jemand etwas sieht oder eben nicht. * Woran arbeite ich, was ist fällig, wo hat jemand geschrieben, was hat sich
* getan. Jede Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die
* Fallliste — das ist der Ort, an dem eine unachtsame Abfrage am ehesten eine
* Akte verrät, die man nicht sehen darf.
*/ */
export default async function OverviewPage() { export default async function DashboardPage() {
const user = await requireUser() const user = await requireUser()
const [roles, level] = await Promise.all([ const [daten, levels, offen] = await Promise.all([
db.userRole.findMany({ loadDashboard(user.permissions),
where: { userId: user.id }, db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
select: { discordRoleId: true }, pendingApprovals(user.permissions),
}),
db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }),
]) ])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const mappings = await db.roleMapping.findMany({
where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } },
orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }],
})
const byId = new Map(mappings.map((m) => [m.discordRoleId, m]))
const granted = [...user.permissions.permissions].sort()
// Offene Exportfreigaben stehen hier, weil die Leitung diese Seite ohnehin
// öffnet. Eine Anfrage, die niemand sieht, ist keine Freigabe.
const offen = await pendingApprovals(user.permissions)
const beschriftet = await beschrifte(offen) const beschriftet = await beschrifte(offen)
const heute = new Date()
return ( return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10"> <main className="mx-auto max-w-5xl px-6 py-10">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h1 className="font-condensed">{de.dashboard.heading}</h1>
<Link href="/zugang" className="font-condensed text-xs">
{de.dashboard.access}
</Link>
</div>
{beschriftet.length > 0 ? ( {beschriftet.length > 0 ? (
<section className="mb-10 border border-stamp-3 p-4"> <section className="mt-8 border border-stamp-3 p-4">
<h2 className="font-condensed text-stamp-3">{de.export.approvals}</h2> <h2 className="font-condensed text-stamp-3">{de.export.approvals}</h2>
<div className="mt-3"> <div className="mt-3">
<Approvals jobs={beschriftet} /> <Approvals jobs={beschriftet} />
@@ -50,63 +49,152 @@ export default async function OverviewPage() {
</section> </section>
) : null} ) : null}
<h1 className="font-condensed">{de.overview.heading}</h1> <div className="mt-10 grid gap-10 md:grid-cols-2">
<p className="mt-2 text-text-dim">{de.overview.intro}</p> <Block titel={de.dashboard.mine} leer={de.dashboard.mineEmpty} eintraege={daten.meine}>
{(akte) => <Akte akte={akte} levelName={levelName} />}
</Block>
<dl className="mt-8 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm"> <Block titel={de.dashboard.due} leer={de.dashboard.dueEmpty} eintraege={daten.fristen}>
<dt className="font-condensed text-text-dim">{de.overview.person}</dt> {(akte) => (
<dd>{user.displayName}</dd> <Akte
akte={akte}
levelName={levelName}
zusatz={
akte.dueAt
? akte.dueAt < heute
? de.dashboard.overdue
: de.dashboard.dueAt(akte.dueAt.toLocaleDateString('de-DE'))
: undefined
}
dringend={Boolean(akte.dueAt && akte.dueAt < heute)}
/>
)}
</Block>
<dt className="font-condensed text-text-dim">{de.overview.discordId}</dt> <section>
<dd className="font-mono text-xs">{user.id}</dd> <h2 className="font-condensed">{de.dashboard.unread}</h2>
{daten.ungelesen.length === 0 ? (
<p className="mt-4 text-text-dim">{de.dashboard.unreadEmpty}</p>
) : (
<ul className="mt-4 border-t border-line">
{daten.ungelesen.map((eintrag) => (
<li key={`${eintrag.scopeType}:${eintrag.scopeId}`} className="border-b border-line">
<Link
href={eintrag.href}
prefetch
className="flex items-baseline gap-4 py-2 text-text no-underline"
>
<span className="font-mono text-xs text-text-dim">{eintrag.label}</span>
<span className="grow font-condensed text-2xs text-accent">
{de.dashboard.unreadCount(eintrag.count)}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<dt className="font-condensed text-text-dim">{de.overview.clearance}</dt> <Block
<dd> titel={de.dashboard.watched}
<StampMark leer={de.dashboard.watchedEmpty}
level={user.permissions.clearanceLevel} eintraege={daten.beobachtet}
name={level?.name ?? de.common.unknown} >
/> {(akte) => <Akte akte={akte} levelName={levelName} />}
</dd> </Block>
</dl>
{user.permissions.isAdmin ? ( <section className="md:col-span-2">
<p className="mt-6 border-l-(length:--spacing-edge) border-stamp-5 pl-3 text-xs text-text-dim"> <h2 className="font-condensed">{de.dashboard.recent}</h2>
{de.overview.bootstrapAdmin} {daten.zuletzt.length === 0 ? (
</p> <p className="mt-4 text-text-dim">{de.dashboard.recentEmpty}</p>
) : null} ) : (
<ul className="mt-4 border-t border-line">
{daten.zuletzt.map((akte) => (
<li key={akte.id} className="border-b border-line">
<Akte
akte={akte}
levelName={levelName}
zusatz={akte.updatedAt.toLocaleDateString('de-DE')}
/>
</li>
))}
</ul>
)}
</section>
</div>
</main>
)
}
<h2 className="mt-10 font-condensed">{de.overview.roles}</h2> type Fall = {
{roles.length === 0 ? ( id: string
<p className="mt-2 text-text-dim">{de.overview.noRoles}</p> caseNumber: string
title: string
status: string
classification: number
updatedAt: Date
dueAt?: Date | null
}
function Block({
titel,
leer,
eintraege,
children,
}: {
titel: string
leer: string
eintraege: Fall[]
children: (akte: Fall) => React.ReactNode
}) {
return (
<section>
<h2 className="font-condensed">{titel}</h2>
{eintraege.length === 0 ? (
<p className="mt-4 text-text-dim">{leer}</p>
) : ( ) : (
<ul className="mt-3 space-y-1.5 text-sm"> <ul className="mt-4 border-t border-line">
{roles.map((role) => { {eintraege.map((akte) => (
const mapping = byId.get(role.discordRoleId) <li key={akte.id} className="border-b border-line">
return ( {children(akte)}
<li key={role.discordRoleId} className="flex flex-wrap items-baseline gap-2"> </li>
<span>{mapping?.label ?? role.discordRoleId}</span>
<span className="font-condensed text-2xs text-text-dim">
{mapping
? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}`
: de.overview.unmapped}
</span>
</li>
)
})}
</ul>
)}
<h2 className="mt-10 font-condensed">{de.overview.permissions}</h2>
{granted.length === 0 ? (
<p className="mt-2 text-text-dim">{de.overview.noPermissions}</p>
) : (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-xs">
{PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => (
<li key={permission}>{permission}</li>
))} ))}
</ul> </ul>
)} )}
</main> </section>
)
}
function Akte({
akte,
levelName,
zusatz,
dringend,
}: {
akte: Fall
levelName: Map<number, string>
zusatz?: string
dringend?: boolean
}) {
return (
<Link
href={`/faelle/${akte.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
<span className="grow">{akte.title}</span>
{zusatz ? (
<span className={`font-condensed text-2xs ${dringend ? 'text-stamp-4' : 'text-text-dim'}`}>
{zusatz}
</span>
) : null}
<StampMark
level={akte.classification}
name={levelName.get(akte.classification) ?? de.common.unknown}
/>
</Link>
) )
} }
@@ -118,7 +206,14 @@ export default async function OverviewPage() {
async function beschrifte( async function beschrifte(
jobs: Awaited<ReturnType<typeof pendingApprovals>>, jobs: Awaited<ReturnType<typeof pendingApprovals>>,
): Promise< ): Promise<
{ id: string; kind: string; label: string; requestedBy: string; clearanceAtTime: number; createdAt: string }[] {
id: string
kind: string
label: string
requestedBy: string
clearanceAtTime: number
createdAt: string
}[]
> { > {
if (jobs.length === 0) return [] if (jobs.length === 0) return []
+95
View File
@@ -0,0 +1,95 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { PERMISSIONS } from '@/lib/auth/permissions'
import { StampMark } from '@/components/StampMark'
export const metadata: Metadata = { title: de.dashboard.access }
export const dynamic = 'force-dynamic'
/**
* Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und
* Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann,
* warum jemand etwas sieht oder eben nicht.
*/
export default async function OverviewPage() {
const user = await requireUser()
const [roles, level] = await Promise.all([
db.userRole.findMany({
where: { userId: user.id },
select: { discordRoleId: true },
}),
db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }),
])
const mappings = await db.roleMapping.findMany({
where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } },
orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }],
})
const byId = new Map(mappings.map((m) => [m.discordRoleId, m]))
const granted = [...user.permissions.permissions].sort()
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<h1 className="font-condensed">{de.overview.heading}</h1>
<p className="mt-2 text-text-dim">{de.overview.intro}</p>
<dl className="mt-8 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
<dt className="font-condensed text-text-dim">{de.overview.person}</dt>
<dd>{user.displayName}</dd>
<dt className="font-condensed text-text-dim">{de.overview.discordId}</dt>
<dd className="font-mono text-xs">{user.id}</dd>
<dt className="font-condensed text-text-dim">{de.overview.clearance}</dt>
<dd>
<StampMark
level={user.permissions.clearanceLevel}
name={level?.name ?? de.common.unknown}
/>
</dd>
</dl>
{user.permissions.isAdmin ? (
<p className="mt-6 border-l-(length:--spacing-edge) border-stamp-5 pl-3 text-xs text-text-dim">
{de.overview.bootstrapAdmin}
</p>
) : null}
<h2 className="mt-10 font-condensed">{de.overview.roles}</h2>
{roles.length === 0 ? (
<p className="mt-2 text-text-dim">{de.overview.noRoles}</p>
) : (
<ul className="mt-3 space-y-1.5 text-sm">
{roles.map((role) => {
const mapping = byId.get(role.discordRoleId)
return (
<li key={role.discordRoleId} className="flex flex-wrap items-baseline gap-2">
<span>{mapping?.label ?? role.discordRoleId}</span>
<span className="font-condensed text-2xs text-text-dim">
{mapping
? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}`
: de.overview.unmapped}
</span>
</li>
)
})}
</ul>
)}
<h2 className="mt-10 font-condensed">{de.overview.permissions}</h2>
{granted.length === 0 ? (
<p className="mt-2 text-text-dim">{de.overview.noPermissions}</p>
) : (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-xs">
{PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => (
<li key={permission}>{permission}</li>
))}
</ul>
)}
</main>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth/current'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const METRIKEN = new Set(['LCP', 'INP', 'CLS', 'TTFB', 'FCP'])
const BEWERTUNGEN = new Set(['good', 'needs-improvement', 'poor'])
/**
* Nimmt Ladewerte aus dem Browser entgegen.
*
* **Was hier ankommt, wird geprüft und beschnitten**, bevor es in die
* Datenbank geht: nur bekannte Metriken, nur plausible Zahlen, und die Route
* als Muster statt als konkreter Pfad. Ein Endpunkt, der ungeprüft
* Zeichenketten aus dem Browser speichert, ist ein Ablagefach für Fremde.
*
* Der Nutzer wird **nicht** mitgeschrieben. Wer wann welche Akte geöffnet hat,
* steht im Zugriffsprotokoll — dort gehört es hin, dort ist es geregelt, und
* eine zweite Spur daneben wäre eine Überwachungsfunktion ohne Auftrag.
*/
export async function POST(request: Request): Promise<Response> {
// Angemeldet sein muss man trotzdem: sonst ist es ein offener Schreibzugang.
const user = await getCurrentUser()
if (!user) return new NextResponse(null, { status: 404 })
let daten: unknown
try {
daten = await request.json()
} catch {
return new NextResponse(null, { status: 400 })
}
const eintrag = daten as Record<string, unknown>
const metric = String(eintrag.metric ?? '')
const rating = String(eintrag.rating ?? '')
const value = Number(eintrag.value)
const route = String(eintrag.route ?? '').slice(0, 120)
if (!METRIKEN.has(metric) || !BEWERTUNGEN.has(rating)) {
return new NextResponse(null, { status: 400 })
}
// CLS liegt bei 0..1, die Zeiten in Millisekunden. Alles darüber ist Unfug
// oder ein Messfehler und würde nur den Durchschnitt verderben.
if (!Number.isFinite(value) || value < 0 || value > 600_000) {
return new NextResponse(null, { status: 400 })
}
try {
await db.webVital.create({ data: { metric, value, route, rating } })
} catch (error) {
console.error('[vitals] nicht gespeichert', error)
}
// Der Browser sendet das per `sendBeacon` und interessiert sich nicht für
// die Antwort.
return new NextResponse(null, { status: 204 })
}
+51 -5
View File
@@ -75,15 +75,54 @@
--spacing-edge: 3px; --spacing-edge: 3px;
} }
/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */ /* Helles Thema.
*
* Es tauscht **nur die semantische Ebene**, nie die Grundpalette und nie die
* Klassifizierungsfarben: eine Stufe muss in beiden Themen dieselbe Farbe
* haben, sonst lernt man sie zweimal. Siehe docs/10-design-system.md.
*
* Kein warmes Cremeweiss, sondern ein leicht kühles Papierweiss — dieselbe
* Anmutung wie das dunkle Thema, nur andersherum. Die Kontraste sind auf
* mindestens 4,5:1 für Fliesstext ausgelegt. */
[data-theme='light'] { [data-theme='light'] {
--color-surface: #eceeea; --color-surface: #eceeea;
--color-panel: #e2e5e1; --color-panel: #e2e5e1;
--color-raised: #f4f5f2; --color-raised: #f7f8f5;
--color-line: #b9beb8; --color-line: #b0b6ae;
--color-text: #1b1f21; --color-text: #1b1f21;
--color-text-dim: #545c58; --color-text-dim: #4d5551;
--color-accent: #3d5f70; --color-accent: #35586a;
}
/* Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, damit
Stempel und Kante lesbar bleiben. Der Farbton bleibt derselbe. */
[data-theme='light'] {
--color-stamp-0: #6b716d;
--color-stamp-1: #4a6577;
--color-stamp-2: #34694f;
--color-stamp-3: #856024;
--color-stamp-4: #8d3c28;
--color-stamp-5: #5c4270;
}
/* Ohne ausdrückliche Wahl folgt die Anwendung dem System. Der Auslieferungs-
zustand bleibt dunkel: `prefers-color-scheme: light` muss gesetzt sein. */
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--color-surface: #eceeea;
--color-panel: #e2e5e1;
--color-raised: #f7f8f5;
--color-line: #b0b6ae;
--color-text: #1b1f21;
--color-text-dim: #4d5551;
--color-accent: #35586a;
--color-stamp-0: #6b716d;
--color-stamp-1: #4a6577;
--color-stamp-2: #34694f;
--color-stamp-3: #856024;
--color-stamp-4: #8d3c28;
--color-stamp-5: #5c4270;
}
} }
@layer base { @layer base {
@@ -420,6 +459,13 @@
color: #000; color: #000;
font-size: 10pt; font-size: 10pt;
line-height: 1.45; line-height: 1.45;
/* Am Bildschirm auf schmalen Geraeten: die Tabellen darin scrollen in
sich, statt die Seite breiter zu machen. Auf Papier greift das nicht. */
overflow-x: auto;
}
.print-meta {
min-width: 0;
} }
.print-sheet h1 { .print-sheet h1 {
+19 -1
View File
@@ -1,5 +1,7 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { headers } from 'next/headers'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { THEME_KEY } from '@/components/ThemeToggle'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -14,10 +16,26 @@ export const viewport: Viewport = {
initialScale: 1, initialScale: 1,
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { /**
* Setzt das gewählte Thema, **bevor** das erste Mal gemalt wird.
*
* Ohne das blitzt beim Laden kurz das falsche Thema auf. Es ist die eine
* Stelle mit `dangerouslySetInnerHTML`, und der Inhalt ist eine Konstante aus
* diesem Modul — kein Nutzertext. Das Verbot aus docs/07 gilt Nutzerinhalten.
*/
const THEME_SCRIPT =
`try{var t=localStorage.getItem(${JSON.stringify(THEME_KEY)});` +
`if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}`
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Die strikte Inhaltsrichtlinie erlaubt nur Skripte mit passendem Nonce,
// siehe middleware.ts.
const nonce = (await headers()).get('x-nonce') ?? undefined
return ( return (
<html lang="de"> <html lang="de">
<head> <head>
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */} {/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
<link <link
rel="preload" rel="preload"
+59
View File
@@ -0,0 +1,59 @@
'use client'
import { useEffect, useState } from 'react'
import { themeText } from '@/lib/i18n/shell'
type Thema = 'dark' | 'light' | 'system'
export const THEME_KEY = 'afis:thema'
/**
* Umschalter zwischen dunklem und hellem Thema.
*
* Drei Zustände, nicht zwei: „System" ist der Auslieferungszustand und folgt
* `prefers-color-scheme`. Wer ausdrücklich wählt, bekommt seine Wahl auf
* diesem Gerät gemerkt — nicht am Konto, denn es ist eine Eigenschaft des
* Bildschirms, an dem jemand sitzt, nicht der Person.
*
* Gesetzt wird das Attribut schon vor dem ersten Malen, siehe das Skript im
* Wurzel-Layout. Sonst blitzt beim Laden das falsche Thema auf.
*/
export function ThemeToggle() {
const [thema, setThema] = useState<Thema>('system')
useEffect(() => {
try {
const gespeichert = localStorage.getItem(THEME_KEY)
if (gespeichert === 'dark' || gespeichert === 'light') setThema(gespeichert)
} catch {
// Privates Fenster: dann eben ohne Merken.
}
}, [])
function waehlen(neu: Thema) {
setThema(neu)
try {
if (neu === 'system') localStorage.removeItem(THEME_KEY)
else localStorage.setItem(THEME_KEY, neu)
} catch {
// s. o.
}
const wurzel = document.documentElement
if (neu === 'system') wurzel.removeAttribute('data-theme')
else wurzel.setAttribute('data-theme', neu)
}
return (
<select
value={thema}
onChange={(event) => waehlen(event.target.value as Thema)}
aria-label={themeText.label}
className="border border-line bg-raised px-2 py-0.5 text-2xs"
>
<option value="system">{themeText.system}</option>
<option value="dark">{themeText.dark}</option>
<option value="light">{themeText.light}</option>
</select>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
/**
* Misst die Ladewerte und schickt sie an `/api/vitals`.
*
* Gesendet wird mit `sendBeacon`: das überlebt den Seitenwechsel, blockiert
* nichts und braucht keine offene Verbindung. Die Bibliothek wird erst
* geladen, wenn der Browser Zeit hat — die Messung darf nicht selbst zur
* Ladezeit beitragen.
*
* Übertragen wird das **Routenmuster**, nicht der konkrete Pfad: eine
* Messreihe soll sagen, ob Fallakten langsam sind, nicht welche.
*/
export function Vitals() {
const pathname = usePathname()
useEffect(() => {
let abgebrochen = false
const starten = () => {
void import('web-vitals').then(({ onLCP, onINP, onCLS, onTTFB, onFCP }) => {
if (abgebrochen) return
const senden = (metric: { name: string; value: number; rating: string }) => {
const nutzlast = JSON.stringify({
metric: metric.name,
value: metric.value,
rating: metric.rating,
route: routenMuster(pathname),
})
try {
navigator.sendBeacon?.('/api/vitals', new Blob([nutzlast], { type: 'application/json' }))
} catch {
// Messwerte sind kein Grund, irgendetwas scheitern zu lassen.
}
}
onLCP(senden)
onINP(senden)
onCLS(senden)
onTTFB(senden)
onFCP(senden)
})
}
if ('requestIdleCallback' in window) {
const id = requestIdleCallback(starten)
return () => {
abgebrochen = true
cancelIdleCallback(id)
}
}
const timer = setTimeout(starten, 2000)
return () => {
abgebrochen = true
clearTimeout(timer)
}
}, [pathname])
return null
}
/**
* Aus `/faelle/AfiS-2026-0042/dokumente` wird `/faelle/[caseNumber]/dokumente`.
*
* Die Kennungen dürfen nicht mit: sonst entstünde aus einer Messreihe eine
* Spur, wer wann welche Akte geöffnet hat.
*/
function routenMuster(pfad: string): string {
return pfad
.replace(/\/faelle\/[^/]+/, '/faelle/[caseNumber]')
.replace(/\/dokumente\/[^/]+/, '/dokumente/[documentNumber]')
.replace(/\/personalakten\/[^/]+/, '/personalakten/[id]')
.replace(/\/kanaele\/[^/]+/, '/kanaele/[key]')
.slice(0, 120)
}
+23
View File
@@ -74,6 +74,29 @@ test.describe('Druckansichten', () => {
} }
}) })
test.describe('Schmale Schirme', () => {
// Die Seite darf auf keinem Gerät waagerecht scrollen. Ein Formular, das
// seitlich hinausragt, ist auf dem Telefon unbedienbar — und genau dort
// wird eine Akte unterwegs gelesen. Siehe docs/09-performance-und-ux.md.
for (const pfad of ['/anmelden', '/anmelden/fehler?grund=keine-afis-rolle']) {
test(`${pfad} laeuft nicht seitlich ueber`, async ({ page }) => {
await page.goto(pfad)
const ueberlauf = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(ueberlauf).toBeLessThanOrEqual(1)
})
}
test('Druckansicht laeuft nicht seitlich ueber', async ({ page }) => {
await page.goto('/faelle/AfiS-2026-0001/druck')
const ueberlauf = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(ueberlauf).toBeLessThanOrEqual(1)
})
})
test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => { test('Fehlerseite benennt den Grund der Ablehnung', async ({ page }) => {
await page.goto('/anmelden/fehler?grund=keine-afis-rolle') await page.goto('/anmelden/fehler?grund=keine-afis-rolle')
await expect(page.getByRole('heading', { level: 1 })).toHaveText( await expect(page.getByRole('heading', { level: 1 })).toHaveText(
+56
View File
@@ -0,0 +1,56 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { AccessError } from '@/lib/errors'
import { loadVisibleCase } from '@/lib/cases/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
/**
* Beobachtungsliste.
*
* Wer eine Akte beobachtet, sieht sie auf der Übersicht und bekommt ihre
* Meldungen, auch ohne beteiligt zu sein. **Zugriff verschafft das nicht**:
* beobachten kann man nur, was man ohnehin sehen darf, und fällt die Akte
* später über die eigene Stufe, verschwindet sie auch aus der Liste — die
* Abfrage läuft durch dieselbe Sichtbarkeitsprüfung wie die Fallliste.
*/
export async function listWatched(p: EffectivePermissions, take = 20) {
return db.case.findMany({
where: { AND: [visibleCaseWhere(p), { watches: { some: { userId: p.userId } } }] },
orderBy: [{ updatedAt: 'desc' }],
take,
select: {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
},
})
}
export async function isWatching(p: EffectivePermissions, caseId: string): Promise<boolean> {
const eintrag = await db.watch.findUnique({
where: { userId_caseId: { userId: p.userId, caseId } },
select: { userId: true },
})
return eintrag !== null
}
/** Umschalten. Gibt zurück, ob danach beobachtet wird. */
export async function toggleWatch(
p: EffectivePermissions,
caseNumber: string,
): Promise<{ watching: boolean }> {
const akte = await loadVisibleCase(p, caseNumber)
if (!akte) throw new AccessError('not-found')
const vorhanden = await isWatching(p, akte.id)
if (vorhanden) {
await db.watch.delete({ where: { userId_caseId: { userId: p.userId, caseId: akte.id } } })
return { watching: false }
}
await db.watch.create({ data: { userId: p.userId, caseId: akte.id } })
return { watching: true }
}
+169
View File
@@ -0,0 +1,169 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { listWatched } from '@/lib/cases/watch'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Was auf der Übersicht steht.
*
* Vier Fragen, die man morgens hat: woran arbeite ich, was ist fällig, wo hat
* jemand geschrieben, was hat sich getan. Jede läuft durch
* `visibleCaseWhere` — die Übersicht ist der Ort, an dem eine unachtsame
* Abfrage am ehesten eine Akte verrät, die man nicht sehen darf.
*/
const AKTE = {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
dueAt: true,
} satisfies Prisma.CaseSelect
/** Fristen innerhalb dieser Anzahl Tage gelten als „steht an". */
const FRIST_TAGE = 14
export async function loadDashboard(p: EffectivePermissions) {
const sichtbar = visibleCaseWhere(p)
const jetzt = new Date()
const frist = new Date(jetzt.getTime() + FRIST_TAGE * 86_400_000)
const [meine, fristen, zuletzt, beobachtet, ungelesen] = await Promise.all([
db.case.findMany({
where: {
AND: [
sichtbar,
{ participants: { some: { userId: p.userId } } },
{ status: { in: ['DRAFT', 'OPEN', 'REVIEW'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: {
AND: [
sichtbar,
{ dueAt: { not: null, lte: frist } },
{ status: { notIn: ['CLOSED', 'ARCHIVED', 'SEALED'] } },
],
},
orderBy: { dueAt: 'asc' },
take: 10,
select: AKTE,
}),
db.case.findMany({
where: sichtbar,
orderBy: { updatedAt: 'desc' },
take: 8,
select: AKTE,
}),
listWatched(p, 10),
ungeleseneChats(p),
])
return { meine, fristen, zuletzt, beobachtet, ungelesen }
}
export type UnreadChat = {
scopeType: 'case' | 'channel'
scopeId: string
label: string
href: string
count: number
}
/**
* Wo seit dem letzten Besuch geschrieben wurde.
*
* Gezählt wird über `ReadMarker`. Nur an Orten, an denen der Nutzer beteiligt
* ist, die er beobachtet, oder in Kanälen, die er sehen darf — eine Zählung
* über alle sichtbaren Akten wäre auf der Übersicht nur Lärm. Eigene
* Nachrichten zählen nicht mit.
*/
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
const marken = await db.readMarker.findMany({
where: { userId: p.userId },
select: { scopeType: true, scopeId: true, lastReadAt: true },
})
const markeVon = new Map(marken.map((m) => [`${m.scopeType}:${m.scopeId}`, m.lastReadAt]))
const [meineAkten, kanaele] = await Promise.all([
db.case.findMany({
where: {
AND: [
visibleCaseWhere(p),
{
OR: [
{ participants: { some: { userId: p.userId } } },
{ watches: { some: { userId: p.userId } } },
],
},
],
},
select: { id: true, caseNumber: true },
take: 50,
}),
db.channel.findMany({
where: {
isArchived: false,
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
},
select: { id: true, key: true },
take: 50,
}),
])
const orte = [
...meineAkten.map((akte) => ({
scopeType: 'case' as const,
scopeId: akte.id,
label: akte.caseNumber,
href: `/faelle/${akte.caseNumber}`,
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
})),
...kanaele.map((kanal) => ({
scopeType: 'channel' as const,
scopeId: kanal.id,
label: `#${kanal.key}`,
href: `/kanaele/${kanal.key}`,
wo: { channelId: kanal.id } as Prisma.ChatMessageWhereInput,
})),
]
if (orte.length === 0) return []
const zaehler = await Promise.all(
orte.map(async (ort) => {
const seit = markeVon.get(`${ort.scopeType}:${ort.scopeId}`)
const count = await db.chatMessage.count({
where: {
AND: [
visibleMessageWhere(p),
ort.wo,
{ withdrawnAt: null },
{ authorId: { not: p.userId } },
...(seit ? [{ createdAt: { gt: seit } }] : []),
],
},
})
return { ort, count }
}),
)
return zaehler
.filter((eintrag) => eintrag.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10)
.map(({ ort, count }) => ({
scopeType: ort.scopeType,
scopeId: ort.scopeId,
label: ort.label,
href: ort.href,
count,
}))
}
+74
View File
@@ -0,0 +1,74 @@
import { db } from '@/lib/db'
/**
* Zusammenfassung der Ladewerte für den Adminbereich.
*
* Ausgewertet wird der **Median und das 75. Perzentil**, nicht der Mittelwert:
* ein einzelner Ausreisser auf einem alten Laptop soll die Zahl nicht
* bestimmen. Zielwerte aus docs/09-performance-und-ux.md.
*/
const TAGE = 7
export type VitalSummary = {
metric: string
count: number
median: number
p75: number
}
export async function summarizeVitals(): Promise<{
metriken: VitalSummary[]
routen: { route: string; metric: string; p75: number; count: number }[]
}> {
const seit = new Date(Date.now() - TAGE * 86_400_000)
const metriken = await db.$queryRaw<
{ metric: string; count: bigint; median: number; p75: number }[]
>`
SELECT "metric",
count(*) AS count,
percentile_cont(0.5) WITHIN GROUP (ORDER BY "value") AS median,
percentile_cont(0.75) WITHIN GROUP (ORDER BY "value") AS p75
FROM "WebVital"
WHERE "at" >= ${seit}
GROUP BY "metric"
ORDER BY "metric"
`
const routen = await db.$queryRaw<
{ route: string; metric: string; p75: number; count: bigint }[]
>`
SELECT "route", "metric",
percentile_cont(0.75) WITHIN GROUP (ORDER BY "value") AS p75,
count(*) AS count
FROM "WebVital"
WHERE "at" >= ${seit} AND "metric" = 'LCP'
GROUP BY "route", "metric"
HAVING count(*) >= 3
ORDER BY p75 DESC
LIMIT 8
`
return {
metriken: metriken.map((zeile) => ({
metric: zeile.metric,
count: Number(zeile.count),
median: zeile.median,
p75: zeile.p75,
})),
routen: routen.map((zeile) => ({
route: zeile.route,
metric: zeile.metric,
p75: zeile.p75,
count: Number(zeile.count),
})),
}
}
/** Millisekunden lesbar machen; CLS ist dimensionslos. */
export function formatVital(metric: string, wert: number): string {
if (metric === 'CLS') return wert.toFixed(3)
if (wert >= 1000) return `${(wert / 1000).toFixed(2)} s`
return `${Math.round(wert)} ms`
}
+21
View File
@@ -85,3 +85,24 @@ export const settingsText = {
OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt', OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt',
} as Record<string, string>, } as Record<string, string>,
} as const } as const
export const vitalsText = {
heading: 'Ladewerte',
intro:
'Gemessen in den Browsern der Nutzer, letzte sieben Tage. Nur Zahlen und das Routenmuster — wer wann welche Akte geöffnet hat, steht im Zugriffsprotokoll.',
empty: 'Noch nichts gemessen.',
metric: 'Wert',
median: 'Median',
p75: '75. Perzentil',
count: 'Messungen',
target: 'Ziel',
worstRoutes: 'Langsamste Routen',
route: 'Route',
targets: {
LCP: 'unter 1,5 s',
INP: 'unter 200 ms',
CLS: 'unter 0,05',
TTFB: 'unter 500 ms',
FCP: 'unter 1,0 s',
} as Record<string, string>,
} as const
+8 -1
View File
@@ -23,6 +23,9 @@ import {
notifyText, notifyText,
searchText, searchText,
paletteText, paletteText,
themeText,
dashboardText,
watchText,
} from '@/lib/i18n/shell' } from '@/lib/i18n/shell'
import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common' import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common'
import { import {
@@ -36,7 +39,7 @@ import { chatText, channelText } from '@/lib/i18n/chat'
import { personnelText, assignmentText } from '@/lib/i18n/people' import { personnelText, assignmentText } from '@/lib/i18n/people'
import { documentText, attachmentText } from '@/lib/i18n/documents' import { documentText, attachmentText } from '@/lib/i18n/documents'
import { exportText } from '@/lib/i18n/export' import { exportText } from '@/lib/i18n/export'
import { roleAdminText, templateAdminText, settingsText } from '@/lib/i18n/admin' import { roleAdminText, templateAdminText, settingsText, vitalsText } from '@/lib/i18n/admin'
export const de = { export const de = {
app: appText, app: appText,
@@ -62,8 +65,12 @@ export const de = {
errors: errorsText, errors: errorsText,
templateAdmin: templateAdminText, templateAdmin: templateAdminText,
settings: settingsText, settings: settingsText,
vitals: vitalsText,
health: healthText, health: healthText,
notify: notifyText, notify: notifyText,
search: searchText, search: searchText,
palette: paletteText, palette: paletteText,
theme: themeText,
dashboard: dashboardText,
watch: watchText,
} as const } as const
+31
View File
@@ -56,6 +56,30 @@ export const authText = {
backToSignIn: 'Zurück zur Anmeldung', backToSignIn: 'Zurück zur Anmeldung',
} as const } as const
export const dashboardText = {
heading: 'Übersicht',
mine: 'Meine Fälle',
mineEmpty: 'Du bist an keiner offenen Akte beteiligt.',
due: 'Fristen',
dueEmpty: 'Nichts steht an.',
dueAt: (datum: string) => `fällig ${datum}`,
overdue: 'überfällig',
unread: 'Ungelesen',
unreadEmpty: 'Nichts Neues.',
unreadCount: (n: number) => (n === 1 ? '1 Nachricht' : `${n} Nachrichten`),
watched: 'Beobachtet',
watchedEmpty: 'Keine Akte beobachtet.',
recent: 'Zuletzt geändert',
recentEmpty: 'Noch keine Akte.',
access: 'Dein Zugang',
} as const
export const watchText = {
watch: 'Beobachten',
unwatch: 'Nicht mehr beobachten',
hint: 'Beobachtete Akten stehen auf deiner Übersicht und melden sich, auch ohne Beteiligung.',
} as const
export const overviewText = { export const overviewText = {
heading: 'Dein Zugang', heading: 'Dein Zugang',
intro: 'Was das System über dich weiss und was daraus folgt.', intro: 'Was das System über dich weiss und was daraus folgt.',
@@ -154,3 +178,10 @@ export const paletteText = {
{ href: '/einstellungen', label: 'Benachrichtigungen' }, { href: '/einstellungen', label: 'Benachrichtigungen' },
], ],
} as const } as const
export const themeText = {
label: 'Thema',
system: 'Wie das System',
dark: 'Dunkel',
light: 'Hell',
} as const
+1
View File
@@ -22,6 +22,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "0.34.4", "sharp": "0.34.4",
"web-vitals": "4.2.4",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const css = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8')
/**
* Das helle Thema ist verpflichtend, siehe docs/10-design-system.md. Diese
* Tests halten die zwei Eigenschaften fest, die es leicht verliert: dass es
* wirklich jede semantische Farbe tauscht, und dass eine Freigabestufe in
* beiden Themen dieselbe Farbe behält — sonst lernt man sie zweimal.
*/
describe('Helles Thema', () => {
const semantisch = [
'--color-surface',
'--color-panel',
'--color-raised',
'--color-line',
'--color-text',
'--color-text-dim',
'--color-accent',
]
function block(selektor: string): string {
const index = css.indexOf(selektor)
expect(index, `${selektor} fehlt`).toBeGreaterThan(-1)
return css.slice(index, css.indexOf('}', index))
}
it('tauscht jede semantische Farbe', () => {
const hell = block("[data-theme='light'] {")
for (const token of semantisch) {
expect(hell, `${token} fehlt im hellen Thema`).toContain(token)
}
})
it('folgt ohne Wahl dem System und bleibt sonst dunkel', () => {
// Der Auslieferungszustand ist dunkel: das helle Thema greift nur bei
// prefers-color-scheme: light oder ausdrücklicher Wahl.
expect(css).toContain('@media (prefers-color-scheme: light)')
expect(css).toContain(":root:not([data-theme='dark'])")
})
it('behält die Klassifizierungsfarben als eigenes System', () => {
// Die Farbtöne dürfen für hellen Grund nachgedunkelt werden, aber es muss
// sie geben — sonst verschwindet der Stempel auf hellem Papier.
const hell = css.slice(css.indexOf("[data-theme='light']"))
for (const stufe of [0, 1, 2, 3, 4, 5]) {
expect(hell).toContain(`--color-stamp-${stufe}`)
}
})
it('lässt die Grundpalette unangetastet', () => {
// `--ink-*` und `--paper*` sind die Rohfarben. Würde das helle Thema sie
// umdefinieren, hinge daran alles, was sie direkt benutzt.
const hell = block("[data-theme='light'] {")
expect(hell).not.toContain('--color-ink-')
expect(hell).not.toContain('--color-paper')
})
})
+133
View File
@@ -0,0 +1,133 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { loadDashboard } from '@/lib/dashboard/load'
import { listWatched, toggleWatch } from '@/lib/cases/watch'
import { AccessError } from '@/lib/errors'
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
/**
* Die Übersicht ist der Ort, an dem eine unachtsame Abfrage am ehesten eine
* Akte verrät: fünf Listen auf einer Seite, jede mit eigenem Filter. Geprüft
* wird deshalb jede einzeln gegen eine zu hoch eingestufte Akte.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
const marke = `dash-${Date.now()}`
function perms(
userId: string,
clearanceLevel: number,
options: { isAdmin?: boolean; permissions?: Permission[] } = {},
): EffectivePermissions {
return {
userId,
clearanceLevel,
permissions: new Set<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
suite('Übersicht und Beobachtungsliste', () => {
const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` }
let offenNummer = ''
let geheimId = ''
let geheimNummer = ''
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
for (const id of Object.values(nutzer)) {
await db.user.create({ data: { id, username: id, displayName: id } })
}
const gemeinsam = {
templateId: vorlage.id,
templateKey: vorlage.key,
createdById: nutzer.stufe4,
}
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Dash-2026-${String(Date.now()).slice(-4)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
dueAt: new Date(Date.now() + 2 * 86_400_000),
},
})
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Dghm-2026-${String(Date.now()).slice(-4)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
// Auch eine Frist: sie darf in keiner Liste auftauchen.
dueAt: new Date(Date.now() + 86_400_000),
},
})
offenNummer = offen.caseNumber
geheimId = geheim.id
geheimNummer = geheim.caseNumber
// Der Stufe-1-Nutzer ist an beiden Akten beteiligt — an der geheimen aber
// nicht über sein Konto, sondern gar nicht. Er darf sie nicht sehen.
await db.caseParticipant.create({
data: { caseId: offen.id, userId: nutzer.stufe1, role: 'INVESTIGATOR' },
})
})
afterAll(async () => {
await db.$disconnect()
})
it('hält eine zu hoch eingestufte Akte aus jeder Liste heraus', async () => {
const daten = await loadDashboard(perms(nutzer.stufe1, 1))
const alle = [
...daten.meine,
...daten.fristen,
...daten.zuletzt,
...daten.beobachtet,
].map((akte) => akte.id)
expect(alle).not.toContain(geheimId)
// Und die eigene ist da, sonst prüft der Test nichts.
expect(daten.meine.map((a) => a.caseNumber)).toContain(offenNummer)
expect(daten.fristen.map((a) => a.caseNumber)).toContain(offenNummer)
})
it('zeigt der Stufe 4 die Frist der geheimen Akte', async () => {
const daten = await loadDashboard(perms(nutzer.stufe4, 4))
expect(daten.fristen.map((a) => a.id)).toContain(geheimId)
})
it('lässt nur beobachten, was man sehen darf', async () => {
// Beobachten ist kein Weg an der Stufe vorbei.
await expect(toggleWatch(perms(nutzer.stufe1, 1), geheimNummer)).rejects.toThrow(AccessError)
const an = await toggleWatch(perms(nutzer.stufe1, 1), offenNummer)
expect(an.watching).toBe(true)
expect((await listWatched(perms(nutzer.stufe1, 1))).map((a) => a.caseNumber)).toContain(
offenNummer,
)
const aus = await toggleWatch(perms(nutzer.stufe1, 1), offenNummer)
expect(aus.watching).toBe(false)
})
it('nimmt eine beobachtete Akte aus der Liste, sobald sie zu hoch steigt', async () => {
const p = perms(nutzer.stufe4, 4)
await toggleWatch(p, geheimNummer)
expect((await listWatched(p)).map((a) => a.id)).toContain(geheimId)
// Derselbe Beobachtungseintrag, aber jetzt aus Sicht einer niedrigeren
// Stufe: die Liste läuft durch dieselbe Sichtbarkeitsprüfung.
const abgestiegen = perms(nutzer.stufe4, 1)
expect((await listWatched(abgestiegen)).map((a) => a.id)).not.toContain(geheimId)
})
})