Mein Fach – Technische Architektur
Diese Seite beschreibt den technischen Aufbau des Moduls Mein Fach (persoenliches DMS, Postfach, Verteiler-Fach).
Eine Endnutzer-Beschreibung und ein Admin-Handbuch sind separat verfuegbar.
Architektur-Ueberblick
┌─────────────────────────────────────────────────────────────────────┐
│ Web-Client (React) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Mein-Fach-Welt (8. Hub-Welt) │ │
│ │ ├── Dokumente (eigene Ablage) │ │
│ │ ├── Notizen (Tiptap) │ │
│ │ └── Postfach │ │
│ │ ├── Neu (ungelesen) │ │
│ │ └── Archiv (vom Empfaenger uebernommen) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Verteiler-Fach (Space-Tab) │ │
│ │ ├── Drop-Form mit Pre-Send-Preview │ │
│ │ ├── Verteilungs-Log mit aggregierten Read-Stats │ │
│ │ └── Update/Replace-Workflow │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────────┘
│ REST API (Platform-API)
┌──────────────────────────┴──────────────────────────────────────────┐
│ Backend API (Fastify) │
│ │
│ ┌────────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ personal-fach │ │ inbox-drop │ │ distribution │ │
│ │ Router │ │ Router │ │ Router │ │
│ │ - GET /personal │ │ - POST drop │ │ - POST distribute│ │
│ │ - POST upload │ │ - GET inbox │ │ - GET log │ │
│ │ - DELETE doc │ │ - PATCH archive │ │ - POST replace │ │
│ └─────────┬──────────┘ └────────┬────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌─────────┴──────────────────────┴────────────────────┴────────┐ │
│ │ Quota-Service (cost-management) │ │
│ │ - reserveQuota / releaseQuota │ │
│ │ - checkAvailable / softWarn / hardStop │ │
│ └─────────────────────────┬────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┴────────────────────────────────────┐ │
│ │ Audit-Service │ │
│ │ - logDrop / logRead / logMove / logDelete / logAdminAccess │ │
│ └─────────────────────────┬────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┴────────────────────────────────────┐ │
│ │ Cron: Auto-Cleanup (nightly) │ │
│ │ - Drops mit retention abgelaufen → soft-delete │ │
│ │ - Soft-Deleted nach 30 Tagen → hard-delete │ │
│ │ - Distribution-Refs ohne aktive Empfaenger → cleanup │ │
│ └─────────────────────────┬────────────────────────────────────┘ │
└────────────────────────────┼────────────────────────────────────────┘
│
┌────────────────────┼────────────────────────┐
│ │ │
┌────┴─────┐ ┌─────────┴──────────┐ ┌────────┴────────┐
│PostgreSQL│ │ S3/MinIO │ │ ClamAV-Proxy │
│ + Prisma │ │ /tenants/{tid}/ │ │ (Upload-Filter) │
│ │ │ users/{uid}/... │ │ │
└──────────┘ └────────────────────┘ └─────────────────┘Datenmodell
Die Erweiterung greift auf das bestehende Document-Modell zurueck und ergaenzt es um die Felder scope, ownerUserId und einige optionale Felder. Damit:
- bleiben Tags, Volltextsuche, Versionen, Papierkorb unveraendert verfuegbar
- entstehen keine doppelten Code-Pfade fuer Upload / Download / Vorschau
- ist die Migration rueckwaertskompatibel
Document — Erweiterungen
model Document {
// ... bestehende Felder ...
// ─── Mein Fach Erweiterung ──────────────────────────────────────
scope DocumentScope @default(SPACE)
ownerUserId String? @map("owner_user_id") @db.VarChar(255)
spaceId String? @map("space_id") @db.VarChar(50) // jetzt nullable
@@index([ownerUserId, scope, deletedAt])
@@index([tenantId, scope, deletedAt])
}
enum DocumentScope {
SPACE // bestehend: Space-DMS (spaceId required)
GLOBAL // bestehend: Tenant-weit (spaceId null, ownerUserId null)
PERSONAL // neu: persoenliches Dokument (ownerUserId required)
INBOX // neu: Drop im Postfach (ownerUserId = Empfaenger)
}InboxDropMeta — Drop-Metadaten
Separate Tabelle, weil die Felder nur fuer INBOX-Documents relevant sind und sich von SPACE/GLOBAL/PERSONAL unterscheiden:
model InboxDropMeta {
documentId String @id @db.VarChar(50)
senderUserId String @map("sender_user_id") @db.VarChar(255)
senderNote String? @map("sender_note") @db.VarChar(140)
readAt DateTime? @map("read_at")
archivedByOwner Boolean @default(false) @map("archived_by_owner")
archivedAt DateTime? @map("archived_at")
expiresAt DateTime @map("expires_at") // Auto-Loeschung Zielzeit
distributionId String? @map("distribution_id") @db.VarChar(50)
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
distribution Distribution? @relation(fields: [distributionId], references: [id])
@@index([senderUserId])
@@index([expiresAt, archivedByOwner])
@@index([distributionId])
@@map("inbox_drop_meta")
}Distribution — Verteiler-Master
Ein Verteiler-Drop erzeugt eine Distribution-Zeile + N InboxDropMeta-Zeilen mit einer gemeinsamen storageKey (Reference statt Copy):
model Distribution {
id String @id @default(cuid()) @db.VarChar(50)
tenantId String @map("tenant_id") @db.VarChar(64)
spaceId String @map("space_id") @db.VarChar(50)
senderUserId String @map("sender_user_id") @db.VarChar(255)
masterDocumentId String @map("master_document_id") @db.VarChar(50)
recipientFilter Json @default("{}") // { "roles": ["student"], "exclude": [...] }
recipientCount Int @default(0) @map("recipient_count")
senderNote String? @db.VarChar(140) @map("sender_note")
createdAt DateTime @default(now()) @map("created_at")
replacedById String? @map("replaced_by_id") @db.VarChar(50) // bei Update
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
master Document @relation(fields: [masterDocumentId], references: [id])
drops InboxDropMeta[]
replacedBy Distribution? @relation("distribution_replaces", fields: [replacedById], references: [id])
replaces Distribution[] @relation("distribution_replaces")
@@index([tenantId, spaceId, createdAt])
@@index([senderUserId, createdAt])
@@map("distributions")
}UserPostfachSettings
Pro Nutzer-Settings:
model UserPostfachSettings {
userId String @id @db.VarChar(255)
tenantId String @map("tenant_id") @db.VarChar(64)
whoCanDrop DropPermission @default(ALL) @map("who_can_drop")
blockList String[] @default([]) @map("block_list")
retentionDays Int @default(60) @map("retention_days")
keepReadDrops Boolean @default(false) @map("keep_read_drops")
warnBeforeDelete Boolean @default(true) @map("warn_before_delete")
notificationMode NotificationMode @default(BOTH) @map("notification_mode")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("user_postfach_settings")
}
enum DropPermission {
ALL // jeder im Tenant
STAFF_ONLY // nur Lehrer / Admin / Mitarbeiter
CONTACTS // nur eigene Kontakte
NOBODY // niemand (Verteiler-Drops kommen trotzdem an)
}
enum NotificationMode {
CHAT // nur Chat-Bot-Nachricht
BELL // nur Bell-Indicator
BOTH // Chat + Bell
NONE // keine Notification
}PostfachAuditLog
model PostfachAuditLog {
id String @id @default(cuid()) @db.VarChar(50)
tenantId String @map("tenant_id") @db.VarChar(64)
ownerUserId String @map("owner_user_id") @db.VarChar(255)
actorUserId String @map("actor_user_id") @db.VarChar(255)
action AuditAction
documentId String? @map("document_id") @db.VarChar(50)
reason String? @db.Text // bei admin_access required
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId, createdAt])
@@index([ownerUserId, createdAt])
@@map("postfach_audit_log")
}
enum AuditAction {
DROP_RECEIVED
DROP_SENT
DROP_READ
DROP_ARCHIVED
DROP_MOVED_TO_DOCS
DROP_DELETED
DROP_RESTORED
DOCUMENT_UPLOADED
DOCUMENT_DELETED
DOCUMENT_RESTORED
ADMIN_ACCESS
DISTRIBUTION_SENT
DISTRIBUTION_REPLACED
DISTRIBUTION_DELETED
PRIVACY_CHANGED
RETENTION_CHANGED
}TenantSetting — Erweiterung
Bestehende tenant_settings-Tabelle bekommt neue Spalten:
model TenantSetting {
// ... bestehende Felder ...
personalFachEnabled Boolean @default(true) @map("personal_fach_enabled")
personalFachReadOnly Boolean @default(false) @map("personal_fach_read_only")
maxInboxRetentionDays Int @default(90) @map("max_inbox_retention_days")
maxDropSizeMb Int @default(50) @map("max_drop_size_mb")
maxDropsPerHour Int @default(20) @map("max_drops_per_hour")
defaultPrivacyByRole Json @default("{}") @map("default_privacy_by_role")
enforcedMinPrivacy Json @default("{}") @map("enforced_min_privacy_by_role")
quotasByRole Json @default("{}") @map("quotas_by_role")
auditLogRetentionDays Int @default(365) @map("audit_log_retention_days")
notifyOwnerOnAdminAccess Boolean @default(true) @map("notify_owner_on_admin_access")
}quotasByRole als JSON, damit pro UserType-Schluessel (z.B. student, parent, teacher) die Werte einstellbar sind:
{
"student": { "personal_gb": 2, "inbox_mb": 500 },
"parent": { "personal_gb": 1, "inbox_mb": 500 },
"teacher": { "personal_gb": 10, "inbox_mb": 1024 },
"admin": { "personal_gb": 20, "inbox_mb": 2048 }
}Per-Tenant S3/MinIO Architektur
Jeder Tenant hat einen eigenen MinIO-Service-Account auf seinem Customer-Server (Pro: dedicated Hardware, Light: shared-host). Das Backend lookuped pro Request aus TenantRuntime die Credentials und stellt einen Tenant-spezifischen S3-Client bereit.
Schema-Erweiterung (Migration 0017)
model TenantRuntime {
// ... bestehende Felder ...
s3Endpoint String? @map("s3_endpoint") @db.VarChar(500)
s3Bucket String? @map("s3_bucket") @db.VarChar(255)
s3AccessKeyId String? @map("s3_access_key_id") @db.VarChar(255)
s3SecretAccessKeyEncrypted String? @map("s3_secret_access_key_encrypted") @db.Text
s3ProvisionedAt DateTime? @map("s3_provisioned_at")
}Secret ist AES-256-GCM verschluesselt mit STORAGE_ENCRYPTION_KEY env (64 Hex). Format iv:tag:ciphertext. Bei Key-Rotation muessten alle Secrets neu encrypted werden.
Service-Layer (object-storage.service.ts)
Alle Public-APIs nehmen tenantId als ersten Parameter — KEIN globaler Fallback:
generateUploadUrl(tenantId, key, mime, sizeBytes): Promise<PresignedUrlResult | null>
generateDownloadUrl(tenantId, key, expiresIn?, contentDisposition?)
deleteObject(tenantId, key)
uploadBuffer(tenantId, key, body, contentType)
getObjectBuffer(tenantId, key): Promise<Buffer | null>
getStorageUsage(tenantId, prefix)LRU-Cache: 200 Eintraege, 30 min TTL. invalidateS3CacheForTenant(tenantId) nach Rotation.
Provisioning-Flow
Tenant-Anlegen / Backfill
↓
provisionStorageForTenant(tenantId)
↓
Backend lookuped Tenant-Runtime → orderId (oder SHARED-HOST-N bei Light)
↓
WS-Command "storage.provision_service_account" an Agent
↓
Agent: mc admin user svcacct add local <parentUser>
--policy {bucket-eingebettete Policy}
↓
Agent returns { bucket, accessKey, secretKey, endpoint }
↓
Backend: encrypt secretKey, save in TenantRuntime
endpoint wird aus runtime.matrixDomain ueberschrieben (nicht Agent-Output)Nginx-Pattern
Auf jedem Customer-Server:
location ~ ^/tenant- {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host <matrixDomain>;
proxy_request_buffering off;
client_max_body_size 200m;
}WICHTIG: KEIN Path-Rewrite. SDK signiert https://<host>/<bucket>/<key>, nginx forwardet ohne Aenderung, MinIO sieht denselben Pfad → Sigv4 stimmt.
AWS-SDK-Konfiguration
new S3Client({
endpoint: runtime.s3Endpoint,
region: 'eu-central-1',
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
// Wichtig: SDK v3.730+ haengt CRC32-Checksum-Header an, MinIO ignoriert
// sie bei der Signatur-Berechnung → SignatureDoesNotMatch.
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});SvcAcct-Rotation
Cron storage-rotation taeglich 04:00. Pro Tenant s3ProvisionedAt < now() - 90 Tage → erneutes provisionStorageForTenant. Alter SvcAcct bleibt aktiv (kein Auto-Cleanup), neuer wird DB-seitig hinterlegt.
MinIO-Pfad-Layout
{minio-bucket}/
├── tenants/
│ └── {tenantId}/
│ ├── spaces/
│ │ └── {spaceId}/
│ │ ├── documents/{docId}/... # bestehend (Space-DMS)
│ │ └── distribution/{distId}/... # NEU: Verteiler-Master-Datei
│ ├── global/
│ │ └── documents/{docId}/... # bestehend (Global-DMS)
│ └── users/
│ └── {userId}/
│ ├── personal/{docId}/... # NEU: eigene Dokumente
│ └── inbox/{dropId}/... # NEU: Postfach-Drops (eigene)Wichtig: Verteiler-Drops nutzen denselben storageKey wie das Master-Document — keine Kopien. Das spart bei 200 Empfaengern entsprechend Storage.
Verteiler-Drop von 200 Schuelern:
1× Distribution-Master → tenants/{tid}/spaces/{sid}/distribution/{distId}/...
200× InboxDropMeta → alle zeigen via documentId.storageKey auf den MasterBeim Loeschen: Reference-Counting im Service-Layer. Master wird erst geloescht, wenn alle Refs weg sind ODER nach Tenant-Aufbewahrungsfrist.
API-Contract
Alle Endpunkte unter /api/platform/v1/personal-fach/ mit JWT-Auth.
Eigene Dokumente
GET /personal-fach/documents
?folder=string&tag=string&search=string&sort=asc|desc
→ { items: Document[], total: number }
POST /personal-fach/documents/upload
multipart/form-data: file, tags[], description
→ { id: string, ... }
GET /personal-fach/documents/{id}
→ Document
PATCH /personal-fach/documents/{id}
Body: { description?, tags?, starred? }
→ Document
DELETE /personal-fach/documents/{id}
→ { ok: true, restorableUntil: ISO }
POST /personal-fach/documents/{id}/restore
→ DocumentPostfach (Empfaenger-Sicht)
GET /personal-fach/inbox?status=new|archived
→ { items: InboxDrop[], unread: number }
GET /personal-fach/inbox/{id}
→ InboxDrop (markiert nicht als gelesen — nur Detail)
POST /personal-fach/inbox/{id}/read
→ { readAt: ISO }
POST /personal-fach/inbox/{id}/archive
→ { archivedAt: ISO, quotaShift: { from: 'inbox', to: 'personal' } }
POST /personal-fach/inbox/{id}/move-to-docs
Body: { folder?: string, tags?: string[] }
→ { documentId: string } // Drop wird zu PERSONAL-Document
DELETE /personal-fach/inbox/{id}
→ { ok: true, restorableUntil: ISO }Drop senden (Sender-Sicht)
POST /personal-fach/drops
multipart/form-data: file, recipientUserId, senderNote?
→ 201 { dropId: string }
→ 403 RECIPIENT_BLOCKED_YOU
→ 413 RECIPIENT_INBOX_FULL
→ 429 RATE_LIMIT_EXCEEDED
→ 422 FILE_TOO_LARGE | VIRUS_DETECTED
GET /personal-fach/drops/sent?recipientUserId=string&limit=50
→ { items: SentDrop[] } (nur Metadaten, keine Lese-Stats)Verteiler-Fach (Lehrer-Sicht)
GET /spaces/{spaceId}/distributions
→ { items: Distribution[], log: DistributionLogEntry[] }
POST /spaces/{spaceId}/distributions
multipart/form-data: file, recipientFilter?, senderNote?
→ 201 { distributionId, recipientCount, dropIds: [] }
GET /spaces/{spaceId}/distributions/{id}/preview
Query: ?filter=...
→ { recipientCount, sampleNames[], totalQuotaImpact, oversaturatedRecipients[] }
POST /spaces/{spaceId}/distributions/{id}/replace
multipart/form-data: file
→ { newDistributionId, replacedDropCount }
POST /spaces/{spaceId}/distributions/{id}/distribute-to
Body: { userIds: string[] }
→ { newDropCount }
DELETE /spaces/{spaceId}/distributions/{id}
→ { masterDeleted: boolean, refsKept: number }
GET /spaces/{spaceId}/distributions/{id}/stats
→ { delivered: number, read: number, archived: number,
deleted: number, percentages: {...} }
// Aggregat ohne PersonenbezugSettings
GET /personal-fach/settings
→ UserPostfachSettings
PATCH /personal-fach/settings
Body: Partial<UserPostfachSettings>
→ UserPostfachSettings (validiert gegen Tenant-Settings)
GET /personal-fach/quota
→ { personal: { used, total, percent },
inbox: { used, total, percent } }Audit
GET /personal-fach/audit?limit=100
→ { items: AuditEntry[] }
(Owner sieht eigenes Log)
POST /admin/personal-fach/access
Body: { targetUserId, reason, gdprCategory }
→ { accessToken: string, expiresAt: ISO }
(Tenant-Admin only — startet Notfall-Zugriff mit Audit-Eintrag)
GET /admin/personal-fach/audit?tenantWide=true&filters
→ { items: AuditEntry[] }
(Tenant-Admin only)Permissions-Modell
Ressourcen
| Permission-Key | Default-Holder |
|---|---|
personal_fach.read.own | Owner |
personal_fach.write.own | Owner |
personal_fach.delete.own | Owner |
personal_fach.inbox.drop | Alle authentifizierten Tenant-User (modifiziert durch Empfaenger-Privacy) |
personal_fach.inbox.read.others | nicht erteilt — auch nicht Admin (nur via admin_access mit Begruendung) |
personal_fach.distribute.in_space | Space-Admin / Mod (Default), pro Space erweiterbar |
personal_fach.audit.read.own | Owner |
personal_fach.audit.read.tenant | Tenant-Admin |
personal_fach.admin_access | Tenant-Admin (mit Begruendungs-Pflicht + Audit) |
Privacy-Logik
Beim POST /drops:
// Pseudocode
async function canSendDrop(senderId, recipientId, tenantId) {
const settings = await getPostfachSettings(recipientId);
const sender = await getUser(senderId);
// Block-Liste
if (settings.blockList.includes(senderId)) return false;
// Privacy-Modus
switch (settings.whoCanDrop) {
case 'NOBODY': return false;
case 'CONTACTS': return await isContact(recipientId, senderId);
case 'STAFF_ONLY': return ['teacher', 'admin', 'staff'].includes(sender.role);
case 'ALL': return true;
}
}Verteiler-Drops umgehen nur das whoCanDrop-Setting (Pflicht-Kommunikation), aber nicht die Block-Liste — wenn ein Schueler einen Lehrer geblockt hat, kommen dessen Verteiler-Drops trotzdem an, aber das Audit-Log markiert sie als "trotz Block geliefert (Verteiler-Pflicht)". So bleibt Transparenz erhalten.
Quota-Service
// Pseudocode
class QuotaService {
async reserveQuota(userId, sizeBytes, scope: 'personal'|'inbox') {
const used = await this.getUsed(userId, scope);
const total = await this.getQuotaForUser(userId, scope);
if (used + sizeBytes > total) {
throw new QuotaExceededError({ used, total, requested: sizeBytes });
}
await this.incrementUsed(userId, scope, sizeBytes);
return { used: used + sizeBytes, total };
}
async releaseQuota(userId, sizeBytes, scope) {
await this.decrementUsed(userId, scope, sizeBytes);
}
// Wird beim Verteiler aufgerufen: nominell pro Empfaenger,
// tatsaechliche Storage-Nutzung wird via Reference-Dedup separat gefuehrt.
async reserveQuotaNominal(recipientUserId, sizeBytes) {
return this.reserveQuota(recipientUserId, sizeBytes, 'inbox');
}
// Soft-Warning bei 80%
async checkSoftLimit(userId, scope) {
const { used, total } = await this.getUsed(userId, scope);
if (used / total >= 0.8) {
// Notification triggern
await notificationService.sendSoftWarning(userId, scope);
}
}
}Speicherung in cost_management-Tabelle (bestehend):
model CostManagement {
// ... bestehende Felder ...
personalUsedBytes BigInt @default(0) @map("personal_used_bytes")
inboxUsedBytes BigInt @default(0) @map("inbox_used_bytes")
}Auto-Cleanup-Cron
Taeglich um 03:00 (Tenant-Zeitzone):
-- Phase 1: Drops, deren retention abgelaufen ist
SELECT * FROM inbox_drop_meta
WHERE archived_by_owner = false
AND expires_at < NOW();
-- Fuer jeden Eintrag:
-- IF user.warn_before_delete = true AND expires_at - NOW() < 3 days
-- → Notification senden, NICHT loeschen
-- IF expires_at < NOW() - 24h (kulanz)
-- → soft-delete (deleted_at = NOW())
-- Phase 2: Soft-Deleted nach 30 Tagen → Hard-Delete
SELECT * FROM documents
WHERE scope IN ('personal', 'inbox')
AND deleted_at < NOW() - INTERVAL '30 days';
-- Fuer jeden:
-- storage.delete(storageKey) IF kein anderes Document mehr darauf zeigt
-- prisma.document.delete(id)
-- audit_log("HARD_DELETE", ...)
-- Phase 3: Verwaiste Distribution-Master ohne aktive Refs
SELECT d.* FROM distributions d
WHERE d.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM inbox_drop_meta m
WHERE m.distribution_id = d.id
AND NOT EXISTS (SELECT 1 FROM documents WHERE id = m.document_id AND deleted_at IS NULL)
);
-- Master soft-deleten
-- Phase 4: Audit-Log-Retention
DELETE FROM postfach_audit_log
WHERE created_at < NOW() - INTERVAL (tenant.audit_log_retention_days || ' days');ClamAV-Integration
Drops gehen durch den ClamAV-Upload-Proxy (Variante D, separater Service):
Client ──upload──▶ Backend ──forward──▶ ClamAV-Proxy ──scan──▶ MinIO
│
├─ clean → speichern
├─ infected → 422 + audit log
└─ scan_error → 503Bei einem Treffer:
{
error: "FILE_REJECTED",
message: "Datei konnte nicht zugestellt werden.",
// Sender bekommt diese generische Meldung — kein "Trojan.X.Y gefunden"
}Im Audit-Log (Tenant-Admin-sichtbar):
{
"action": "DROP_REJECTED",
"metadata": {
"reason": "virus_detected",
"clamav_signature": "Trojan.Generic.1234",
"file_hash": "abc123...",
"sender_user_id": "@user1:tenant.prilog.team",
"recipient_user_id": "@user2:tenant.prilog.team",
"file_size": 1234567
}
}Web-Client-Integration
Hub-Welt-Erweiterung
/home/lee/prilog-web-client/src/components/app/app-sidebar.tsx:
const WORLDS = [
{ key: 'users', label: 'Kontakte', icon: Users, defaultUrl: '/contacts' },
{ key: 'spaces', label: 'Spaces', icon: LayoutGrid, defaultUrl: '/' },
{ key: 'calendar', label: 'Kalender', icon: Calendar, defaultUrl: '/calendar' },
{ key: 'my-tasks', label: 'Aufgaben', icon: CheckSquare, defaultUrl: '/meine-aufgaben' },
{ key: 'cascades', label: 'Kaskaden', icon: GitBranch, defaultUrl: '/kaskaden' },
{ key: 'concepts', label: 'Konzepte', icon: BookOpen, defaultUrl: '/konzepte' },
{ key: 'documents', label: 'Dokumente', icon: FileText, defaultUrl: '/documents' },
{ key: 'mein-fach', label: 'Mein Fach', icon: Inbox, defaultUrl: '/mein-fach' }, // NEU
];
const WORLD_VISIBILITY_MAP = {
// ... bestehend ...
'mein-fach': 'hub_personal_fach', // NEU - in Visibility-Matrix anlegen
};Routes
// app/routes.tsx (neu)
{
path: '/mein-fach',
element: <MeinFachLayout />,
children: [
{ index: true, element: <MeinFachOverview /> },
{ path: 'documents', element: <PersonalDocumentsView /> },
{ path: 'documents/:id', element: <PersonalDocumentDetail /> },
{ path: 'notes', element: <PersonalNotesView /> },
{ path: 'inbox', element: <InboxView /> },
{ path: 'inbox/:id', element: <InboxDropDetail /> },
{ path: 'archive', element: <InboxArchiveView /> },
{ path: 'settings', element: <PostfachSettings /> },
{ path: 'storage', element: <StorageView /> },
{ path: 'audit', element: <PersonalAuditView /> },
],
},
{
path: '/spaces/:spaceId/distribution',
element: <DistributionWorld />,
},Komponenten-Wiederverwendung
Die bestehenden DMS-Komponenten lassen sich grossteils wiederverwenden:
| Bestehend (Space-DMS) | Wiederverwendet fuer Mein Fach |
|---|---|
documents-hub.tsx | als Basis fuer personal-documents-hub.tsx (scope=PERSONAL) |
tiptap-viewer.tsx | direkt fuer Notizen |
mobile-documents-list.tsx | mit scope-Filter |
use-documents.ts | erweitert um scope-Param |
Damit sparen wir ~70% UI-Code-Wachstum.
Migration
Die Migration ist destruktiv-frei (no data loss):
-- 1. Document-Schema-Erweiterung
ALTER TABLE documents
ADD COLUMN scope VARCHAR(20) NOT NULL DEFAULT 'SPACE',
ADD COLUMN owner_user_id VARCHAR(255),
ALTER COLUMN space_id DROP NOT NULL;
-- 2. Bestehende Documents auf scope='SPACE' setzen — Default greift
-- 3. Index-Anpassungen
CREATE INDEX idx_documents_owner_scope ON documents(owner_user_id, scope, deleted_at);
CREATE INDEX idx_documents_tenant_scope ON documents(tenant_id, scope, deleted_at);
-- 4. Neue Tabellen
CREATE TABLE inbox_drop_meta (...);
CREATE TABLE distributions (...);
CREATE TABLE user_postfach_settings (...);
CREATE TABLE postfach_audit_log (...);
-- 5. tenant_settings erweitern
ALTER TABLE tenant_settings
ADD COLUMN personal_fach_enabled BOOLEAN DEFAULT TRUE,
ADD COLUMN personal_fach_read_only BOOLEAN DEFAULT FALSE,
ADD COLUMN max_inbox_retention_days INT DEFAULT 90,
ADD COLUMN max_drop_size_mb INT DEFAULT 50,
ADD COLUMN max_drops_per_hour INT DEFAULT 20,
ADD COLUMN default_privacy_by_role JSONB DEFAULT '{}',
ADD COLUMN enforced_min_privacy_by_role JSONB DEFAULT '{}',
ADD COLUMN quotas_by_role JSONB DEFAULT '{}',
ADD COLUMN audit_log_retention_days INT DEFAULT 365,
ADD COLUMN notify_owner_on_admin_access BOOLEAN DEFAULT TRUE;
-- 6. cost_management erweitern
ALTER TABLE cost_management
ADD COLUMN personal_used_bytes BIGINT DEFAULT 0,
ADD COLUMN inbox_used_bytes BIGINT DEFAULT 0;Rollback:
-- 1. Spalten abbauen (Vorsicht: Daten gehen verloren wenn Mein Fach aktiv genutzt wurde)
ALTER TABLE documents
DROP COLUMN scope,
DROP COLUMN owner_user_id,
ALTER COLUMN space_id SET NOT NULL;
DROP TABLE inbox_drop_meta;
DROP TABLE distributions;
DROP TABLE user_postfach_settings;
DROP TABLE postfach_audit_log;
-- tenant_settings, cost_management Spalten droppenCustomer-Server-Template-Sync
Wichtig: Die Schema-Aenderungen muessen auch im prilog-agent-Template gepflegt werden, sonst entsteht Drift bei dedicated Tenants.
Sync-Punkte:
prilog-agent/prisma/schema.prisma— Models + Enumsprilog-agent/prisma/migrations/...— Migration anlegen, identisch zur Backend-Migrationprilog-agent/src/modules/...— falls personal-fach-Logik im Agent laeuft (typisch nicht — Agent ist nur Synapse-Connector)
Siehe Drift-Management fuer den Sync-Workflow.
Performance-Ueberlegungen
Indizes
Die wichtigsten Lookups:
| Query | Index |
|---|---|
| "Mein Postfach laden" | (owner_user_id, scope, deleted_at) mit scope=INBOX |
| "Meine eigenen Docs" | (owner_user_id, scope, deleted_at) mit scope=PERSONAL |
| "Tenant-weiter Storage-Report" | (tenant_id, scope, deleted_at) |
| "Drops von Sender X" | inbox_drop_meta(sender_user_id) |
| "Drops zum Auto-Loeschen" | inbox_drop_meta(expires_at, archived_by_owner) |
| "Verteiler-Log eines Spaces" | distributions(tenant_id, space_id, created_at) |
Caching
- Quota-Werte pro User in Redis cachen (TTL 60s) — wird bei jedem Upload abgefragt
- Postfach-Unread-Count in Redis cachen, invalidieren bei Drop / Read
- Distribution-Stats (delivered/read/archived) Background-Cache, refresh alle 5 min
Reference-Dedup beim Verteiler
Statt N Storage-Kopien zu erzeugen:
- Verteiler-Master als 1 Document mit scope=GLOBAL (oder neuer scope=DISTRIBUTION_MASTER)
- N InboxDropMeta-Eintraege, die alle via documentId.storageKey auf den Master zeigen
- Beim Empfaenger-Read: storageKey wird vom InboxDropMeta-Lookup geholt
- Empfaenger-Delete: nur InboxDropMeta-Zeile + zugehoerige Document-Zeile (mit scope=INBOX)
- Master-Delete: erst wenn keine aktiven Refs mehr und Distribution gestoppt
Trade-off: leicht komplexerer Loeschpfad, dafuer massiv weniger Storage bei grossen Verteilungen.
Tests
Schema-Tests
- Document mit scope=PERSONAL ohne ownerUserId → DB-Constraint-Error
- Document mit scope=SPACE ohne spaceId → DB-Constraint-Error
- InboxDropMeta ohne korrespondierendes Document → FK-Violation
API-Tests
- Drop senden → Quota-Reservierung greift
- Empfaenger blockiert Sender → 403
- Empfaenger Postfach voll → 413
- Verteiler-Drop → N InboxDropMeta-Eintraege erzeugt, 1 Master-storageKey
- Verteiler-Replace → alte Refs aktualisiert auf neuen Master
- Verteiler-Delete → Master soft-deleted, Refs bleiben mit Vermerk
- Auto-Loeschung-Cron → Drops nach retention weg, Archiv bleibt
- Admin-Access ohne Grund → 422 (Validation)
- Admin-Access mit Grund → Audit-Log + Owner-Notification
Integration-Tests
- Drop-Flow End-to-End: Sender → ClamAV → MinIO → Empfaenger sieht Drop
- Verteiler-Flow: Lehrer drop → 27 Schueler bekommen Refs → Reads aggregieren
- Quota-Soft-Warning: bei 80% wird Notification ausgeloest
- Privacy-Setting: STAFF_ONLY blockt Schueler-zu-Schueler-Drop
- Audit-Log: jede Aktion landet im Log
Mail-zu-DMS (Phase 10)
Zusatz-Modell UserDmsEmailAlias:
model UserDmsEmailAlias {
id String @id @default(cuid())
tenantId String @map("tenant_id")
matrixUserId String @map("matrix_user_id")
slug String @db.VarChar(20)
fullAddress String @unique @map("full_address") @db.VarChar(320)
enabled Boolean @default(true)
createdAt DateTime @default(now())
lastReceivedAt DateTime? @map("last_received_at")
@@unique([tenantId, slug])
@@unique([tenantId, matrixUserId])
}Webhook-Routing
Stalwart sendet events-Payload an POST /api/public/inbound/stalwart. Der Router (space-email.router.ts) iteriert und matched in dieser Reihenfolge:
- Recipient passt zu
Space.emailAddress→ Space-Email-Flow - Recipient passt zu
UserDmsEmailAlias.fullAddress→ DMS-Personal-Flow (dms-personal-side-effects.service.ts) - Sonst: log + ignore
DMS-Personal-Flow
Async (fire-and-forget, eigenes try/catch):
1. fetchEmailByMessageId(messageId) // JMAP
2. uploadDmsPersonalEmail(alias, email)
├── buildMailContent(...) // Body als .txt
├── uploadPersonalDocument(.txt) // Body
└── for each attachment:
├── fetchBlob(attachment)
└── uploadPersonalDocument(att) // mit scope=PERSONAL, ownerUserId=alias.matrixUserId
3. Update alias.lastReceivedAtSpeicherpfad: tenants/{tenantId}/users/{matrixUserId}/personal/{uuid}-{name}. uploadedBy = 'system:dms-mail'.
Migrations-Reihenfolge fuer Production
- Backend-Schema deployen (additive Migration, alte Code-Pfade unberuehrt)
- Backend-API-Endpunkte deployen (Feature-Flag
personal_fach_enabledper Tenant default false) - Cron-Job deployen (laeuft sicher, da keine INBOX-Drops existieren)
- Web-Client deployen mit ausgeblendeter Welt (CSS-Hide bei Feature-Flag off)
- Bei einzelnem Test-Tenant Feature-Flag auf true setzen, smoke-test
- Bei Erfolg: Feature-Flag fuer alle Tenants auf true (manuell pro Tenant via Admin-Portal)
- Customer-Server-Template synchronisieren (siehe oben)
Damit ist kein Bing-Bang-Deployment noetig, jeder Schritt einzeln ruckabwickelbar.