# Architecture Patterns

**Domain:** Dimension47 — TTRPG-Plattform (PF2e), Brownfield-Erweiterung
**Researched:** 2026-04-27
**Scope:** Additive Architektur für PWA + Multi-Screen-Battle + Dice/Chat + GM-Live-Tools + Obsidian-Vault + Level-Up
**Confidence:** HIGH (basiert auf gemappter Bestands-Codebase + bekannten Web-Patterns)

> Lese-Hinweis: Dieses Dokument ergänzt `.planning/codebase/ARCHITECTURE.md` (Bestand). Es definiert nur, was **neu** dazukommt und wie es sich an bestehende Patterns andockt. Keine Re-Definition existierender Layer.

---

## 1. Architektur-Leitplanken (gelten für alles unten)

Der Bestand zementiert sechs Patterns, die jede neue Komponente respektieren muss:

1. **NestJS-Modul = Feature-Schnitt**: Jedes neue Feature bekommt ein eigenes Modul unter `server/src/modules/<feature>/` mit `controller.ts`, `service.ts`, optional `gateway.ts`, optional `dto/`. Wird in `app.module.ts` importiert.
2. **React-Feature-Schnitt**: Jedes neue UI-Feature bekommt `client/src/features/<feature>/` mit `components/`, optional `hooks/`, `index.ts` als Barrel.
3. **Shared Hooks zentralisiert**: WebSocket-Hooks und übergreifende Hooks gehören nach `client/src/shared/hooks/`. Lokale Feature-Hooks bleiben in `features/<feature>/hooks/`.
4. **Daten in DB, nicht in JSON**: Jede neue Entity ist Prisma-Modell mit Migration. JSON-Dateien sind nur Seed-Quellen.
5. **Migrations statt `db push`**: Schema-Änderungen ausschließlich `prisma migrate dev` mit sprechendem Namen (`add_dice_rolls`, `add_push_subscriptions`).
6. **Gateway-Convention**: WebSocket-Gateways nutzen Namespace pro Feature (`/characters`, `/battles`), JWT in `handshake.auth.token`, Room pro Resource-ID, `forwardRef` zwischen Service und Gateway, `Logger` mit Klassennamen.

Alles weitere folgt aus diesen Leitplanken.

---

## 2. Recommended Architecture (additiv, pro Feature)

### 2.1 Übersicht: Was kommt neu dazu

```
NEU (Backend Module)              NEU (Frontend Features)        NEU (Prisma Modelle)
─────────────────────────         ──────────────────────         ────────────────────
modules/leveling/                 features/leveling/             LevelUpSession
modules/push/                     features/push/                 PushSubscription
modules/dice/                     features/dice/                 DiceRoll
modules/chat/                     features/chat/                 ChatMessage
modules/vault/                    features/vault/                VaultConfig
                                  features/battle/display/       VaultNoteCache
                                                                 BattleEffect
                                                                 (BattleSession +turnTokenId)
GEÄNDERT
─────────────────────────
modules/battle/ (neue Events)     features/battle/ (display      Migrations
modules/characters/ (Level-Up      route, GM mode flag, init      ─────────
  Hooks ins Service)                tracker UI, effects UI)      add_level_up_sessions
modules/auth/ (vapid, role         features/characters/         add_push_subscriptions
  broadcast helper)                 (level-up tab/wizard)        add_dice_and_chat
                                  shared/hooks/                  add_battle_effects
                                    use-dice-socket.ts           add_vault_config
                                    use-chat-socket.ts           add_battle_session_turn_pointer
                                  shared/sw/
                                    service-worker.ts (Vite PWA)
```

### 2.2 Neue NestJS-Module (Component Boundaries)

| Modul | Verantwortung | Exports | Dependencies |
|-------|---------------|---------|--------------|
| `LevelingModule` | Draft-Session erzeugen, validieren (PF2e-Regeln), commit/abort, Snapshot des Vorher-Stands | `LevelingService` | `CharactersModule` (für Recompute), `FeatsModule`, `PrismaModule` |
| `PushModule` | VAPID-Setup, `POST /push/subscribe`, `DELETE /push/subscribe`, `sendToUser`, `sendToCampaign` (Helper für andere Services) | `PushService` | `PrismaModule`, `web-push` lib |
| `DiceModule` | PF2e-Notation parsen (`1d20+7`, Crits), Roll persistieren, Broadcast | `DiceService`, `DiceGateway` | `PrismaModule`, `CampaignsModule` (Access-Check) |
| `ChatModule` | Append-only Chat-Messages, Pagination, Broadcast, Roll-Embed-Verweis | `ChatService`, `ChatGateway` | `PrismaModule`, `CampaignsModule`, `DiceModule` (für Embed-Lookup) |
| `VaultModule` | Vault-Config (per Campaign), Markdown-Liste, Markdown-Read, Wikilink-Resolve, Bild-Proxy, optional Cache-Sweep | `VaultService` | `PrismaModule`, `CampaignsModule`, evtl. `axios`/WebDAV-Client |

**Geänderte Bestandsmodule:**

- `CharactersModule`: Bekommt `LevelingModule` als Konsument; eigener Service exponiert `recomputeStatsAfterLevelUp()` als Hook-Punkt; `CharactersGateway` bekommt neuen Update-Type `'level_up_committed'`.
- `BattleModule`: `BattleGateway` bekommt neue Events (`effect_added`, `effect_removed`, `effect_updated`, `turn_advanced`, `viewer_role` informativ). `BattleService` bekommt CRUD für `BattleEffect` und Turn-Pointer.
- `AuthModule`: Bekommt einen schmalen `BroadcastHelper` (oder dieser lebt im `PushModule`), der Userlisten pro Campaign-Rolle auflöst — wird von Push, Chat, Dice genutzt.

### 2.3 Neue Prisma-Modelle (kompakt, Felder auf das Wesentliche)

