Skip to content

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

prisma
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:

prisma
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):

prisma
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:

prisma
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

prisma
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:

prisma
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:

json
{
  "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)

prisma
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:

typescript
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:

nginx
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

typescript
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 Master

Beim 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
       → Document

Postfach (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 Personenbezug

Settings

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-KeyDefault-Holder
personal_fach.read.ownOwner
personal_fach.write.ownOwner
personal_fach.delete.ownOwner
personal_fach.inbox.dropAlle authentifizierten Tenant-User (modifiziert durch Empfaenger-Privacy)
personal_fach.inbox.read.othersnicht erteilt — auch nicht Admin (nur via admin_access mit Begruendung)
personal_fach.distribute.in_spaceSpace-Admin / Mod (Default), pro Space erweiterbar
personal_fach.audit.read.ownOwner
personal_fach.audit.read.tenantTenant-Admin
personal_fach.admin_accessTenant-Admin (mit Begruendungs-Pflicht + Audit)

Privacy-Logik

Beim POST /drops:

typescript
// 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

typescript
// 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):

prisma
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):

sql
-- 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 → 503

Bei einem Treffer:

typescript
{
  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):

json
{
  "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:

typescript
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

typescript
// 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.tsxals Basis fuer personal-documents-hub.tsx (scope=PERSONAL)
tiptap-viewer.tsxdirekt fuer Notizen
mobile-documents-list.tsxmit scope-Filter
use-documents.tserweitert um scope-Param

Damit sparen wir ~70% UI-Code-Wachstum.


Migration

Die Migration ist destruktiv-frei (no data loss):

sql
-- 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:

sql
-- 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 droppen

Customer-Server-Template-Sync

Wichtig: Die Schema-Aenderungen muessen auch im prilog-agent-Template gepflegt werden, sonst entsteht Drift bei dedicated Tenants.

Sync-Punkte:

  1. prilog-agent/prisma/schema.prisma — Models + Enums
  2. prilog-agent/prisma/migrations/... — Migration anlegen, identisch zur Backend-Migration
  3. prilog-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:

QueryIndex
"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:

  1. Verteiler-Master als 1 Document mit scope=GLOBAL (oder neuer scope=DISTRIBUTION_MASTER)
  2. N InboxDropMeta-Eintraege, die alle via documentId.storageKey auf den Master zeigen
  3. Beim Empfaenger-Read: storageKey wird vom InboxDropMeta-Lookup geholt
  4. Empfaenger-Delete: nur InboxDropMeta-Zeile + zugehoerige Document-Zeile (mit scope=INBOX)
  5. 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:

prisma
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:

  1. Recipient passt zu Space.emailAddress → Space-Email-Flow
  2. Recipient passt zu UserDmsEmailAlias.fullAddress → DMS-Personal-Flow (dms-personal-side-effects.service.ts)
  3. Sonst: log + ignore

DMS-Personal-Flow

Async (fire-and-forget, eigenes try/catch):

typescript
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.lastReceivedAt

Speicherpfad: tenants/{tenantId}/users/{matrixUserId}/personal/{uuid}-{name}. uploadedBy = 'system:dms-mail'.


Migrations-Reihenfolge fuer Production

  1. Backend-Schema deployen (additive Migration, alte Code-Pfade unberuehrt)
  2. Backend-API-Endpunkte deployen (Feature-Flag personal_fach_enabled per Tenant default false)
  3. Cron-Job deployen (laeuft sicher, da keine INBOX-Drops existieren)
  4. Web-Client deployen mit ausgeblendeter Welt (CSS-Hide bei Feature-Flag off)
  5. Bei einzelnem Test-Tenant Feature-Flag auf true setzen, smoke-test
  6. Bei Erfolg: Feature-Flag fuer alle Tenants auf true (manuell pro Tenant via Admin-Portal)
  7. Customer-Server-Template synchronisieren (siehe oben)

Damit ist kein Bing-Bang-Deployment noetig, jeder Schritt einzeln ruckabwickelbar.