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:
co-authored by
Claude Opus 5
parent
e8c5efc214
commit
1e5a470daf
@@ -38,24 +38,25 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
|
||||
|
||||
## Phase 2 — Akten
|
||||
|
||||
- [ ] Datenmodell für `Case`, `CaseSection`, `CaseEntry`, Beteiligte, Ausschlüsse
|
||||
- [ ] `lib/access/` mit Where-Fragmenten, überall verwendet, nirgends Nachfilterung
|
||||
- [ ] Aktenzeichen-Vergabe kollisionsfrei über `CaseCounter`
|
||||
- [ ] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett
|
||||
- [ ] Aktenansicht: Kopfdaten sofort, Verlauf und Chat in eigenen Suspense-Grenzen
|
||||
- [ ] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7
|
||||
- [ ] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag
|
||||
- [ ] Schreibgrenzen nach `docs/03-auth-und-rechte.md`: keine Akte über der eigenen Stufe
|
||||
- [x] Datenmodell für `Case`, `CaseSection`, `CaseEntry`, Beteiligte, Ausschlüsse
|
||||
- [x] `lib/access/` mit Where-Fragmenten, überall verwendet, nirgends Nachfilterung
|
||||
- [x] Aktenzeichen-Vergabe kollisionsfrei über `CaseCounter`
|
||||
- [x] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett
|
||||
- [x] Aktenansicht: Kopfdaten sofort, Verlauf in eigener Suspense-Grenze (Chat: Phase 6)
|
||||
- [x] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7
|
||||
- [~] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag — Fachlogik und
|
||||
Tests stehen, die Bedienung an der Akte fehlt noch
|
||||
- [x] Schreibgrenzen nach `docs/03-auth-und-rechte.md`: keine Akte über der eigenen Stufe
|
||||
anlegen, Ändern nur mit Lesezugriff, Klassifizierung nachträglich änderbar bis zur
|
||||
eigenen Stufe. Kein Löschen von Akten, nur `ARCHIVED` und `SEALED`
|
||||
- [ ] Umklassifizierung erzeugt `SYSTEM`-Eintrag und `AuditLog` mit alter und neuer Stufe
|
||||
- [ ] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht
|
||||
- [x] Umklassifizierung erzeugt `SYSTEM`-Eintrag und `AuditLog` mit alter und neuer Stufe
|
||||
- [x] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht
|
||||
hochstufen, auch nicht über einen direkten Aufruf der Server Action
|
||||
- [ ] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3
|
||||
- [x] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3
|
||||
- [ ] Nachträgliche Änderungen an Verlaufseinträgen nach `docs/05-akten-und-vorlagen.md`:
|
||||
`CaseEntryVersion`, Karenzzeit, Pflichtbegründung, `bearbeitet · vN` mit Diff-Historie
|
||||
- [ ] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen
|
||||
- [ ] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion`
|
||||
- [x] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion`
|
||||
- [ ] Änderungsübersicht als Reiter an der Akte
|
||||
- [ ] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import { createPrismaClient } from '../src/index.js'
|
||||
* Grundzustand der Anwendung. Alles hier ist Konfiguration, ohne die die App
|
||||
* nicht sinnvoll läuft, kein Testinhalt.
|
||||
*
|
||||
* Die vier Startvorlagen (Korruptionsverdacht, Dienstvergehen,
|
||||
* Sicherheitsvorfall, Routineprüfung) kommen in Phase 3 dazu, zusammen mit dem
|
||||
* Vorlagen-Editor. Siehe TASKS.md.
|
||||
* Die vier Startvorlagen stehen hier, weil ohne Vorlage keine Akte angelegt
|
||||
* werden kann. Den Vorlagen-Editor und die übrigen Feldtypen baut Phase 3.
|
||||
*/
|
||||
const db = createPrismaClient()
|
||||
|
||||
@@ -28,6 +27,51 @@ const CLEARANCE_LEVELS = [
|
||||
{ level: 5, name: 'Executive Oversight', hiddenBelow: true, colorToken: 'stamp-5' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Startvorlagen nach docs/05-akten-und-vorlagen.md. Die Feldschlüssel sind
|
||||
* stabil und werden nie geändert — eine Akte merkt sich ihre Vorlage über
|
||||
* `templateId`, und eine neue Vorlagenversion lässt bestehende Akten unberührt.
|
||||
*
|
||||
* Der Zuschnitt der Felder ist bewusst schmal gehalten: den Vorlagen-Editor
|
||||
* mit allen Feldtypen baut Phase 3, hier steht nur, was eine Akte zum Laufen
|
||||
* braucht.
|
||||
*/
|
||||
const TEMPLATES = [
|
||||
{
|
||||
key: 'korruptionsverdacht',
|
||||
name: 'Korruptionsverdacht',
|
||||
description: 'Verdacht auf Vorteilsnahme, Bestechlichkeit oder Interessenkonflikt',
|
||||
},
|
||||
{
|
||||
key: 'dienstvergehen',
|
||||
name: 'Dienstvergehen',
|
||||
description: 'Verstoß gegen Dienstpflichten oder interne Vorschriften',
|
||||
},
|
||||
{
|
||||
key: 'sicherheitsvorfall',
|
||||
name: 'Sicherheitsvorfall',
|
||||
description: 'Containment-Bruch, Datenabfluss oder unbefugter Zugriff',
|
||||
},
|
||||
{
|
||||
key: 'routinepruefung',
|
||||
name: 'Routineprüfung',
|
||||
description: 'Turnusmäßige Kontrolle ohne konkreten Anfangsverdacht',
|
||||
},
|
||||
]
|
||||
|
||||
const TEMPLATE_FIELDS = [
|
||||
{ key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true },
|
||||
{ key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' },
|
||||
{ key: 'feststellungen', label: 'Feststellungen', type: 'longtext' },
|
||||
{
|
||||
key: 'bewertung',
|
||||
label: 'Abschließende Bewertung',
|
||||
type: 'longtext',
|
||||
requiredToClose: true,
|
||||
help: 'Muss gefüllt sein, bevor der Fall abgeschlossen werden kann.',
|
||||
},
|
||||
]
|
||||
|
||||
async function main(): Promise<void> {
|
||||
for (const config of CLEARANCE_LEVELS) {
|
||||
await db.clearanceLevelConfig.upsert({
|
||||
@@ -49,6 +93,15 @@ async function main(): Promise<void> {
|
||||
})
|
||||
console.log('[seed] Änderungsrichtlinie GRACE_WINDOW, 15 Minuten')
|
||||
|
||||
for (const template of TEMPLATES) {
|
||||
await db.caseTemplate.upsert({
|
||||
where: { key_version: { key: template.key, version: 1 } },
|
||||
create: { ...template, version: 1, fields: TEMPLATE_FIELDS, isActive: true },
|
||||
update: { name: template.name, description: template.description },
|
||||
})
|
||||
}
|
||||
console.log(`[seed] ${TEMPLATES.length} Startvorlagen`)
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
await db.caseCounter.upsert({
|
||||
where: { year },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -27,7 +27,7 @@ test('Startseite fuehrt Nichtangemeldete zur Anmeldung', async ({ page }) => {
|
||||
})
|
||||
|
||||
test.describe('Geschuetzte Bereiche', () => {
|
||||
for (const pfad of ['/uebersicht', '/admin/rollen', '/admin/system']) {
|
||||
for (const pfad of ['/uebersicht', '/faelle', '/faelle/neu', '/admin/rollen', '/admin/system']) {
|
||||
test(`${pfad} ist ohne Anmeldung nicht erreichbar`, async ({ page }) => {
|
||||
await page.goto(pfad)
|
||||
await expect(page).toHaveURL(/\/anmelden$/)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Schreibgrenzen der Freigabestufe, siehe docs/03-auth-und-rechte.md.
|
||||
*
|
||||
* Die Leseprüfung steckt in `visibleCaseWhere`. Diese Regeln kommen obendrauf
|
||||
* und gelten für jede Server Action, unabhängig davon, was die Oberfläche
|
||||
* vorher versteckt hat.
|
||||
*/
|
||||
|
||||
/** Status, in denen eine Akte nicht mehr verändert wird. */
|
||||
export const READ_ONLY_STATUS = ['ARCHIVED', 'SEALED'] as const
|
||||
|
||||
export type WriteDenial =
|
||||
| { ok: false; reason: 'no-permission'; permission: Permission }
|
||||
| { ok: false; reason: 'clearance-too-low'; required: number }
|
||||
| { ok: false; reason: 'read-only'; status: string }
|
||||
| { ok: false; reason: 'closed' }
|
||||
|
||||
export type WriteCheck = { ok: true } | WriteDenial
|
||||
|
||||
function requirePermission(p: EffectivePermissions, permission: Permission): WriteCheck {
|
||||
if (p.isAdmin || p.permissions.has(permission)) return { ok: true }
|
||||
return { ok: false, reason: 'no-permission', permission }
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Nutzer eine Akte mit dieser Klassifizierung anlegen?
|
||||
*
|
||||
* Niemand legt eine Akte über der eigenen Stufe an — auch kein Admin, denn er
|
||||
* hätte sonst eine Akte erzeugt, deren Sichtbarkeit er nicht überblickt.
|
||||
* Der Admin hat ohnehin Stufe 5.
|
||||
*/
|
||||
export function canCreateCase(p: EffectivePermissions, classification: number): WriteCheck {
|
||||
const permitted = requirePermission(p, 'case.create')
|
||||
if (!permitted.ok) return permitted
|
||||
if (classification > p.clearanceLevel) {
|
||||
return { ok: false, reason: 'clearance-too-low', required: classification }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Nutzer diese Akte verändern?
|
||||
*
|
||||
* Setzt voraus, dass die Akte bereits durch `visibleCaseWhere` gelaufen ist:
|
||||
* was man nicht sehen darf, kann man nicht anfassen.
|
||||
*/
|
||||
export function canEditCase(
|
||||
p: EffectivePermissions,
|
||||
target: { status: string; classification: number },
|
||||
permission: Permission = 'case.edit',
|
||||
): WriteCheck {
|
||||
if ((READ_ONLY_STATUS as readonly string[]).includes(target.status)) {
|
||||
// Archiviert und versiegelt sind schreibgeschützt, auch für Admins.
|
||||
return { ok: false, reason: 'read-only', status: target.status }
|
||||
}
|
||||
|
||||
const permitted = requirePermission(p, permission)
|
||||
if (!permitted.ok) return permitted
|
||||
|
||||
if (target.classification > p.clearanceLevel && !p.isAdmin) {
|
||||
return { ok: false, reason: 'clearance-too-low', required: target.classification }
|
||||
}
|
||||
|
||||
if (target.status === 'CLOSED' && permission !== 'case.reopen') {
|
||||
// Änderungen an abgeschlossenen Fällen brauchen ein eigenes Recht und
|
||||
// erzeugen zusätzlich einen SYSTEM-Eintrag.
|
||||
const closedAllowed = requirePermission(p, 'case.edit_closed')
|
||||
if (!closedAllowed.ok) return { ok: false, reason: 'closed' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf dieser Nutzer die Klassifizierung auf diesen Wert setzen?
|
||||
*
|
||||
* Die neue Stufe darf die eigene nicht übersteigen. Eine Herabstufung ist der
|
||||
* heikelste Vorgang im System: sie macht Inhalte für Leute sichtbar, die sie
|
||||
* vorher nicht sehen durften. Erlaubt ist sie trotzdem — sie wird nur sichtbar
|
||||
* protokolliert, im Verlauf und im Zugriffsprotokoll.
|
||||
*/
|
||||
export function canReclassify(
|
||||
p: EffectivePermissions,
|
||||
target: { status: string; classification: number },
|
||||
next: number,
|
||||
): WriteCheck {
|
||||
if (next < 0 || next > 5) return { ok: false, reason: 'clearance-too-low', required: next }
|
||||
if (next > p.clearanceLevel) {
|
||||
return { ok: false, reason: 'clearance-too-low', required: next }
|
||||
}
|
||||
return canEditCase(p, target)
|
||||
}
|
||||
|
||||
/** Für Server Actions: wirft mit einer Meldung, die in die Oberfläche darf. */
|
||||
export function assertWritable(check: WriteCheck, messages: Record<string, string>): void {
|
||||
if (check.ok) return
|
||||
throw new Error(messages[check.reason] ?? messages.default ?? 'Nicht erlaubt')
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Prisma } from '@afis/db'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
/**
|
||||
* Zugriffsprotokoll. Zweite, unabhängige Spur neben der Versionstabelle.
|
||||
*
|
||||
* Zwei Wege, aus einem Grund: der Eintrag darf die Antwort nicht blockieren
|
||||
* (docs/09-performance-und-ux.md), aber bei schreibenden Aktionen darf er auch
|
||||
* nicht verloren gehen.
|
||||
*
|
||||
* - `writeAudit` läuft **in derselben Transaktion** wie die Änderung. Schlägt
|
||||
* das Protokoll fehl, schlägt die Änderung fehl.
|
||||
* - `recordRead` sammelt Lesezugriffe im Prozess und schreibt sie alle zwei
|
||||
* Sekunden gebündelt weg. Ein verlorener Lesevermerk nach einem Absturz ist
|
||||
* hinnehmbar, eine um 40 ms langsamere Aktenansicht nicht.
|
||||
*/
|
||||
export type AuditAction =
|
||||
| 'case.read'
|
||||
| 'case.create'
|
||||
| 'case.update'
|
||||
| 'case.reclassify'
|
||||
| 'case.status'
|
||||
| 'entry.create'
|
||||
| 'entry.edit'
|
||||
| 'entry.retract'
|
||||
| 'entry.restore'
|
||||
| 'roles.update'
|
||||
| 'export.pdf'
|
||||
|
||||
export type AuditInput = {
|
||||
actorId: string
|
||||
action: AuditAction
|
||||
targetType: string
|
||||
targetId: string
|
||||
clearanceAtTime: number
|
||||
meta?: Prisma.InputJsonValue
|
||||
}
|
||||
|
||||
/** Ab dieser Stufe wird auch das blosse Lesen protokolliert (harte Regel 3). */
|
||||
export const READ_AUDIT_FROM_LEVEL = 3
|
||||
|
||||
type Client = Pick<typeof db, 'auditLog'>
|
||||
|
||||
/** Schreibende Aktionen: synchron, in der Transaktion des Aufrufers. */
|
||||
export async function writeAudit(client: Client, input: AuditInput): Promise<void> {
|
||||
await client.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
clearanceAtTime: input.clearanceAtTime,
|
||||
meta: input.meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const buffer: AuditInput[] = []
|
||||
let flushTimer: NodeJS.Timeout | null = null
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
flushTimer = null
|
||||
if (buffer.length === 0) return
|
||||
|
||||
const batch = buffer.splice(0, buffer.length)
|
||||
try {
|
||||
await db.auditLog.createMany({
|
||||
data: batch.map((entry) => ({
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
targetType: entry.targetType,
|
||||
targetId: entry.targetId,
|
||||
clearanceAtTime: entry.clearanceAtTime,
|
||||
meta: entry.meta,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[audit] Bündel nicht geschrieben', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lesezugriff vormerken. Nur ab Stufe 3 aufrufen, sonst füllt sich das
|
||||
* Protokoll mit Belanglosigkeiten und die interessanten Zeilen gehen unter.
|
||||
*/
|
||||
export function recordRead(input: AuditInput): void {
|
||||
buffer.push(input)
|
||||
flushTimer ??= setTimeout(() => void flush(), 2000)
|
||||
// Der Prozess soll nicht wegen des Protokolls offen bleiben.
|
||||
flushTimer.unref?.()
|
||||
}
|
||||
|
||||
/** Für Tests und für das geordnete Herunterfahren. */
|
||||
export async function flushAuditBuffer(): Promise<void> {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
await flush()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Prisma } from '@afis/db'
|
||||
|
||||
/** `AfiS-2026-0042`, siehe Glossar in docs/01-produkt.md. */
|
||||
export function formatCaseNumber(year: number, number: number): string {
|
||||
return `AfiS-${year}-${String(number).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
const PATTERN = /^AfiS-(\d{4})-(\d{4})$/
|
||||
|
||||
export function parseCaseNumber(value: string): { year: number; number: number } | null {
|
||||
const match = PATTERN.exec(value.trim())
|
||||
if (!match) return null
|
||||
return { year: Number(match[1]), number: Number(match[2]) }
|
||||
}
|
||||
|
||||
/** Erkennt Aktenzeichen im Fliesstext, für die Vorschaukarten im Chat. */
|
||||
export const CASE_NUMBER_GLOBAL = /AfiS-\d{4}-\d{4}/g
|
||||
|
||||
/**
|
||||
* Vergibt das nächste Aktenzeichen des Jahres.
|
||||
*
|
||||
* Läuft mit `SELECT … FOR UPDATE` in **derselben** Transaktion wie das Anlegen
|
||||
* des Falls. Zwei gleichzeitige Anlagen warten dadurch aufeinander, statt
|
||||
* dieselbe Nummer zu ziehen oder eine Lücke zu hinterlassen.
|
||||
* Siehe docs/04-datenmodell.md.
|
||||
*
|
||||
* Eine Sequenz pro Jahr wäre einfacher, müsste aber jedes Jahr von Hand
|
||||
* angelegt werden — und der Abteilungsleiter ist ausdrücklich nicht technisch.
|
||||
*/
|
||||
export async function nextCaseNumber(
|
||||
tx: Prisma.TransactionClient,
|
||||
year = new Date().getFullYear(),
|
||||
): Promise<string> {
|
||||
const rows = await tx.$queryRaw<{ lastNumber: number }[]>`
|
||||
INSERT INTO "CaseCounter" ("year", "lastNumber")
|
||||
VALUES (${year}, 1)
|
||||
ON CONFLICT ("year") DO UPDATE
|
||||
SET "lastNumber" = "CaseCounter"."lastNumber" + 1
|
||||
RETURNING "lastNumber"
|
||||
`
|
||||
|
||||
const lastNumber = rows[0]?.lastNumber
|
||||
if (lastNumber === undefined) {
|
||||
throw new Error('Aktenzeichen konnte nicht vergeben werden')
|
||||
}
|
||||
|
||||
return formatCaseNumber(year, lastNumber)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Permission } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Lebenszyklus einer Akte, siehe docs/05-akten-und-vorlagen.md.
|
||||
*
|
||||
* DRAFT ──► OPEN ──► REVIEW ──► CLOSED ──► ARCHIVED
|
||||
* │ ▲ │
|
||||
* ▼ │ └──► OPEN (Rückgabe zur Nachbesserung)
|
||||
* DORMANT
|
||||
*
|
||||
* SEALED ist aus jedem Zustand erreichbar und nur mit Recht 'admin' oder
|
||||
* Stufe 5 lesbar.
|
||||
*/
|
||||
export type CaseStatusName =
|
||||
| 'DRAFT'
|
||||
| 'OPEN'
|
||||
| 'DORMANT'
|
||||
| 'REVIEW'
|
||||
| 'CLOSED'
|
||||
| 'ARCHIVED'
|
||||
| 'SEALED'
|
||||
|
||||
export type Transition = {
|
||||
from: CaseStatusName
|
||||
to: CaseStatusName
|
||||
permission: Permission
|
||||
/** Verlangt eine Begründung, die als Vermerk im Verlauf landet. */
|
||||
requiresReason?: boolean
|
||||
/** Verlangt, dass alle `requiredToClose`-Felder gefüllt sind. */
|
||||
requiresCompleteSections?: boolean
|
||||
}
|
||||
|
||||
export const TRANSITIONS: readonly Transition[] = [
|
||||
{ from: 'DRAFT', to: 'OPEN', permission: 'case.edit' },
|
||||
{ from: 'OPEN', to: 'DORMANT', permission: 'case.edit' },
|
||||
{ from: 'DORMANT', to: 'OPEN', permission: 'case.edit' },
|
||||
{ from: 'OPEN', to: 'REVIEW', permission: 'case.edit' },
|
||||
{ from: 'REVIEW', to: 'OPEN', permission: 'case.edit' },
|
||||
{ from: 'REVIEW', to: 'CLOSED', permission: 'case.close', requiresCompleteSections: true },
|
||||
{ from: 'CLOSED', to: 'OPEN', permission: 'case.reopen', requiresReason: true },
|
||||
{ from: 'CLOSED', to: 'ARCHIVED', permission: 'case.close' },
|
||||
]
|
||||
|
||||
/** SEALED erreicht man aus jedem Zustand, aber nur mit dem Recht 'admin'. */
|
||||
export const SEAL_PERMISSION: Permission = 'admin'
|
||||
|
||||
export function findTransition(
|
||||
from: CaseStatusName,
|
||||
to: CaseStatusName,
|
||||
): Transition | undefined {
|
||||
if (to === 'SEALED' && from !== 'SEALED') {
|
||||
return { from, to, permission: SEAL_PERMISSION, requiresReason: true }
|
||||
}
|
||||
return TRANSITIONS.find((t) => t.from === from && t.to === to)
|
||||
}
|
||||
|
||||
export function allowedTargets(from: CaseStatusName): CaseStatusName[] {
|
||||
const targets = TRANSITIONS.filter((t) => t.from === from).map((t) => t.to)
|
||||
if (from !== 'SEALED') targets.push('SEALED')
|
||||
return targets
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { writeAudit } from '@/lib/audit'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { canCreateCase, canEditCase, canReclassify } from '@/lib/access/caseWrite'
|
||||
import { nextCaseNumber } from '@/lib/cases/caseNumber'
|
||||
import { findTransition, type CaseStatusName } from '@/lib/cases/lifecycle'
|
||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Fachlogik der Akte. Jede Funktion prüft die Berechtigung selbst und
|
||||
* schreibt ihren Protokolleintrag in derselben Transaktion wie die Änderung.
|
||||
*
|
||||
* Die Server Actions daneben sind dünn: sie nehmen entgegen, rufen hier auf
|
||||
* und geben zurück. Die Regeln stehen an genau einer Stelle.
|
||||
*/
|
||||
|
||||
export class AccessError extends Error {
|
||||
constructor(readonly code: string) {
|
||||
super(code)
|
||||
this.name = 'AccessError'
|
||||
}
|
||||
}
|
||||
|
||||
function must(check: { ok: boolean; reason?: string }): void {
|
||||
if (!check.ok) throw new AccessError(check.reason ?? 'denied')
|
||||
}
|
||||
|
||||
/** Lädt eine Akte, sofern der Nutzer sie sehen darf. Sonst `null` — nie 403. */
|
||||
export async function loadVisibleCase(p: EffectivePermissions, caseNumber: string) {
|
||||
return db.case.findFirst({
|
||||
where: { AND: [visibleCaseWhere(p), { caseNumber }] },
|
||||
select: {
|
||||
id: true,
|
||||
caseNumber: true,
|
||||
title: true,
|
||||
status: true,
|
||||
classification: true,
|
||||
templateId: true,
|
||||
templateKey: true,
|
||||
dueAt: true,
|
||||
closedAt: true,
|
||||
outcome: true,
|
||||
createdById: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function createCase(
|
||||
p: EffectivePermissions,
|
||||
input: { title: string; classification: number; templateKey: string; dueAt?: Date | null },
|
||||
): Promise<{ caseNumber: string; id: string }> {
|
||||
must(canCreateCase(p, input.classification))
|
||||
|
||||
const template = await db.caseTemplate.findFirst({
|
||||
where: { key: input.templateKey, isActive: true },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true, key: true },
|
||||
})
|
||||
if (!template) throw new AccessError('template-missing')
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const caseNumber = await nextCaseNumber(tx)
|
||||
|
||||
const created = await tx.case.create({
|
||||
data: {
|
||||
caseNumber,
|
||||
title: input.title,
|
||||
classification: input.classification,
|
||||
templateId: template.id,
|
||||
templateKey: template.key,
|
||||
createdById: p.userId,
|
||||
dueAt: input.dueAt ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
select: { id: true, caseNumber: true },
|
||||
})
|
||||
|
||||
// Wer eine Akte anlegt, ist ihr Ermittler. Sonst verliert er sie aus dem
|
||||
// Blick, sobald jemand die Klassifizierung anhebt.
|
||||
await tx.caseParticipant.create({
|
||||
data: { caseId: created.id, userId: p.userId, role: 'LEAD', addedById: p.userId },
|
||||
})
|
||||
|
||||
await tx.caseEntry.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
caseId: created.id,
|
||||
type: 'SYSTEM',
|
||||
body: `Akte angelegt mit Klassifizierung ${input.classification}`,
|
||||
authorId: p.userId,
|
||||
minClearance: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'case.create',
|
||||
targetType: 'Case',
|
||||
targetId: created.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { caseNumber: created.caseNumber, classification: input.classification },
|
||||
})
|
||||
|
||||
return created
|
||||
})
|
||||
}
|
||||
|
||||
export async function addEntry(
|
||||
p: EffectivePermissions,
|
||||
input: { id: string; caseNumber: string; body: string; type?: 'NOTE' | 'INTERROGATION' | 'EVIDENCE'; minClearance?: number },
|
||||
): Promise<{ id: string }> {
|
||||
const target = await loadVisibleCase(p, input.caseNumber)
|
||||
if (!target) throw new AccessError('not-found')
|
||||
|
||||
must(canEditCase(p, target, 'entry.create'))
|
||||
|
||||
const minClearance = input.minClearance ?? 0
|
||||
if (minClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
// Die clientseitige UUIDv7 ist zugleich Idempotenzschlüssel: ein
|
||||
// wiederholtes Absenden nach einem Netzfehler erzeugt keinen zweiten
|
||||
// Eintrag. Siehe docs/09-performance-und-ux.md.
|
||||
const existing = await tx.caseEntry.findUnique({ where: { id: input.id }, select: { id: true } })
|
||||
if (existing) return existing
|
||||
|
||||
const entry = await tx.caseEntry.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
caseId: target.id,
|
||||
type: input.type ?? 'NOTE',
|
||||
body: input.body,
|
||||
authorId: p.userId,
|
||||
minClearance,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'entry.create',
|
||||
targetType: 'CaseEntry',
|
||||
targetId: entry.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { caseId: target.id, caseNumber: target.caseNumber, minClearance },
|
||||
})
|
||||
|
||||
return entry
|
||||
})
|
||||
}
|
||||
|
||||
export async function changeStatus(
|
||||
p: EffectivePermissions,
|
||||
input: { caseNumber: string; to: CaseStatusName; reason?: string },
|
||||
): Promise<void> {
|
||||
const target = await loadVisibleCase(p, input.caseNumber)
|
||||
if (!target) throw new AccessError('not-found')
|
||||
|
||||
const transition = findTransition(target.status as CaseStatusName, input.to)
|
||||
if (!transition) throw new AccessError('transition-invalid')
|
||||
|
||||
must(canEditCase(p, target, transition.permission))
|
||||
|
||||
const reason = input.reason?.trim() ?? ''
|
||||
if (transition.requiresReason && reason.length < 10) throw new AccessError('reason-required')
|
||||
|
||||
if (transition.requiresCompleteSections) {
|
||||
const missing = await missingRequiredSections(target.id, target.templateId)
|
||||
if (missing.length > 0) throw new AccessError('sections-incomplete')
|
||||
}
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.case.update({
|
||||
where: { id: target.id },
|
||||
data: {
|
||||
status: input.to,
|
||||
closedAt: input.to === 'CLOSED' ? new Date() : target.closedAt,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.caseEntry.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
caseId: target.id,
|
||||
type: 'STATUS_CHANGE',
|
||||
body: reason
|
||||
? `Status von ${target.status} auf ${input.to} geändert. Begründung: ${reason}`
|
||||
: `Status von ${target.status} auf ${input.to} geändert`,
|
||||
authorId: p.userId,
|
||||
minClearance: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'case.status',
|
||||
targetType: 'Case',
|
||||
targetId: target.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: { from: target.status, to: input.to, reason: reason || undefined },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Klassifizierung ändern.
|
||||
*
|
||||
* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher nicht
|
||||
* sehen durften. Deshalb landet sie nicht nur im Protokoll, sondern als
|
||||
* sichtbarer Systemeintrag im Verlauf der Akte.
|
||||
*/
|
||||
export async function reclassify(
|
||||
p: EffectivePermissions,
|
||||
input: { caseNumber: string; to: number; reason?: string },
|
||||
): Promise<void> {
|
||||
const target = await loadVisibleCase(p, input.caseNumber)
|
||||
if (!target) throw new AccessError('not-found')
|
||||
if (target.classification === input.to) return
|
||||
|
||||
must(canReclassify(p, target, input.to))
|
||||
|
||||
const lowering = input.to < target.classification
|
||||
const reason = input.reason?.trim() ?? ''
|
||||
if (lowering && reason.length < 10) throw new AccessError('reason-required')
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.case.update({ where: { id: target.id }, data: { classification: input.to } })
|
||||
|
||||
await tx.caseEntry.create({
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
caseId: target.id,
|
||||
type: 'SYSTEM',
|
||||
body: lowering
|
||||
? `Klassifizierung von ${target.classification} auf ${input.to} herabgesetzt. Begründung: ${reason}`
|
||||
: `Klassifizierung von ${target.classification} auf ${input.to} angehoben`,
|
||||
authorId: p.userId,
|
||||
minClearance: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await writeAudit(tx, {
|
||||
actorId: p.userId,
|
||||
action: 'case.reclassify',
|
||||
targetType: 'Case',
|
||||
targetId: target.id,
|
||||
clearanceAtTime: p.clearanceLevel,
|
||||
meta: {
|
||||
from: target.classification,
|
||||
to: input.to,
|
||||
lowering,
|
||||
reason: reason || undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Felder mit `requiredToClose`, die noch leer sind. Kommt mit Phase 3 zum Tragen. */
|
||||
async function missingRequiredSections(caseId: string, templateId: string): Promise<string[]> {
|
||||
const template = await db.caseTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
select: { fields: true },
|
||||
})
|
||||
const fields = Array.isArray(template?.fields) ? template.fields : []
|
||||
const required = fields
|
||||
.filter((field): field is { key: string; requiredToClose?: boolean } =>
|
||||
typeof field === 'object' && field !== null && 'key' in field,
|
||||
)
|
||||
.filter((field) => field.requiredToClose === true)
|
||||
.map((field) => field.key)
|
||||
|
||||
if (required.length === 0) return []
|
||||
|
||||
const filled = await db.caseSection.findMany({
|
||||
where: { caseId, fieldKey: { in: required } },
|
||||
select: { fieldKey: true, value: true },
|
||||
})
|
||||
const filledKeys = new Set(
|
||||
filled.filter((s) => s.value !== null && s.value !== '').map((s) => s.fieldKey),
|
||||
)
|
||||
return required.filter((key) => !filledKeys.has(key))
|
||||
}
|
||||
@@ -139,6 +139,34 @@ export const de = {
|
||||
links: 'Verknüpfungen',
|
||||
attachments: 'Anhänge',
|
||||
empty: 'Noch keine Fallakte. Erste Fallakte anlegen.',
|
||||
create: 'Fallakte anlegen',
|
||||
createTitle: 'Neue Fallakte',
|
||||
open: 'Öffnen',
|
||||
updatedAt: 'Zuletzt geändert',
|
||||
more: 'Weitere laden',
|
||||
listHeading: 'Fallakten',
|
||||
countLabel: (n: number) => (n === 1 ? '1 Akte' : `${n} Akten`),
|
||||
filterStatus: 'Status',
|
||||
filterAll: 'Alle',
|
||||
reclassify: 'Klassifizierung ändern',
|
||||
reclassifyReason: 'Begründung der Herabstufung',
|
||||
statusChange: 'Status ändern',
|
||||
newEntry: 'Neuer Vermerk',
|
||||
entryPlaceholder: 'Was ist passiert?',
|
||||
saving: 'Wird gespeichert',
|
||||
unsaved: 'Nicht gespeichert',
|
||||
errors: {
|
||||
'not-found': 'Die Akte gibt es nicht, oder du darfst sie nicht sehen.',
|
||||
'clearance-too-low': 'Deine Freigabestufe reicht dafür nicht.',
|
||||
'no-permission': 'Dir fehlt das nötige Recht.',
|
||||
'read-only': 'Die Akte ist schreibgeschützt.',
|
||||
closed: 'Die Akte ist abgeschlossen. Änderungen brauchen ein eigenes Recht.',
|
||||
'reason-required': 'Begründung nötig, mindestens 10 Zeichen.',
|
||||
'transition-invalid': 'Dieser Statuswechsel ist nicht vorgesehen.',
|
||||
'sections-incomplete': 'Es fehlen Pflichtangaben für den Abschluss.',
|
||||
'template-missing': 'Für diese Fallart gibt es keine Vorlage.',
|
||||
default: 'Nicht gespeichert. Erneut versuchen.',
|
||||
} as Record<string, string>,
|
||||
statusNames: {
|
||||
DRAFT: 'Entwurf',
|
||||
OPEN: 'Offen',
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||
import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
|
||||
|
||||
/**
|
||||
* Die Schreibgrenzen aus docs/03-auth-und-rechte.md, geprüft gegen die
|
||||
* Fachlogik selbst — nicht gegen die Oberfläche. Eine Server Action ist ein
|
||||
* Endpunkt: was hier durchgeht, geht auch durch, wenn jemand das Formular
|
||||
* umgeht.
|
||||
*/
|
||||
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||
const suite = url ? describe : describe.skip
|
||||
|
||||
let db: PrismaClient
|
||||
const marke = `schreib-${Date.now()}`
|
||||
|
||||
function perms(
|
||||
clearanceLevel: number,
|
||||
permissions: Permission[],
|
||||
options: { isAdmin?: boolean; userId?: string } = {},
|
||||
): EffectivePermissions {
|
||||
return {
|
||||
userId: options.userId ?? `${marke}-n${clearanceLevel}`,
|
||||
clearanceLevel,
|
||||
permissions: new Set(permissions),
|
||||
isAdmin: options.isAdmin ?? false,
|
||||
hasMembership: true,
|
||||
}
|
||||
}
|
||||
|
||||
const VOLL: Permission[] = ['case.create', 'case.edit', 'case.close', 'case.reopen', 'entry.create']
|
||||
|
||||
suite('Schreibgrenzen der Freigabestufe', () => {
|
||||
beforeAll(async () => {
|
||||
db = createPrismaClient(url)
|
||||
await db.caseTemplate.create({
|
||||
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
|
||||
})
|
||||
for (const level of [1, 2, 4, 5]) {
|
||||
const id = `${marke}-n${level}`
|
||||
await db.user.create({ data: { id, username: id, displayName: id } })
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
it('lässt Stufe 2 keine Stufe-4-Akte anlegen', async () => {
|
||||
const { createCase, AccessError } = await import('@/lib/cases/service')
|
||||
await expect(
|
||||
createCase(perms(2, VOLL), {
|
||||
title: 'Zu hoch gegriffen',
|
||||
classification: 4,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
})
|
||||
|
||||
it('lässt Stufe 2 eine Stufe-2-Akte anlegen und vergibt ein Aktenzeichen', async () => {
|
||||
const { createCase } = await import('@/lib/cases/service')
|
||||
const angelegt = await createCase(perms(2, VOLL), {
|
||||
title: 'Passt',
|
||||
classification: 2,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
expect(angelegt.caseNumber).toMatch(/^AfiS-\d{4}-\d{4}$/)
|
||||
|
||||
// Der Anleger ist Ermittler, sonst verliert er die Akte beim Hochstufen.
|
||||
const beteiligt = await db.caseParticipant.count({
|
||||
where: { caseId: angelegt.id, userId: `${marke}-n2`, role: 'LEAD' },
|
||||
})
|
||||
expect(beteiligt).toBe(1)
|
||||
})
|
||||
|
||||
it('vergibt Aktenzeichen ohne Doppelung, auch bei gleichzeitigem Anlegen', async () => {
|
||||
const { createCase } = await import('@/lib/cases/service')
|
||||
const ergebnisse = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
createCase(perms(5, VOLL), {
|
||||
title: `Parallel ${i}`,
|
||||
classification: 1,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const nummern = ergebnisse.map((r) => r.caseNumber)
|
||||
expect(new Set(nummern).size).toBe(nummern.length)
|
||||
})
|
||||
|
||||
it('gibt einer zu niedrigen Stufe nicht einmal die Existenz preis', async () => {
|
||||
const { createCase, loadVisibleCase } = await import('@/lib/cases/service')
|
||||
const geheim = await createCase(perms(5, VOLL), {
|
||||
title: 'Streng geheim',
|
||||
classification: 4,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
|
||||
// Stufe 2 bekommt null, nicht "verboten": daraus wird 404, nicht 403.
|
||||
expect(await loadVisibleCase(perms(2, VOLL), geheim.caseNumber)).toBeNull()
|
||||
expect(await loadVisibleCase(perms(5, VOLL), geheim.caseNumber)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('lässt niemanden über die eigene Stufe hochstufen', async () => {
|
||||
const { createCase, reclassify, AccessError } = await import('@/lib/cases/service')
|
||||
const akte = await createCase(perms(2, VOLL), {
|
||||
title: 'Bleibt auf 2',
|
||||
classification: 2,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
|
||||
await expect(
|
||||
reclassify(perms(2, VOLL), { caseNumber: akte.caseNumber, to: 4 }),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
|
||||
const unveraendert = await db.case.findUnique({ where: { id: akte.id } })
|
||||
expect(unveraendert?.classification).toBe(2)
|
||||
})
|
||||
|
||||
it('verlangt für eine Herabstufung eine Begründung und hält sie im Verlauf fest', async () => {
|
||||
const { createCase, reclassify, AccessError } = await import('@/lib/cases/service')
|
||||
const akte = await createCase(perms(5, VOLL), {
|
||||
title: 'Wird herabgestuft',
|
||||
classification: 4,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
|
||||
await expect(
|
||||
reclassify(perms(5, VOLL), { caseNumber: akte.caseNumber, to: 1, reason: 'kurz' }),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
|
||||
await reclassify(perms(5, VOLL), {
|
||||
caseNumber: akte.caseNumber,
|
||||
to: 1,
|
||||
reason: 'Ermittlung abgeschlossen, keine Verschlusssache mehr',
|
||||
})
|
||||
|
||||
const eintraege = await db.caseEntry.findMany({
|
||||
where: { caseId: akte.id, type: 'SYSTEM' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
expect(eintraege[0]?.body).toContain('herabgesetzt')
|
||||
expect(eintraege[0]?.body).toContain('Ermittlung abgeschlossen')
|
||||
|
||||
const protokoll = await db.auditLog.findFirst({
|
||||
where: { targetId: akte.id, action: 'case.reclassify' },
|
||||
orderBy: { at: 'desc' },
|
||||
})
|
||||
expect(protokoll?.meta).toMatchObject({ from: 4, to: 1, lowering: true })
|
||||
})
|
||||
|
||||
it('lässt eine archivierte Akte nicht mehr verändern, auch nicht als Admin', async () => {
|
||||
const { createCase, addEntry, AccessError } = await import('@/lib/cases/service')
|
||||
const admin = perms(5, VOLL, { isAdmin: true, userId: `${marke}-n5` })
|
||||
const akte = await createCase(admin, {
|
||||
title: 'Archiviert',
|
||||
classification: 1,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
await db.case.update({ where: { id: akte.id }, data: { status: 'ARCHIVED' } })
|
||||
|
||||
await expect(
|
||||
addEntry(admin, { id: crypto.randomUUID(), caseNumber: akte.caseNumber, body: 'Nachtrag' }),
|
||||
).rejects.toBeInstanceOf(AccessError)
|
||||
})
|
||||
|
||||
it('behandelt denselben Vermerk zweimal gesendet als einen', async () => {
|
||||
const { createCase, addEntry } = await import('@/lib/cases/service')
|
||||
const akte = await createCase(perms(5, VOLL), {
|
||||
title: 'Doppelt gesendet',
|
||||
classification: 1,
|
||||
templateKey: `${marke}-vorlage`,
|
||||
})
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' })
|
||||
await addEntry(perms(5, VOLL), { id, caseNumber: akte.caseNumber, body: 'Einmal' })
|
||||
|
||||
const anzahl = await db.caseEntry.count({ where: { caseId: akte.id, type: 'NOTE' } })
|
||||
expect(anzahl).toBe(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user