'use server' import { revalidatePath } from 'next/cache' import { z } from 'zod' import { db, nichtGeloescht } from '@/lib/db' import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung' import { protokollieren } from '@/lib/audit' import { ablegenMitTyp } from '@/lib/speicher' import { speicherfehlerMeldung } from '@/lib/speicher/fehler' import { heuteAlsTag } from '@/lib/fristen/datum' import { tagAusText } from '@/lib/finanzen/datum' import { SCHREIBEN_EIGENSCHAFTEN, dateinameFuerSchreiben, istSchreibenArt, type SchreibenArt, } from '@/lib/schreiben/arten' import { briefPdfErzeugen } from '@/lib/schreiben/pdf' import { emailFassung } from '@/lib/schreiben/email' /** * Server Actions rund um erzeugte Schreiben. * * Der Kern ist "In die Akte legen". Bisher endete ein Schreiben beim Text * zum Kopieren; alles danach - ausdrucken, abschicken, als Dokument * nachtragen, Wiedervorlage setzen - war Handarbeit, und der letzte * Schritt wurde am haeufigsten vergessen. Dann fehlt der Nachweis genau * dort, wo er gebraucht wird: im Bericht ans Gericht. * * Hier passiert alles in einem Zug, und zwar so, dass ein Abbruch nichts * Halbes hinterlaesst. */ export type SchreibenZustand = { fehler?: string erfolg?: string /** Kurze Aufzaehlung dessen, was angelegt wurde. */ meldung?: string[] } function zugriffsfehler(ausnahme: unknown): SchreibenZustand | null { if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message } return null } const ablegenSchema = z.object({ betreuteId: z.string().min(1), art: z.string().refine(istSchreibenArt, 'Unbekannte Art von Schreiben.'), betreff: z.string().trim().min(1, 'Bitte einen Betreff eintragen.').max(200), text: z.string().trim().min(30, 'Der Text ist zu kurz für ein Schreiben.').max(50_000), empfaengerName: z.string().trim().max(200).default(''), versandAm: z.string().trim().default(''), wiedervorlageAm: z.string().trim().default(''), vertragId: z.string().trim().default(''), vorgangId: z.string().trim().default(''), /** "BRIEF" oder "EMAIL" - bestimmt Fassung und Kennzeichnung. */ versandweg: z.enum(['BRIEF', 'EMAIL']).default('BRIEF'), empfaengerEmail: z.string().trim().max(200).default(''), }) /** * Legt das Schreiben als Ausgangsdokument in die Akte und setzt die * Wiedervorlage. * * Reihenfolge mit Absicht: Erst die Datei in den Objektspeicher, dann der * Datensatz. Bricht das Ablegen ab, gibt es keinen Dokumentdatensatz, der * auf eine Datei zeigt, die es nicht gibt. Umgekehrt bliebe im * schlimmsten Fall eine verwaiste Datei liegen - die kostet Platz, aber * sie luegt niemanden an. */ export async function schreibenAblegen( _zustand: SchreibenZustand, daten: FormData, ): Promise { const gelesen = ablegenSchema.safeParse({ betreuteId: daten.get('betreuteId'), art: daten.get('art'), betreff: daten.get('betreff'), text: daten.get('text'), empfaengerName: daten.get('empfaengerName') ?? '', versandweg: daten.get('versandweg') ?? 'BRIEF', empfaengerEmail: daten.get('empfaengerEmail') ?? '', versandAm: daten.get('versandAm') ?? '', wiedervorlageAm: daten.get('wiedervorlageAm') ?? '', vertragId: daten.get('vertragId') ?? '', vorgangId: daten.get('vorgangId') ?? '', }) if (!gelesen.success) { return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' } } const werte = gelesen.data const art = werte.art as SchreibenArt const betreuteId = werte.betreuteId try { const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben') const versandAm = tagAusText(werte.versandAm) ?? heuteAlsTag() // --- Bezuege pruefen, bevor irgendetwas geschrieben wird --------------- const vertrag = werte.vertragId ? await db.vertrag.findFirst({ where: { id: werte.vertragId, betreuteId, ...nichtGeloescht }, select: { id: true, bezeichnung: true }, }) : null if (werte.vertragId && !vertrag) return { fehler: 'Diesen Vertrag gibt es nicht (mehr).' } const vorgang = werte.vorgangId ? await db.vorgang.findFirst({ where: { id: werte.vorgangId, betreuteId, ...nichtGeloescht }, select: { id: true, titel: true }, }) : null if (werte.vorgangId && !vorgang) return { fehler: 'Diesen Vorgang gibt es nicht (mehr).' } // --- Datei zuerst ------------------------------------------------------- // Bei einer E-Mail wird die E-Mail-Fassung abgelegt, nicht der // Brieftext: In der Akte soll stehen, was tatsaechlich hinausging - // samt Empfaengeradresse im Kopf der PDF. const istEmail = werte.versandweg === 'EMAIL' const fassung = istEmail ? emailFassung(werte.text, werte.betreff) : null const ablageText = fassung ? [ `E-Mail an: ${werte.empfaengerEmail || werte.empfaengerName || '[Adresse]'}`, `Betreff: ${fassung.betreff}`, '', fassung.text, ].join('\n') : werte.text const pdf = await briefPdfErzeugen(ablageText, versandAm) const dateiname = dateinameFuerSchreiben(art, werte.empfaengerName || null, versandAm, 'pdf') const ablage = await ablegenMitTyp(betreuteId, 'dokumente', dateiname, pdf, 'application/pdf') // --- Dann der Datensatz ------------------------------------------------- const dokument = await db.dokument.create({ data: { betreuteId, titel: (istEmail ? `E-Mail: ${werte.betreff}` : werte.betreff).slice(0, 200), art: 'SCHREIBEN', richtung: 'AUSGANG', schreibenVom: versandAm, versandAm, empfaenger: werte.empfaengerName || null, objektSchluessel: ablage.schluessel, dateiname: ablage.dateiname, mimeTyp: ablage.typ.mimeTyp, groesseBytes: ablage.groesseBytes, pruefsumme: ablage.pruefsumme, // Was wir selbst gesetzt haben, muss nicht erkannt werden. ocrStatus: 'nicht_verfuegbar', // Ein eigenes Schreiben gehoert nicht in den Posteingang - es ist // ja nichts angekommen, das noch zuzuordnen waere. posteingangStatus: 'ZUGEORDNET', ...(vertrag || vorgang ? { verknuepfungen: { create: [ { betreuteId, ...(vertrag ? { vertragId: vertrag.id } : {}), ...(vorgang ? { vorgangId: vorgang.id } : {}), }, ], }, } : {}), }, select: { id: true, titel: true }, }) const meldung = [`Als Ausgangsdokument abgelegt: „${dokument.titel}“.`] // --- Wiedervorlage ------------------------------------------------------ const wiedervorlage = tagAusText(werte.wiedervorlageAm) if (wiedervorlage) { const frist = await db.frist.create({ data: { betreuteId, vorgangId: vorgang?.id ?? null, titel: `Antwort abwarten: ${werte.betreff}`.slice(0, 200), beschreibung: `Am ${versandAm.toLocaleDateString('de-DE')} verschickt` + (werte.empfaengerName ? ` an ${werte.empfaengerName}` : '') + '. Kommt bis zu diesem Tag nichts, nachfassen.', datum: wiedervorlage, typ: 'WIEDERVORLAGE', verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] }, }, select: { id: true }, }) meldung.push( `Wiedervorlage zum ${wiedervorlage.toLocaleDateString('de-DE')} angelegt.`, ) void frist } else if (SCHREIBEN_EIGENSCHAFTEN[art].wiedervorlageTage !== null) { meldung.push('Ohne Wiedervorlage abgelegt — es erinnert dich also nichts daran.') } // --- Beim Vorgang festhalten, dass wir gerade geschrieben haben -------- if (vorgang) { await db.vorgang.update({ where: { id: vorgang.id }, data: { letzterKontaktAm: versandAm }, }) } await protokollieren({ aktion: 'ERSTELLT', entitaet: 'Dokument', entitaetId: dokument.id, bezeichnung: `Schreiben ausgegangen: ${dokument.titel}`, betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }) revalidatePath('/dokumente') revalidatePath('/fristen') revalidatePath('/heute') if (vertrag) revalidatePath(`/vertraege/${vertrag.id}`) if (vorgang) revalidatePath(`/vorgaenge/${vorgang.id}`) return { erfolg: 'Das Schreiben liegt in der Akte.', meldung } } catch (ausnahme) { const verweigert = zugriffsfehler(ausnahme) if (verweigert) return verweigert console.error('[schreiben] Ablegen fehlgeschlagen:', ausnahme) return { fehler: speicherfehlerMeldung(ausnahme, 'Das Schreiben konnte nicht abgelegt werden.'), } } } const kontaktSchema = z.object({ betreuteId: z.string().min(1), name: z.string().trim().min(1, 'Bitte einen Namen eintragen.').max(200), anschrift: z.string().trim().max(500).default(''), }) /** * Legt aus einer frei eingegebenen Anschrift einen Kontakt an. * * Damit man dieselbe Stelle beim naechsten Mal aus der Liste waehlen kann, * statt sie erneut abzutippen. Genau so waechst das Adressbuch einer * Betreuung: nicht durch Pflege, sondern nebenbei beim Schreiben. */ export async function kontaktAusAnschrift( _zustand: SchreibenZustand, daten: FormData, ): Promise { const gelesen = kontaktSchema.safeParse({ betreuteId: daten.get('betreuteId'), name: daten.get('empfaengerName'), anschrift: daten.get('empfaengerAnschrift') ?? '', }) if (!gelesen.success) { return { fehler: gelesen.error.issues[0]?.message ?? 'Bitte prüfe deine Eingaben.' } } const { betreuteId, name, anschrift } = gelesen.data try { const { benutzer } = await erfordereZugang(betreuteId, 'KONTAKTE', 'schreiben') // Die freie Anschrift kommt als Block; die erste Zeile ist die Strasse, // die zweite PLZ und Ort. Mehr wird nicht geraten - was nicht sicher // zuzuordnen ist, landet in den Notizen. const zeilen = anschrift .split('\n') .map((z) => z.trim()) .filter(Boolean) const strasse = zeilen[0] ?? null const ortszeile = zeilen[1] ?? '' const treffer = ortszeile.match(/^(\d{5})\s+(.+)$/) const kontakt = await db.kontakt.create({ data: { betreuteId, name: name.slice(0, 200), organisation: name.slice(0, 200), rolle: 'SONSTIGES', strasse, plz: treffer?.[1] ?? null, ort: treffer?.[2] ?? (treffer ? null : ortszeile || null), }, select: { id: true, name: true }, }) await protokollieren({ aktion: 'ERSTELLT', entitaet: 'Kontakt', entitaetId: kontakt.id, bezeichnung: kontakt.name, betreuteId, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, }) revalidatePath('/kontakte') return { erfolg: `„${kontakt.name}“ steht jetzt in den Kontakten.` } } catch (ausnahme) { const verweigert = zugriffsfehler(ausnahme) if (verweigert) return verweigert console.error('[schreiben] Kontakt konnte nicht angelegt werden:', ausnahme) return { fehler: 'Der Kontakt konnte nicht angelegt werden.' } } }