Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben

Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.

Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.

Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.

Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.

Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.

Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-01 22:57:38 +02:00
co-authored by Claude Opus 5
parent e8c5efc214
commit 1e5a470daf
19 changed files with 1600 additions and 16 deletions
+198
View File
@@ -0,0 +1,198 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { visibleEntryWhere } from '@/lib/access/caseFilter'
import { loadVisibleCase } from '@/lib/cases/service'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
import { EntryComposer } from './EntryComposer'
export const dynamic = 'force-dynamic'
export async function generateMetadata({
params,
}: {
params: Promise<{ caseNumber: string }>
}): Promise<Metadata> {
const { caseNumber } = await params
return { title: caseNumber }
}
/**
* Der Verlauf hängt in einer eigenen Suspense-Grenze und streamt nach. Die
* Kopfdaten stehen sofort, der Nutzer liest schon, während der Rest lädt.
* Siehe docs/09-performance-und-ux.md.
*/
async function Timeline({ caseId, userId, clearanceLevel, isAdmin }: {
caseId: string
userId: string
clearanceLevel: number
isAdmin: boolean
}) {
const entries = await db.caseEntry.findMany({
where: {
AND: [
visibleEntryWhere({
userId,
clearanceLevel,
isAdmin,
permissions: new Set(),
hasMembership: true,
}),
{ caseId },
],
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 100,
select: {
id: true,
type: true,
body: true,
minClearance: true,
authorId: true,
createdAt: true,
version: true,
editedAt: true,
editVisible: true,
retractedAt: true,
retractionReason: true,
},
})
if (entries.length === 0) {
return <p className="text-text-dim">{de.entry.empty}</p>
}
const authors = await db.user.findMany({
where: { id: { in: [...new Set(entries.map((e) => e.authorId))] } },
select: { id: true, displayName: true },
})
const nameOf = new Map(authors.map((a) => [a.id, a.displayName]))
return (
<ol className="timeline">
{entries.map((entry) => (
<li key={entry.id} className="pb-6">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xs text-text-dim">
<span className="font-condensed">{de.entry.types[entry.type]}</span>
<span className="font-mono">{nameOf.get(entry.authorId) ?? entry.authorId}</span>
<time dateTime={entry.createdAt.toISOString()}>
{entry.createdAt.toLocaleString('de-DE')}
</time>
{entry.editVisible && entry.editedAt ? (
<span className="font-condensed">{de.entry.edited(entry.version)}</span>
) : null}
</div>
{entry.retractedAt ? (
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm text-text-dim">
{de.entry.retracted(
entry.retractedAt.toLocaleDateString('de-DE'),
nameOf.get(entry.authorId) ?? entry.authorId,
entry.retractionReason ?? '',
)}
</p>
) : (
<p className="mt-1 max-w-(--container-measure) whitespace-pre-wrap text-sm">
{entry.body}
</p>
)}
</li>
))}
</ol>
)
}
export default async function CasePage({
params,
}: {
params: Promise<{ caseNumber: string }>
}) {
const user = await requireUser()
const { caseNumber } = await params
const akte = await loadVisibleCase(user.permissions, caseNumber)
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
if (!akte) notFound()
// Harte Regel 3: jeder Lesezugriff ab Stufe 3 wird protokolliert.
if (akte.classification >= READ_AUDIT_FROM_LEVEL) {
recordRead({
actorId: user.id,
action: 'case.read',
targetType: 'Case',
targetId: akte.id,
clearanceAtTime: user.permissions.clearanceLevel,
meta: { caseNumber: akte.caseNumber, classification: akte.classification },
})
}
const level = await db.clearanceLevelConfig.findUnique({
where: { level: akte.classification },
})
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<header
className="pl-3"
style={classificationEdgeStyle(akte.classification)}
>
<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>
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
</div>
<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">
<div className="flex gap-2">
<dt className="font-condensed">{de.case.status}</dt>
<dd>{de.case.statusNames[akte.status]}</dd>
</div>
<div className="flex gap-2">
<dt className="font-condensed">{de.case.type}</dt>
<dd>{akte.templateKey}</dd>
</div>
{akte.dueAt ? (
<div className="flex gap-2">
<dt className="font-condensed">{de.case.dueAt}</dt>
<dd>{akte.dueAt.toLocaleDateString('de-DE')}</dd>
</div>
) : null}
</dl>
</header>
<section className="mt-10">
<h2 className="font-condensed">{de.case.timeline}</h2>
<div className="mt-4">
<EntryComposer caseNumber={akte.caseNumber} />
</div>
<div className="mt-8">
<Suspense
fallback={
<div className="timeline">
<SkeletonLine width="18rem" />
<SkeletonLine />
<SkeletonLine width="80%" />
<div className="h-4" />
<SkeletonLine width="16rem" />
<SkeletonLine width="70%" />
</div>
}
>
<Timeline
caseId={akte.id}
userId={user.id}
clearanceLevel={user.permissions.clearanceLevel}
isAdmin={user.permissions.isAdmin}
/>
</Suspense>
</div>
</section>
</main>
)
}