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.
This commit is contained in:
Harlekin
2026-09-01 22:57:38 +02:00
parent 31ac436fde
commit 651f239c68
18 changed files with 1587 additions and 4 deletions
@@ -0,0 +1,103 @@
'use client'
import { useOptimistic, useRef, useState, useTransition } from 'react'
import { uuidv7 } from 'uuidv7'
import { de } from '@/lib/i18n/de'
import { addEntryAction } from '../actions'
type Pending = { id: string; body: string; failed: boolean }
/**
* Vermerk anlegen, optimistisch.
*
* Die clientseitig erzeugte UUIDv7 ist zugleich Idempotenzschlüssel: ein
* Wiederholungsversuch nach einem Netzfehler erzeugt keinen zweiten Eintrag.
* Siehe docs/09-performance-und-ux.md.
*
* Nach dem Absenden springt der Fokus sofort zurück ins leere Feld, Enter
* sendet, Shift+Enter macht einen Zeilenumbruch.
*/
export function EntryComposer({ caseNumber }: { caseNumber: string }) {
const [draft, setDraft] = useState('')
const [failures, setFailures] = useState<Pending[]>([])
const [optimistic, addOptimistic] = useOptimistic<Pending[], Pending>(
[],
(current, entry) => [entry, ...current],
)
const [pending, startTransition] = useTransition()
const field = useRef<HTMLTextAreaElement>(null)
function submit(text: string) {
const body = text.trim()
if (!body) return
const id = uuidv7()
setDraft('')
field.current?.focus()
startTransition(async () => {
addOptimistic({ id, body, failed: false })
const result = await addEntryAction({ id, caseNumber, body, minClearance: 0 })
if (!result.ok) {
// Der Eintrag bleibt stehen und ist als nicht gespeichert markiert.
setFailures((current) => [{ id, body, failed: true }, ...current])
}
})
}
return (
<div>
<form
action={() => submit(draft)}
className="max-w-(--container-measure)"
>
<label htmlFor="vermerk" className="font-condensed text-xs text-text-dim">
{de.case.newEntry}
</label>
<textarea
id="vermerk"
ref={field}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit(draft)
}
}}
rows={3}
placeholder={de.case.entryPlaceholder}
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={pending || draft.trim().length === 0}
className="mt-2 border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.entry.create}
</button>
</form>
{optimistic.length > 0 || failures.length > 0 ? (
<ol className="mt-6 timeline">
{[...failures, ...optimistic].map((entry) => (
<li key={entry.id} className={entry.failed ? 'pb-4' : 'pb-4 opacity-60'}>
<p
className={
entry.failed
? 'max-w-(--container-measure) border-l-(length:--spacing-edge) border-stamp-4 pl-3 text-sm whitespace-pre-wrap'
: 'max-w-(--container-measure) text-sm whitespace-pre-wrap'
}
>
{entry.body}
</p>
{entry.failed ? (
<p className="mt-1 font-condensed text-2xs text-stamp-4">{de.case.unsaved}</p>
) : null}
</li>
))}
</ol>
) : null}
</div>
)
}
@@ -0,0 +1,24 @@
import { Skeleton, SkeletonLine } from '@/components/Skeleton'
export default function Loading() {
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="pl-3">
<Skeleton className="h-[0.8rem]" width="9rem" />
<Skeleton className="mt-2 h-[1.5rem]" width="22rem" />
<div className="mt-3 flex gap-8">
<Skeleton className="h-[0.8rem]" width="6rem" />
<Skeleton className="h-[0.8rem]" width="8rem" />
</div>
</div>
<div className="mt-10">
<Skeleton className="h-[1.125rem]" width="6rem" />
<div className="mt-8 timeline">
<SkeletonLine width="18rem" />
<SkeletonLine />
<SkeletonLine width="80%" />
</div>
</div>
</main>
)
}
+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>
)
}
+123
View File
@@ -0,0 +1,123 @@
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError, addEntry, changeStatus, createCase, reclassify } from '@/lib/cases/service'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
function toMessage(error: unknown): string {
if (error instanceof AccessError) {
return de.case.errors[error.code] ?? de.case.errors.default!
}
console.error('[faelle] unerwartet', error)
return de.case.errors.default!
}
const createSchema = z.object({
title: z.string().trim().min(3).max(200),
classification: z.coerce.number().int().min(0).max(5),
templateKey: z.string().min(1),
})
export type CreateState = { error?: string }
/**
* Wird über `useActionState` aufgerufen, damit Fehler an der Stelle stehen, an
* der sie entstanden sind, und der Knopf einen Wartezustand bekommt.
* Bei Erfolg leitet die Aktion auf die neue Akte weiter.
*/
export async function createCaseAction(
_previous: CreateState,
formData: FormData,
): Promise<CreateState> {
const user = await requireUser()
const parsed = createSchema.safeParse({
title: formData.get('title'),
classification: formData.get('classification'),
templateKey: formData.get('templateKey'),
})
if (!parsed.success) return { error: de.case.errors.default! }
let caseNumber: string
try {
const created = await createCase(user.permissions, parsed.data)
caseNumber = created.caseNumber
} catch (error) {
return { error: toMessage(error) }
}
revalidatePath('/faelle')
// redirect wirft, deshalb steht danach nichts mehr.
redirect(`/faelle/${caseNumber}`)
}
const entrySchema = z.object({
id: z.uuid(),
caseNumber: z.string().min(1),
body: z.string().trim().min(1).max(20_000),
minClearance: z.coerce.number().int().min(0).max(5).default(0),
})
export async function addEntryAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = entrySchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.case.errors.default! }
try {
await addEntry(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidateTag(`case:${parsed.data.caseNumber}`)
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
return { ok: true }
}
const statusSchema = z.object({
caseNumber: z.string().min(1),
to: z.enum(['DRAFT', 'OPEN', 'DORMANT', 'REVIEW', 'CLOSED', 'ARCHIVED', 'SEALED']),
reason: z.string().trim().optional(),
})
export async function changeStatusAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = statusSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.case.errors.default! }
try {
await changeStatus(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
revalidatePath('/faelle')
return { ok: true }
}
const reclassifySchema = z.object({
caseNumber: z.string().min(1),
to: z.coerce.number().int().min(0).max(5),
reason: z.string().trim().optional(),
})
export async function reclassifyAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = reclassifySchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.case.errors.default! }
try {
await reclassify(user.permissions, parsed.data)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
revalidatePath(`/faelle/${parsed.data.caseNumber}`)
revalidatePath('/faelle')
return { ok: true }
}
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton, SkeletonLine } from '@/components/Skeleton'
/**
* Bildet die spätere Struktur nach: gleiche Höhen, gleiche Spaltenbreiten.
* Damit springt nichts, wenn die Daten kommen.
*/
export default function Loading() {
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<Skeleton className="h-[1.5rem]" width="12rem" />
<div className="mt-4 flex gap-3">
{['4rem', '5rem', '5rem', '6rem'].map((width, index) => (
<Skeleton key={index} className="h-[0.9rem]" width={width} />
))}
</div>
<div className="mt-6 border-t border-line">
{Array.from({ length: 8 }, (_, index) => (
<div key={index} className="border-b border-line py-3 pl-3">
<SkeletonLine width={`${55 + ((index * 7) % 35)}%`} />
</div>
))}
</div>
</main>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { useActionState } from 'react'
import { de } from '@/lib/i18n/de'
import { createCaseAction, type CreateState } from '../actions'
export function NewCaseForm({
templates,
levels,
defaultLevel,
}: {
templates: { key: string; name: string }[]
levels: { level: number; name: string }[]
defaultLevel: number
}) {
const [state, action, pending] = useActionState<CreateState, FormData>(createCaseAction, {})
return (
<form action={action} className="mt-6 space-y-5">
<div>
<label htmlFor="title" className="font-condensed text-xs text-text-dim">
{de.case.title}
</label>
<input
id="title"
name="title"
required
minLength={3}
maxLength={200}
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="templateKey" className="font-condensed text-xs text-text-dim">
{de.case.type}
</label>
<select
id="templateKey"
name="templateKey"
required
className="mt-1.5 block border border-line bg-raised px-3 py-2 text-sm"
>
{templates.map((template) => (
<option key={template.key} value={template.key}>
{template.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="classification" className="font-condensed text-xs text-text-dim">
{de.clearance.classification}
</label>
{/* Die Auswahl zeigt nur, was erlaubt ist. Geprüft wird trotzdem serverseitig. */}
<select
id="classification"
name="classification"
defaultValue={defaultLevel}
className="mt-1.5 block border border-line bg-raised px-3 py-2 text-sm"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim"
>
{pending ? de.case.saving : de.case.create}
</button>
{state.error ? (
<span className="font-condensed text-2xs text-stamp-4">{state.error}</span>
) : null}
</div>
</form>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { NewCaseForm } from './NewCaseForm'
export const metadata: Metadata = { title: de.case.createTitle }
export const dynamic = 'force-dynamic'
export default async function NewCasePage() {
const user = await requirePermission('case.create')
const [templates, levels] = await Promise.all([
db.caseTemplate.findMany({
where: { isActive: true },
orderBy: [{ key: 'asc' }, { version: 'desc' }],
distinct: ['key'],
select: { key: true, name: true },
}),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<h1 className="font-condensed">{de.case.createTitle}</h1>
<NewCaseForm
templates={templates}
levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)}
defaultLevel={Math.min(1, user.permissions.clearanceLevel)}
/>
</main>
)
}
+134
View File
@@ -0,0 +1,134 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
export const metadata: Metadata = { title: de.case.listHeading }
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
/**
* Fallliste.
*
* Sichtbarkeit und Trefferzahl laufen über **dieselbe** Abfrage. Würde man
* nachfiltern, stimmten Zählung und Seitenlänge nicht mehr, und aus den Lücken
* liesse sich auf verborgene Akten schliessen. Siehe docs/03-auth-und-rechte.md.
*
* Blättern über einen Cursor auf `(updatedAt, id)`, kein OFFSET: die Liste
* bleibt stabil, auch wenn währenddessen Akten dazukommen.
*/
export default async function CaseListPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; cursor?: string }>
}) {
const user = await requireUser()
const { status, cursor } = await searchParams
const sichtbar = visibleCaseWhere(user.permissions)
const statusFilter = status && status !== 'alle' ? { status: status as never } : {}
const cursorFilter = cursor
? (() => {
const [at, id] = cursor.split('_')
if (!at || !id) return {}
const date = new Date(Number(at))
return {
OR: [{ updatedAt: { lt: date } }, { updatedAt: date, id: { lt: id } }],
}
})()
: {}
const where = { AND: [sichtbar, statusFilter, cursorFilter] }
const [cases, total, levels] = await Promise.all([
db.case.findMany({
where,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
take: PAGE_SIZE + 1,
select: {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
},
}),
db.case.count({ where: { AND: [sichtbar, statusFilter] } }),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
const hasMore = cases.length > PAGE_SIZE
const page = hasMore ? cases.slice(0, PAGE_SIZE) : cases
const last = page.at(-1)
const levelName = new Map(levels.map((l) => [l.level, l.name]))
return (
<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.case.listHeading}</h1>
<span className="font-mono text-xs text-text-dim">{de.case.countLabel(total)}</span>
</div>
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs">
<Link href="/faelle">{de.case.filterAll}</Link>
{(['OPEN', 'REVIEW', 'DORMANT', 'CLOSED', 'ARCHIVED'] as const).map((value) => (
<Link key={value} href={`/faelle?status=${value}`}>
{de.case.statusNames[value]}
</Link>
))}
{user.permissions.isAdmin || user.permissions.permissions.has('case.create') ? (
<Link href="/faelle/neu" className="ml-auto">
{de.case.create}
</Link>
) : null}
</nav>
{page.length === 0 ? (
<p className="mt-10 text-text-dim">{de.case.empty}</p>
) : (
<ul className="mt-6 border-t border-line">
{page.map((akte) => (
<li key={akte.id} className="border-b border-line">
<Link
href={`/faelle/${akte.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 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>
<span className="font-condensed text-2xs text-text-dim">
{de.case.statusNames[akte.status]}
</span>
<StampMark
level={akte.classification}
name={levelName.get(akte.classification) ?? de.common.unknown}
/>
</Link>
</li>
))}
</ul>
)}
{hasMore && last ? (
<p className="mt-6">
<Link
href={`/faelle?${new URLSearchParams({
...(status ? { status } : {}),
cursor: `${last.updatedAt.getTime()}_${last.id}`,
})}`}
className="font-condensed text-xs"
>
{de.case.more}
</Link>
</p>
) : null}
</main>
)
}
+1
View File
@@ -21,6 +21,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<nav className="flex gap-4 font-condensed text-xs">
<Link href="/uebersicht">{de.nav.dashboard}</Link>
<Link href="/faelle">{de.nav.cases}</Link>
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
<Link href="/admin/rollen">{de.nav.admin}</Link>
) : null}