Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
archivdms
GoBD-konformes Dokumentenmanagementsystem (DMS) für den DACH-Raum. Eigenständiges Produkt, getrennt von archivmail (E-Mail-Archivierung) — spätere Mail-Anbindung nur optional über archivmails REST-API, kein gemeinsamer Code.
Konzeption basiert auf Recherche zu Paperless-ngx und ecoDMS (siehe dms-featureliste-prompt.md). Grundgerüst (Auth/Tenant/Audit/Mailer-Architektur) aus archivmail portiert und auf ein dokumentzentriertes Modell umgebaut.
Stack
- Backend: Go (
net/http, PostgreSQL via pgx/v5, JWT-Auth) - Frontend: Next.js + shadcn/ui
- Multi-Tenancy: applikationsseitig (
tenant_id-Filter je Query, kein Postgres-RLS) - Suche: Manticore Search (BM25) — Backend-Endpunkt
GET /api/documents/searchund Frontend (globale Suchleiste in der TopBar, Ergebnisseite/searchmit Tag-/Dokumenttyp-Filter und Pagination) integriert; Hybrid/Vektor geplant
CI-Pipeline (FDN-07)
.gitea/workflows/ci.yml definiert eine Gitea-Actions-Pipeline (Syntax kompatibel zu GitHub Actions) mit zwei Jobs:
- backend-lint-test:
go vet ./...,go test ./... -covergegen eine pro Lauf frisch gestartete Postgres-Service-Container-Instanz, danachmake buildals reproduzierbares Artefakt. - frontend-lint-test-build:
npm ci/npm install-Fallback, ESLint (npm run lint),tsc --noEmit,make build-web(next build) als Artefakt.
Läuft bei jedem Push/PR; ein fehlschlagender Schritt bricht den jeweiligen Job ab (kein continue-on-error), das blockiert den Merge sobald in Gitea die Branch-Protection-Regel "Require status checks to pass" auf diese Jobs gesetzt ist. Wichtig: archivdms hat aktuell noch keinen Gitea-Remote (nur lokales git init), die Pipeline greift daher erst, sobald das Repo zu einer Gitea-Instanz mit aktivierten Actions und registriertem Runner gepusht wird.
Frontend: Login-Pflicht & UI-Struktur
Alle Seiten außer /login sind hinter einer Session-Cookie-Prüfung (middleware.ts, Cookie archivdms_session). Ohne gültiges Cookie wird sofort (ohne sichtbares Flackern) zu /login umgeleitet; die vollständige JWT-Prüfung bleibt Aufgabe des Go-Backends bei jedem echten API-Call. Eingeloggte Bereiche laufen unter der Routegruppe src/app/(app)/ mit gemeinsamer App-Shell (Sidebar, TopBar, Cmd+K-Befehlspalette, Dark Mode). /documents und /reminders sind Server Components mit serverseitigem Datenfetch (src/lib/session.ts reicht das Session-Cookie manuell an die Go-API weiter) statt der bisherigen "use client" + useEffect-Ladeschleife. Die Dokumenten-Detail-/Vorschauseite /documents/{id} (Server Component src/app/(app)/documents/[id]/page.tsx + Client-Island DocumentPreview) zeigt die Datei-Vorschau in einem <iframe src="/api/documents/{id}/file"> (same-origin, Cookie-Auth greift automatisch, Browser-natives PDF/Bild-Rendering). Die Metadaten-Seitenleiste (Reiter Details/Inhalt/Verlauf) ist in die Unterkomponente DocumentDetailsTab ausgelagert und in einem kompakten 2-Spalten-Raster angeordnet (kurze Felder Belegdatum/Dokumenttyp/Korrespondent/Erstellt nebeneinander, Titel/Akte/Tags in voller Breite). Editierbar: Inline-Titel, Belegdatum (<input type="date">, PUT /api/documents/{id}/document-date), Dokumenttyp/Korrespondent/Akte (Command-Popover, Auswahl wird direkt beim Klick gespeichert) sowie Tags hinzufügen (POST /api/documents/{id}/tags/{tagId}) / entfernen (X am Badge, DELETE ...). Heuristische Vorschlags-Chips (inkl. erkanntem Belegdatum aus dem OCR-Text) lassen sich per Klick übernehmen. In der Kopfzeile der Detailseite liegen die Aktionen "Export" (Download-Icon, <a href="/api/documents/{id}/export" download> — ZIP mit Originaldatei, metadata.json und ocr_text.txt, same-origin per Session-Cookie, kein Blob-Umweg) und "Neu verarbeiten". Erreichbar über den "Vorschau"-Button bzw. das Auge-Icon in der Dokumentenliste.
Projektstatus
Scaffold + erstes fachliches Feature (Wiedervorlage/Reminder, PROJ-1) als Code geschrieben, noch nicht gebaut/getestet (kein Go/Node-Toolchain in dieser Umgebung ausgeführt). Vor Inbetriebnahme: go build ./..., npm install && npm run build, Migrationen gegen echte PostgreSQL-Instanz prüfen.
Upload/OCR-Pipeline (POST /api/documents/upload, WORM-Storage, Duplikaterkennung per Content-Hash) ist implementiert, siehe Abschnitt "Storage-Struktur & OCR" unten. Nicht enthalten: Tests, LDAP/TOTP.
Hardware-Anforderungen
Grobe Schätzung aus Architektur (Go+PostgreSQL+Manticore+Next.js, analog archivmail-Stack) — nicht durch Lasttests validiert, Code noch nicht gebaut/getestet.
Betriebssystem/Abhängigkeiten:
- Debian 13 (trixie)
- Go ≥ 1.24 (empfohlen: 1.26.x, aktuell 1.26.5, Stand Juli 2026)
- Node.js ≥ 20 + npm (empfohlen: 24.x Active LTS, Stand Juli 2026 — Node 26 noch Current, LTS erst ab Oktober 2026)
- PostgreSQL ≥ 14 (empfohlen: 18.x, aktuell stabil 18.4, Stand Mai 2026 — Version 19 noch in Beta)
- Manticore Search ≥ 27.x (aktuell 27.1.5, Stand Juni 2026 — Built-in Auth, Sharded Tables, Vektor-/Hybrid-Suche)
- Tesseract OCR + poppler-utils (für Scan/OCR-Pipeline; ohne diese Pakete läuft der Server weiter, OCR wird dann übersprungen):
apt install tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng poppler-utils - zbar-tools (für Barcode-Erkennung auf Trennblättern/Scans,
internal/barcode; ohne dieses Paket läuft der Server weiter, Barcode-Decoding wird dann übersprungen):apt install zbar-tools
| Szenario | vCPU | RAM | Disk |
|---|---|---|---|
| Minimal (Kleinbetrieb, <5 User, wenig Volumen) | 2 | 4 GB | 50 GB (wächst mit Archiv) |
| Empfohlen (KMU, OCR aktiv, Volltextindex) | 4 | 8 GB | nach Archivgröße¹ |
| Enterprise/Multi-Tenant mit Last | 8+ | 16+ GB | SSD Pflicht, separates Storage-Volume² |
¹ Faustregel: gescannte PDFs ~200KB–2MB/Dokument, Manticore-Index zusätzlich ~20–30% vom Rohtext-Volumen. ² WORM/Aufbewahrungsfristen bedeuten nur Wachstum, kein Löschen vor Fristablauf — Storage entsprechend planen.
Storage-Struktur & OCR
config.yml → storage.base_path (ersetzt das alte store_path) ist die Wurzel für drei Unterverzeichnisse, die beim Start automatisch angelegt werden (os.MkdirAll):
<base_path>/inbox/<tenant_id>/<random>.<ext> Roh-Upload, vor Hashing/Verschieben
<base_path>/store/<tenant_id>/<yyyy>/<mm>/<sha256>.<ext> fertiges Archiv, WORM (chmod 0440)
<base_path>/ocr-tmp/<random>/ pdftoppm-Zwischenbilder, nach Gebrauch gelöscht
Ablauf beim Upload (POST /api/documents/upload, multipart, Feld file + Pflichtfeld title):
- Datei wird nach
inbox/<tenant_id>/geschrieben, dabei SHA-256 berechnet. - Zielpfad
store/<tenant_id>/<yyyy>/<mm>/<hash>.<ext>wird ermittelt; existiert der Hash für den Mandanten bereits (Unique-Indexidx_documents_tenant_hash), wird der Upload mit HTTP 409 als Duplikat abgelehnt. - Datei wird per
os.Rename(Fallback: Kopieren+Löschen bei Cross-Device) verschoben und mitchmod 0440unveränderlich gemacht (WORM). - Dokument-Metadaten und ein Verarbeitungsjob (
processing_jobs) werden in einer Postgres-Transaktion angelegt, Audit-Log-Eintrag geschrieben, Antwort 201 an den Client. Das Dokument steht aufprocessing_status = 'queued'. - OCR-Extraktion (
internal/ocr), Taxonomie-Autozuordnung undon_upload-Workflows laufen asynchron über die Job-Queue (siehe unten) — nicht mehr im Request.
Weil OCR erst nachgelagert läuft, richtet sich der Archivordner <yyyy>/<mm> nach dem Upload-Zeitpunkt; das erkannte Belegdatum landet danach in documents.document_date, die archivierte Datei wird dabei nie verschoben (WORM).
Trennseiten-Split (Barcode-Trennblätter)
internal/pagesplit zerlegt mehrseitige PDF-Scans beim Ingest an Barcode-Trennblättern in Einzeldokumente (Vorbild: Paperless-ngx, an die archivdms-Pipeline angepasst). Ablauf im synchronen Staging-Schritt, bevor irgendetwas archiviert wird (Schritt 1a in storeUploadedFile): pdfinfo liefert die Seitenzahl, pdftoppm -r 150 rastert jede Seite einmal, zbarimg (via internal/barcode) dekodiert die Barcodes; Seiten mit dem konfigurierten Marker (pagesplit.marker, Default ARCHIVDMS-SPLIT, Vergleich case-insensitiv, optional Präfix-Match) gelten als Trennblatt. Die Segmente dazwischen werden mit pdfseparate + pdfunite (poppler-utils, kein qpdf/pdftk) zu je einem Teil-PDF zusammengesetzt; das Trennblatt selbst wird verworfen. Jedes Teildokument durchläuft danach exakt denselben Pfad wie ein normaler Einzel-Upload (eigener Hash + Duplikatprüfung, eigene WORM-Ablage mit chmod 0440, eigener Verarbeitungsjob mit OCR/Taxonomie/Workflows).
Fail-safe: Detektor aus, fehlende Binaries, Nicht-PDF, weniger als zwei Seiten, mehr als max_pages, kein Trennblatt gefunden oder ein Fehler beim Split → das Dokument wird ungesplittet wie bisher archiviert, ein Upload wird nie deswegen abgelehnt. Ein nur teilweise erzeugter Split wird nie ausgeliefert.
GoBD: Der hochgeladene Stapel wird bei einem Split nicht zusätzlich archiviert (sonst läge jede Seite doppelt im Archiv). Nachvollziehbar bleibt die Aufteilung über das Audit-Event document_split, das Originaldateinamen, SHA-256 des Originals, Seitenzahl, die verworfenen Trennblatt-Seitennummern sowie je Teildokument Seitenbereich und Dokument-ID festhält — Erfolg und Fehlschlag (success=false, wenn ein Split angefangen und abgebrochen wurde).
Voraussetzung: apt install poppler-utils zbar-tools. Default aus (pagesplit.enabled: false), bewusst konservativ wie ocr.binarize_ocr. Aktuell globaler Config-Schalter; eine Mandanten-Einstellung samt UI ist der nächste Schritt.
Job-Queue (Mandanten-Fairness)
internal/jobqueue arbeitet die Nachverarbeitung in Worker-Goroutinen im selben Prozess ab (kein separater Dienst, kein Redis — die Queue ist die Postgres-Tabelle processing_jobs). Dispatch läuft Round-Robin über die Mandanten: pro Runde wird je Mandant genau ein Job gezogen (FOR UPDATE SKIP LOCKED), damit ein Massen-Upload eines Mandanten die anderen nicht aushungert. Fehlgeschlagene Jobs werden mit exponentiellem Backoff (2^retry_count Sekunden) neu eingeplant und bleiben ab max_retries dauerhaft auf failed. Ein Reaper setzt hängende processing-Jobs nach job_timeout_seconds zurück. Erfolg und Fehlschlag werden als Audit-Event document_processed protokolliert.
Sichtbarkeit in der UI: documents.processing_status (queued/processing/done/failed) wird mit der Dokumentenliste servergerendert ausgeliefert. Die Liste (DocumentsTable) und die Vorschau zeigen für alles außer done ein Badge („Warteschlange" / „Wird verarbeitet …" / „Fehlgeschlagen"); bei failed gibt es einen Button „Erneut versuchen". Endpunkte: GET /api/documents/{id}/processing-job (Status, retry_count, error_message) und POST /api/documents/{id}/processing-job/retry (nur aus failed heraus, sonst 409). Das Frontend pollt den GET-Endpunkt alle 3 s nur für Dokumente, die gerade nicht fertig sind — ist die Ansicht durchverarbeitet, läuft kein Timer und es entsteht kein Traffic.
Relevante Config-Keys (siehe config/config.yml.example):
storage:
base_path: "/var/lib/archivdms" # enthält inbox/, store/, ocr-tmp/
retention_days: 3650
max_upload_size_mb: 50
ocr:
tesseract_path: "tesseract"
pdftoppm_path: "pdftoppm"
languages: "deu+eng"
timeout_seconds: 60
pagesplit:
enabled: false # Trennseiten-Split, Default AUS
marker: "ARCHIVDMS-SPLIT"
marker_prefix: false
raster_dpi: 150
max_pages: 200
timeout_seconds: 120
jobqueue:
disabled: false # true = keine Nachverarbeitung, Dokumente bleiben "queued"
workers: 2
poll_interval_ms: 2000
job_timeout_seconds: 600
max_retries: 5
index:
# Volltext-Index (Manticore, MySQL-Protokoll Port 9306). Leer = deaktiviert.
# Phase 1: nur Schreib-/Sync-Layer, noch KEIN Such-Endpunkt.
manticore_dsn: "" # z.B. "archivdms@tcp(127.0.0.1:9306)/?charset=utf8mb4"
Volltext-Index (Manticore, Phase 1)
Optionaler sekundärer Volltext-Index (internal/index) über Manticore Search (MySQL-Protokoll, Port 9306, github.com/go-sql-driver/mysql, CGO-frei). PostgreSQL bleibt Source of Truth — der Index wird nur best-effort synchron gehalten: jeder Sync-Fehler wird geloggt, blockiert aber nie den auslösenden Request. Ist index.manticore_dsn leer, ist der Indexer nil und alle Sync-Aufrufe sind No-ops.
Pro Mandant existiert eine RT-Tabelle documents_tenant_<tenant_id> (Tabellenname gegen Injection validiert). Synchronisiert wird nach Upload/Create, ACL-/Tag-/Dokumenttyp-Änderung (RecomputeVisibility), Korrespondent- und Custom-Field-Änderung; beim Verschieben in den Papierkorb sowie bei finaler Löschung (Vier-Augen-bestätigt) wird der Eintrag aus dem Index entfernt (GoBD: endgültig gelöschte Dokumente dürfen nicht mehr auffindbar sein), beim Wiederherstellen neu indexiert. Der Such-Endpunkt und ein Reindex-CLI folgen in Phase 2/3.
OCR-Wortkoordinaten & Vorschau-Overlay
Tesseract liefert pro Wort eine Bounding-Box (TSV), die in der Tabelle ocr_words persistiert und über GET /api/documents/{id}/ocr-words ausgeliefert wird (JSON-Array, Keys text/left/top/width/height/confidence/page/block/par/line, leeres Ergebnis []). Die Koordinaten sind Pixelwerte im Koordinatenraum des Original-Bildes.
Im Frontend legt DocumentImagePreview einen absolut positionierten Overlay-Layer über die Bildvorschau und rechnet die Boxen per min(clientWidth/naturalWidth, clientHeight/naturalHeight) inkl. object-contain-Letterbox-Versatz um; ein ResizeObserver hält das Overlay bei Sidebar-Drag, Resize und Vollbildwechsel deckungsgleich. Auf dieser Berechnung liegen drei Schichten: der Debug-Umschalter „OCR-Boxen" (Rahmen je Wort, rein visuell), das Suchtreffer-Highlight (amberfarbene Marker, ohne Toggle sichtbar) und die Copy/Select-Textebene (pro Wort ein transparentes, exakt auf die Box skaliertes <span> mit echtem Text — Markieren und Kopieren funktioniert damit wie in einer durchsuchbaren PDF; nur diese Spans haben pointer-events, die Bildfläche dazwischen bleibt normal bedienbar). Den Suchbegriff fürs Highlight reicht die Suchergebnisseite über ?q= an /documents/{id} durch (DocumentsTable-Prop highlightQuery → Vorschau-Links → searchParams → DocumentPreview → DocumentImagePreview); Matching ist ein case-insensitiver Teilstring-Vergleich pro Token, serverseitig hat Manticore bereits gefiltert. Nur für Bilddokumente — PDFs werden im <iframe> gerendert und bringen ihre eigene Textebene mit.
SFTP-Zugang pro Mandant
Alternative zum Web-Upload: ein eingebetteter SFTP-Server (internal/sftpserver, golang.org/x/crypto/ssh + github.com/pkg/sftp) läuft direkt im archivdms-Binary — kein separater OpenSSH-Prozess, keine echten Linux-System-User pro Mandant. Ein Mandant, der per SFTP eine Datei ablegt, landet virtuell "eingesperrt" in seinem eigenen inbox/<tenant_id>/-Ordner (Software-Chroot, keine OS-Chroot-Mechanik); ein Watcher (Polling alle 5s) holt neue Dateien dort ab und schickt sie durch dieselbe Pipeline wie POST /api/documents/upload (Hash, Duplikaterkennung, Move nach store/, chmod 0440, Job-Anlage, Audit-Log) — OCR/Taxonomie/Workflows laufen anschließend über dieselbe Job-Queue.
Aktivierung in config.yml:
sftp:
enabled: true
bind: ":2222"
host_key_path: "" # leer = <storage.base_path>/.ssh/host_key, wird beim ersten Start generiert
Zugangsdaten sind vom normalen Login getrennt — ein SFTP-Zugang ist ein eigenständiges, jederzeit widerrufbares Credential (Tabelle sftp_credentials, bcrypt-gehashtes Passwort), kein volles Benutzerkonto. Ein Admin (domain_admin/superadmin) legt einen Zugang über die Admin-API an:
POST /api/admin/sftp-credentials { "username": "mandant1-sftp" }
-> Antwort enthält das Passwort im Klartext, aber nur dieses eine Mal
GET /api/admin/sftp-credentials
DELETE /api/admin/sftp-credentials/{id}
Danach kann der Mandant sich direkt per SFTP-Client verbinden und Dateien flach in sein Wurzelverzeichnis hochladen (v1: kein Anlegen/Navigieren von Unterordnern):
sftp -P 2222 mandant1-sftp@dms.example.com
put rechnung.pdf
Tenant-Verwaltung (Mandanten)
Mandanten werden über eine superadmin-only API verwaltet (internal/api/tenant_handlers.go):
POST /api/tenants { "name": "...", "slug": "...", "domain": "..." } -> legt einen Mandanten an
GET /api/tenants -> listet alle Mandanten
Frontend: /settings/tenants (nur für Rolle superadmin sichtbar/aufrufbar, Server-Component-Rollencheck über GET /api/auth/me; für andere Rollen nur ein Hinweistext statt Formular). Beim Anlegen eines Users (POST /api/users) darf ein Superadmin tenant_id explizit im Body angeben (auch null für einen weiteren Superadmin); für domain_admin wird tenant_id serverseitig immer hart auf den eigenen Tenant erzwungen — ein im Body mitgeschickter abweichender Wert wird ignoriert (IDOR-Schutz gegen Cross-Tenant-User-Anlage).
Benutzerverwaltung: /settings/users (für domain_admin und superadmin; gleicher Server-Component-Rollencheck, andernfalls nur Hinweistext). Liste aller Konten des eigenen Mandanten mit Anlegen (Dialog: Benutzername, E-Mail, Passwort, Rolle), Bearbeiten (E-Mail, Rolle, Aktiv-Status, optionaler Passwort-Reset) und Löschen mit Bestätigungsdialog. Ein domain_admin kann nur die Rollen user/domain_admin vergeben, ein superadmin zusätzlich superadmin. Der Menüpunkt "Benutzer" in der Sidebar erscheint nur bei passender Rolle; die eigentliche Autorisierung erzwingt weiterhin das Backend (requireRole).
Benutzerdefinierte Felder: /settings/custom-fields (admin-only, Server-Component-Rollencheck). Definition von Zusatzfeldern mit Typ text/number/date/boolean/enum/monetary (enum mit Optionsliste, monetary mit optionaler Währung). Technischer Name und Typ sind nach dem Anlegen unveränderlich (Backend erzwingt), Bearbeiten betrifft nur Label/Optionen/Währung; Löschen ist blockiert, solange noch Werte referenzieren (409). Die Zuordnung welcher Felder bei welchem Dokumenttyp erscheinen (inkl. required/visible/Reihenfolge) erfolgt als Sektion unter /settings/document-types (Bulk-PUT /api/document-types/{id}/fields). Pro Dokument werden die zugeordneten (sichtbaren) Feldwerte über den Button "Felder" in der Dokumentenliste bearbeitet (GET/PUT /api/documents/{id}/fields, typgerechte Eingaben, Pflichtfeld-Validierung serverseitig).
Klassifizierungsvorlagen: /settings/classification-templates (admin-only, Server-Component-Rollencheck). Eine Vorlage bündelt Dokumenttyp, Tags, Custom-Field-Vorgaben (je Feld mit optionalem Overwrite-Flag) und eine Aufbewahrungsdauer (retain_years), ohne persistent an Dokumente gekoppelt zu sein — spätere Vorlagen-Änderungen wirken nie rückwirkend, jede Anwendung wird nur im Audit-Log festgehalten (GoBD-Nachvollziehbarkeit). Verwaltung (CRUD + Bulk-Replace von Tags/Feld-Vorgaben) über GET/POST/PUT/DELETE /api/classification-templates[/{id}] sowie PUT .../{id}/tags und PUT .../{id}/field-defaults. Anwendung auf ein Dokument über den Button "Vorlage anwenden" in der Dokumentenliste (POST /api/documents/{id}/apply-template): eine Dry-Run-Vorschau zeigt zunächst das Diff (Tags hinzufügen/vorhanden, Felder setzen/überschreiben/überspringen, Aufbewahrung vorher→nachher). Eine Aufbewahrungsfrist wird nie verkürzt (auch nicht mit Overwrite — dann Hinweis "blockiert"); bereits gesetzte Feldwerte werden nur bei explizit angehaktem Overwrite überschrieben.
Aufbewahrungsregeln (Retention Rules): /settings/retention-rules (admin-only, Server-Component-Rollencheck über GET /api/auth/me, sonst Hinweistext). Verwaltung GoBD-konformer Aufbewahrungsfristen je Dokumenttyp (doc_type_id = null = mandantenweiter Default). Pro Regel: Auslöser (document_date/upload_date/fixed_date/event; bei fixed_date ein Datum YYYY-MM-DD), Frist in Jahren + zusätzlichen Tagen, Rechtsgrundlage (z.B. §147 AO), Freigabepflicht für die Vernichtung, DSGVO-Konflikt-Flag und Aktiv-Status. CRUD über GET /api/retention-rules, POST /api/retention-rules, PATCH /api/retention-rules/{id}, DELETE /api/retention-rules/{id} (Mutationen domain_admin/superadmin). Dashboard-Kachel "Warten auf Löschfreigabe" zählt Dokumente mit abgelaufener Frist, die noch nicht im Papierkorb sind (GET /api/retention-rules/eligible); Dry-Run-Vorschau über GET /api/retention-rules/preview.
Verfahrensdokumentation (GoBD, Entwurf): GET /api/compliance/procedure-documentation liefert einen live aus dem Datenbankstand erzeugten Markdown-Baustein einer Verfahrensdokumentation als Download (text/markdown, verfahrensdokumentation-entwurf-<mandant>-<datum>.md). Erfordert domain_admin/superadmin; ein superadmin kann über ?tenant_id=N einen fremden Mandanten exportieren, alle anderen Rollen erhalten dabei 403. Automatisch befüllt werden Aufbewahrungsfristen, Zugriffsschutz (Rollen, Gruppen, Grants), Löschkonzept (WORM + Vier-Augen-Prinzip), Erfassungsautomatisierung (Workflows/Klassifizierungsvorlagen) und Nachvollziehbarkeit (Audit-Log); Organisationsbeschreibung, Verantwortliche und Server-/Backup-/Notfallkonzept sind als [MANUELL ZU ERGÄNZEN] gekennzeichnete Platzhalter. Das Ergebnis ist ausdrücklich ein Entwurf und kein rechtsverbindliches Fertigdokument. Frontend: Abschnitt "GoBD-Verfahrensdokumentation" am Fuß von /settings/retention-rules mit Button "Verfahrensdokumentation (Entwurf) herunterladen" (ProcedureDocumentationDownload, Blob-Download mit Session-Cookie) und Entwurfs-Warnhinweis.
Installation
Native Installation auf Debian 13 (trixie), z.B. in einem unprivilegierten Proxmox-LXC-Container — kein Docker. Es gibt (noch) kein Git-Remote-Repository für archivdms; install.sh/update.sh erwarten daher, dass der Quellcode bereits lokal auf dem Zielserver liegt (z.B. per rsync/scp kopiert), statt per git clone/git pull zu arbeiten.
scp -r . root@<server>:/opt/archivdms-src
ssh root@<server>
cd /opt/archivdms-src
ARCHIVDMS_SRC=/opt/archivdms-src bash install.sh
install.sh installiert Go/Node/PostgreSQL/Manticore/Tesseract, legt Systembenutzer und Storage-Struktur an, generiert config.yml sowie ein selbstsigniertes TLS-Zertifikat (Let's-Encrypt optional per certbot --nginx), richtet nginx und die systemd-Dienste archivdms/archivdms-web ein und ruft für den ersten Build update.sh auf.
Für spätere Deploys aus einem neuen lokalen Codestand genügt danach:
bash update.sh
update.sh baut aus dem lokalen Quellverzeichnis neu (kein git pull), synchronisiert nach /opt/archivdms und startet die Dienste neu. Details und Kommentare zum "Warum" einzelner Entscheidungen stehen direkt in den Skripten.
Beleg-Erfassung per Handy
Route /scan (in der Sidebar als "Beleg erfassen") öffnet auf mobilen Browsern direkt die Rückkamera (<input type="file" capture="environment">), kein natives App nötig. Ein Foto genügt, Titel wird automatisch aus Datum/Uhrzeit gesetzt, Upload läuft über dieselbe Pipeline wie /documents (Hash, WORM-Ablage, OCR, automatisches Tag-/Korrespondent-Matching). Dokumenttyp/Korrespondent lassen sich danach unter /documents nachträglich ergänzen.
Struktur
cmd/archivdms/ CLI-Einstiegspunkt (serve, reminders notify)
config/ YAML-Konfiguration
internal/api/ HTTP-Handler (auth, user, audit, document, reminder, sftp-credentials)
internal/audit/ Append-only Audit-Log (GoBD-Nachvollziehbarkeit)
internal/auth/ JWT-Session-Handling
internal/mailer/ SMTP-Versand (Benachrichtigungen)
internal/sftpserver/ Eingebetteter Per-Mandant-SFTP-Server + Inbox-Watcher
internal/storage/ Postgres-Schema (documents, reminders, sftp_credentials) + Migrations-Doku
internal/tenantstore/ Mandantenverwaltung
internal/userstore/ Benutzerverwaltung
deploy/cron.d/ Cron-Jobs (Wiedervorlage-Benachrichtigung)
features/ Feature-Spezifikationen (PROJ-N-name.md)
src/ Next.js-Frontend
Features (Ziel, siehe dms-featureliste-prompt.md)
Umgesetzt: Grundgerüst (Auth, Tenant, Audit, Mailer), Dokument-Kernmodell, Dashboard-Startseite (Kennzahlen-Kacheln + Dokumente-nach-Typ, Server-Component), Wiedervorlage (PROJ-1), Papierkorb mit gestaffelter Löschung (Soft-Delete + Vier-Augen-Prinzip, Aufbewahrungsfrist-Prüfung), Berechtigungsmodell (gruppenaufgelöste Dokument-ACL).
Geplant: GoBD-Compliance (WORM, Aufbewahrungsfristen-Engine), ZUGFeRD/XRechnung-Parser, Hybrid-Suche, Workflow-Engine, DATEV-Schnittstelle, OIDC/SSO, Kanban-Wiedervorlage-Ansicht, später nativer Linux-Client (nutzt dieselbe REST-API).
Berechtigungsmodell (Dokument-ACL)
Zugriff auf Dokumente wird über Berechtigungsgruppen (nie direkt pro User) vergeben und über drei Ebenen aufgelöst, spezifischer schlägt allgemeiner: (1) Einzeldokument-Grant (document_grants, inkl. deny), (2) Tag-Grant (tag_grants), (3) Dokumenttyp-Default (document_type_grants). Das Ergebnis wird nach document_visibility materialisiert (RecomputeVisibility, neu berechnet bei Grant-, Tag- oder Dokumenttyp-Änderung). Rollen bleiben Außengrenze: superadmin sieht alles mandantenübergreifend, domain_admin sieht per Default alles im eigenen Mandanten (bypasst die ACL), nur Rolle user wird gegen document_visibility gefiltert.
API (alle domain_admin+): POST/GET/DELETE /api/permission-groups, GET/POST/DELETE /api/permission-groups/{id}/members, GET/POST/DELETE /api/document-types/{id}/grants, GET/POST/DELETE /api/tags/{id}/grants, GET/POST/DELETE /api/documents/{id}/grants (access read|write|deny). Die GET-Endpunkte liefern Mitglieder ({user_id,username,email}) bzw. aufgelöste Grants ({group_id,group_name,access}). Schema-Doku: internal/storage/migrations/008_permissions.sql.
Frontend (admin-only, Server-Component-Rollencheck + Sidebar-Eintrag "Berechtigungsgruppen"): /settings/permission-groups verwaltet Gruppen (anlegen/löschen) und ihre Mitglieder (User-Picker aus dem Mandanten). Default-Grants je Dokumenttyp bzw. Tag werden als Sektion "Zugriffsrechte" unter /settings/document-types und /settings/tags gesetzt (Gruppe + Lesen/Schreiben). Einzeldokument-Freigaben inkl. deny (Sperren) über den Button "Freigaben" in der Dokumentenliste. Die UIs laden den tatsächlichen Serverstand (Mitglieder beim Öffnen des Dialogs, Grants beim Auswählen der Entität) und halten ihn nach jeder Mutation synchron.
Externe Share-Links
Ablaufende, optional passwortgeschützte öffentliche Links auf ein Dokument. Der Token (32 Byte, base64url) wird nur beim Erzeugen genau einmal zurückgegeben; gespeichert wird ausschließlich sein SHA-256-Hash. expires_at ist Pflicht (kein unbegrenzter Share), password (bcrypt) und max_accesses optional. Kein Hard-Delete — Revoke setzt nur revoked_at/revoked_by. Jeder öffentliche Zugriffsversuch wird in document_share_accesses protokolliert (success/expired/revoked/max_reached/bad_password/rate_limited) und zusätzlich im Audit-Log (EventShareAccessed).
Authentifiziert (tenant-scoped): POST /api/documents/{id}/shares (Body: expires_at Pflicht, optional max_accesses, password), GET /api/documents/{id}/shares, DELETE /api/shares/{share_id}, GET /api/shares (domain_admin+, alle Shares des Mandanten). Öffentlich (ohne Auth, per-IP rate-limited): GET /public/share/{token} (Metadaten: Titel, ob Passwort nötig, Ablauf), POST /public/share/{token}/download (Body optional {password}, streamt die Datei serverseitig aus dem WORM-Store — storage_path/content_hash werden nie exponiert). Prüfreihenfolge beim Abruf: revoked → expired → max_accesses → Passwort → Auslieferung (access_count++ atomar). Schema-Doku: internal/storage/migrations/009_shares.sql.
Frontend: Button "Teilen" in der Dokumentenliste öffnet ShareDialog — listet bestehende Freigaben (Status-Badge grün=aktiv / rot=abgelaufen bzw. Limit erreicht / blass=widerrufen, Ablauf, Zugriffszähler, Widerrufen-Button) und erzeugt neue (Ablaufdatum Pflicht via datetime-local, optional max. Zugriffe + Passwort). Der Klartext-Token wird nach dem Erzeugen einmalig prominent als kopierbarer Link (/public/share/<token>) angezeigt. Admin-Übersicht aller Mandanten-Shares unter /settings/shares. Die öffentliche, login-freie Seite liegt außerhalb der (app)-Route-Gruppe unter src/app/public/share/[token]/page.tsx (kein App-Shell) und ist in middleware.ts via PUBLIC_PATHS = ["/login", "/public"] vom Auth-Gate ausgenommen. Da die Backend-/public/*-Routen nicht unter /api liegen, proxyt next.config.ts sie über den kollisionsfreien Prefix /public/api/* → <backend>/public/*.
Buchhaltungs-Pull-API
Lesender Maschinen-Zugang, damit ein Buchhaltungssystem belegdatum-bewertete Dokumente abholen kann.
API-Keys je Mandant (accounting_api_keys): Rohkey adms_ + 32 Byte base64url, wird genau einmal beim Erzeugen zurückgegeben, gespeichert wird nur der SHA-256-Hash (Muster wie Share-Links). Kein Hard-Delete — Revoke setzt revoked_at. Verwaltung mit normaler Session, domain_admin+: POST /api/accounting/api-keys (Body {label} → {key, plaintext_key}), GET /api/accounting/api-keys (ohne Klartext/Hash), DELETE /api/accounting/api-keys/{id}. Frontend: /settings/accounting-keys (admin-only, Server-Component-Rollencheck über GET /api/auth/me) — Tabelle mit Bezeichnung, Erstellt, Zuletzt verwendet und Status-Badge (grün=aktiv, blass=widerrufen), Anlege-Dialog mit Bezeichnung und Widerruf-Dialog mit Warnung. Der Klartext-Key wird nach dem Anlegen einmalig in einem eigenen Dialog mit Warnhinweis und Kopieren-Button angezeigt und beim Schließen aus dem Client-State verworfen.
Pull-Endpunkte (Auth ausschließlich per Authorization: Bearer <key>, keine Cookie-Session, per-IP rate-limited): GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit= liefert {documents, next_cursor, has_more} mit Cursor-Pagination über (created_at, id) und nur reduzierten Metadaten (id, title, document_date, document_date_score, doc_type(_id), correspondent(_id), created_at) — storage_path/content_hash/ocr_text werden nie exponiert. GET /api/v1/accounting/documents/{id}/file streamt die WORM-Datei serverseitig. since/until grenzen document_date ein (YYYY-MM-DD oder RFC3339), min_date_score ist der Konfidenz-Filter (z.B. 0.75), limit max. 500 (Default 100).
Mandantentrennung: Der Mandant wird ausschließlich aus dem aufgelösten API-Key abgeleitet und in den Request-Context gelegt; ein ?tenant_id= wird ignoriert. Fremde/unbekannte Dokumente liefern 404 (kein 403). Jeder Datei-Download, jede Listenabfrage (mit count/id-Range) und jeder abgelehnte Auth-Versuch landen im Audit-Log (accounting_pull, accounting_key_created, accounting_key_revoked). Schema-Doku: internal/storage/migrations/026_accounting_api_keys.sql.
Bewusst nicht enthalten (erster Schritt): Changes-Feed für geänderte/gelöschte Belege, Betragserkennung.
Zukunftsnotiz
Linux-Desktop-Client ist geplant, aber nachrangig — erst Web-UI/API vollständig, Client greift dann auf dieselbe API zu (Token-Auth von Anfang an vorgesehen).