```prisma
// Migration: add_level_up_sessions
model LevelUpSession {
  id              String   @id @default(uuid())
  characterId     String
  fromLevel       Int
  toLevel         Int
  state           String   // "DRAFT" | "COMMITTED" | "ABORTED"
  draft           Json     // Wahl-Pfad (Boosts, Feats, Skills, Class Features)
  snapshotBefore  Json     // Vorher-Stand für Audit/Undo (committed: read-only)
  createdAt       DateTime @default(now())
  committedAt     DateTime?
  character       Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
  @@index([characterId])
  @@index([characterId, state])
}

// Migration: add_push_subscriptions
model PushSubscription {
  id          String   @id @default(uuid())
  userId      String
  endpoint    String   @unique  // Browser-eindeutig
  p256dh      String
  authKey     String
  userAgent   String?
  createdAt   DateTime @default(now())
  lastSeenAt  DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@index([userId])
}

// Migration: add_dice_and_chat
model DiceRoll {
  id            String   @id @default(uuid())
  campaignId    String
  battleSessionId String?       // null → außerhalb des Battle
  characterId   String?         // null → GM/anonymer Roll
  userId        String          // wer hat gewürfelt
  expression    String          // "1d20+7"
  parsed        Json            // strukturiertes Parse-Ergebnis
  rolls         Json            // [[14], [3], ...] pro Würfel
  total         Int
  isCrit        Boolean         @default(false)
  isFumble      Boolean         @default(false)
  label         String?         // "Athletics check", "Longsword damage"
  createdAt     DateTime        @default(now())
  campaign      Campaign        @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  battleSession BattleSession?  @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
  character     Character?      @relation(fields: [characterId], references: [id], onDelete: SetNull)
  user          User            @relation(fields: [userId], references: [id])
  chatMessages  ChatMessage[]   // Embedding via embedRollId
  @@index([campaignId, createdAt])
  @@index([battleSessionId, createdAt])
}

model ChatMessage {
  id              String   @id @default(uuid())
  campaignId      String
  battleSessionId String?
  userId          String
  type            String   // "TEXT" | "SYSTEM" | "GM_PING"
  body            String   // Markdown light
  embedRollId     String?  // FK auf DiceRoll, falls Roll gepostet wurde
  recipientUserId String?  // gezielte GM-Nachricht; null → an alle in Room
  createdAt       DateTime @default(now())
  campaign        Campaign     @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  battleSession   BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
  user            User         @relation(fields: [userId], references: [id])
  embedRoll       DiceRoll?    @relation(fields: [embedRollId], references: [id])
  @@index([campaignId, createdAt])
  @@index([battleSessionId, createdAt])
}

// Migration: add_battle_effects + add_battle_session_turn_pointer
model BattleEffect {
  id            String   @id @default(uuid())
  battleTokenId String
  name          String
  nameGerman    String?
  kind          String   // "BUFF" | "DEBUFF" | "AURA" | "MARKER"
  value         Int?     // z.B. Stufe von Frightened
  rounds        Int?     // verbleibende Runden, null = unbegrenzt
  source        String?
  battleToken   BattleToken @relation(fields: [battleTokenId], references: [id], onDelete: Cascade)
  @@index([battleTokenId])
}
// BattleSession bekommt: turnTokenId String? (welcher Token ist gerade dran)
// BattleToken bekommt:   effects BattleEffect[]

// Migration: add_vault_config
model VaultConfig {
  id            String   @id @default(uuid())
  campaignId    String   @unique
  transport     String   // "WEBDAV_PROXY" | "GIT_PULL" | "HTTP_DIRECT"
  endpointUrl   String
  authSecretRef String?  // Referenz auf separates Secret-Storage; nicht plain
  rootPath      String?  // Subpfad im Vault
  lastSyncAt    DateTime?
  campaign      Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
}

model VaultNoteCache {
  id          String   @id @default(uuid())
  campaignId  String
  path        String   // relative Note-Path z.B. "Lore/Stadt-Ironvale.md"
  content     String   // Markdown
  contentHash String
  fetchedAt   DateTime @default(now())
  campaign    Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
  @@unique([campaignId, path])
  @@index([campaignId])
}
```

> Hinweis Push-Storage: VAPID-Public/Private liegen in `.env` (`VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`). Endpoint-Tabelle hält **nur** Subscriptions, keine Schlüssel.

---

## 3. Komponenten-Boundaries pro Feature-Bereich

### 3.1 Level-Up

**Backend**
- `modules/leveling/leveling.module.ts`
- `modules/leveling/leveling.controller.ts`
  - `POST   /characters/:id/level-up/start` → erzeugt `LevelUpSession` (state=DRAFT), kopiert Snapshot
  - `PATCH  /characters/:id/level-up/draft` → Wahl im Draft updaten (idempotent, validiert)
  - `POST   /characters/:id/level-up/commit` → Transaktion: Draft anwenden, Stat-Recompute, state=COMMITTED, Gateway-Broadcast `level_up_committed`
  - `POST   /characters/:id/level-up/abort` → state=ABORTED, kein Charakter-Mutation
  - `GET    /characters/:id/level-up/session` → aktiver DRAFT (für Wizard-Resume)
- `leveling.service.ts`: PF2e-Regelvalidierung (Boosts alle 5 Levels, Skill-Increases je Klasse, Feat-Prerequisites, Class-Features pro Level), nutzt `FeatsService`.
- **Existierender `CharactersService`** bekommt `applyLevelUp(characterId, draft)` als idempotente Transaktion und `recomputeDerivedStats(characterId)` (HP-Max, Save-Boni, AC-Bonus).
- **Existierender `CharactersGateway`** bekommt `level_up_committed` als Update-Type (in `CharacterUpdatePayload['type']` ergänzen).

**Frontend**
- `features/leveling/components/level-up-wizard.tsx` (Modal/Dialog mit Schritten: Class Features → Boosts → Class Feat → Skill Increases → General/Skill Feat → Review)
- `features/leveling/components/level-up-step-*.tsx` (ein File pro Step)
- `features/leveling/hooks/use-level-up-session.ts` (lokaler Hook, lädt/patchet DRAFT, lokaler optimistischer State)
- Trigger sitzt im bestehenden `character-sheet-page.tsx` als „Stufe steigen"-Button neben Level-Anzeige.

