Die restlichen zwei Änderungswünsche. **Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog — harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion, Art und Zeitraum, rückwärts blätternd. Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das niemand liest, ist keine Kontrolle. **Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt. Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren, weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein Upload mit Prüfkette ist keine Feldänderung. lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { redirect } from 'next/navigation'
|
|
import { z } from 'zod'
|
|
import { requireUser } from '@/lib/auth/current'
|
|
import { AccessError, ConflictError } from '@/lib/errors'
|
|
import {
|
|
createDocument,
|
|
finalizeDocument,
|
|
reclassifyDocument,
|
|
retractDocument,
|
|
saveDocumentSection,
|
|
type Owner,
|
|
} from '@/lib/documents/service'
|
|
import { de } from '@/lib/i18n/de'
|
|
import type { SaveSectionResult, SaveSectionsInput } from '@/components/sections/types'
|
|
|
|
export type ActionResult = { ok: true } | { ok: false; error: string }
|
|
|
|
function toMessage(error: unknown): string {
|
|
if (error instanceof AccessError) {
|
|
return de.actionErrors[error.code] ?? de.actionErrors.default!
|
|
}
|
|
console.error('[dokumente] unerwartet', error)
|
|
return de.actionErrors.default!
|
|
}
|
|
|
|
const createSchema = z.object({
|
|
templateKey: z.string().min(1),
|
|
title: z.string().max(200).optional(),
|
|
classification: z.coerce.number().int().min(0).max(5),
|
|
caseNumber: z.string().max(60).optional(),
|
|
personnelFileId: z.string().max(64).optional(),
|
|
})
|
|
|
|
export async function createDocumentAction(
|
|
_prev: ActionResult | null,
|
|
formData: FormData,
|
|
): Promise<ActionResult> {
|
|
const user = await requireUser()
|
|
const parsed = createSchema.safeParse(Object.fromEntries(formData))
|
|
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
|
|
|
|
const owner: Owner = parsed.data.caseNumber
|
|
? { kind: 'case', caseNumber: parsed.data.caseNumber }
|
|
: parsed.data.personnelFileId
|
|
? { kind: 'personnel', personnelFileId: parsed.data.personnelFileId }
|
|
: { kind: 'standalone' }
|
|
|
|
let documentNumber: string
|
|
try {
|
|
const created = await createDocument(user.permissions, {
|
|
templateKey: parsed.data.templateKey,
|
|
title: parsed.data.title,
|
|
classification: parsed.data.classification,
|
|
owner,
|
|
})
|
|
documentNumber = created.documentNumber
|
|
} catch (error) {
|
|
return { ok: false, error: toMessage(error) }
|
|
}
|
|
|
|
redirect(`/dokumente/${documentNumber}`)
|
|
}
|
|
|
|
const sectionSchema = z.object({
|
|
fieldKey: z.string().min(1),
|
|
value: z.unknown(),
|
|
baseVersion: z.coerce.number().int().min(0),
|
|
minClearance: z.coerce.number().int().min(0).max(5).optional(),
|
|
})
|
|
|
|
/** Das Dokument kommt gebunden herein, siehe `.bind`. */
|
|
export async function saveDocumentSectionAction(
|
|
documentNumber: string,
|
|
input: unknown,
|
|
): Promise<SaveSectionResult> {
|
|
const user = await requireUser()
|
|
const parsed = sectionSchema.safeParse(input)
|
|
if (!parsed.success) return { ok: false, conflict: false, error: de.actionErrors.default! }
|
|
|
|
try {
|
|
const { version } = await saveDocumentSection(user.permissions, {
|
|
documentNumber,
|
|
...parsed.data,
|
|
})
|
|
revalidatePath(`/dokumente/${documentNumber}`)
|
|
return { ok: true, version }
|
|
} catch (error) {
|
|
if (error instanceof ConflictError) {
|
|
return {
|
|
ok: false,
|
|
conflict: true,
|
|
current: { value: error.current.value, version: error.current.version },
|
|
}
|
|
}
|
|
return { ok: false, conflict: false, error: toMessage(error) }
|
|
}
|
|
}
|
|
|
|
const numberSchema = z.object({ documentNumber: z.string().min(1) })
|
|
|
|
export async function finalizeDocumentAction(input: unknown): Promise<ActionResult> {
|
|
const user = await requireUser()
|
|
const parsed = numberSchema.safeParse(input)
|
|
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
|
|
|
|
try {
|
|
await finalizeDocument(user.permissions, parsed.data.documentNumber)
|
|
revalidatePath(`/dokumente/${parsed.data.documentNumber}`)
|
|
return { ok: true }
|
|
} catch (error) {
|
|
return { ok: false, error: toMessage(error) }
|
|
}
|
|
}
|
|
|
|
const retractSchema = numberSchema.extend({ reason: z.string().min(10).max(2000) })
|
|
|
|
export async function retractDocumentAction(input: unknown): Promise<ActionResult> {
|
|
const user = await requireUser()
|
|
const parsed = retractSchema.safeParse(input)
|
|
if (!parsed.success) return { ok: false, error: de.actionErrors['reason-required']! }
|
|
|
|
try {
|
|
await retractDocument(user.permissions, parsed.data.documentNumber, parsed.data.reason)
|
|
revalidatePath(`/dokumente/${parsed.data.documentNumber}`)
|
|
return { ok: true }
|
|
} catch (error) {
|
|
return { ok: false, error: toMessage(error) }
|
|
}
|
|
}
|
|
|
|
const reclassifySchema = numberSchema.extend({
|
|
to: z.coerce.number().int().min(0).max(5),
|
|
reason: z.string().max(2000).optional(),
|
|
})
|
|
|
|
export async function reclassifyDocumentAction(input: unknown): Promise<ActionResult> {
|
|
const user = await requireUser()
|
|
const parsed = reclassifySchema.safeParse(input)
|
|
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
|
|
|
|
try {
|
|
await reclassifyDocument(
|
|
user.permissions,
|
|
parsed.data.documentNumber,
|
|
parsed.data.to,
|
|
parsed.data.reason,
|
|
)
|
|
revalidatePath(`/dokumente/${parsed.data.documentNumber}`)
|
|
return { ok: true }
|
|
} catch (error) {
|
|
return { ok: false, error: toMessage(error) }
|
|
}
|
|
}
|
|
|
|
/** Dasselbe für ein Dokument: ein Knopf, ein Ergebnis je Feld. */
|
|
export async function saveDocumentSectionsAction(
|
|
documentNumber: string,
|
|
input: unknown,
|
|
): Promise<Record<string, SaveSectionResult>> {
|
|
const parsed = z.array(sectionSchema).max(200).safeParse(input)
|
|
if (!parsed.success) return {}
|
|
|
|
const ergebnisse: Record<string, SaveSectionResult> = {}
|
|
for (const feld of parsed.data as SaveSectionsInput) {
|
|
ergebnisse[feld.fieldKey] = await saveDocumentSectionAction(documentNumber, feld)
|
|
}
|
|
return ergebnisse
|
|
}
|