diff --git a/packages/db/scripts/prepare-test-db.mjs b/packages/db/scripts/prepare-test-db.mjs
index 635cab1..9538c42 100644
--- a/packages/db/scripts/prepare-test-db.mjs
+++ b/packages/db/scripts/prepare-test-db.mjs
@@ -51,4 +51,23 @@ execFileSync(process.execPath, [tsxCli, 'prisma/seed.ts'], {
env: testEnv,
})
+// Die Änderungsrichtlinie ist eine einzige Zeile für die ganze Anwendung. Der
+// Seed lässt sie bewusst in Ruhe, damit er die Einstellung des Leiters nicht
+// überschreibt — in der Testdatenbank soll sie aber bei jedem Lauf im
+// Auslieferungszustand starten, sonst hängt das Ergebnis davon ab, was der
+// vorige Lauf hinterlassen hat.
+const reset = new pg.Client({ connectionString: testUrl })
+await reset.connect()
+try {
+ await reset.query(
+ `UPDATE "AppSetting"
+ SET "entryEditPolicy" = 'GRACE_WINDOW',
+ "entryEditGraceMinutes" = 15,
+ "entryEditMarkGrace" = false
+ WHERE id = 1`,
+ )
+} finally {
+ await reset.end()
+}
+
console.log(`[test-db] ${dbName} ist auf Stand`)
diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx
new file mode 100644
index 0000000..cda3a21
--- /dev/null
+++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { de } from '@/lib/i18n/de'
+import { savePolicy } from './actions'
+
+const POLICIES = ['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN'] as const
+
+/**
+ * Nicht optimistisch: die Richtlinie wirkt sofort für alle Akten.
+ * Bestätigungsdialog, Wartezustand am Knopf, klare Rückmeldung.
+ * Siehe docs/09-performance-und-ux.md.
+ */
+export function PolicyForm({
+ initial,
+}: {
+ initial: { entryEditPolicy: string; entryEditGraceMinutes: number; entryEditMarkGrace: boolean }
+}) {
+ const [policy, setPolicy] = useState(initial.entryEditPolicy)
+ const [grace, setGrace] = useState(initial.entryEditGraceMinutes)
+ const [mark, setMark] = useState(initial.entryEditMarkGrace)
+ const [saved, setSaved] = useState(false)
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ function submit() {
+ if (!confirm(de.settings.confirm)) return
+ setError(null)
+ startTransition(async () => {
+ const result = await savePolicy({
+ entryEditPolicy: policy,
+ entryEditGraceMinutes: grace,
+ entryEditMarkGrace: mark,
+ })
+ if (result.ok) setSaved(true)
+ else setError(result.error)
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/web/app/(app)/admin/richtlinie/actions.ts b/web/app/(app)/admin/richtlinie/actions.ts
new file mode 100644
index 0000000..8c8850c
--- /dev/null
+++ b/web/app/(app)/admin/richtlinie/actions.ts
@@ -0,0 +1,57 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { requirePermissionForAction } from '@/lib/auth/current'
+import { writeAudit } from '@/lib/audit'
+import { de } from '@/lib/i18n/de'
+
+const schema = z.object({
+ entryEditPolicy: z.enum(['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN']),
+ entryEditGraceMinutes: z.coerce.number().int().min(0).max(1440),
+ entryEditMarkGrace: z.boolean(),
+})
+
+export type PolicyResult = { ok: true } | { ok: false; error: string }
+
+/**
+ * Die Änderungsrichtlinie gilt für die ganze Abteilung und wird serverseitig
+ * gelesen, nie aus dem Request. Deshalb ist das hier die einzige Stelle, an
+ * der sie sich ändern lässt — mit Recht `admin` und mit Protokolleintrag.
+ */
+export async function savePolicy(input: unknown): Promise {
+ const user = await requirePermissionForAction('admin')
+
+ const parsed = schema.safeParse(input)
+ if (!parsed.success) return { ok: false, error: de.case.errors.default! }
+
+ const before = await db.appSetting.findUnique({ where: { id: 1 } })
+
+ await db.$transaction(async (tx) => {
+ await tx.appSetting.upsert({
+ where: { id: 1 },
+ create: { id: 1, ...parsed.data, updatedById: user.id },
+ update: { ...parsed.data, updatedById: user.id },
+ })
+
+ await writeAudit(tx, {
+ actorId: user.id,
+ action: 'roles.update',
+ targetType: 'AppSetting',
+ targetId: '1',
+ clearanceAtTime: user.permissions.clearanceLevel,
+ meta: {
+ from: {
+ policy: before?.entryEditPolicy,
+ grace: before?.entryEditGraceMinutes,
+ mark: before?.entryEditMarkGrace,
+ },
+ to: parsed.data,
+ },
+ })
+ })
+
+ revalidatePath('/admin/richtlinie')
+ return { ok: true }
+}
diff --git a/web/app/(app)/admin/richtlinie/page.tsx b/web/app/(app)/admin/richtlinie/page.tsx
new file mode 100644
index 0000000..6e78957
--- /dev/null
+++ b/web/app/(app)/admin/richtlinie/page.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from 'next'
+import { db } from '@/lib/db'
+import { requirePermission } from '@/lib/auth/current'
+import { de } from '@/lib/i18n/de'
+import { PolicyForm } from './PolicyForm'
+
+export const metadata: Metadata = { title: de.settings.heading }
+export const dynamic = 'force-dynamic'
+
+export default async function PolicyPage() {
+ await requirePermission('admin')
+
+ const setting = await db.appSetting.findUnique({ where: { id: 1 } })
+
+ return (
+
+ {de.settings.heading}
+ {de.settings.intro}
+
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx
new file mode 100644
index 0000000..a89fc9d
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx
@@ -0,0 +1,140 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { de } from '@/lib/i18n/de'
+import { changeStatusAction, reclassifyAction } from '../actions'
+
+/**
+ * Statuswechsel und Umklassifizierung.
+ *
+ * Bewusst **nicht** optimistisch: beides ist folgenreich und teils nicht
+ * umkehrbar. Stattdessen ein sofortiger Wartezustand am Knopf und eine klare
+ * Rückmeldung. Siehe docs/09-performance-und-ux.md.
+ */
+export function CaseActions({
+ caseNumber,
+ classification,
+ statusTargets,
+ levels,
+ maxLevel,
+}: {
+ caseNumber: string
+ classification: number
+ statusTargets: string[]
+ levels: { level: number; name: string }[]
+ maxLevel: number
+}) {
+ const [nextStatus, setNextStatus] = useState(statusTargets[0] ?? '')
+ const [statusReason, setStatusReason] = useState('')
+ const [nextLevel, setNextLevel] = useState(classification)
+ const [levelReason, setLevelReason] = useState('')
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ const lowering = nextLevel < classification
+
+ function run(action: () => Promise<{ ok: boolean; error?: string }>, confirmText: string) {
+ if (!confirm(confirmText)) return
+ setError(null)
+ startTransition(async () => {
+ const result = await action()
+ if (!result.ok) setError(result.error ?? de.case.errors.default!)
+ })
+ }
+
+ return (
+
+ {statusTargets.length > 0 ? (
+
+
{de.case.statusChange}
+
+
+ setStatusReason(event.target.value)}
+ placeholder={de.entry.changeReason}
+ className="w-56 border border-line bg-raised px-2 py-1"
+ />
+
+
+
+ ) : null}
+
+
+
{de.case.reclassify}
+
+
+ {/* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher
+ nicht sehen durften. Deshalb ist die Begründung Pflicht. */}
+ {lowering ? (
+ setLevelReason(event.target.value)}
+ placeholder={de.case.reclassifyReason}
+ className="w-64 border border-line bg-raised px-2 py-1"
+ />
+ ) : null}
+
+
+
+
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx
new file mode 100644
index 0000000..8153334
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx
@@ -0,0 +1,141 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { de } from '@/lib/i18n/de'
+import { editEntryAction, restoreEntryAction, retractEntryAction } from '../actions'
+
+/**
+ * Ändern und Zurückziehen eines Verlaufseintrags.
+ *
+ * Nicht optimistisch: die Begründung gehört erst abgeschickt und bestätigt,
+ * bevor der neue Text steht. Siehe docs/09-performance-und-ux.md.
+ */
+export function EntryActions({
+ entryId,
+ caseNumber,
+ body,
+ retracted,
+ canEdit,
+ canRetract,
+}: {
+ entryId: string
+ caseNumber: string
+ body: string
+ retracted: boolean
+ canEdit: boolean
+ canRetract: boolean
+}) {
+ const [mode, setMode] = useState<'idle' | 'edit' | 'retract'>('idle')
+ const [draft, setDraft] = useState(body)
+ const [reason, setReason] = useState('')
+ const [error, setError] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ function run(action: () => Promise<{ ok: boolean; error?: string }>) {
+ setError(null)
+ startTransition(async () => {
+ const result = await action()
+ if (result.ok) {
+ setMode('idle')
+ setReason('')
+ } else {
+ setError(result.error ?? de.case.errors.default!)
+ }
+ })
+ }
+
+ if (retracted) {
+ if (!canRetract) return null
+ return (
+
+ setReason(event.target.value)}
+ placeholder={de.entry.retractReason}
+ className="w-64 border border-line bg-raised px-2 py-1"
+ />
+
+ {error ? {error} : null}
+
+ )
+ }
+
+ if (mode === 'idle') {
+ if (!canEdit && !canRetract) return null
+ return (
+
+ {canEdit ? (
+
+ ) : null}
+ {canRetract ? (
+
+ ) : null}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
new file mode 100644
index 0000000..4ef79e6
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
@@ -0,0 +1,95 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requireUser } from '@/lib/auth/current'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { loadChangeHistory } from '@/lib/cases/entryService'
+import { de } from '@/lib/i18n/de'
+
+export const metadata: Metadata = { title: de.entry.changesHeading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Änderungsübersicht als eigener Reiter an der Akte.
+ *
+ * Für die Abteilungsleitung ist das die Ansicht, mit der sie im Zweifel prüft,
+ * ob an einer Akte nachträglich gedreht wurde. Sichtbar für alle, die die Akte
+ * lesen dürfen — nicht nur für die Leitung. Das ist der ganze Sinn der Sache.
+ */
+export default async function ChangesPage({
+ params,
+}: {
+ params: Promise<{ caseNumber: string }>
+}) {
+ const user = await requireUser()
+ const { caseNumber } = await params
+
+ const akte = await loadVisibleCase(user.permissions, caseNumber)
+ if (!akte) notFound()
+
+ const versionen = await loadChangeHistory(user.permissions, akte.id)
+
+ const namen = new Map(
+ (
+ await db.user.findMany({
+ where: { id: { in: [...new Set(versionen.map((v) => v.editedById))] } },
+ select: { id: true, displayName: true },
+ })
+ ).map((u) => [u.id, u.displayName]),
+ )
+
+ return (
+
+
+ {akte.caseNumber}
+
+ {de.entry.changesHeading}
+ {de.entry.changesIntro}
+
+ {versionen.length === 0 ? (
+ {de.entry.changesEmpty}
+ ) : (
+
+ {versionen.map((version) => (
+ -
+
+
+ {de.entry.versionLabel(version.version)} → {version.version + 1}
+
+
+ {namen.get(version.editedById) ?? version.editedById}
+
+
+
+
+ {version.changeReason ? (
+
+ {de.entry.changeReason}:
+ {version.changeReason}
+
+ ) : null}
+
+
+
+
{de.entry.before}
+
+ {version.body}
+
+
+
+
{de.entry.after}
+
+ {version.entry.body}
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx
index d5a1a35..07253c3 100644
--- a/web/app/(app)/faelle/[caseNumber]/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/page.tsx
@@ -10,6 +10,12 @@ import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
import { EntryComposer } from './EntryComposer'
+import { CaseActions } from './CaseActions'
+import { EntryActions } from './EntryActions'
+import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
+import { evaluateEntryEdit, canRetract } from '@/lib/cases/entryPolicy'
+import Link from 'next/link'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
@@ -27,25 +33,27 @@ export async function generateMetadata({
* 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 }: {
+async function Timeline({
+ caseId,
+ caseNumber,
+ permissions,
+}: {
caseId: string
- userId: string
- clearanceLevel: number
- isAdmin: boolean
+ caseNumber: string
+ permissions: EffectivePermissions
}) {
+ const settings = await db.appSetting.findUnique({ where: { id: 1 } })
+ const policy = {
+ entryEditPolicy: (settings?.entryEditPolicy ?? 'GRACE_WINDOW') as
+ | 'APPEND_ONLY'
+ | 'GRACE_WINDOW'
+ | 'OPEN',
+ entryEditGraceMinutes: settings?.entryEditGraceMinutes ?? 15,
+ entryEditMarkGrace: settings?.entryEditMarkGrace ?? false,
+ }
+
const entries = await db.caseEntry.findMany({
- where: {
- AND: [
- visibleEntryWhere({
- userId,
- clearanceLevel,
- isAdmin,
- permissions: new Set(),
- hasMembership: true,
- }),
- { caseId },
- ],
- },
+ where: { AND: [visibleEntryWhere(permissions), { caseId }] },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 100,
select: {
@@ -84,7 +92,12 @@ async function Timeline({ caseId, userId, clearanceLevel, isAdmin }: {
{entry.createdAt.toLocaleString('de-DE')}
{entry.editVisible && entry.editedAt ? (
- {de.entry.edited(entry.version)}
+
+ {de.entry.edited(entry.version)}
+
) : null}
@@ -101,6 +114,15 @@ async function Timeline({ caseId, userId, clearanceLevel, isAdmin }: {
{entry.body}
)}
+
+
))}
@@ -131,9 +153,8 @@ export default async function CasePage({
})
}
- const level = await db.clearanceLevelConfig.findUnique({
- where: { level: akte.classification },
- })
+ const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } })
+ const level = levels.find((l) => l.level === akte.classification)
return (
@@ -162,11 +183,21 @@ export default async function CasePage({
) : null}
+ ({ level: l.level, name: l.name }))}
+ maxLevel={user.permissions.clearanceLevel}
+ />
-
- {de.case.timeline}
+
+
@@ -186,9 +217,8 @@ export default async function CasePage({
>
diff --git a/web/app/(app)/faelle/actions.ts b/web/app/(app)/faelle/actions.ts
index e059ac6..1ea84d9 100644
--- a/web/app/(app)/faelle/actions.ts
+++ b/web/app/(app)/faelle/actions.ts
@@ -5,6 +5,7 @@ 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 { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
@@ -121,3 +122,62 @@ export async function reclassifyAction(input: unknown): Promise {
revalidatePath('/faelle')
return { ok: true }
}
+
+const editSchema = z.object({
+ entryId: z.string().min(1),
+ caseNumber: z.string().min(1),
+ body: z.string().trim().min(1).max(20_000),
+ minClearance: z.coerce.number().int().min(0).max(5).optional(),
+ changeReason: z.string().trim().optional(),
+})
+
+export async function editEntryAction(input: unknown): Promise {
+ const user = await requireUser()
+ const parsed = editSchema.safeParse(input)
+ if (!parsed.success) return { ok: false, error: de.case.errors.default! }
+
+ try {
+ await editEntry(user.permissions, parsed.data)
+ } catch (error) {
+ return { ok: false, error: toMessage(error) }
+ }
+
+ revalidatePath(`/faelle/${parsed.data.caseNumber}`)
+ return { ok: true }
+}
+
+const retractSchema = z.object({
+ entryId: z.string().min(1),
+ caseNumber: z.string().min(1),
+ reason: z.string().trim().min(10),
+})
+
+export async function retractEntryAction(input: unknown): Promise {
+ const user = await requireUser()
+ const parsed = retractSchema.safeParse(input)
+ if (!parsed.success) return { ok: false, error: de.entry.reasonTooShort }
+
+ try {
+ await retractEntry(user.permissions, parsed.data)
+ } catch (error) {
+ return { ok: false, error: toMessage(error) }
+ }
+
+ revalidatePath(`/faelle/${parsed.data.caseNumber}`)
+ return { ok: true }
+}
+
+export async function restoreEntryAction(input: unknown): Promise {
+ const user = await requireUser()
+ const parsed = retractSchema.safeParse(input)
+ if (!parsed.success) return { ok: false, error: de.entry.reasonTooShort }
+
+ try {
+ await restoreEntry(user.permissions, parsed.data)
+ } catch (error) {
+ return { ok: false, error: toMessage(error) }
+ }
+
+ revalidatePath(`/faelle/${parsed.data.caseNumber}`)
+ return { ok: true }
+}
diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx
index 6b45ce7..11f1134 100644
--- a/web/app/(app)/layout.tsx
+++ b/web/app/(app)/layout.tsx
@@ -23,7 +23,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{de.nav.dashboard}
{de.nav.cases}
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
- {de.nav.admin}
+ <>
+ {de.nav.admin}
+ {user.permissions.isAdmin ? (
+ {de.settings.heading}
+ ) : null}
+ >
) : null}
diff --git a/web/lib/cases/entryPolicy.ts b/web/lib/cases/entryPolicy.ts
new file mode 100644
index 0000000..d66cfc2
--- /dev/null
+++ b/web/lib/cases/entryPolicy.ts
@@ -0,0 +1,91 @@
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Wer darf einen Verlaufseintrag nachträglich ändern, und ist die Änderung
+ * sichtbar zu markieren? Siehe docs/05-akten-und-vorlagen.md.
+ *
+ * Bewusst eine reine Funktion ohne Datenbank: das ist die Regel, an der die
+ * Nachvollziehbarkeit der ganzen Akte hängt, und sie gehört einzeln prüfbar.
+ * Die Karenzzeit wird **serverseitig** aus `AppSetting` gelesen, nie aus dem
+ * Request.
+ */
+export type EditPolicyName = 'APPEND_ONLY' | 'GRACE_WINDOW' | 'OPEN'
+
+export type EntrySnapshot = {
+ authorId: string
+ type: string
+ version: number
+ createdAt: Date
+ retractedAt: Date | null
+ minClearance: number
+}
+
+export type EditSettings = {
+ entryEditPolicy: EditPolicyName
+ entryEditGraceMinutes: number
+ entryEditMarkGrace: boolean
+}
+
+export type EditDecision =
+ | {
+ ok: true
+ /** Begründung Pflicht (mindestens 10 Zeichen)? */
+ reasonRequired: boolean
+ /** Bekommt der Eintrag ein sichtbares „bearbeitet · vN"? */
+ markVisible: boolean
+ withinGrace: boolean
+ }
+ | { ok: false; reason: EditDenial }
+
+export type EditDenial =
+ | 'system-entry'
+ | 'retracted'
+ | 'append-only'
+ | 'grace-expired'
+ | 'not-author'
+
+/** Systemeinträge sind von jeder Änderung ausgenommen. */
+const IMMUTABLE_TYPES = new Set(['SYSTEM', 'STATUS_CHANGE'])
+
+export function evaluateEntryEdit(
+ p: EffectivePermissions,
+ entry: EntrySnapshot,
+ settings: EditSettings,
+ now: Date = new Date(),
+): EditDecision {
+ if (IMMUTABLE_TYPES.has(entry.type)) return { ok: false, reason: 'system-entry' }
+ if (entry.retractedAt) return { ok: false, reason: 'retracted' }
+ if (settings.entryEditPolicy === 'APPEND_ONLY') return { ok: false, reason: 'append-only' }
+
+ const isOwn = entry.authorId === p.userId
+ const ageMs = now.getTime() - entry.createdAt.getTime()
+ const withinGrace =
+ settings.entryEditPolicy === 'GRACE_WINDOW' &&
+ isOwn &&
+ ageMs <= settings.entryEditGraceMinutes * 60_000
+
+ // Ab der zweiten Änderung oder bei einem fremden Eintrag ist die Begründung
+ // Pflicht. Sie steht danach in der Historie, die jeder sieht, der den
+ // Eintrag sehen darf.
+ const reasonRequired = entry.version >= 2 || !isOwn
+
+ if (withinGrace) {
+ // Ein Tippfehler, der zwei Minuten nach dem Absenden korrigiert wird, soll
+ // die Akte nicht mit „bearbeitet"-Marken zupflastern. Versioniert und
+ // protokolliert wird trotzdem.
+ return { ok: true, reasonRequired, markVisible: settings.entryEditMarkGrace, withinGrace }
+ }
+
+ const needed = isOwn ? 'entry.edit_own' : 'entry.edit_any'
+ if (!p.isAdmin && !p.permissions.has(needed)) {
+ return { ok: false, reason: isOwn ? 'grace-expired' : 'not-author' }
+ }
+
+ return { ok: true, reasonRequired, markVisible: true, withinGrace: false }
+}
+
+/** Zurückziehen und Wiederherstellen hängen beide am Recht `entry.retract`. */
+export function canRetract(p: EffectivePermissions, entry: EntrySnapshot): boolean {
+ if (IMMUTABLE_TYPES.has(entry.type)) return false
+ return p.isAdmin || p.permissions.has('entry.retract')
+}
diff --git a/web/lib/cases/entryService.ts b/web/lib/cases/entryService.ts
new file mode 100644
index 0000000..91b7779
--- /dev/null
+++ b/web/lib/cases/entryService.ts
@@ -0,0 +1,254 @@
+import { db } from '@/lib/db'
+import { writeAudit } from '@/lib/audit'
+import { visibleEntryWhere } from '@/lib/access/caseFilter'
+import { canEditCase } from '@/lib/access/caseWrite'
+import { AccessError } from '@/lib/cases/service'
+import { canRetract, evaluateEntryEdit, type EditSettings } from '@/lib/cases/entryPolicy'
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+
+/**
+ * Nachträgliche Änderungen an Verlaufseinträgen.
+ *
+ * Der Kern von harter Regel 2: geändert werden darf, unsichtbar werden nie.
+ * Jede Änderung schreibt den **vorherigen** Stand nach `CaseEntryVersion` und
+ * erhöht die Versionsnummer. Es gibt kein Löschen, nur Zurückziehen mit
+ * Grabstein. Siehe docs/05-akten-und-vorlagen.md.
+ */
+
+const MIN_REASON_LENGTH = 10
+
+async function loadSettings(): Promise {
+ const setting = await db.appSetting.findUnique({ where: { id: 1 } })
+ return {
+ entryEditPolicy: (setting?.entryEditPolicy ?? 'GRACE_WINDOW') as EditSettings['entryEditPolicy'],
+ entryEditGraceMinutes: setting?.entryEditGraceMinutes ?? 15,
+ entryEditMarkGrace: setting?.entryEditMarkGrace ?? false,
+ }
+}
+
+/** Lädt einen Eintrag, sofern der Nutzer ihn und seine Akte sehen darf. */
+async function loadVisibleEntry(p: EffectivePermissions, entryId: string) {
+ return db.caseEntry.findFirst({
+ where: { AND: [visibleEntryWhere(p), { id: entryId }] },
+ select: {
+ id: true,
+ caseId: true,
+ type: true,
+ body: true,
+ minClearance: true,
+ authorId: true,
+ createdAt: true,
+ version: true,
+ retractedAt: true,
+ case: { select: { caseNumber: true, status: true, classification: true } },
+ },
+ })
+}
+
+/** Ein knapper Diff für das Protokoll, ohne den ganzen Text zu doppeln. */
+function compactDiff(before: string, after: string) {
+ return {
+ lengthBefore: before.length,
+ lengthAfter: after.length,
+ firstDivergenceAt: (() => {
+ const max = Math.min(before.length, after.length)
+ for (let i = 0; i < max; i++) if (before[i] !== after[i]) return i
+ return before.length === after.length ? -1 : max
+ })(),
+ }
+}
+
+export async function editEntry(
+ p: EffectivePermissions,
+ input: { entryId: string; body: string; minClearance?: number; changeReason?: string },
+): Promise<{ version: number; markVisible: boolean }> {
+ const entry = await loadVisibleEntry(p, input.entryId)
+ if (!entry) throw new AccessError('not-found')
+
+ // Archiviert und versiegelt sind dicht, abgeschlossen braucht ein eigenes Recht.
+ const caseCheck = canEditCase(p, entry.case, 'entry.create')
+ if (!caseCheck.ok) throw new AccessError(caseCheck.reason)
+
+ const settings = await loadSettings()
+ const decision = evaluateEntryEdit(p, entry, settings)
+ if (!decision.ok) throw new AccessError(decision.reason)
+
+ const reason = input.changeReason?.trim() ?? ''
+ if (decision.reasonRequired && reason.length < MIN_REASON_LENGTH) {
+ throw new AccessError('reason-required')
+ }
+
+ const nextClearance = input.minClearance ?? entry.minClearance
+ if (nextClearance !== entry.minClearance) {
+ if (!p.isAdmin && !p.permissions.has('case.edit')) throw new AccessError('no-permission')
+ if (nextClearance > p.clearanceLevel) throw new AccessError('clearance-too-low')
+ }
+
+ const nextVersion = entry.version + 1
+ const closedNote = entry.case.status === 'CLOSED'
+
+ await db.$transaction(async (tx) => {
+ // Der vorherige Stand wandert in die Versionstabelle, bevor der Eintrag
+ // überschrieben wird.
+ await tx.caseEntryVersion.create({
+ data: {
+ entryId: entry.id,
+ version: entry.version,
+ body: entry.body,
+ minClearance: entry.minClearance,
+ changeReason: reason || null,
+ editedById: p.userId,
+ },
+ })
+
+ await tx.caseEntry.update({
+ where: { id: entry.id },
+ data: {
+ body: input.body,
+ minClearance: nextClearance,
+ version: nextVersion,
+ editedAt: new Date(),
+ editedById: p.userId,
+ editVisible: decision.markVisible,
+ },
+ })
+
+ // Umklassifizierung wirkt rückwirkend: sonst liest jemand mit niedrigerer
+ // Stufe den ursprünglichen Text einfach in der Historie. Das ist die
+ // Stelle, an der eine naive Umsetzung leckt.
+ if (nextClearance > entry.minClearance) {
+ await tx.caseEntryVersion.updateMany({
+ where: { entryId: entry.id, minClearance: { lt: nextClearance } },
+ data: { minClearance: nextClearance },
+ })
+ }
+
+ if (closedNote) {
+ await tx.caseEntry.create({
+ data: {
+ id: crypto.randomUUID(),
+ caseId: entry.caseId,
+ type: 'SYSTEM',
+ body: 'Nachträgliche Änderung nach Abschluss',
+ authorId: p.userId,
+ minClearance: 0,
+ },
+ })
+ }
+
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'entry.edit',
+ targetType: 'CaseEntry',
+ targetId: entry.id,
+ clearanceAtTime: p.clearanceLevel,
+ meta: {
+ caseNumber: entry.case.caseNumber,
+ version: nextVersion,
+ reason: reason || undefined,
+ withinGrace: decision.withinGrace,
+ markVisible: decision.markVisible,
+ minClearanceFrom: entry.minClearance,
+ minClearanceTo: nextClearance,
+ diff: compactDiff(entry.body, input.body),
+ },
+ })
+ })
+
+ return { version: nextVersion, markVisible: decision.markVisible }
+}
+
+/**
+ * Zurückziehen statt Löschen. Der Eintrag bleibt an seiner Stelle im Verlauf
+ * stehen und zeigt einen Grabstein. Der ursprüngliche Text bleibt in der
+ * Datenbank und ist für Nutzer mit `audit.read` weiterhin lesbar.
+ */
+export async function retractEntry(
+ p: EffectivePermissions,
+ input: { entryId: string; reason: string },
+): Promise {
+ const entry = await loadVisibleEntry(p, input.entryId)
+ if (!entry) throw new AccessError('not-found')
+
+ const caseCheck = canEditCase(p, entry.case, 'entry.create')
+ if (!caseCheck.ok) throw new AccessError(caseCheck.reason)
+
+ if (!canRetract(p, entry)) throw new AccessError('no-permission')
+ if (entry.retractedAt) throw new AccessError('retracted')
+
+ const reason = input.reason.trim()
+ if (reason.length < MIN_REASON_LENGTH) throw new AccessError('reason-required')
+
+ await db.$transaction(async (tx) => {
+ await tx.caseEntry.update({
+ where: { id: entry.id },
+ data: { retractedAt: new Date(), retractedById: p.userId, retractionReason: reason },
+ })
+
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'entry.retract',
+ targetType: 'CaseEntry',
+ targetId: entry.id,
+ clearanceAtTime: p.clearanceLevel,
+ meta: { caseNumber: entry.case.caseNumber, reason },
+ })
+ })
+}
+
+/** Zurückziehen lässt sich rückgängig machen, was wiederum protokolliert wird. */
+export async function restoreEntry(
+ p: EffectivePermissions,
+ input: { entryId: string; reason: string },
+): Promise {
+ const entry = await loadVisibleEntry(p, input.entryId)
+ if (!entry) throw new AccessError('not-found')
+
+ const caseCheck = canEditCase(p, entry.case, 'entry.create')
+ if (!caseCheck.ok) throw new AccessError(caseCheck.reason)
+
+ if (!canRetract(p, entry)) throw new AccessError('no-permission')
+ if (!entry.retractedAt) throw new AccessError('not-retracted')
+
+ const reason = input.reason.trim()
+ if (reason.length < MIN_REASON_LENGTH) throw new AccessError('reason-required')
+
+ await db.$transaction(async (tx) => {
+ await tx.caseEntry.update({
+ where: { id: entry.id },
+ data: { retractedAt: null, retractedById: null, retractionReason: null },
+ })
+
+ await writeAudit(tx, {
+ actorId: p.userId,
+ action: 'entry.restore',
+ targetType: 'CaseEntry',
+ targetId: entry.id,
+ clearanceAtTime: p.clearanceLevel,
+ meta: { caseNumber: entry.case.caseNumber, reason },
+ })
+ })
+}
+
+/**
+ * Änderungsübersicht einer Akte: alle nachträglichen Änderungen am Verlauf,
+ * chronologisch. Sichtbar für alle, die die Akte lesen dürfen — das ist der
+ * ganze Sinn der Sache.
+ */
+export async function loadChangeHistory(p: EffectivePermissions, caseId: string) {
+ return db.caseEntryVersion.findMany({
+ where: { entry: { AND: [visibleEntryWhere(p), { caseId }] } },
+ orderBy: [{ editedAt: 'desc' }],
+ take: 200,
+ select: {
+ id: true,
+ entryId: true,
+ version: true,
+ body: true,
+ changeReason: true,
+ editedById: true,
+ editedAt: true,
+ entry: { select: { body: true, version: true, type: true, createdAt: true } },
+ },
+ })
+}
diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts
index 52fab35..aef2aaa 100644
--- a/web/lib/i18n/de.ts
+++ b/web/lib/i18n/de.ts
@@ -185,6 +185,20 @@ export const de = {
empty: 'Noch kein Vermerk. Ersten Vermerk anlegen.',
edited: (v: number) => `bearbeitet · v${v}`,
history: 'Änderungsverlauf',
+ edit: 'Bearbeiten',
+ editSave: 'Änderung speichern',
+ retract: 'Zurückziehen',
+ restore: 'Wiederherstellen',
+ retractReason: 'Begründung der Zurücknahme',
+ reasonTooShort: 'Begründung nötig, mindestens 10 Zeichen.',
+ cancel: 'Abbrechen',
+ changesHeading: 'Änderungen an dieser Akte',
+ changesIntro:
+ 'Jede nachträgliche Änderung am Verlauf, chronologisch. Sichtbar für alle, die die Akte lesen dürfen.',
+ changesEmpty: 'Bisher wurde nichts nachträglich geändert.',
+ versionLabel: (v: number) => `Fassung ${v}`,
+ before: 'Vorher',
+ after: 'Jetzt',
changeReason: 'Begründung',
retracted: (date: string, person: string, reason: string) =>
`Eintrag zurückgezogen am ${date} durch ${person} · Begründung: ${reason}`,
@@ -229,6 +243,23 @@ export const de = {
conflict: 'Nicht gespeichert, die Akte wurde zwischenzeitlich geändert. Fassungen vergleichen.',
},
+ settings: {
+ heading: 'Änderungsrichtlinie',
+ intro:
+ 'Regelt, ob und wie lange Verlaufseinträge nachträglich geändert werden dürfen. Gilt für die ganze Abteilung.',
+ policy: 'Richtlinie',
+ graceMinutes: 'Karenzzeit in Minuten',
+ markGrace: 'Auch Änderungen in der Karenzzeit sichtbar markieren',
+ save: 'Richtlinie speichern',
+ saved: 'Richtlinie gespeichert',
+ confirm: 'Änderungsrichtlinie ändern? Das gilt sofort für alle Akten.',
+ policies: {
+ APPEND_ONLY: 'Keine Änderung möglich, nur Korrektureinträge',
+ GRACE_WINDOW: 'Eigene Einträge in der Karenzzeit frei änderbar, danach nur mit Recht',
+ OPEN: 'Jederzeit änderbar, sofern das Recht vorliegt',
+ } as Record,
+ },
+
health: {
heading: 'Systemzustand',
service: 'Dienst',
diff --git a/web/tests/eintragsaenderung.test.ts b/web/tests/eintragsaenderung.test.ts
new file mode 100644
index 0000000..fac76d2
--- /dev/null
+++ b/web/tests/eintragsaenderung.test.ts
@@ -0,0 +1,273 @@
+// @vitest-environment node
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import { createPrismaClient, type PrismaClient } from '@afis/db'
+import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
+
+/**
+ * Nachträgliche Änderungen an Verlaufseinträgen, harte Regel 2.
+ * Grundlage: docs/05-akten-und-vorlagen.md.
+ */
+const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
+const suite = url ? describe : describe.skip
+
+let db: PrismaClient
+const marke = `edit-${Date.now()}`
+const AUTOR = `${marke}-autor`
+const FREMD = `${marke}-fremd`
+
+function perms(userId: string, level: number, permissions: Permission[]): EffectivePermissions {
+ return {
+ userId,
+ clearanceLevel: level,
+ permissions: new Set(permissions),
+ isAdmin: false,
+ hasMembership: true,
+ }
+}
+
+const BASIS: Permission[] = ['case.create', 'case.edit', 'entry.create']
+
+let caseId = ''
+let caseNumber = ''
+
+suite('Nachträgliche Änderungen am Verlauf', () => {
+ beforeAll(async () => {
+ db = createPrismaClient(url)
+ await db.caseTemplate.create({
+ data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
+ })
+ for (const id of [AUTOR, FREMD]) {
+ await db.user.create({ data: { id, username: id, displayName: id } })
+ }
+ const { createCase } = await import('@/lib/cases/service')
+ const akte = await createCase(perms(AUTOR, 5, BASIS), {
+ title: 'Änderungsprüfung',
+ classification: 1,
+ templateKey: `${marke}-vorlage`,
+ })
+ caseId = akte.id
+ caseNumber = akte.caseNumber
+ })
+
+ afterAll(async () => {
+ // Die Änderungsrichtlinie ist eine einzige Zeile für die ganze Anwendung.
+ // Wer sie im Test umstellt, stellt sie zurück, sonst kippt sie anderen
+ // Testdateien den Auslieferungszustand um.
+ await db.appSetting.update({
+ where: { id: 1 },
+ data: { entryEditPolicy: 'GRACE_WINDOW', entryEditGraceMinutes: 15, entryEditMarkGrace: false },
+ })
+ await db.$disconnect()
+ })
+
+ beforeEach(async () => {
+ await db.appSetting.update({
+ where: { id: 1 },
+ data: { entryEditPolicy: 'GRACE_WINDOW', entryEditGraceMinutes: 15, entryEditMarkGrace: false },
+ })
+ })
+
+ async function neuerEintrag(body: string, minClearance = 0): Promise {
+ const { addEntry } = await import('@/lib/cases/service')
+ const id = crypto.randomUUID()
+ await addEntry(perms(AUTOR, 5, BASIS), { id, caseNumber, body, minClearance })
+ return id
+ }
+
+ it('versioniert eine Änderung und lässt Autor und Entstehungszeit unangetastet', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const id = await neuerEintrag('Ursprünglicher Wortlaut')
+ const vorher = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+
+ await editEntry(perms(AUTOR, 5, BASIS), { entryId: id, body: 'Korrigierter Wortlaut' })
+
+ const nachher = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+ expect(nachher.body).toBe('Korrigierter Wortlaut')
+ expect(nachher.version).toBe(2)
+ expect(nachher.authorId).toBe(vorher.authorId)
+ expect(nachher.createdAt.getTime()).toBe(vorher.createdAt.getTime())
+
+ // Der vorherige Stand steht in der Versionstabelle.
+ const versionen = await db.caseEntryVersion.findMany({ where: { entryId: id } })
+ expect(versionen).toHaveLength(1)
+ expect(versionen[0]?.body).toBe('Ursprünglicher Wortlaut')
+ })
+
+ it('markiert eine Änderung in der Karenzzeit nicht sichtbar, protokolliert sie aber', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const id = await neuerEintrag('Tipfehler')
+
+ await editEntry(perms(AUTOR, 5, BASIS), { entryId: id, body: 'Tippfehler' })
+
+ const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+ expect(eintrag.editVisible).toBe(false)
+ expect(eintrag.editedAt).not.toBeNull()
+
+ const protokoll = await db.auditLog.findFirst({
+ where: { targetId: id, action: 'entry.edit' },
+ orderBy: { at: 'desc' },
+ })
+ expect(protokoll).not.toBeNull()
+ expect(protokoll?.meta).toMatchObject({ withinGrace: true, markVisible: false })
+ })
+
+ it('verlangt nach Ablauf der Karenzzeit das Recht entry.edit_own', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const id = await neuerEintrag('Alter Eintrag')
+
+ // Karenzzeit serverseitig auf null setzen — nicht über den Request.
+ await db.appSetting.update({ where: { id: 1 }, data: { entryEditGraceMinutes: 0 } })
+
+ await expect(
+ editEntry(perms(AUTOR, 5, BASIS), {
+ entryId: id,
+ body: 'Zu spät',
+ changeReason: 'Nachträgliche Korrektur des Sachverhalts',
+ }),
+ ).rejects.toBeInstanceOf(AccessError)
+
+ // Mit dem Recht geht es, und die Änderung ist sichtbar markiert.
+ await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), {
+ entryId: id,
+ body: 'Zu spät, aber erlaubt',
+ changeReason: 'Nachträgliche Korrektur des Sachverhalts',
+ })
+ const eintrag = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+ expect(eintrag.editVisible).toBe(true)
+ })
+
+ it('lässt einen fremden Eintrag ohne entry.edit_any nicht ändern', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const id = await neuerEintrag('Vom Autor geschrieben')
+
+ await expect(
+ editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_own']), {
+ entryId: id,
+ body: 'Fremdzugriff',
+ changeReason: 'Ich finde das besser so formuliert',
+ }),
+ ).rejects.toBeInstanceOf(AccessError)
+ })
+
+ it('verlangt bei einem fremden Eintrag eine Begründung', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const id = await neuerEintrag('Vom Autor geschrieben')
+
+ await expect(
+ editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_any']), { entryId: id, body: 'Ohne Grund' }),
+ ).rejects.toBeInstanceOf(AccessError)
+
+ await editEntry(perms(FREMD, 5, [...BASIS, 'entry.edit_any']), {
+ entryId: id,
+ body: 'Mit Grund',
+ changeReason: 'Sachverhalt war falsch wiedergegeben',
+ })
+ const version = await db.caseEntryVersion.findFirstOrThrow({
+ where: { entryId: id },
+ orderBy: { version: 'desc' },
+ })
+ expect(version.changeReason).toBe('Sachverhalt war falsch wiedergegeben')
+ })
+
+ it('lässt einen Systemeintrag nicht ändern', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const system = await db.caseEntry.findFirstOrThrow({
+ where: { caseId, type: 'SYSTEM' },
+ })
+ await expect(
+ editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_any']), {
+ entryId: system.id,
+ body: 'Umgeschrieben',
+ changeReason: 'Ich will die Historie schönen',
+ }),
+ ).rejects.toBeInstanceOf(AccessError)
+ })
+
+ it('zieht die Höherstufung auf alle bestehenden Versionen nach', async () => {
+ // Das ist die Stelle, an der eine naive Umsetzung leckt: sonst liest
+ // jemand mit niedrigerer Stufe den ursprünglichen Text in der Historie.
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const id = await neuerEintrag('Zunächst harmlos', 0)
+
+ await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), {
+ entryId: id,
+ body: 'Immer noch harmlos',
+ changeReason: 'Formulierung geglättet',
+ })
+
+ await editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own']), {
+ entryId: id,
+ body: 'Jetzt vertraulich',
+ minClearance: 4,
+ changeReason: 'Enthält den Namen eines Zeugen',
+ })
+
+ const versionen = await db.caseEntryVersion.findMany({ where: { entryId: id } })
+ expect(versionen.length).toBeGreaterThanOrEqual(2)
+ for (const version of versionen) {
+ expect(version.minClearance).toBe(4)
+ }
+
+ // Gegenprobe über die Sichtbarkeitsabfrage: Stufe 1 findet nichts davon.
+ const { visibleEntryWhere } = await import('@/lib/access/caseFilter')
+ const sichtbar = await db.caseEntry.findMany({
+ where: { AND: [visibleEntryWhere(perms('wer-auch-immer', 1, [])), { id }] },
+ })
+ expect(sichtbar).toHaveLength(0)
+ })
+
+ it('zieht einen Eintrag mit Grabstein zurück und stellt ihn wieder her', async () => {
+ const { retractEntry, restoreEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const id = await neuerEintrag('Gehört in einen anderen Fall')
+
+ await expect(
+ retractEntry(perms(AUTOR, 5, BASIS), { entryId: id, reason: 'Falscher Fall' }),
+ ).rejects.toBeInstanceOf(AccessError)
+
+ await retractEntry(perms(AUTOR, 5, [...BASIS, 'entry.retract']), {
+ entryId: id,
+ reason: 'Gehört in den Fall AfiS-2026-0002',
+ })
+
+ const zurueckgezogen = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+ expect(zurueckgezogen.retractedAt).not.toBeNull()
+ expect(zurueckgezogen.retractionReason).toContain('AfiS-2026-0002')
+ // Der ursprüngliche Text bleibt in der Datenbank stehen.
+ expect(zurueckgezogen.body).toBe('Gehört in einen anderen Fall')
+
+ await restoreEntry(perms(AUTOR, 5, [...BASIS, 'entry.retract']), {
+ entryId: id,
+ reason: 'Zurückziehen war ein Versehen',
+ })
+ const wiederhergestellt = await db.caseEntry.findUniqueOrThrow({ where: { id } })
+ expect(wiederhergestellt.retractedAt).toBeNull()
+
+ const aktionen = await db.auditLog.findMany({
+ where: { targetId: id },
+ orderBy: { at: 'asc' },
+ select: { action: true },
+ })
+ expect(aktionen.map((a) => a.action)).toContain('entry.retract')
+ expect(aktionen.map((a) => a.action)).toContain('entry.restore')
+ })
+
+ it('lässt bei APPEND_ONLY gar keine Änderung zu', async () => {
+ const { editEntry } = await import('@/lib/cases/entryService')
+ const { AccessError } = await import('@/lib/cases/service')
+ const id = await neuerEintrag('Unveränderlich')
+ await db.appSetting.update({ where: { id: 1 }, data: { entryEditPolicy: 'APPEND_ONLY' } })
+
+ await expect(
+ editEntry(perms(AUTOR, 5, [...BASIS, 'entry.edit_own', 'entry.edit_any']), {
+ entryId: id,
+ body: 'Doch geändert',
+ changeReason: 'Sollte nicht durchgehen',
+ }),
+ ).rejects.toBeInstanceOf(AccessError)
+ })
+})
diff --git a/web/vitest.config.mts b/web/vitest.config.mts
index bc50411..1e67353 100644
--- a/web/vitest.config.mts
+++ b/web/vitest.config.mts
@@ -9,5 +9,9 @@ export default defineConfig({
globals: true,
include: ['tests/**/*.test.{ts,tsx}'],
setupFiles: ['./tests/setup.ts'],
+ // Mehrere Testdateien arbeiten gegen dieselbe Datenbank, und AppSetting ist
+ // eine einzige Zeile für die ganze Anwendung. Parallel laufende Dateien
+ // würden sich gegenseitig die Änderungsrichtlinie umstellen.
+ fileParallelism: false,
},
})