**Datenfluss (User klickt „Stufe steigen" → andere Spieler sehen neuen Level):**
```
[character-sheet-page] "Stufe steigen"
   └─ POST /characters/:id/level-up/start            (LevelingController)
        └─ LevelUpSession DRAFT erzeugt              (LevelingService → Prisma)
   ◀─ Wizard öffnet
[Wizard Step] User wählt Boost/Feat
   └─ PATCH /characters/:id/level-up/draft            (validiert, kein Char-Mutate)
   ◀─ aktualisierter Draft zurück
[Wizard Review] User klickt „Bestätigen"
   └─ POST /characters/:id/level-up/commit
        └─ Transaktion:
             • LevelingService.commit(draft)
             • CharactersService.applyLevelUp(...)
             • CharactersService.recomputeDerivedStats(...)
             • CharactersGateway.broadcast({type:'level_up_committed', data:{level, hpMax, ...}})
   ◀─ neuer Charakter-State
[andere Clients] hören 'level_up_committed' via use-character-socket
   └─ React Query invalidate(character) + UI Refresh
```

### 3.2 PWA + Service Worker

**Frontend (zentral, kein eigenes Feature-Modul, aber dedizierte Files)**
- Plugin: `vite-plugin-pwa` in `vite.config.ts`
- `client/public/manifest.webmanifest` (Name, Icons, theme_color = `#c26dbc`, display=standalone, start_url=`/`)
- `client/src/sw/service-worker.ts` (custom SW via Workbox, von Vite gebündelt)
- `client/src/shared/hooks/use-pwa-update.ts` (zeigt „Neue Version verfügbar"-Banner)
- `client/src/shared/lib/sw-cache-bridge.ts` (Helper, der über `postMessage` mit dem SW redet — Cache invalidieren bei Socket-Events)

**Caching-Strategien (pro Route):**

| Route-Pattern | Strategie | Warum |
|---|---|---|
| `*.js`, `*.css`, `*.png`, `*.svg` (Vite-Assets) | CacheFirst, immutable | Vite hash-named, sicher cachebar |
| `GET /api/equipment*` | StaleWhileRevalidate | 5.482 Items, ändern sich quasi nie, schneller Listen-View |
| `GET /api/feats*` | StaleWhileRevalidate | identisch |
| `GET /api/campaigns/:id/characters/:id` | NetworkFirst mit 3s-Timeout, fallback Cache | Live-Daten bevorzugt, Offline-Read als Sicherheitsnetz |
| `GET /api/campaigns/:id/characters/:id/feats` | NetworkFirst | s.o. |
| `GET /api/translations*` | StaleWhileRevalidate | gecached, aber refresh on background |
| `GET /api/vault/*` (Markdown) | StaleWhileRevalidate | Vault-Inhalt darf leicht stale sein |
| `POST/PATCH/PUT/DELETE *` | NetworkOnly | Mutationen niemals cachen |
| `POST /api/auth/*` | NetworkOnly | Auth nie cachen |
| `*/socket.io/*` | NetworkOnly (vom SW ausgenommen) | WebSocket geht nicht durch SW-Caching |

**JWT-Header-Interaktion:** Token kommt aus localStorage/sessionStorage und wird vom API-Client (`shared/lib/api.ts`) als Authorization-Header gesetzt. Der SW darf die Authorization-Header nicht entfernen — Workbox passiert sie standardmäßig durch. **Wichtig**: Antworten mit `Authorization`-Header werden im Cache gespeichert; das ist akzeptabel, weil der SW-Cache pro Browser-Profil ist und der App ohnehin Single-User pro Browser ist (Family-Gaming-Use-Case). Falls mehrere User denselben Browser teilen, muss der Cache bei Login-Wechsel geleert werden (Hook in `useAuthStore.logout()`).

**Manifest-Hosting:** `client/public/manifest.webmanifest` wird von Vite mit-deployed; in `index.html` als `<link rel="manifest" href="/manifest.webmanifest">`. Icons in `client/public/icons/pwa-*.png` (192, 512, maskable).

### 3.3 Web Push

**Backend**
- `modules/push/push.module.ts`
- `push.controller.ts`:
  - `POST /push/subscribe` (Body: endpoint, keys.p256dh, keys.auth, userAgent) → upsert auf `endpoint`-unique
  - `DELETE /push/subscribe/:id` → User entfernt eigenes Device
  - `GET /push/vapid-public-key` (public, oder beim ersten Bootstrap-Call mitgeben)
- `push.service.ts`:
  - `sendToUser(userId, payload)`
  - `sendToCampaign(campaignId, role?, payload)` — joined `CampaignMember` + ggf. role-filter
  - `sendToBattleSession(sessionId, role?, payload)`
  - **Lifecycle**: Bei Send-Fehler `410 Gone` oder `404` → Subscription löschen. Bei `429` exponential backoff. Bei Erfolg `lastSeenAt = now()`.
- TTL pro Push-Typ: GM-Ping = 30s (kurz, soll nicht später schlummernd ankommen), System-Notification = 24h.

**Frontend**
- `features/push/components/notification-permission-prompt.tsx` — wird beim ersten Visit nach Login gezeigt, wenn `Notification.permission === 'default'`.
- `features/push/hooks/use-push-subscription.ts` — registriert SW, holt VAPID-Key, abonniert, sendet an `POST /push/subscribe`. Hook idempotent — kein Doppel-Subscribe.

**GM-Ping-Datenfluss:**
```
[GM klickt "Ping an alle"]
   └─ POST /push/ping  body:{campaignId, role:'PLAYER', message}
       └─ PushService.sendToCampaign(...)
            └─ webPush.sendNotification(sub, payload) je Subscription
                 [Browser] zeigt Notification, klick → öffnet App auf /campaigns/:id
```

### 3.4 Multi-Screen-Battle (GM-Laptop + Tisch-Display)

**Empfehlung: Option (b) — separate Display-Route + serverseitiger Read-Only-Filter.**

Begründung gegen die Alternativen:
- (a) Same-component mit `mode`-Prop ist anfällig: GM-only-Daten landen im React-Tree des Display-Clients. Ein neugieriger Spieler mit Browser-Devtools könnte sie sehen. **Sicherheit fail.**
- (c) BroadcastChannel funktioniert nur same-origin/same-browser-profile. Sobald das Tisch-Display ein eigener Browser/Pi ist (was real ist), bricht das. Plus: kein Persistenz-Vorteil gegenüber WebSocket. **Skaliert nicht.**
- (b) Eigene Route `/campaigns/:id/battle/display` mit eigenem Bundle-Subset, Server-API liefert nur reduziertes DTO. Display-Client darf sich mit Auth-Token verbinden, aber Server filtert GM-only-Felder weg (z.B. NPC-statBlock-Klartext, GM-Notizen, Effekt-Quellen). Damit ist Display-Mode auch für Spieler-Devices sicher zugänglich, falls jemand „mein Handy soll auch nur die Map zeigen" nutzt.

**Backend**
- `BattleController`: neuer Endpoint `GET /battles/:id/display-view` → DTO ohne GM-Felder.
- `BattleGateway`: bei `joinBattle`-Event akzeptiert `viewerMode: 'GM' | 'DISPLAY'`. Server speichert das pro Socket. Bei Broadcasts unterscheidet Gateway zwei Sub-Rooms: `battle:<id>:gm` und `battle:<id>:display`. Kritische Events (z.B. `gm_note_added`) gehen nur in den GM-Sub-Room.

**Frontend**
- `features/battle/components/battle-display-page.tsx` — eigene Route, kein Sidebar, kein Inventar-Panel, fullscreen Map, große Initiative-Liste, Token-Status nur Public-Felder.
- `features/battle/components/battle-page.tsx` (existiert) — bekommt Toolbar-Erweiterungen (Effects-Picker, Initiative-Sortier-UI, „Display-Tab in neuem Fenster öffnen"-Button mit deep-link zu `/campaigns/:id/battle/display?session=X`).
- `features/battle/components/initiative-tracker.tsx` (neu) — sortierte Liste, wer ist dran (highlighted), prev/next-Buttons (GM-only).
- `features/battle/components/effect-pill.tsx` (neu) — gerendert auf Token + in Display-Liste.
- `features/battle/components/effect-add-modal.tsx` (neu, GM-only).

**Route-Tabelle (Erweiterung von App.tsx):**
```
+ /campaigns/:id/battle/display          → BattleDisplayPage (eigener Layout-Mode, kein Navbar)
+ /campaigns/:id/dice                    → DiceLogPage (oder als Sidebar im Battle/Character-View)
+ /campaigns/:id/chat                    → ChatPage (oder Slide-in-Panel)
+ /campaigns/:id/vault                   → VaultBrowserPage
+ /campaigns/:id/vault/*                 → VaultNotePage (Wildcard-Pfad)
```

### 3.5 Würfeln & Chat

**Empfehlung: zwei Tabellen + zwei Gateways oder ein gemeinsamer Gateway. Polymorphes Schema (`MessageType` mit verschiedenen Spalten in einem Table) **abgelehnt** — getrennte Tabellen bleiben sauberer (DiceRoll hat strukturierte Daten, ChatMessage ist Text+Embed).**

**Gateway-Frage:**
- Ein **eigener `DiceGateway` (Namespace `/dice`)** und **eigener `ChatGateway` (Namespace `/chat`)** — konsistent mit der Bestands-Architektur (jedes Feature eigener Namespace).
- Nicht in `CharactersGateway` einbauen: Character-Updates sind charakter-bezogen (Room = characterId). Chat/Dice sind campaign- und session-bezogen (Room = campaignId oder battleSessionId). Verschiedene Rooms → verschiedene Gateways.

**Datenmodell-Wahl: getrennte Tabellen, ChatMessage hat `embedRollId` als FK auf DiceRoll.** Vorteile gegenüber polymorphem Schema:
- DiceRoll ist append-only, hochfrequent, klar typisiert (`expression`, `total`, `isCrit`).
- ChatMessage ist append-only, niederfrequent, Markdown-Body.
- Pagination ist pro Tabelle eigenständig (DiceLog vs ChatLog können separat gerendert werden).
- Einbettung über FK ist sauber: Chat-Renderer joint optional `embedRoll` und rendert eine Roll-Card.

**Pagination-Pattern:** Cursor-basiert über `createdAt` + `id` (deterministisch). Endpoint `GET /campaigns/:id/dice?cursor=...&limit=50`. Default 50 letzte Einträge. WebSocket pusht neue Einträge live, Pagination lädt historisch.

**Frontend**
- `features/dice/components/dice-roller.tsx` — Eingabefeld + Quick-Buttons (d20, d4, d6, d8, d10, d12, d100).
- `features/dice/components/dice-log.tsx` — Liste der Rolls, virtualisiert für Scroll.
- `features/dice/utils/parse-pf2e-notation.ts` — Pure Function: `"1d20+7"` → strukturiert (Anzahl, Sides, Mod). Krit/Fumble erst auf Server berechnet (single source of truth).
- `features/chat/components/chat-panel.tsx` — Slide-in / Sidebar, Eingabe + Liste.
- `features/chat/components/message-card.tsx` — rendert Text + optionales `embedRoll`.
- `shared/hooks/use-dice-socket.ts`, `shared/hooks/use-chat-socket.ts` — analog zu `use-character-socket.ts`.

### 3.6 Obsidian-Vault (Read-Only)

**Empfehlung: Transport-Option 1 (WebDAV-Proxy via NestJS) als Default.** Begründung im Vergleich:

| Option | Browser-Sec | Server-Last | Setup-Komplexität | Auth-Modell | Verdict |
|---|---|---|---|---|---|
| WebDAV-Proxy via NestJS | Vault nie direkt im Browser, JWT vor allem | Mittel (Stream-Proxy) | Niedrig (libs vorhanden) | Eigener JWT, Vault-Creds in `.env` | **Default** |
| Direkter HTTP+CORS | Vault muss CORS-konfigurierbar sein, Auth-Header doppelt | Niedrig | Hoch (CORS, Vault-Auth-Header in Browser) | Vault-Auth direkt im Browser → Token-Leak-Risiko | Verworfen |
| Git-Pull periodisch | Vault muss Git-Repo sein | Niedrig nach Pull, hoch bei Pull | Mittel (cron, conflict-handling) | SSH-Key am Server | Optional, wenn Vault Git-basiert |
| Snapshot-Upload (Zip) | Sehr sicher | Niedrig | Mittel (User-Workflow) | Kein Vault-Zugriff nötig | Optional, wenn kein Server beim User |

**Self-hosted Charakter:** User hat eigenen Server. WebDAV-Proxy ist ideal — Vault liegt auf dem User-Server (z.B. Synology, Nextcloud, eigener nginx mit `mod_dav`), NestJS verbindet sich mit Service-Account, Browser sieht nur saubere Dimension47-API. Gleiches Sec-Modell wie Equipment-API.

**Backend**
- `VaultService.list(campaignId, dirPath)` → ruft WebDAV `PROPFIND`, gibt Files+Subdirs zurück.
- `VaultService.read(campaignId, filePath)` → ruft WebDAV `GET`, parst Markdown, resolviert `[[Wikilinks]]` per Index-Lookup, gibt `{ content, frontmatter, links: [{name, path?}] }` zurück. Cached in `VaultNoteCache`.
- `VaultService.image(campaignId, imagePath)` → streamt Bild über NestJS-Response.
- `GET /vault/:campaignId/list?path=...`, `GET /vault/:campaignId/note?path=...`, `GET /vault/:campaignId/image?path=...`, `GET /vault/:campaignId/search?q=...`.

**Frontend**
- `features/vault/components/vault-browser-page.tsx` — Folder-Tree links, Note-Reader rechts.
- `features/vault/components/note-renderer.tsx` — `react-markdown` + `remark-gfm` + Custom-Plugin für `[[Wikilink]]` (klickbar zu interner Vault-Route).
- `features/vault/utils/wikilink-plugin.ts` — Regex-basierter Remark-Plugin.
- `features/vault/hooks/use-vault-note.ts` — React Query, kombiniert mit SW-Cache (StaleWhileRevalidate).

### 3.7 GM-Live-Tools

Größtenteils **keine neuen Module**. Existierende `CharactersService.updateHp/addCondition/...` haben bereits GM-Bypass für Owner-Check (`checkCharacterAccess` lässt GM passieren). Was fehlt ist UI:

**Frontend**
- `features/campaigns/components/gm-control-panel.tsx` — Sidebar im `campaign-detail-page.tsx`, listet alle Spieler-Charaktere mit Quick-Actions (HP+/-, Condition setzen, Geld geben, Push-Ping senden).
- Re-use existierender Modals: `add-condition-modal`, `add-item-modal`, `hp-control` mit `gmMode`-Prop (zeigt Charakter-Selector).

**Backend**
- `PushService.sendToUser(userId, {type:'GM_PING', message})` für gezielte Pings — bereits in PushModule.
- Keine neuen Endpoints — bestehende Character-Endpoints reichen, da GM bereits berechtigt ist.

---

## 4. Datenfluss: Übergreifende Patterns

### 4.1 PWA-Cache-Invalidation bei Socket-Push

Wenn der SW `/api/.../characters/:id` cached und ein HP-Update via WebSocket reinkommt, ist der Cache veraltet. Lösung **kombiniert**:

1. **Primärer Pfad — Reaktiv via postMessage:** Beim Empfang eines `character_update`-Events ruft `use-character-socket.ts` einen Helper `invalidateCacheForCharacter(characterId)`, der per `navigator.serviceWorker.controller.postMessage({type:'INVALIDATE', urls:[...]})` an den SW signalisiert. SW löscht die betroffenen Cache-Einträge.

2. **Sekundärer Pfad — NetworkFirst-Strategie für Live-Daten:** Charakter-Endpoints nutzen NetworkFirst statt StaleWhileRevalidate. Online → frische Daten, Offline → Cache-Fallback. Damit ist auch ohne postMessage-Sync das schlimmste, was passiert, ein 3-Sekunden-Timeout, dann Cache.

3. **Statische Daten** (Equipment, Feats) bleiben StaleWhileRevalidate — die ändern sich nur bei DB-Reseed, nicht zur Laufzeit.

**Anti-Pattern explizit ablehnen:** Cache-Bypass „wenn WebSocket connected" zu komplex und race-condition-anfällig. NetworkFirst + postMessage-Invalidation reicht.

### 4.2 Level-Up Datenfluss (vollständig)

Bereits in 3.1 oben. Schlüsselpunkte:
- DRAFT-State macht Undo trivial (einfach Session löschen, Charakter unverändert).
- Commit ist eine einzige Prisma-Transaktion: `applyLevelUp` + `recomputeDerivedStats` + `LevelUpSession.state = COMMITTED` + Gateway-Broadcast.
- Snapshot-Before in `LevelUpSession.snapshotBefore` erlaubt theoretisch nachträgliches Undo eines committed Level-Ups (out of scope für jetzt, aber Daten sind da).

### 4.3 Multi-Screen-Battle Datenfluss

```
[GM-Client]                              [Display-Client]
  Socket.io connect                        Socket.io connect
  emit 'joinBattle' {id, viewerMode:'GM'}   emit 'joinBattle' {id, viewerMode:'DISPLAY'}
  → joins room battle:<id>:gm               → joins room battle:<id>:display

[GM klickt "Token bewegen"]
  PATCH /battles/:id/tokens/:t
    → BattleService.moveToken
        → BattleGateway.broadcast(sessionId, 'token_moved', ...)
            → emit to room battle:<id>:gm        ← GM-Client sieht Update
            → emit to room battle:<id>:display   ← Display-Client sieht Update

[GM klickt "GM-Notiz hinzufügen" (kein Display-Event)]
  PATCH /battles/:id/gm-note
    → BattleGateway.broadcast(sessionId, 'gm_note_added', ..., onlyRole:'GM')
        → emit nur an battle:<id>:gm
```

### 4.4 Dice + Chat Datenfluss

```
[Spieler tippt "1d20+7"]
  POST /campaigns/:id/dice  body:{expression, characterId, label}
    → DiceService.roll(...)                          ← würfelt serverseitig (kein Client-Cheat)
        → Prisma DiceRoll insert
        → DiceGateway.broadcast(campaignId, 'roll', diceRoll)
[alle Clients in Room campaign:<id>]
  use-dice-socket.ts: onRoll(diceRoll)
    → React Query: queryClient.setQueryData(['diceLog', campaignId], (old) => [diceRoll, ...old])
```

---

## 5. Patterns to Follow

### Pattern 1: Neues Feature = neues Modul + neues Feature-Dir
**Wann:** Immer für eigenständige Domänen (Push, Dice, Chat, Vault, Leveling).
**Beispiel:**
```ts
// server/src/modules/dice/dice.module.ts
@Module({
  imports: [PrismaModule, AuthModule, CampaignsModule],
  controllers: [DiceController],
  providers: [DiceService, DiceGateway],
  exports: [DiceService],
})
export class DiceModule {}
```

### Pattern 2: Gateway-Authentifizierung (existiert, weiter nutzen)
**Wann:** Jeder neue Gateway.
**Beispiel:** Kopiervorlage `characters.gateway.ts` → JWT in `handshake.auth.token`, User-Lookup, Disconnect bei Fail. Für Battle-Display: zusätzlich `viewerMode` aus `handshake.auth` lesen und Sub-Room zuweisen.

### Pattern 3: Service-Method = Access-Check + Mutation + Broadcast
**Wann:** Jede Mutation.
**Beispiel:** `CharactersService.updateHp` Reihenfolge:
1. `checkCampaignAccess(userId, campaignId)`
2. `checkCharacterAccess(userId, characterId)`
3. Prisma-Update
4. `gateway.broadcast(...)`

### Pattern 4: WebSocket-Hook mit Singleton + Ref-Counting
**Wann:** Jeder neue Frontend-WebSocket-Konsument.
**Beispiel:** `use-dice-socket.ts` folgt Pattern aus `use-character-socket.ts` — globaler Socket pro Namespace, `subscriberCount` für Cleanup.

### Pattern 5: DTO-Validation mit class-validator
**Wann:** Jeder neue Endpoint.
**Beispiel:**
```ts
export class StartLevelUpDto {
  @IsString() @IsNotEmpty() characterId: string;
}
```

### Pattern 6: Service-Worker-Update via postMessage
**Wann:** Wenn Live-Daten den Cache invalidieren sollen.
**Beispiel:**
```ts
// shared/lib/sw-cache-bridge.ts
export function invalidateSwCache(urls: string[]) {
  navigator.serviceWorker?.controller?.postMessage({type:'INVALIDATE', urls});
}
// In SW:
self.addEventListener('message', (e) => {
  if (e.data?.type === 'INVALIDATE') {
    e.data.urls.forEach(url => caches.open('api-cache').then(c => c.delete(url)));
  }
});
```

---

## 6. Anti-Patterns to Avoid

### Anti-Pattern 1: Polymorphic-Table für Dice+Chat
**Was:** Eine `Message`-Tabelle mit `type: TEXT|ROLL|SYSTEM` und 20 nullable Spalten.
**Warum schlecht:** Spalten-Sparsity, jede neue MessageType-Variante explodiert das Schema, Pagination wird umständlich (DiceLog will nur Rolls, joint trotzdem alles), keine sauberen FK-Constraints.
**Stattdessen:** Zwei Tabellen `DiceRoll` + `ChatMessage`, FK `embedRollId` für Embeds.

### Anti-Pattern 2: Display-Mode als React-Prop am gleichen Komponentenbaum
**Was:** `<BattlePage mode="display" />` versteckt GM-Controls per CSS.
**Warum schlecht:** GM-only-Daten landen via React Query im Display-Client. Devtools zeigen sie. Sicherheits-Leak.
**Stattdessen:** Eigene Route + serverseitig gefiltertes DTO + Sub-Rooms im Gateway.

### Anti-Pattern 3: Service-Worker cached Mutationen
**Was:** SW cached POST/PATCH-Responses oder ersetzt fehlgeschlagene Mutations durch optimistic offline-queue.
**Warum schlecht:** PROJECT.md definiert „Offline-Bearbeiten ist explizit raus". Konflikt-Logik wäre teuer, Spielsessions sind online.
**Stattdessen:** SW NetworkOnly für alle Mutationen. Offline-Mode = nur Lesen.

### Anti-Pattern 4: Roll-Berechnung im Client
**Was:** Frontend würfelt `Math.random()` und schickt Ergebnis an Server.
**Warum schlecht:** Spieler kann lokal manipulieren. PF2e-Werte (Crits, Persistent Damage) müssen autoritativ sein.
**Stattdessen:** Server-side Roll. Client schickt nur `expression` + Kontext, Server würfelt + persistiert + broadcastet.

### Anti-Pattern 5: Vault-Credentials im Browser
**Was:** WebDAV-User/Password im React-Code, Browser ruft Vault direkt.
**Warum schlecht:** Token-Leak in Browser-Devtools, CORS-Konfig am Vault, Rotation unmöglich.
**Stattdessen:** Vault-Creds in `.env` am NestJS-Server, Browser spricht nur via Dimension47-JWT mit `/vault/*`-Endpoints.

### Anti-Pattern 6: `db push` bei Schema-Änderung
**Was:** Schnell-Fix bei Schema-Änderung mit `prisma db push`.
**Warum schlecht:** Verstößt gegen CLAUDE.md, keine Versionierung, kann nicht ausgerollt werden.
**Stattdessen:** Immer `prisma migrate dev --name <descriptive>`.

### Anti-Pattern 7: GM-Ping ohne Push-Subscription
**Was:** GM-Ping nur als Toast im aufgemachten Browser-Tab.
**Warum schlecht:** Spieler sieht es nicht, wenn Tab nicht aktiv ist. Use-Case ist gerade, dass Spieler aufmerksam wird.
**Stattdessen:** Push-Notification via Service-Worker. Toast als zusätzlicher Inline-Hinweis, nicht als Ersatz.

---

## 7. Build Order / Dependencies (kritisch für Roadmap)

**Empfohlene Phasen-Reihenfolge** (begründet, nicht beliebig):

```
Phase A: Level-Up
  ├─ keine externen Abhängigkeiten
  ├─ erweitert nur CharactersService + CharactersGateway (Bestand)
  ├─ neues Modul LevelingModule, neue Migration LevelUpSession
  └─ kein PWA/Push nötig
  ──> liefert: erstes komplettes neues Feature, validiert das Pattern „Bestand erweitern + neues Modul"

Phase B: PWA Foundation (ohne Push)
  ├─ vite-plugin-pwa, manifest, service-worker, install-prompt
  ├─ Caching-Strategien für existierende Endpoints
  ├─ keine Backend-Änderungen
  └─ blockiert: Push (braucht SW), Vault-Offline-Cache (braucht SW)
  ──> liefert: installierbare App, Offline-Read

Phase C: Web Push
  ├─ braucht Phase B (Service-Worker existiert)
  ├─ neues Modul PushModule, neue Migration PushSubscription
  ├─ Permission-Prompt im Frontend
  └─ blockiert: GM-Ping in GM-Live-Tools, Battle-Turn-Alert
  ──> liefert: Notification-Pfad steht

Phase D: Dice + Chat (parallel zu C möglich, aber nach B)
  ├─ braucht keine Push (Toast für Inline-Anzeige reicht intern)
  ├─ neue Module DiceModule, ChatModule, neue Migration add_dice_and_chat
  ├─ neue Gateways /dice, /chat
  ├─ neue UI-Panels
  └─ blockiert: Battle-Turn-Alert (kombiniert Dice mit Battle-Events)
  ──> liefert: In-App-Würfel + Chat überall

Phase E: Battle-Ausbau (Display-Mode, Initiative-Tracker, Effekte)
  ├─ braucht keine Push, aber profitiert von Phase D (Dice-Anbindung in Initiative)
  ├─ erweitert BattleGateway um neue Events
  ├─ neue Route /battle/:id/display, neuer DTO-Filter
  ├─ neue Migration add_battle_effects + turnTokenId
  └─ blockiert: nichts
  ──> liefert: zwei sichere Sichten auf den Battle, vollständiger Initiative-Flow

Phase F: GM-Live-Tools
  ├─ braucht Phase C (Push für GM-Ping) + Phase D (Chat für gezielte Nachrichten)
  ├─ kein neues Backend-Modul, kein neues Schema
  ├─ neue UI: gm-control-panel, GM-Mode in bestehenden Modals
  └─ blockiert: nichts
  ──> liefert: GM hat zentrales Cockpit

Phase G: Obsidian-Vault
  ├─ braucht Phase B (SW für Offline-Cache der Notes)
  ├─ neues Modul VaultModule, neue Migration add_vault_config + VaultNoteCache
  ├─ neue Routen + neuer Frontend-Bereich
  ├─ unabhängig vom Rest
  └─ blockiert: nichts
  ──> liefert: Read-Only Vault im Browser
```

**Dependency-Graph:**
```
A (Level-Up)        ──── unabhängig
B (PWA)             ──── unabhängig  ─┐
C (Push)            ──── braucht B    │
D (Dice/Chat)       ──── unabhängig   ├── nichts blockiert E zwingend, aber D hilft Initiative
E (Battle-Ausbau)   ──── (nutzt D opt) │
F (GM-Live-Tools)   ──── braucht C, D ─┘
G (Vault)           ──── braucht B
```

**Anti-Reihenfolge (zu vermeiden):**
- Push vor PWA → ohne SW kein Push, Hängepartie.
- GM-Live-Tools vor Push/Chat → halbe Lösung, GM-Ping fehlt.
- Battle-Display vor Dice → User-Test zeigt: ohne sichtbare Initiative-Rolls fehlt Wert.

---

## 8. Integration mit Bestand: Was wird wo angefasst

### Migration-Liste (in der Reihenfolge unten ausführen)

| # | Migration | Phase | Modelle |
|---|---|---|---|
| 1 | `add_level_up_sessions` | A | LevelUpSession |
| 2 | `add_push_subscriptions` | C | PushSubscription |
| 3 | `add_dice_and_chat` | D | DiceRoll, ChatMessage |
| 4 | `add_battle_effects_and_turn_pointer` | E | BattleEffect, BattleSession.turnTokenId |
| 5 | `add_vault_config` | G | VaultConfig, VaultNoteCache |

### Bestandsdateien, die geändert werden (nicht neu)

| Datei | Phase | Änderung |
|---|---|---|
| `server/src/app.module.ts` | A,C,D,G | Neue Module importieren (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule) |
| `server/src/modules/characters/characters.service.ts` | A | Neue Methoden `applyLevelUp`, `recomputeDerivedStats` |
| `server/src/modules/characters/characters.gateway.ts` | A | Update-Type `'level_up_committed'` ergänzen |
| `server/src/modules/battle/battle.gateway.ts` | E | Sub-Rooms (`:gm`, `:display`), neue Events `effect_*`, `turn_advanced` |
| `server/src/modules/battle/battle.service.ts` | E | CRUD für `BattleEffect`, Turn-Pointer setzen |
| `server/prisma/schema.prisma` | A,C,D,E,G | Neue Modelle ergänzen + `BattleSession.turnTokenId`, `BattleToken.effects` |
| `client/src/App.tsx` | E,G | Neue Routen `/battle/display`, `/vault`, `/vault/*` |
| `client/vite.config.ts` | B | `vite-plugin-pwa`-Konfig |
| `client/public/manifest.webmanifest` | B | Neu anlegen |
| `client/src/features/auth/hooks/use-auth-store.ts` | B | Bei `logout()` SW-Cache leeren |
| `client/src/features/characters/components/character-sheet-page.tsx` | A | „Stufe steigen"-Button + Wizard-Mount |
| `client/src/features/battle/components/battle-page.tsx` | E | Initiative-Tracker mounten, Effects-Toolbar, „Display öffnen"-Button |
| `client/src/shared/lib/api.ts` | A,C,D,G | Neue API-Calls (Leveling, Push, Dice, Chat, Vault) |
| `client/src/shared/types/index.ts` | A,C,D,E,G | Neue Interfaces |

### Net-neue Dateien (zentrale Übersicht)

```
server/src/modules/leveling/
  leveling.module.ts, leveling.controller.ts, leveling.service.ts
  dto/start-level-up.dto.ts, dto/update-draft.dto.ts

server/src/modules/push/
  push.module.ts, push.controller.ts, push.service.ts
  dto/subscribe.dto.ts

server/src/modules/dice/
  dice.module.ts, dice.controller.ts, dice.service.ts, dice.gateway.ts
  utils/parse-pf2e-notation.ts (server-seitig autoritativ)
  dto/roll.dto.ts

server/src/modules/chat/
  chat.module.ts, chat.controller.ts, chat.service.ts, chat.gateway.ts
  dto/send-message.dto.ts

server/src/modules/vault/
  vault.module.ts, vault.controller.ts, vault.service.ts
  utils/wikilink-resolver.ts, utils/webdav-client.ts (oder Adapter pro Transport)
  dto/vault-config.dto.ts

client/src/features/leveling/
  components/level-up-wizard.tsx + step-*.tsx
  hooks/use-level-up-session.ts
  index.ts

client/src/features/push/
  components/notification-permission-prompt.tsx
  hooks/use-push-subscription.ts
  index.ts

client/src/features/dice/
  components/dice-roller.tsx, dice-log.tsx
  utils/parse-pf2e-notation.ts (Client-Spiegel, nur fürs Pre-Validation)
  index.ts

client/src/features/chat/
  components/chat-panel.tsx, message-card.tsx
  index.ts

client/src/features/vault/
  components/vault-browser-page.tsx, vault-note-page.tsx, note-renderer.tsx
  utils/wikilink-plugin.ts
  hooks/use-vault-note.ts
  index.ts

client/src/features/battle/components/
  battle-display-page.tsx, initiative-tracker.tsx, effect-pill.tsx, effect-add-modal.tsx

client/src/shared/hooks/
  use-dice-socket.ts, use-chat-socket.ts, use-pwa-update.ts

client/src/shared/lib/
  sw-cache-bridge.ts

client/src/sw/
  service-worker.ts (Workbox-customized)

client/public/
  manifest.webmanifest
  icons/pwa-192.png, pwa-512.png, pwa-maskable-512.png
```

---

## 9. Scalability-Betrachtung (kontextspezifisch)

| Concern | Single Group (4-6 User, aktueller Use-Case) | 10 Gruppen (selbst-gehostete Forks) | Anmerkung |
|---|---|---|---|
| WebSocket-Connections | <30 gleichzeitig | <300 | Socket.io single-server reicht klar |
| DiceRoll-Volumen | ~200/Session, ~2k/Monat | ~20k/Monat | Index `(campaignId, createdAt)` reicht; kein Archivierungsdruck |
| ChatMessage-Volumen | <1k/Session | <10k/Monat | identisch |
| PushSubscription-Volumen | ~10 Devices | ~100 | Trivial |
| Vault-Cache-Größe | <100MB pro Campaign | <1GB | DB-storage, alternativ FS-cache; bei >1GB FS-Cache erwägen |
| Multi-Tenant | nicht gefordert (Out-of-Scope) | nicht gefordert | self-hosted-Modell |

Keine horizontale Skalierung nötig. Single-Server reicht. Bei späterem Multi-Tenant müsste Socket.io mit Redis-Adapter laufen — explizit Out-of-Scope.

---

## 10. Sources

- `.planning/codebase/ARCHITECTURE.md` — Bestand der Layer und Patterns (Primärquelle, HIGH)
- `.planning/codebase/STRUCTURE.md` — Verzeichnis-Konventionen (HIGH)
- `.planning/codebase/INTEGRATIONS.md` — Auth, WebSocket, Pathbuilder (HIGH)
- `.planning/PROJECT.md` — Constraints, Out-of-Scope, Validated/Active (HIGH)
- `server/prisma/schema.prisma` — bestehende Modelle, Beziehungen (HIGH)
- `server/src/modules/characters/characters.gateway.ts`, `battle/battle.gateway.ts` — bestehende Gateway-Patterns (HIGH)
- `server/src/app.module.ts` — Modul-Wiring (HIGH)
- `client/src/App.tsx` — Routing-Konvention (HIGH)
- Web Push Spec / VAPID — RFC 8030 + RFC 8292 (Standard, MEDIUM, allgemein bekannt)
- Workbox/vite-plugin-pwa — etabliertes Pattern für Vite-Apps (MEDIUM)
- Socket.io Rooms + Namespaces — bekannte Standards (HIGH, identisch mit Bestandsnutzung)

---

*Architecture research: 2026-04-27 — additive design, respektiert NestJS-Modul-Schnitt und React-Feature-Schnitt des Bestands.*
