# Datenbank-Aufbau (Appwrite, self-hosted)

**Stand:** Juli 2026 · basiert auf Appwrite TablesDB (Tabellen/Zeilen/Relationen), Functions, Storage mit S3-Adapter, Realtime, Auth + Teams, Messaging.

**Grundsatz:** Die `brand-knowledge/`-Ordnerstruktur aus dem Konzept wird 1:1 in Tabellen abgebildet. Die .md-Textebenen (Beschreibung, Essenz, Details) bleiben als Markdown-Felder erhalten – strukturierte Daten (Scores, Zähler, Status) werden eigene Spalten, damit man danach filtern und sortieren kann.

> **Update Juli 2026 – Bild-Modus, Feed & Ordner** (Konzept: `konzept-bilder-feed.md`)
> Zwei strukturelle Änderungen, keine reinen Ergänzungen:
> 1. **`attributes` ist aufgetrennt.** Scores und Zähler liegen jetzt in `attribute_scores` – ein Attribut trägt **mehrere Scores, einen je Ordner**. Ohne diesen Schnitt gäbe es keine ordnerbezogene Wissens-Segmentierung.
> 2. **`assets` heißt fachlich „Modelle"** und kennt den neuen Typ `kulisse`. Der Begriff „Modell" meint in diesem Projekt durchgehend das Asset (Person, Produkt, Kulisse) – **nie** das KI-Modell (Seedance, Veo, Sora). Diese Sprachregelung gilt für Doku und Code.
>
> Neu dazu: `folders`, `attribute_scores`, `posts`, `post_images`, `post_folders`, `post_metrics`.

---

## 1. Appwrite-Features → unsere Nutzung

| Appwrite-Feature | Wofür wir es nutzen |
|---|---|
| **Auth + Teams** | Jede Brand = ein Team. Zeilen-Rechte pro Team → sauberes Multi-Tenant ohne eigene Logik. Internes Admin-Team für den Dev-Modus. |
| **TablesDB + Relationen** | Alle Tabellen unten; 4 Relationstypen decken Kategorie→Attribut, Attribut→Unter-Attribut (self-relation), Szene→Videos ab. |
| **Storage (S3-Adapter!)** | Buckets laufen direkt auf **Hetzner Object Storage** als Backend – Appwrite verwaltet Rechte/Uploads, Hetzner speichert. Kein doppeltes System. |
| **Functions** | Alle deterministischen Abläufe (Elo, Cron-Jobs) + Auslöser für LLM-Jobs. Event-getriggert oder geplant. |
| **Realtime** | Live-Status in der App: „Script fertig → generiere Video 2/4 → Analyse läuft" ohne Polling. |
| **Messaging** | Push/E-Mail: „Deine 4 Videos sind fertig – wähle das Beste." |

---

## 2. Tabellen

### Mandant & Konfiguration

**`brands`** – eine Zeile pro Kunde
| Spalte | Typ | Hinweis |
|---|---|---|
| team_id | string | Appwrite-Team (Rechte) |
| label_name, produkte, zielgruppe, brand_worte[3], am_markt_seit | string | aus dem Onboarding (6 Pflichtfragen) |
| **nische** | enum: kosmetik/auto/gastro/fitness/mode/… | **neu** – feste Branchenliste, Pflichtfrage im Onboarding. Filtert den Feed und steuert die Kopier-Kompatibilität |
| stripe_customer_id | string | Payment |
| default_video_count | int (3–6) | einstellbare Videoanzahl |
| **plan** | enum: bild/video | **neu** – Bild-Modus ist die günstige Einstiegsstufe (Preismodell noch offen) |
| status | enum: trial/aktiv/pausiert | |

**`rules`** – feste Regeln (Dev-Modus) 🔒 *nur internes Team*
| Spalte | Typ |
|---|---|
| brand_id | rel → brands (null = global für alle Brands) |
| titel, prompt_text | string |
| aktiv, sortierung | bool, int |

**`prompt_templates`** – die statischen Prompts P1–P22 (Dev-Modus) 🔒 *nur internes Team*
| Spalte | Typ | Hinweis |
|---|---|---|
| key | enum P1…**P22** | siehe Prompt-Inventar (P19–P22 = Bild-Modus & Feed) |
| version | int | Prompts sind versioniert – man muss zurückrollen können |
| static_core_md | string | der statische Kern |
| slots | json | welche Slots injiziert werden (REGELN, ASSETS, …) |
| model_adapter | enum: null/seedance/veo/sora/wan | P8-Familie: ein Kern, mehrere Dialekte |
| aktiv | bool | |

