docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
+69
View File
@@ -0,0 +1,69 @@
import { redirect } from 'next/navigation'
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { UploadFormular } from '@/components/dokumente/upload-formular'
import { dokumentenkontext, vorgaengeDerAkte } from '@/lib/dokumente/kontext'
import { ZugriffVerweigert } from '@/lib/auth/sitzung'
import { env } from '@/lib/env'
import { formatBytes } from '@/lib/speicher/dateityp'
export const metadata = { title: 'Dokument aufnehmen' }
export const dynamic = 'force-dynamic'
/**
* Post aufnehmen.
*
* Der wichtigste Weg durch diese Anwendung: am Heimbett stehen, einen
* Bescheid mit dem Handy fotografieren, ihn dem Vorgang zuordnen und die
* Widerspruchsfrist daraus erzeugen. Alles auf einer Seite, ohne
* Zwischenschritte.
*/
export default async function NeuesDokumentSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
let kontext
try {
kontext = await dokumentenkontext('schreiben')
} catch (fehler) {
if (fehler instanceof ZugriffVerweigert) redirect('/dokumente')
throw fehler
}
const vorgaenge = await vorgaengeDerAkte(kontext.betreuteId)
const einzeln = (name: string) => {
const wert = parameter[name]
return Array.isArray(wert) ? wert[0] : wert
}
return (
<div className="space-y-6">
<SeitenKopf
titel="Dokument aufnehmen"
beschreibung="Foto oder PDF – der Text wird danach automatisch gelesen und du bekommst Absender, Datum, Aktenzeichen und mögliche Fristen als Vorschlag."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Aufnehmen' }]}
/>
<Alert variant="hinweis">
<ShieldCheck aria-hidden="true" />
<AlertDescription>
Erlaubt sind PDF, JPEG, PNG, WEBP, TIFF und HEIC, bis{' '}
{formatBytes(env.uploadMaxBytes)} je Datei. Geprüft wird der Inhalt, nicht die Endung –
eine Datei, die sich nur als PDF ausgibt, wird abgelehnt. Die Dateien verlassen diesen
Server nicht.
</AlertDescription>
</Alert>
<UploadFormular
vorgaenge={vorgaenge}
vorgangVorgewaehlt={einzeln('vorgang') ?? null}
kameraZuerst={einzeln('quelle') === 'kamera'}
/>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import Link from 'next/link'
import { BookOpen, FileText, Inbox, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { DokumentListe } from '@/components/dokumente/dokument-liste'
import { FilterLeiste } from '@/components/dokumente/filter-leiste'
import { BelegmappeFormular } from '@/components/dokumente/belegmappe-formular'
import { dokumentenkontext, tagsDerAkte, vorgaengeDerAkte } from '@/lib/dokumente/kontext'
import { dokumenteListe } from '@/lib/dokumente/suche'
import { filterAusParametern } from '@/lib/validierung/dokumente'
import { db, nichtGeloescht } from '@/lib/db'
export const metadata = { title: 'Dokumente' }
export const dynamic = 'force-dynamic'
/**
* Die Dokumentenliste mit Volltextsuche und Filtern.
*
* Die Suche geht ueber Titel, Absender, Aktenzeichen, Dateiname UND den
* per OCR erkannten Text - man findet einen Bescheid also auch dann, wenn
* man sich nur noch an eine Formulierung darin erinnert.
*/
export default async function DokumenteSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const parameter = await searchParams
const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen')
const filter = filterAusParametern(parameter)
const proSeite = 25
const [{ eintraege, gesamt }, tags, vorgaenge, neuImPosteingang] = await Promise.all([
dokumenteListe(betreuteId, { ...filter, proSeite }),
tagsDerAkte(betreuteId),
vorgaengeDerAkte(betreuteId),
db.dokument.count({ where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht } }),
])
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
const gefiltert = Boolean(
filter.suche || filter.art || filter.richtung || filter.posteingangStatus || filter.von || filter.bis || filter.tagId,
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Dokumente"
beschreibung="Die Akte in digitaler Form. Suche findet auch Text aus abfotografierten Schreiben."
pfad={[{ titel: 'Dokumente' }]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/dokumente/posteingang">
<Inbox aria-hidden="true" />
Posteingang
{neuImPosteingang > 0 && (
<span className="ml-1 rounded-full bg-ampel-orange/15 px-2 py-0.5 text-xs font-semibold text-ampel-orange">
{neuImPosteingang}
</span>
)}
</Link>
</Button>
{darfSchreiben('DOKUMENTE') && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Dokument aufnehmen
</Link>
</Button>
)}
</>
}
/>
<FilterLeiste
werte={{
suche: filter.suche,
art: filter.art,
richtung: filter.richtung,
status: filter.posteingangStatus,
von: einzelnerParameter(parameter.von),
bis: einzelnerParameter(parameter.bis),
tag: filter.tagId,
}}
tags={tags}
/>
{eintraege.length === 0 ? (
<Leerzustand
icon={FileText}
titel={gefiltert ? 'Kein Dokument passt zu diesen Filtern' : 'Noch keine Dokumente'}
beschreibung={
gefiltert
? 'Setze die Filter zurück oder suche mit weniger Wörtern. Die Volltextsuche findet nur Dokumente, deren Text schon erkannt wurde.'
: 'Fotografiere den nächsten Brief, der im Kasten liegt – der Rest geht fast von selbst.'
}
aktion={
darfSchreiben('DOKUMENTE') && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Erstes Dokument aufnehmen
</Link>
</Button>
)
}
/>
) : (
<>
<p className="text-sm text-muted-foreground">
{gesamt} {gesamt === 1 ? 'Dokument' : 'Dokumente'}
{gefiltert ? ' passen zu den Filtern' : ' in der Akte'}
{seiten > 1 ? ` · Seite ${filter.seite} von ${seiten}` : ''}
</p>
<DokumentListe eintraege={eintraege} />
{seiten > 1 && (
<Blaettern aktuelleSeite={filter.seite} seiten={seiten} parameter={parameter} />
)}
</>
)}
<section className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex items-start gap-2">
<BookOpen className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div>
<h2 className="font-semibold">Belegmappe für Gericht und Bezirk</h2>
<p className="text-sm text-muted-foreground">
Alle Belege eines Zeitraums oder eines Vorgangs als eine chronologisch sortierte PDF –
mit Deckblatt, Inhaltsverzeichnis und durchgehender Seitenzahl.
</p>
</div>
</div>
<BelegmappeFormular vorgaenge={vorgaenge} />
</section>
</div>
)
}
function einzelnerParameter(wert: string | string[] | undefined): string | null {
const roh = Array.isArray(wert) ? wert[0] : wert
return roh && roh.trim() !== '' ? roh : null
}
function Blaettern({
aktuelleSeite,
seiten,
parameter,
}: {
aktuelleSeite: number
seiten: number
parameter: Record<string, string | string[] | undefined>
}) {
const adresse = (seite: number) => {
const suche = new URLSearchParams()
for (const [name, wert] of Object.entries(parameter)) {
const roh = Array.isArray(wert) ? wert[0] : wert
if (roh && name !== 'seite') suche.set(name, roh)
}
suche.set('seite', String(seite))
return `/dokumente?${suche.toString()}`
}
return (
<nav className="flex items-center justify-between gap-3" aria-label="Blättern">
<Button variant="outline" disabled={aktuelleSeite <= 1} asChild={aktuelleSeite > 1}>
{aktuelleSeite > 1 ? <Link href={adresse(aktuelleSeite - 1)}>Zurück</Link> : <span>Zurück</span>}
</Button>
<span className="text-sm text-muted-foreground">
Seite {aktuelleSeite} von {seiten}
</span>
<Button variant="outline" disabled={aktuelleSeite >= seiten} asChild={aktuelleSeite < seiten}>
{aktuelleSeite < seiten ? (
<Link href={adresse(aktuelleSeite + 1)}>Weiter</Link>
) : (
<span>Weiter</span>
)}
</Button>
</nav>
)
}
@@ -0,0 +1,143 @@
import Link from 'next/link'
import { Inbox, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { ArtAbzeichen, OcrAbzeichen, RichtungAbzeichen } from '@/components/dokumente/abzeichen'
import { PosteingangStufen } from '@/components/dokumente/posteingang-stufen'
import { dokumentenkontext } from '@/lib/dokumente/kontext'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils'
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_ERKLAERUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
export const metadata = { title: 'Posteingang' }
export const dynamic = 'force-dynamic'
/**
* Das Posteingangsbuch.
*
* Vier Spalten, vier Stufen: NEU -> GESICHTET -> ZUGEORDNET ->
* BEANTWORTET. Das ist der digitale Nachbau des Poststapels auf dem
* Kuechentisch - mit dem Unterschied, dass hier nichts unter den Stapel
* rutschen kann.
*/
export default async function PosteingangSeite() {
const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen')
const schreibrecht = darfSchreiben('DOKUMENTE')
const dokumente = await db.dokument.findMany({
where: { betreuteId, ...nichtGeloescht },
select: {
id: true,
titel: true,
art: true,
richtung: true,
absender: true,
eingangAm: true,
schreibenVom: true,
posteingangStatus: true,
ocrStatus: true,
_count: { select: { verknuepfungen: true } },
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: 400,
})
const nachStufe = Object.fromEntries(
POSTEINGANG_STUFEN.map((stufe) => [stufe, dokumente.filter((d) => d.posteingangStatus === stufe)]),
) as Record<(typeof POSTEINGANG_STUFEN)[number], typeof dokumente>
return (
<div className="space-y-6">
<SeitenKopf
titel="Posteingang"
beschreibung="Was ist angekommen, was habe ich gesehen, was ist zugeordnet, was beantwortet? Nichts soll durchrutschen."
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: 'Posteingang' }]}
aktionen={
schreibrecht && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Post aufnehmen
</Link>
</Button>
)
}
/>
{dokumente.length === 0 ? (
<Leerzustand
icon={Inbox}
titel="Der Posteingang ist leer"
beschreibung="Sobald du einen Brief fotografierst oder ein PDF hochlädst, taucht er hier auf."
aktion={
schreibrecht && (
<Button asChild>
<Link href="/dokumente/neu">
<Plus aria-hidden="true" />
Post aufnehmen
</Link>
</Button>
)
}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
{POSTEINGANG_STUFEN.map((stufe) => (
<section key={stufe} className="flex flex-col rounded-lg border bg-card">
<header className="border-b px-4 py-3">
<h2 className="flex items-center justify-between gap-2 font-semibold">
{POSTEINGANG_BESCHRIFTUNG[stufe]}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground">
{nachStufe[stufe].length}
</span>
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
{POSTEINGANG_ERKLAERUNG[stufe]}
</p>
</header>
{nachStufe[stufe].length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Nichts auf dieser Stufe.
</p>
) : (
<ul className="divide-y">
{nachStufe[stufe].map((dokument) => (
<li key={dokument.id} className="space-y-2 px-4 py-3">
<Link
href={`/dokumente/${dokument.id}`}
className="block font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{dokument.titel}
</Link>
<p className="text-sm text-muted-foreground">
{dokument.absender ?? 'Ohne Absender'} ·{' '}
{formatDatum(dokument.eingangAm ?? dokument.schreibenVom)}
</p>
<div className="flex flex-wrap gap-1.5">
<ArtAbzeichen art={dokument.art} />
<RichtungAbzeichen richtung={dokument.richtung} />
<OcrAbzeichen status={dokument.ocrStatus} />
</div>
{dokument._count.verknuepfungen === 0 && stufe !== 'NEU' && (
<p className="text-xs text-ampel-orange">
Noch keinem Vorgang zugeordnet.
</p>
)}
<PosteingangStufen
dokumentId={dokument.id}
status={dokument.posteingangStatus}
schreibrecht={schreibrecht}
kompakt
/>
</li>
))}
</ul>
)}
</section>
))}
</div>
)}
</div>
)
}