Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne Zugang zum Serverlog waere die Suche aussichtslos gewesen. Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die Eingaben bleiben. Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen, Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle; alles andere bleibt bei der allgemeinen Meldung. Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest sie zurueck und raeumt sie weg - und beantwortet damit erreichbar, Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in /api/gesundheit und als Zeile im Log. ## Warum nicht instrumentation.ts Der naheliegende Ort waere der Starthaken von Next. Der wird aber auch fuer die Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path in ein Buendel, in dem es das nicht gibt - der Build schlaegt fehl. Das war ausprobiert, nicht vermutet. Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer Aenderung. Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar - wegen eines Teils, den ein Neustart nicht repariert. Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird rot. Refs #44
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
'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<SchreibenZustand> {
|
|
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<SchreibenZustand> {
|
|
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.' }
|
|
}
|
|
}
|