### Knowledge Base

**`folders`** – Wissens-Scope (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel → brands | **privat pro Brand** – Ordner sind kein soziales Objekt |
| name, theme_md | string, string (Markdown) | „Dark Studio"; theme_md schreibt P20 beim Anlegen |
| **zweck** | enum: sammlung / wissens_scope | `sammlung` = nur kategorisieren, keine Scores, kein Einfluss auf Generierung. `wissens_scope` = aus dem Ordner heraus wird erstellt |
| **startwert_modus** | enum: erben / aus_posts | nur bei `wissens_scope`. `erben` = Kopie der brand-weiten Scores beim Anlegen · `aus_posts` = nur was in den gesammelten Posts steckt, Startwert = Kategorie-Ø **innerhalb des Ordners** |
| ist_default | bool | Anzeige-Ordner „Allgemein" im UI. **Die brand-weite Basisebene ist keine Ordner-Zeile, sondern `folder_id = null` in `attribute_scores`** – der Default-Ordner zeigt sie nur an, er speichert sie nicht |
| post_count, signal_count | int | Reifegrad – steuert den K-Faktor und die UI-Anzeige „Ordner lernt noch" |
| created_at | datetime | |

> **Kaltstart:** keine Mindestanzahl an Posts, keine Sperre. Ein Ordner ist ab dem Anlegen nutzbar; die statistische Unsicherheit steckt im K-Faktor (32 → 8), nicht in einer Sperre. Das ist dieselbe Regel wie in `projekt-uebersicht.md` §5, nur auf Ordner-Ebene.

**`categories`**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| name | string (location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen) | |
| **folder_id** | rel → folders (null = brand-weit) | **neu** – `avg_score` ist scope-abhängig, also gibt es die Zeile je Ordner |
| avg_score | int | **Cache für Startwerte neuer Einträge** – per Function aktualisiert, nicht live berechnet |
| attribute_count | int | |

**`attributes`** – nur noch die **Definition** (Scores siehe unten)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, category_id | rel | |
| parent_id | rel → attributes (self) | Unter-Attribute (kaiserslautern--stadion) |
| name, slug | string | |
| status | enum: aktiv/archiviert | **nie löschen** |
| tags | string[] | Volltext-Index |
| beschreibung_md, essenz_md, details_md | string (Markdown) | die drei Textebenen |
| prompt_bausteine, negativ_prompts | string[] | gehen direkt in P7/P8-Slots |

> **Warum aufgetrennt:** Ein Attribut („badezimmer") ist einmal definiert, trägt aber **mehrere Scores** – einen brand-weit und je einen pro Ordner. „Warmes Abendlicht" kann im Ordner *Sommerkampagne* auf 6800 stehen und im Ordner *Dark Studio* gar nicht existieren. Die Textebenen bleiben bewusst am Attribut, nicht am Score: die Beschreibung ändert sich nicht mit dem Scope.

**`attribute_scores`** – Score je Attribut je Ordner (neu, Herzstück)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel → attributes | |
| **folder_id** | rel → folders (**null = brand-weite Basisebene**) | Unique-Constraint auf (attribute_id, folder_id) |
| brand_id, category_id | rel | denormalisiert. **Index: (brand, folder, category, score ↓)** – das ist die Top-N-Abfrage, die P4 bei jeder Generierung fährt |
| score | int (0–10.000) | |
| k_factor | int | 32 neu → 8 etabliert; bleibt hoch, solange `folders.signal_count` niedrig ist |
| start_value, start_quelle | int, enum: kategorie_avg / geerbt / feed_import | dokumentiert, woher der Startwert kam – ohne das ist später nicht nachvollziehbar, warum ein Score dort steht |
| used_count, wins, losses | int | |
| last_used_at | datetime | |

**`score_events`** – das Log (append-only, nie ändern)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel | Index: (attribute, created_at) |
| **folder_id** | rel → folders (null = brand-weit) | **neu und zwingend** – ohne den Scope ist nicht nachvollziehbar, in welcher Wissensinsel ein Punkt vergeben wurde |
| video_id, **post_id**, opponent_attribute_id | rel | |
| event_type | enum: vote / vote_favorit_bestaetigt / performance / gezielte_frage / llm_vergleich / **feed_import** / **post_reichweite** | die zwei neuen Typen kommen aus dem Bild-Modus |
| delta, new_score, weight | int, int, float | weight: ×0,3 / ×1 / ×2 · `feed_import` und `post_reichweite` laufen mit **×0,3** |
| kommentar | string | z. B. P12-Erkenntnis („Stadion war der Unterschied") |

### Assets / „Modelle" (Release-Prinzip)

**`assets`** – im UI **„Modelle"**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| typ | enum: gesicht/produkt/**kulisse**/logo/sonstiges | **`kulisse` ist neu** – die Umgebung ist ein eigenständiges, einzeln austauschbares Modell |
| name | string | „kleine Kerze" |
| **nische** | enum wie `brands.nische` | Feed-Filter |
| **typ_tags** | string[] (Enum-Zwang) | fahrzeug, getraenk, tube, person_weiblich, innenraum, … – **gegen diese Liste prüft die Slot-Kompatibilität beim Kopieren**. Feste Liste + Code-Validator, keine freie KI-Vergabe |
| **feed_score, feed_score_updated_at** | float, datetime | Modelle werden getrennt von Posts gerankt (siehe `post_metrics`) |
| **ist_teilbar** | bool (default **false**) | Modelle werden grundsätzlich **nicht** geteilt. Das Feld existiert nur, um die Regel explizit und prüfbar zu machen |
| released_version_id | rel → asset_versions (**nur diese wird in Videos und Bildern verwendet**) | |

> **Regel:** Ein Kopierer bringt immer eigene Modelle mit. Fremde Modelle erscheinen nur als **Beschriftung** im Kopier-Screen („Modell: kleine Kerze"), damit klar ist, was das Rezept braucht – nie als nutzbares Asset. Begründung in `konzept-bilder-feed.md` §3 (Marken-/Designrecht bei Produkten, Konsistenzentwertung bei Personen).

**`asset_versions`** – Versionskette
| Spalte | Typ | Hinweis |
|---|---|---|
| asset_id, version_no | rel, int | |
| status | enum: entwurf/freigegeben/archiviert | |
| beschreibung_md, merkmale | string, string[] | |
| change_reason | string | „Haare dunkler, natürlicher" |
| won_against_version_id | rel | Vorher/Nachher-Vote |
| reference_file_ids | string[] | → Storage-Bucket |
| einwilligung_file_id | string | dokumentierte Einwilligung bei echten Gesichtern |

### Produktion

**`scenes`**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| **folder_id** | rel → folders (null = brand-weit) | **neu** – der Scope wird **vor** der Generierung gewählt und bestimmt, welches Wissen P4 zieht |
| user_prompt | string | Original, unverändert aufheben |
| selected_context | json | was P4 injiziert hat (Attribute + Scores zum Zeitpunkt!) |
| script_md, script_final_md | string | Original vs. vom Nutzer bearbeitet → Diff = P6-Signal |
| status | enum: entwurf/script/generiert/voting/fertig | **Realtime-Kanal fürs UI** |
| video_count | int | |

**`videos`**
| Spalte | Typ | Hinweis |
|---|---|---|
| scene_id, brand_id | rel | |
| ki_model | enum: seedance/veo/sora/wan | |
| prompt_sent | string 🔒 | exakt was ans KI-Modell ging (Dev-Modus „Prompt-Einblick") |
| storage_file_id | string | → Bucket generated-videos |
| tags | json | P9-Analyse |
| attribute_ids | rel many→many | für Attribution |
| asset_version_ids | string[] | welche Versionen genau (v3? v4?) |
| is_system_favorite, vote_result | bool, enum: gewonnen/verloren/– | |
| published, platforms, published_at | bool, string[], datetime | |

**`video_metrics`** – Zeitreihe (Cron-Function, Ads-APIs)
| Spalte | Typ |
|---|---|
| video_id, fetched_at | rel, datetime |
| ctr, thumbstop, roas, follower_gained, spend | float |

**`votes`**
| Spalte | Typ |
|---|---|
| scene_id, winner_video_id | rel |
| favorit_bestaetigt | bool (→ Gewichtung ×0,3) |
| created_at | datetime |

**`questions`** – gezielte Fragen (P13)
| Spalte | Typ |
|---|---|
| brand_id, scene_id | rel |
| frage, attribute_a_id, attribute_b_id | string, rel, rel |
| antwort | enum: a/b/egal/offen |

### Bild-Modus: Posts, Feed & Ordner

**`posts`** – das Slot-Rezept (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | Ersteller |
| **format** | enum: 1_1 / 4_5 / 9_16 | gilt für die ganze Kette. Beim Kopieren **deterministisch** übernommen – kein LLM |
| **nische** | enum wie `brands.nische` | Feed-Filter |
| kulisse_asset_id, person_asset_id, produkt_asset_id | rel → assets | die Modell-Slots |
| asset_version_ids | string[] | welche Versionen genau – wie bei `videos` |
| attribute_ids | rel many→many → attributes | Licht / Kamera / Farbe als Slots |
| werbetext | string | Inhalt des Text-Overlay-Bildes |
| prompt_sent | string 🔒 | exakt was ans Bildmodell ging – **nur internes Team**, nie im Kopier-Screen |
| **slot_summary** | json | die vereinfachte Chip-Darstellung für den Kopier-Screen (Output von P19). **Das ist das Einzige, was ein fremder Nutzer sieht** – eine Abstraktion, keine Offenlegung |
| **copied_from_post_id** | rel → posts (self) | Herkunftskette – erlaubt später „dieses Rezept wurde 400× kopiert" |
| **sichtbarkeit** | enum: privat / oeffentlich (default **privat**) | Veröffentlichen ist ein aktiver Schritt |
| published_at | datetime | |
| social_permalink, social_plattform | string, enum: instagram/tiktok/… | Grundlage für den Reichweiten-Abgleich |
| **feed_score, feed_score_updated_at** | float, datetime | Cache, per Cron berechnet – nie live |
| ki_kennzeichnung | bool | EU AI Act – im Feed sichtbar, nicht nur beim Export |

**`post_images`** – die Bilderkette
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, position | rel, int | Reihenfolge im Karussell |
| typ | enum: motiv / **text_overlay** | Das Text-Overlay ist ein **eigenes Bild**, keine Ebene auf dem Motiv – so lässt sich der Text tauschen, ohne die Motive neu zu generieren |
| storage_file_id | string | → Bucket `generated-images` |
| winkel, position_beschreibung | string | über die Kette variiert **nur** Position/Winkel; Modelle, Kulisse, Licht, Farbe und Text bleiben konstant |
| tags | json | P22-Analyse (Bild-Tagger) |

**`post_folders`** – Zuordnung (m:n, neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, folder_id | rel, rel | Unique auf (post_id, folder_id) |
| **ist_fremd** | bool | Post stammt nicht vom Ordner-Besitzer. Ordner nehmen **eigene und fremde** Posts auf – Wissen entsteht durch Sammeln, nicht erst durch Ausgeben |
| quelle | enum: eigen / feed_gespeichert / kopiert | |
| zugeordnet_von | enum: nutzer / p20_vorschlag | P20 schlägt vor, der Nutzer bestätigt |
| created_at | datetime | |

**`post_metrics`** – Feed-Signale (Zeitreihe, append-only)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, fetched_at | rel, datetime | |
| views, likes, **kopien** | int | In-App-Signale |
| social_reach, social_engagement | int | von verbundenen Accounts |
| ist_verifiziert | bool | nur verifizierte Konten zählen voll – Manipulationsschutz |

> **Gewichtung Feed-Score:** Views ×0,1 · Likes ×0,5 · **Kopien ×1** · Social-Reichweite ×2. Eine Kopie wiegt mehr als ein Like – ein Like ist eine Meinung, eine Kopie eine Handlung mit Aufwand.
>
> **Der Feed-Score ist kein Elo.** Elo braucht Duelle; hier werden Zähler aufsummiert. Deshalb eigene Tabelle, eigener Name, eigene Function. Details und Begründung: `konzept-bilder-feed.md` §8.

---

### Betrieb

**`jobs`** – jede KI-Aktion als Zeile (Warteschlange + Kostenkontrolle)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, typ | rel, enum: bild_gen/video_gen/tagging/vergleich/script/**slot_analyse**/**ordner_vorschlag**/**werbetext** | die drei neuen Typen kommen aus dem Bild-Modus (P19, P20, P21) |
| prompt_template_key, prompt_template_version | string, int | **welcher Prompt in welcher Version lief** – ohne das ist kein Prompt-Debugging möglich |
| status | enum: wartend/läuft/fertig/fehler | |
| cost_usd, tokens | float, int | speist die Beispielrechnung mit echten Zahlen |
| error, refs | string, json | |

**`usage_records`** – für Stripe (metered billing: Preis pro Video)
| Spalte | Typ |
|---|---|
| brand_id, periode, videos_generiert, kosten_usd | rel, string, int, float |

---

## 3. Storage-Buckets (Backend: Hetzner Object Storage via S3-Adapter)

| Bucket | Inhalt | Rechte |
|---|---|---|
| `uploads` | Onboarding: Logo, Produktbilder, Top-Ads; Referenzen beim Verbessern | Team der Brand |
| `asset-references` | generierte Referenzbilder der Asset-Versionen | Team der Brand |
| `generated-videos` | alle generierten Videos | Team der Brand |
| **`generated-images`** | alle generierten Bilder (Motive + Text-Overlays) | Team der Brand; **öffentlich lesbar, sobald `posts.sichtbarkeit = oeffentlich`** |
| `consents` | Einwilligungs-Dokumente (Gesichter) | Team + intern |

---

## 4. Functions (deterministisch – die ⚙️-Punkte aus dem Prompt-Inventar)

| Function | Trigger | Macht |
|---|---|---|
| `elo-update` | Event: votes.create / video_metrics.create | Elo-Mathe, K-Faktor, Gewichtung, schreibt score_events – **jetzt scope-bewusst: schreibt in `attribute_scores` der passenden `folder_id`** |
| `favorit-berechnen` | Event: alle Videos einer Szene getaggt | Score-Summe → is_system_favorite |
| `ads-metriken-holen` | Cron täglich | Meta/TikTok-API → video_metrics |
| `kategorie-durchschnitt` | Cron täglich | avg_score-Cache für Startwerte – **je Ordner** |
| `archivierung` | Cron wöchentlich | Score unter Schwelle + inaktiv → Status archiviert |
| `job-dispatcher` | Event: jobs.create | ruft OpenRouter, schreibt Ergebnis + Kosten zurück |
| **`ordner-initialisieren`** | Event: folders.create | Bei `startwert_modus = erben` die brand-weiten Scores nach `attribute_scores` kopieren; bei `aus_posts` nur die Attribute der zugeordneten Posts anlegen, Startwert = Kategorie-Ø im Ordner. Setzt `start_quelle` |
| **`feed-score-berechnen`** | Cron stündlich | Gewichtete Summe aus `post_metrics` + **exponentieller Zeit-Decay** (Halbwertszeit-Parameter, Startwert 30 Tage) → `posts.feed_score` und aggregiert → `assets.feed_score`. **Rein deterministisch, kein LLM** |
| **`feed-import`** | Event: post kopiert | Legt die Slots des Originals als Attribute in der KB des Kopierers an (Scope = gewählter Ordner), `start_quelle = feed_import`, schreibt `score_events` |
| **`social-reichweite-holen`** | Cron täglich | Instagram/TikTok-API → `post_metrics.social_reach`; löst Rückfluss mit ×0,3 in die KB aus |

---

## 5. Rechte-Modell

- **Brand-Team:** sieht nur eigene Zeilen (Appwrite Row-Level-Permissions pro Team) – Multi-Tenant ohne Extra-Code.
- **Internes Team (Dev-Modus):** exklusiver Zugriff auf `rules`, `prompt_templates`, `jobs.prompt_sent`, **`posts.prompt_sent`** – das App-Geheimnis liegt in den Rechten, nicht nur im UI.
- **append-only:** `score_events`, `video_metrics`, **`post_metrics`**, `jobs` werden nie geändert oder gelöscht – das ist die Log-Anforderung aus dem Konzept.

**Neu mit dem Feed – die einzige öffentlich lesbare Ebene:**

| Öffentlich lesbar | Bleibt privat |
|---|---|
| `posts` bei `sichtbarkeit = oeffentlich`: Bilder, `slot_summary`, `werbetext`, `nische`, `feed_score` | `posts.prompt_sent` 🔒 · `attribute_scores` · `folders` (immer privat) · `rules` · alle Modelle als **Assets** |
| `assets`: nur Name, Typ und Typ-Tags als **Beschriftung** im Kopier-Screen | die Asset-Version selbst, Referenzbilder, `einwilligung_file_id` |

> Das ist die kritische Grenze des ganzen Features: Der Kopier-Screen zeigt **`slot_summary`**, nie `prompt_sent`. Eine Abstraktion, keine Offenlegung – sonst wandert die Knowledge Base fremder Brands nach außen.

**Zusätzlich nötig, weil der Feed öffentlich ist:** Rate-Limits auf `post_metrics`-Signale pro Konto und eine Melde-/Moderationsfunktion (DSA). Beim geschlossenen B2B-Produkt gab es beides nicht.
