Dokumenten-Management — Architektur
Diese Seite beschreibt den technischen Aufbau des Prilog-DMS für Entwickler, technische Administratoren und alle, die das System tiefer verstehen oder erweitern wollen.
Stand: DMS-Phase 0-10 vollständig implementiert (2026-05-03).
Architektur-Überblick
┌─────────────────────────────────────────────────────────────────┐
│ Web-Client (React) │
│ │
│ ┌─────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Sidebar │ │ Liste/Grid/ │ │ Detail-Panel │ │
│ │ (Filter,│ │ Folders/ │ │ (Vorschau + alle Phase-3- │ │
│ │ Bäume) │ │ Timeline) │ │ bis Phase-10-Felder) │ │
│ └─────────┘ └──────────────┘ └──────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ REST + JWT
┌──────────────────────────┴──────────────────────────────────────┐
│ Backend API (Fastify) │
│ │
│ Documents FolderTrees DocTypes Retention ShareLinks │
│ Relations Signatures Annotations Templates EmailAlias │
│ │ │ │ │ │ │
│ └───────────┴────────────┴───────────┴───────────┘ │
│ │ │
│ Prisma ORM │
└──────────────────────────┬──────────────────────────────────────┘
│
┌───────────────────┼─────────────────────────────────┐
│ │ │
┌──────┴──────┐ ┌─────────┴────────┐ ┌──────────────┐ ┌──┴────────────┐
│ PostgreSQL │ │ Per-Tenant │ │ Tesseract + │ │ Stalwart Mail │
│ + tsvector │ │ MinIO (S3) │ │ poppler │ │ + JMAP-Fetch │
│ + pgvector │ │ AES-256-GCM │ │ (OCR) │ │ (Mail-zu-DMS) │
│ (geplant) │ │ │ │ │ │ │
└─────────────┘ └──────────────────┘ └──────────────┘ └───────────────┘Datenmodell
Alle DMS-Modelle liegen im zentralen prisma/schema.prisma. Migrations: 0033_dms_folder_trees bis 0040_dms_annotations_templates_email.
Document (zentrales Modell)
| Feld | Typ | Beschreibung |
|---|---|---|
id | String (cuid) | Eindeutige ID |
tenantId | String | Tenant-Scope |
spaceId | String? | Space-Scope (nur bei scope='SPACE') |
scope | Enum | SPACE oder PERSONAL |
ownerUserId | String? | Eigentümer (nur bei scope='PERSONAL') |
title | String | Anzeigename |
description | Text? | Optionale Beschreibung |
mimeType | String | MIME-Typ |
sizeBytes | Int | Dateigröße |
storageKey | String | S3-Objektpfad |
uploadedBy | String | matrixUserId des Uploaders (oder system:*) |
fileHash | String? | SHA-256 für Duplikat-Erkennung |
content | Text? | Extrahierter Text für Volltextsuche |
searchVector | tsvector | Volltextindex (Trigger-aktualisiert) |
starred | Boolean | Lesezeichen |
locked | Boolean | Lösch-Sperre |
version | Int | Version (1+) |
parentId | String? | Vorgänger-Version |
lastOpenedAt | DateTime? | Letzter Zugriff |
expiresAt | DateTime? | Manuelles Ablaufdatum |
archivedAt | DateTime? | Archiviert |
deletedAt | DateTime? | Soft-Delete (Papierkorb) |
| Phase 3 | ||
documentTypeId | String? | FK auf DocumentType |
customFields | JSON? | Werte der Custom-Fields |
| Phase 5 | ||
retentionUntil | DateTime? | Berechnete Aufbewahrungsfrist |
legalHold | Boolean | Aktiver Legal Hold |
legalHoldReason | Text? | Begründung |
legalHoldBy | String? | Wer hat gesetzt |
legalHoldAt | DateTime? | Wann gesetzt |
| Phase 10 | ||
isTemplate | Boolean | Vorlagen-Markierung |
templateCategory | String? | Kategorie (z.B. "Verträge") |
FolderTree + Folder + DocumentFolderPlacement (Phase 1)
Mehrere parallele Hierarchien pro Tenant.
| Modell | Zweck |
|---|---|
FolderTree | Top-Level-Hierarchie (Name, Symbol, Reihenfolge) |
Folder | Knoten im Baum (parentId, treeId) |
DocumentFolderPlacement | M:N — ein Doc kann in mehreren Ordnern liegen |
SavedSearch (Phase 2)
{ id, tenantId, userId, label, filter: JSON, color?, icon? }Smart Folders: Filter-Definition wird gespeichert, beim Aufruf serverseitig ausgewertet.
DocumentType + Custom Fields (Phase 3)
DocumentType {
id, tenantId, key, label, iconEmoji?, description?,
fields: JSON, // Array<{ key, label, type, required, options?, regex? }>
retentionPolicyId?, // Default-Aufbewahrung
}Document.customFields ist eine JSON-Spalte: { field_key: value, ... }. Werte werden gegen DocumentType.fields validiert (Pflicht, Typ, Auswahl).
DocumentRelation (Phase 4)
{ id, tenantId, fromDocumentId, toDocumentId, relationType, note?, createdAt, createdBy }
relationType ∈ { BELONGS_TO | RELATED_TO | SUPERSEDES | PART_OF }Bidirektional sichtbar — der API-Endpoint liefert beim Lookup Outgoing und Incoming.
RetentionPolicy (Phase 5)
RetentionPolicy {
id, tenantId, name,
retentionDays: Int,
actionAfter: 'archive' | 'delete' | 'offer',
legalHoldOverride: Boolean (default true),
appliesToTypeId?, // optional: gilt automatisch für Doc-Typ
}Daily-Cron läuft durch alle Policies → matched alle fälligen Dokumente → führt Aktion aus (außer offer, das eine AdminTask erzeugt).
PublicShareLink + PublicShareView (Phase 6)
PublicShareLink {
id, tenantId, documentId, slug (unique),
passwordHash?, // bcrypt
expiresAt?, maxViews?, viewCount,
revokedAt?, createdBy, createdAt
}
PublicShareView { id, linkId, ip, userAgent, viewedAt }Public-Endpoint /s/:slug (kein Login). bcrypt-Vergleich, Counter-Increment, Audit-Log.
DocumentSignatureRequest + DocumentSignature (Phase 9)
DocumentSignatureRequest {
id, tenantId, documentId, title?, note?,
status: 'pending' | 'partially_signed' | 'fully_signed' | 'cancelled' | 'expired',
expiresAt?, cancelledAt?, cancelledBy?, createdBy, createdAt
}
DocumentSignature {
id, requestId, signerEmail, signerName?,
status: 'pending' | 'signed' | 'declined',
inviteSlug (unique),
invitedAt, signedAt?, declineReason?,
signerName?, ipAddress?,
signatureHash?, signatureNonce?, signedDocHash?
}Signatur-Hash = SHA256(fileHash | signedAt.iso | email | ip | nonce). Reproduzierbar verifizierbar — jede Manipulation am Original wird sichtbar (signedDocHash ≠ aktueller fileHash).
DocumentAnnotation (Phase 10)
{
id, documentId, parentId?, // self-FK für Threads
authorId, body,
pageNumber?, posX?, posY?, posWidth?, posHeight?, // Anchor (optional)
resolvedAt?, resolvedBy?,
createdAt, updatedAt
}Self-FK auf parentId = Threading. resolvedAt setzt einen Thread auf gelöst.
UserDmsEmailAlias (Phase 10)
{
id, tenantId, matrixUserId,
slug (12 char base32),
fullAddress (unique, dms-<slug>@mail.prilog.chat),
enabled, createdAt, lastReceivedAt?
}Eine Adresse pro Nutzer pro Tenant. Wird vom Stalwart-Webhook per fullAddress-Lookup gematcht.
Volltextsuche
Technologie
PostgreSQL tsvector mit deutscher Sprachkonfiguration (german).
Trigger
NEW.search_vector :=
setweight(to_tsvector('german', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', coalesce(NEW.description, '')), 'B') ||
setweight(to_tsvector('german', coalesce(NEW.content, '')), 'C');Treffer im Titel werden höher gewichtet (A > B > C). GIN-Index auf search_vector für O(log n) Lookup.
Suchsyntax (Frontend)
| Eingabe | DB-Query (vereinfacht) |
|---|---|
Protokoll | Plain prefix |
Protokoll Kollegium | UND-Verknüpfung (&) |
Prot | prot:* (Prefix-Match) |
Protokoll ODER Bericht | OR (` |
-Entwurf | NOT (!) |
"Beschluss vom" | Phrase (<->) |
Die Übersetzung passiert serverseitig im documents-global.router.ts.
Textextraktion
Nach jedem Upload läuft asynchron eine Extraktion:
| Format | Bibliothek |
|---|---|
| PDF (mit Text-Layer) | pdf-parse |
| PDF (gescannt) | tesseract.js via poppler-utils (pdftoppm) |
| Word (.docx) | mammoth |
| Excel (.xlsx, .ods) | xlsx |
| PowerPoint (.pptx) | officeparser |
| OpenDocument | officeparser |
| RTF | rtf-parser |
| E-Mail (.eml) | mailparser |
| EPUB | epub2 |
| Text-Formate | nativ UTF-8 |
Extrahierter Text wird in Document.content gespeichert → Trigger aktualisiert search_vector.
OCR (Phase 7)
Wenn ein PDF kein eingebettetes Text-Layer hat:
pdftoppmrendert jede Seite zu PNG (300 DPI)tesseractläuft mit deutschen Sprachdaten (-l deu) über jede Seite- Resultierender Text wird in
Document.contentgeschrieben
Tesseract + poppler-utils sind auf api-server systemweit installiert (apt-get install -y tesseract-ocr tesseract-ocr-deu poppler-utils).
Auto-Klassifizierung (Phase 7)
Naive-Bayes-Klassifikator in document-classifier.service.ts:
- Tokenisierung (deutsche Stopwords entfernt)
- Pro Typ: Wortwahrscheinlichkeiten aus existierenden typisierten Dokumenten (5min cache)
- Beim Klassifizieren: argmax(P(typ|text))
- Konfidenz = relative Wahrscheinlichkeit
suggestType(tenantId, content): { typeId, confidence, secondTypeId? }Voraussetzungen: ≥5 typisierte Dokumente, ≥2 Typen, ≥3 Docs pro Typ.
Auto-Apply-Schwelle: confidence ≥ 0.95 → Typ wird automatisch gesetzt; sonst Vorschlag in customFields._suggestion.
Cache-Invalidation: nach jedem Type-Set wird invalidateClassifierCache(tenantId) gerufen.
Field-Extraction (Regex)
Pro Text-Feld eines Typs kann ein Regex hinterlegt werden. Beim Klassifizieren werden alle Regex-Felder gematcht, leere Doc-Felder werden mit Capture-Group 1 befüllt. Bereits gefüllte Felder werden nicht überschrieben.
Datei-Speicherung (Per-Tenant S3)
Layout
Jeder Tenant hat sein eigenes MinIO-Bucket-Service-Account (TenantRuntime). Schlüssel werden AES-256-GCM verschlüsselt in der DB gehalten.
Space-Dokumente: spaces/{spaceId}/docs/{uuid}-{name}
Personal: tenants/{tenantId}/users/{userId}/personal/{uuid}-{name}
Versionen: spaces/{spaceId}/docs/{uuid}-v{n}-{name}
Vorlage-Kopien: {scope-prefix}/{uuid}-{name}Upload-Flow (Presigned)
Browser → POST /upload → { uploadUrl, key }
Browser → PUT uploadUrl (direkt zu MinIO)
Browser → POST /confirm → DB-Eintrag + Async-ExtractionDer Backend-Server überträgt die Datei selbst nicht — nur Presigned-URLs.
API-Endpunkte (vollständig)
Dokumente
| Methode | Pfad | Beschreibung |
|---|---|---|
GET | /platform/v1/documents | Cross-Space-Suche, Pagination |
GET | /platform/v1/documents/stats | Sidebar-Zähler |
GET | /platform/v1/spaces/:spaceId/documents/:id | Detail |
POST | /platform/v1/spaces/:spaceId/documents/upload | Presigned Upload |
POST | /platform/v1/spaces/:spaceId/documents/confirm-upload | Bestätigung |
PATCH | /platform/v1/spaces/:spaceId/documents/:id | Title, Description, Tags, Expiry |
DELETE | /platform/v1/spaces/:spaceId/documents/:id | Soft-Delete |
GET | /platform/v1/spaces/:spaceId/documents/:id/download | Presigned Download |
GET | /platform/v1/spaces/:spaceId/documents/:id/versions | Versionshistorie |
Folder Trees (Phase 1)
| Methode | Pfad |
|---|---|
GET/POST/PATCH/DELETE | /platform/v1/folder-trees |
GET/POST/PATCH/DELETE | /platform/v1/folders |
POST/DELETE | /platform/v1/documents/:id/folders/:folderId |
Saved Searches (Phase 2)
| Methode | Pfad |
|---|---|
GET/POST/PATCH/DELETE | /platform/v1/saved-searches |
Document Types (Phase 3)
| Methode | Pfad |
|---|---|
GET/POST/PATCH/DELETE | /platform/v1/document-types |
PATCH | /platform/v1/documents/:id/type |
Relations (Phase 4)
| Methode | Pfad |
|---|---|
GET | /platform/v1/documents/:id/relations |
POST | /platform/v1/documents/:id/relations |
DELETE | /platform/v1/documents/:id/relations/:relationId |
Retention (Phase 5)
| Methode | Pfad |
|---|---|
GET/POST/PATCH/DELETE | /platform/v1/retention-policies |
POST | /platform/v1/documents/:id/legal-hold |
DELETE | /platform/v1/documents/:id/legal-hold |
Public Share Links (Phase 6)
| Methode | Pfad |
|---|---|
GET/POST/DELETE | /platform/v1/documents/:id/share-links |
GET | /api/public/share/:slug |
POST | /api/public/share/:slug/verify-password |
Signatures (Phase 9)
| Methode | Pfad |
|---|---|
GET/POST | /platform/v1/documents/:id/signature-requests |
POST | /platform/v1/signature-requests/:id/cancel |
GET | /platform/v1/signature-requests/:id/certificate (PDF) |
GET | /api/public/sign/:slug |
POST | /api/public/sign/:slug/confirm |
Annotations (Phase 10)
| Methode | Pfad |
|---|---|
GET/POST | /platform/v1/documents/:id/annotations |
PATCH/DELETE | /platform/v1/annotations/:id |
POST | /platform/v1/annotations/:id/resolve |
POST | /platform/v1/annotations/:id/reopen |
Templates (Phase 10)
| Methode | Pfad |
|---|---|
GET | /platform/v1/dms-templates |
PATCH | /platform/v1/documents/:id/template |
POST | /platform/v1/dms-templates/:id/instantiate |
Email Alias (Phase 10)
| Methode | Pfad |
|---|---|
GET | /platform/v1/dms/email-alias |
POST | /platform/v1/dms/email-alias/enable |
POST | /platform/v1/dms/email-alias/disable |
POST | /platform/v1/dms/email-alias/rotate |
Mail-zu-DMS — Stalwart Integration
Eingehende Mail
Stalwart MTA empfängt Mail an dms-xxx@mail.prilog.chat
↓
POST /api/public/inbound/stalwart (IP-Whitelist Stalwart)
↓
space-email.router iteriert events:
├─ Recipient passt zu Space.emailAddress → Space-Email-Flow
├─ Recipient passt zu UserDmsEmailAlias.fullAddress → DMS-Personal-Flow
└─ sonst log + ignore
↓
DMS-Personal-Flow (async):
1. JMAP-Fetch via fetchEmailByMessageId
2. Body als .txt → personal Document
3. Pro Anhang: fetchBlob → personal Document
4. UserDmsEmailAlias.lastReceivedAt updatenSpeicherort
Mails landen unter tenants/{tenantId}/users/{userId}/personal/.... Document.uploadedBy = 'system:dms-mail'.
Process-Engine DMS-App (Phase 8)
DmsApp im Modul dms-engine mit 5 ComponentKinds:
| Kind | Action |
|---|---|
dms.upload-trigger | Startet Flow bei Upload (filterbar nach Typ, Space, Tag) |
dms.approve | Aufgabe an Person — onActivate setzt assignedTo via separate UpdateMany |
dms.set-type | Setzt documentTypeId + customFields (mit Template-Resolution) |
dms.move-to-folder | Erstellt DocumentFolderPlacement |
dms.archive | Setzt archivedAt |
Flow-Auto-Trigger
dms-flow-trigger.service.ts → triggerDmsFlowsForDocument(documentId):
- Alle Flow-Templates mit DMS-Upload-Trigger prüfen
- Template-Match (Filter) → ProcessInstance starten mit
inputData.documentId - Idempotent: kein Doppel-Start für gleiches Doc/Template
Hooks: bei Auto-Klassifizierung und manuellem Type-Set.
Frontend-Architektur
src/features/
├── documents/
│ ├── documents-hub.tsx # 3-Panel Hauptseite
│ ├── documents-panel.tsx # Space-Sidebar Tab
│ ├── documents-world.tsx # Welt-Navigation
│ ├── tiptap-viewer.tsx # Read-only Tiptap
│ └── use-documents.ts # React Hooks
└── dms/
├── folder-trees-view.tsx
├── folder-trees-sidebar.tsx
├── document-folders-panel.tsx
├── saved-searches-sidebar.tsx
├── document-type-panel.tsx
├── document-types-settings.tsx
├── document-relations-panel.tsx
├── retention-panel.tsx
├── retention-settings.tsx
├── share-link-modal.tsx
├── signature-modal.tsx
├── public-share-page.tsx # /s/:slug
├── public-sign-page.tsx # /sign/:slug
├── document-annotations-panel.tsx
├── dms-templates-settings.tsx
├── template-picker-modal.tsx
├── dms-email-alias-settings.tsx
└── use-* hooks (1 pro Domain)Vorschau-System
| Dateityp | Renderer |
|---|---|
<iframe> mit Browser-PDF | |
| Bilder | <img> |
| Markdown / Text | Tiptap (read-only) |
| Office | Tiptap mit extrahiertem Text |
Tiptap mit StarterKit, Tabellen, Code-Highlight, Markdown, Highlight, Typography.
Hintergrund-Jobs
| Job | Auslöser | Aufgabe |
|---|---|---|
| Text-Extraction | Upload | Text + OCR + DB-Update |
| Auto-Classifier | Upload (nach Extract) | Typ-Vorschlag, ggf. Auto-Apply |
| DMS-Flow-Trigger | Upload + Type-Set | ProcessInstances starten |
| Retention-Cron | Daily | Aufbewahrungs-Aktionen |
| Trash-Cleanup | Daily | Papierkorb >30d endgültig löschen |
| Re-Indexierung | Server-Start | Docs ohne content neu indizieren |
| Mail-zu-DMS | Stalwart-Webhook | Mail + Anhänge → Personal Documents |
Migrations-Übersicht
| Migration | Inhalt |
|---|---|
0033_dms_folder_trees | FolderTree, Folder, DocumentFolderPlacement |
0034_dms_tags_saved_searches | SavedSearch + Tag-Verbesserungen |
0035_dms_document_types | DocumentType + customFields |
0036_dms_retention_policies | RetentionPolicy + Document.retention*/legalHold* |
0037_dms_public_share_links | PublicShareLink + PublicShareView |
0038_dms_document_relations | DocumentRelation |
0039_dms_signatures | DocumentSignatureRequest + DocumentSignature |
0040_dms_annotations_templates_email | DocumentAnnotation + Document.isTemplate + UserDmsEmailAlias |
Sicherheit & Compliance
| Aspekt | Umsetzung |
|---|---|
| Tenant-Isolation | tenantId-Scope in jedem Query, eigene S3-Buckets |
| S3-Verschlüsselung | AES-256-GCM für Service-Account-Keys in DB |
| DSGVO-Löschung | Hard-Delete-Endpoint (löscht S3 + DB, bricht legalHold nicht) |
| Audit | Pro Doc Activity-Log + Share-Views + Signatur-Trail |
| Public Endpoints | Slug = Geheimnis (24+ char base64url), Rate-Limit, kein Login-Hint |
| eIDAS Art. 26 | Hash über Doc + Time + Email + IP + Nonce, PDF-Zertifikat |
| OCR-Server | Lokal auf api-server, kein 3rd-Party |
| Mail-Webhook-Auth | IP-Whitelist + (geplant) HMAC |
Geplante Erweiterungen
- PDF.js Inline-Annotation-Highlights — Datenmodell mit page/pos-Feldern steht (Phase 10), UI fehlt
- Globale Admin-Übersichten — alle Share-Links, alle Email-Aliases pro Tenant
- Y.js Multi-User-Editing — Tiptap + Matrix als Transport, Phase 11+
- Drag & Drop zwischen Spaces — derzeit nur Folder-Drop
- Erinnerungen vor Ablaufdatum — push/email
- Embedding-Suche (pgvector) — semantische Suche zusätzlich zu tsvector