first commit
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
# 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/search` und Frontend (globale Suchleiste in der TopBar, Ergebnisseite `/search` mit 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 ./... -cover` gegen eine pro Lauf frisch gestartete Postgres-Service-Container-Instanz, danach `make build` als 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`):
|
||||||
|
1. Datei wird nach `inbox/<tenant_id>/` geschrieben, dabei SHA-256 berechnet.
|
||||||
|
2. Zielpfad `store/<tenant_id>/<yyyy>/<mm>/<hash>.<ext>` wird ermittelt; existiert der Hash für den Mandanten bereits (Unique-Index `idx_documents_tenant_hash`), wird der Upload mit HTTP 409 als Duplikat abgelehnt.
|
||||||
|
3. Datei wird per `os.Rename` (Fallback: Kopieren+Löschen bei Cross-Device) verschoben und mit `chmod 0440` unveränderlich gemacht (WORM).
|
||||||
|
4. 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 auf `processing_status = 'queued'`.
|
||||||
|
5. OCR-Extraktion (`internal/ocr`), Taxonomie-Autozuordnung und `on_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`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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).
|
||||||
Reference in New Issue
Block a user