716 lines
32 KiB
JavaScript
716 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* BrandLoop – Appwrite-Schema-Setup (idempotent)
|
||
*
|
||
* Legt im selbst gehosteten Appwrite (Projekt BrandLoop) die komplette
|
||
* Datenbank-Struktur aus dem DB-Plan an: Datenbank, 15 Tabellen (TablesDB),
|
||
* Spalten, Indizes, 4 Storage-Buckets und das interne Dev-Team.
|
||
*
|
||
* Aufruf:
|
||
* APPWRITE_API_KEY=... node scripts/setup-appwrite.mjs
|
||
* oder Key als BRANDLOOP_APPWRITE_API_KEY in /home/webklar/apps/.env hinterlegen.
|
||
*
|
||
* Idempotent: bereits existierende Objekte (HTTP 409) werden übersprungen –
|
||
* das Skript kann nach Plan-Erweiterungen jederzeit erneut laufen.
|
||
*
|
||
* Abweichung vom Plan: Fremdschlüssel sind indizierte String-Spalten (size 64)
|
||
* statt Appwrite-Relationship-Spalten. Grund: Relationships sind in Appwrite
|
||
* nicht filter-/indizierbar – der geforderte Top-N-Index
|
||
* (brand, category, status, score DESC) und alle Listen-Queries brauchen aber
|
||
* genau das. Many-to-many (videos.attribute_ids) ist ein String-Array
|
||
* (Query.contains funktioniert darauf).
|
||
*/
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1';
|
||
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
|
||
const ENV_FILE = process.env.ENV_FILE || '/home/webklar/apps/.env';
|
||
const DB_ID = 'brandloop';
|
||
|
||
function resolveApiKey() {
|
||
if (process.env.APPWRITE_API_KEY) return process.env.APPWRITE_API_KEY;
|
||
try {
|
||
const env = readFileSync(ENV_FILE, 'utf8');
|
||
const m = env.match(/^BRANDLOOP_APPWRITE_API_KEY=(.+)$/m);
|
||
if (m) return m[1].trim();
|
||
} catch { /* .env nicht lesbar – unten Fehlermeldung */ }
|
||
console.error(`Kein API-Key: APPWRITE_API_KEY setzen oder BRANDLOOP_APPWRITE_API_KEY in ${ENV_FILE} hinterlegen.`);
|
||
process.exit(1);
|
||
}
|
||
const API_KEY = resolveApiKey();
|
||
|
||
let created = 0, skipped = 0, warned = 0, updated = 0;
|
||
|
||
async function api(method, path, body) {
|
||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||
method,
|
||
headers: {
|
||
'X-Appwrite-Project': PROJECT,
|
||
'X-Appwrite-Key': API_KEY,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const text = await res.text();
|
||
let json;
|
||
try { json = JSON.parse(text); } catch { json = { message: text }; }
|
||
return { status: res.status, json };
|
||
}
|
||
|
||
async function ensure(label, method, path, body) {
|
||
const { status, json } = await api(method, path, body);
|
||
if (status >= 200 && status < 300) { created++; console.log(` + ${label}`); return json; }
|
||
if (status === 409) { skipped++; console.log(` = ${label} (existiert)`); return null; }
|
||
throw new Error(`${label}: HTTP ${status} – ${json.message}`);
|
||
}
|
||
|
||
async function tryEnsure(label, method, path, body) {
|
||
try { return await ensure(label, method, path, body); }
|
||
catch (e) { warned++; console.warn(` ! ${label}: ${e.message}`); return null; }
|
||
}
|
||
|
||
// ---- Spalten-Helfer -------------------------------------------------------
|
||
const str = (key, size, opts = {}) => ({ type: 'string', body: { key, size, required: false, ...opts } });
|
||
const id = (key, opts = {}) => str(key, 64, opts); // Referenzen: uuid4 = 36 Zeichen, 64 mit Luft
|
||
const md = (key, opts = {}) => str(key, 65535, opts); // Markdown-/Prompt-Texte
|
||
const jsonCol = (key, size = 16384, opts = {}) => str(key, size, opts); // JSON als String
|
||
const int = (key, opts = {}) => ({ type: 'integer', body: { key, required: false, ...opts } });
|
||
const flt = (key, opts = {}) => ({ type: 'float', body: { key, required: false, ...opts } });
|
||
const bool = (key, opts = {}) => ({ type: 'boolean', body: { key, required: false, ...opts } });
|
||
const dt = (key, opts = {}) => ({ type: 'datetime', body: { key, required: false, ...opts } });
|
||
const enm = (key, elements, opts = {}) => ({ type: 'enum', body: { key, elements, required: false, ...opts } });
|
||
|
||
const P_KEYS = Array.from({ length: 22 }, (_, i) => `P${i + 1}`);
|
||
|
||
// Feste Branchenliste (Onboarding-Pflichtfrage 3, Feed-Filter, Kopier-Kompatibilität)
|
||
// und Motiv-Tags des Bild-Modus. Beides sind Enums – eine spätere Änderung ist eine
|
||
// Migration mit Datenverlustrisiko, nicht ein Anhängen (programmier-plan.md §2.6).
|
||
const NISCHEN = [
|
||
'mode', 'beauty_kosmetik', 'fitness_sport', 'food_getraenke', 'gastronomie',
|
||
'gesundheit', 'handwerk_bau', 'immobilien', 'finanzen_versicherung', 'reisen_hotel',
|
||
'auto_mobilitaet', 'technik_software', 'moebel_interior', 'schmuck_accessoires',
|
||
'haustier', 'bildung_coaching', 'dienstleistung', 'handel_ecommerce', 'kunst_kultur',
|
||
'sonstiges',
|
||
];
|
||
const TYP_TAGS = [
|
||
'portrait', 'ganzkoerper', 'gruppe', 'produkt_freisteller', 'produkt_inszeniert',
|
||
'detail_makro', 'flatlay', 'innenraum', 'aussen', 'studio', 'natur', 'urban',
|
||
'bewegung', 'stillleben', 'text_overlay', 'logo', 'sonstiges',
|
||
];
|
||
|
||
const INTERNAL = 'team:internal';
|
||
const internalOnly = [
|
||
`read("${INTERNAL}")`, `create("${INTERNAL}")`,
|
||
`update("${INTERNAL}")`, `delete("${INTERNAL}")`,
|
||
];
|
||
|
||
/**
|
||
* Tabellen-Recht "anlegen darf jeder Angemeldete".
|
||
*
|
||
* Warum das nötig ist: bei `rowSecurity: true` regeln Zeilenrechte *lesen,
|
||
* ändern, löschen* – aber eine Zeile, die es noch nicht gibt, hat keine Rechte.
|
||
* Ohne ein Tabellen-Recht zum Anlegen kann der Client also gar nichts
|
||
* schreiben, auch nicht seine eigenen Daten.
|
||
*
|
||
* Warum das trotzdem dicht ist: `create` erlaubt nur das Anlegen. Gelesen wird
|
||
* ausschließlich, was die Zeilenrechte hergeben – und die setzt der Client beim
|
||
* Anlegen auf sein eigenes Team. Eine fremde Zeile wird dadurch nicht sichtbar.
|
||
*/
|
||
const userCreate = ['create("users")'];
|
||
|
||
/**
|
||
* Wer legt in welcher Tabelle Zeilen an.
|
||
*
|
||
* NICHT in dieser Liste und das mit Absicht:
|
||
* - `score_events`, `video_metrics`, `post_metrics`, `usage_records`
|
||
* – append-only, geschrieben von Functions mit Server-Key. Dürfte der Client
|
||
* hier schreiben, könnte er seine eigenen Elo-Werte und Feed-Signale
|
||
* fälschen (projekt-uebersicht.md §11, "Manipulation des Feed-Scores").
|
||
* - `rules`, `prompt_templates` – Betriebsgeheimnis, nur Team `internal`.
|
||
*/
|
||
const CLIENT_CREATE = new Set([
|
||
'brands', 'folders', 'categories', 'attributes', 'attribute_scores',
|
||
'assets', 'asset_versions', 'scenes', 'videos', 'votes', 'questions',
|
||
'posts', 'post_images', 'post_folders', 'follows',
|
||
'jobs', // jede KI-Aktion wird vom Client eingereiht (app-aufbau.md §2.1)
|
||
]);
|
||
|
||
const tablePermissions = (t) => t.permissions || (CLIENT_CREATE.has(t.id) ? userCreate : []);
|
||
|
||
// ---- Tabellen-Definitionen (Spalten + Indizes) ----------------------------
|
||
const TABLES = [
|
||
{
|
||
id: 'brands', name: 'Brands', rowSecurity: true,
|
||
columns: [
|
||
id('team_id'),
|
||
str('label_name', 255, { required: true }),
|
||
str('produkte', 1024), str('zielgruppe', 1024),
|
||
str('brand_worte', 64, { array: true }),
|
||
str('am_markt_seit', 64),
|
||
str('stripe_customer_id', 64),
|
||
int('default_video_count', { min: 3, max: 6, default: 4 }),
|
||
enm('status', ['trial', 'aktiv', 'pausiert'], { default: 'trial' }),
|
||
// Feed & Folgen: öffentliches Profil der Brand
|
||
str('anzeigename', 255),
|
||
enm('nische', NISCHEN), // Onboarding-Pflichtfrage 3, filtert den Feed
|
||
id('avatar_file_id'),
|
||
int('follower_count', { default: 0 }),
|
||
bool('ist_oeffentlich', { default: false }),
|
||
enm('plan', ['trial', 'starter', 'pro', 'business'], { default: 'trial' }),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_team', type: 'key', columns: ['team_id'] },
|
||
{ key: 'idx_oeffentlich', type: 'key', columns: ['ist_oeffentlich', 'nische'] },
|
||
],
|
||
},
|
||
{
|
||
// 🔒 Dev-Modus: nur internes Team
|
||
id: 'rules', name: 'Rules', rowSecurity: false, permissions: internalOnly,
|
||
columns: [
|
||
id('brand_id'), // null = global für alle Brands
|
||
str('titel', 255, { required: true }),
|
||
md('prompt_text'),
|
||
bool('aktiv', { default: true }),
|
||
int('sortierung', { default: 0 }),
|
||
],
|
||
indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }],
|
||
},
|
||
{
|
||
// 🔒 Dev-Modus: nur internes Team; Prompts sind versioniert
|
||
id: 'prompt_templates', name: 'Prompt Templates', rowSecurity: false, permissions: internalOnly,
|
||
columns: [
|
||
enm('key', P_KEYS, { required: true }),
|
||
int('version', { min: 1, default: 1 }),
|
||
md('static_core_md'),
|
||
jsonCol('slots'),
|
||
enm('model_adapter', ['seedance', 'veo', 'sora', 'wan']), // null = kein Adapter
|
||
bool('aktiv', { default: true }),
|
||
],
|
||
// model_adapter gehört in den Index: P8 hat 5 Zeilen (Kern + 4 Adapter) je Version.
|
||
// MariaDB erlaubt mehrere NULLs im Unique-Index – Eindeutigkeit der Kern-Zeilen
|
||
// (model_adapter=null) sichert das Seed-Skript (einziger Schreiber).
|
||
indexes: [{ key: 'uq_key_adapter_version', type: 'unique', columns: ['key', 'model_adapter', 'version'] }],
|
||
},
|
||
{
|
||
id: 'categories', name: 'Categories', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
str('name', 64, { required: true }), // location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen
|
||
int('avg_score', { min: 0, max: 10000, default: 5000 }), // Cache – per Function aktualisiert
|
||
int('attribute_count', { default: 0 }),
|
||
id('folder_id'), // null = brand-weite Ebene; Kategorie-Ø wird je Ordner geführt
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] },
|
||
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
|
||
],
|
||
},
|
||
{
|
||
id: 'attributes', name: 'Attributes', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
id('category_id', { required: true }),
|
||
id('parent_id'), // Unter-Attribute (self-reference)
|
||
str('name', 255, { required: true }),
|
||
str('slug', 255, { required: true }),
|
||
enm('status', ['aktiv', 'archiviert'], { default: 'aktiv' }), // nie löschen
|
||
// Score, k_factor, start_value, used_count, wins, losses, last_used_at liegen
|
||
// NICHT mehr hier, sondern in `attribute_scores` – ein Attribut hat je Ordner
|
||
// einen eigenen Score. Diese Tabelle hält nur noch die Definition.
|
||
str('tags', 255, { array: true }),
|
||
md('beschreibung_md'), md('essenz_md'), md('details_md'),
|
||
str('prompt_bausteine', 1024, { array: true }),
|
||
str('negativ_prompts', 1024, { array: true }),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_parent', type: 'key', columns: ['parent_id'] },
|
||
{ key: 'idx_slug', type: 'key', columns: ['brand_id', 'slug'] },
|
||
// Fulltext ist auf Array-Spalten verboten → Key-Index (reicht für Query.contains)
|
||
{ key: 'idx_tags', type: 'key', columns: ['tags'] },
|
||
// Der Top-N-Index liegt jetzt auf attribute_scores – dort steht der Score.
|
||
{ key: 'idx_brand_cat', type: 'key', columns: ['brand_id', 'category_id', 'status'] },
|
||
],
|
||
},
|
||
{
|
||
// append-only Log – nie ändern/löschen
|
||
id: 'score_events', name: 'Score Events', rowSecurity: true,
|
||
columns: [
|
||
id('attribute_id', { required: true }),
|
||
id('video_id'), id('opponent_attribute_id'),
|
||
enm('event_type', [
|
||
'vote', 'vote_favorit_bestaetigt', 'performance', 'gezielte_frage', 'llm_vergleich',
|
||
'feed_import', 'post_reichweite', // Bild-Modus: Import beim Kopieren, Rückfluss ×0,3
|
||
], { required: true }),
|
||
int('delta'), int('new_score', { min: 0, max: 10000 }),
|
||
flt('weight', { default: 1 }), // ×0,3 / ×1 / ×2
|
||
str('kommentar', 1024), // z. B. P12-Erkenntnis
|
||
id('folder_id'), // in welchem Wissens-Scope die Änderung galt
|
||
id('post_id'), // bei feed_import / post_reichweite
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_attr_created', type: 'key', columns: ['attribute_id', '$createdAt'] },
|
||
{ key: 'idx_folder_created', type: 'key', columns: ['folder_id', '$createdAt'] },
|
||
],
|
||
},
|
||
{
|
||
id: 'assets', name: 'Assets', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
enm('typ', ['gesicht', 'produkt', 'logo', 'sonstiges', 'kulisse'], { required: true }),
|
||
str('name', 255, { required: true }),
|
||
id('released_version_id'), // nur diese Version wird in Videos verwendet
|
||
// Feed: Modelle werden nie geteilt (Marken-/Designrecht, Konsistenz), erscheinen
|
||
// aber mit eigenem Feed-Score in der Modell-Rangliste.
|
||
enm('nische', NISCHEN),
|
||
enm('typ_tags', TYP_TAGS, { array: true }),
|
||
flt('feed_score', { default: 0 }),
|
||
dt('feed_score_updated_at'),
|
||
bool('ist_teilbar', { default: false }),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_brand', type: 'key', columns: ['brand_id'] },
|
||
{ key: 'idx_teilbar', type: 'key', columns: ['ist_teilbar', 'nische'] },
|
||
],
|
||
},
|
||
{
|
||
id: 'asset_versions', name: 'Asset Versions', rowSecurity: true,
|
||
columns: [
|
||
id('asset_id', { required: true }),
|
||
int('version_no', { min: 1 }),
|
||
enm('status', ['entwurf', 'freigegeben', 'archiviert'], { default: 'entwurf' }),
|
||
md('beschreibung_md'),
|
||
str('merkmale', 255, { array: true }),
|
||
str('change_reason', 512),
|
||
id('won_against_version_id'), // Vorher/Nachher-Vote
|
||
id('reference_file_ids', { array: true }), // EINGABE: hochgeladene Referenzen
|
||
// ERGEBNIS: das für diese Version erzeugte Bild. Bewusst getrennt von
|
||
// `reference_file_ids` – sonst ließe sich später nicht mehr unterscheiden,
|
||
// was der Nutzer beigesteuert und was das KI-Modell erzeugt hat.
|
||
id('bild_file_id'), // → Bucket generated-images
|
||
id('einwilligung_file_id'), // → Bucket consents
|
||
],
|
||
indexes: [{ key: 'idx_asset', type: 'key', columns: ['asset_id', 'version_no'] }],
|
||
},
|
||
{
|
||
id: 'scenes', name: 'Scenes', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
md('user_prompt'), // Original, unverändert aufheben
|
||
jsonCol('selected_context', 65535), // was P4 injiziert hat (inkl. Scores zum Zeitpunkt)
|
||
md('script_md'), md('script_final_md'), // Diff = P6-Signal
|
||
enm('status', ['entwurf', 'script', 'generiert', 'voting', 'fertig'], { default: 'entwurf' }), // Realtime-Kanal fürs UI
|
||
int('video_count', { min: 1, default: 4 }),
|
||
id('folder_id'), // Ordner wird VOR der Generierung gewählt; P4 zieht nur dessen Attribute
|
||
],
|
||
indexes: [{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] }],
|
||
},
|
||
{
|
||
id: 'videos', name: 'Videos', rowSecurity: true,
|
||
columns: [
|
||
id('scene_id', { required: true }),
|
||
id('brand_id', { required: true }),
|
||
enm('ki_model', ['seedance', 'veo', 'sora', 'wan']),
|
||
md('prompt_sent'), // exakt was ans Modell ging (Dev-Modus)
|
||
id('storage_file_id'), // → Bucket generated-videos
|
||
jsonCol('tags'), // P9-Analyse
|
||
id('attribute_ids', { array: true }), // Attribution (many-to-many)
|
||
id('asset_version_ids', { array: true }),
|
||
bool('is_system_favorite', { default: false }),
|
||
enm('vote_result', ['gewonnen', 'verloren']), // null = noch offen ("–")
|
||
bool('published', { default: false }),
|
||
str('platforms', 64, { array: true }),
|
||
dt('published_at'),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_scene', type: 'key', columns: ['scene_id'] },
|
||
{ key: 'idx_brand', type: 'key', columns: ['brand_id'] },
|
||
],
|
||
},
|
||
{
|
||
// Zeitreihe (Cron-Function, Ads-APIs) – append-only
|
||
id: 'video_metrics', name: 'Video Metrics', rowSecurity: true,
|
||
columns: [
|
||
id('video_id', { required: true }),
|
||
dt('fetched_at'),
|
||
flt('ctr'), flt('thumbstop'), flt('roas'), flt('follower_gained'), flt('spend'),
|
||
],
|
||
indexes: [{ key: 'idx_video_fetched', type: 'key', columns: ['video_id', 'fetched_at'] }],
|
||
},
|
||
{
|
||
id: 'votes', name: 'Votes', rowSecurity: true,
|
||
columns: [
|
||
id('scene_id', { required: true }),
|
||
id('winner_video_id', { required: true }),
|
||
bool('favorit_bestaetigt', { default: false }), // → Gewichtung ×0,3
|
||
],
|
||
indexes: [{ key: 'idx_scene', type: 'key', columns: ['scene_id'] }],
|
||
},
|
||
{
|
||
// gezielte Fragen (P13)
|
||
id: 'questions', name: 'Questions', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
id('scene_id'),
|
||
str('frage', 1024, { required: true }),
|
||
id('attribute_a_id'), id('attribute_b_id'),
|
||
enm('antwort', ['a', 'b', 'egal', 'offen'], { default: 'offen' }),
|
||
],
|
||
indexes: [{ key: 'idx_brand_antwort', type: 'key', columns: ['brand_id', 'antwort'] }],
|
||
},
|
||
{
|
||
// jede KI-Aktion als Zeile (Warteschlange + Kostenkontrolle) – append-only
|
||
id: 'jobs', name: 'Jobs', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
enm('typ', [
|
||
'bild_gen', 'video_gen', 'tagging', 'vergleich', 'script',
|
||
'slot_analyse', 'ordner_vorschlag', 'werbetext', // P19, P20, P21
|
||
// Eigener Typ statt `bild_gen` mitzubenutzen: Modell-Generierung hat
|
||
// andere Kosten, andere Fehlerbilder und einen anderen Ergebnis-Screen.
|
||
// Alles über `refs` zu unterscheiden, macht Kostenauswertung und
|
||
// Prompt-Debugging unnötig mühsam.
|
||
'modell_gen',
|
||
], { required: true }),
|
||
str('prompt_template_key', 16), // welcher Prompt …
|
||
int('prompt_template_version'), // … in welcher Version lief
|
||
// 🔒 Was wirklich ans Modell ging. projekt-uebersicht.md §9 führt
|
||
// `jobs.prompt_sent` als eines der drei Felder, die exklusiv dem internen
|
||
// Team gehören – im Schema fehlte es bisher. Ohne dieses Feld ist bei
|
||
// Job-Typen ohne eigene Ergebnis-Tabelle kein Prompt-Debugging möglich.
|
||
md('prompt_sent'),
|
||
enm('status', ['wartend', 'laeuft', 'fertig', 'fehler'], { default: 'wartend' }),
|
||
flt('cost_usd'), int('tokens'),
|
||
str('error', 4096),
|
||
jsonCol('refs', 8192),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] },
|
||
{ key: 'idx_queue', type: 'key', columns: ['status', '$createdAt'] },
|
||
],
|
||
},
|
||
{
|
||
// für Stripe (metered billing: Preis pro Video)
|
||
id: 'usage_records', name: 'Usage Records', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
str('periode', 16), // z. B. "2026-07"
|
||
int('videos_generiert', { default: 0 }),
|
||
flt('kosten_usd', { default: 0 }),
|
||
],
|
||
indexes: [{ key: 'uq_brand_periode', type: 'unique', columns: ['brand_id', 'periode'] }],
|
||
},
|
||
|
||
// ---- Wissens-Scope: Ordner + Scores je Ordner ---------------------------
|
||
{
|
||
// Ein Ordner ist ein privater Wissens-Scope, kein Sortier-Ordner.
|
||
id: 'folders', name: 'Folders', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
str('name', 255, { required: true }),
|
||
md('theme_md'),
|
||
str('zweck', 1024),
|
||
enm('startwert_modus', ['erben', 'aus_posts', 'neutral'], { default: 'erben' }),
|
||
bool('ist_default', { default: false }),
|
||
int('post_count', { default: 0 }),
|
||
int('signal_count', { default: 0 }), // Reifegrad des Ordners
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_brand', type: 'key', columns: ['brand_id'] },
|
||
{ key: 'idx_brand_default', type: 'key', columns: ['brand_id', 'ist_default'] },
|
||
],
|
||
},
|
||
{
|
||
// Herzstück: ein Attribut hat je Ordner einen eigenen Score.
|
||
// folder_id = null ist die brand-weite Ebene.
|
||
id: 'attribute_scores', name: 'Attribute Scores', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
id('attribute_id', { required: true }),
|
||
id('folder_id'), // null = brand-weit
|
||
id('category_id', { required: true }),
|
||
int('score', { min: 0, max: 10000, default: 5000 }),
|
||
int('k_factor', { default: 32 }), // 32 neu → 8 etabliert
|
||
int('start_value', { min: 0, max: 10000 }), // Kategorie-Ø bei Anlage
|
||
enm('start_quelle', ['kategorie_schnitt', 'geerbt', 'aus_posts', 'feed_import', 'neutral'], { default: 'neutral' }),
|
||
int('used_count', { default: 0 }), int('wins', { default: 0 }), int('losses', { default: 0 }),
|
||
dt('last_used_at'),
|
||
],
|
||
indexes: [
|
||
// der Top-N-Index, wegen dem Referenzen indizierte Strings sind (siehe Kopf)
|
||
{ key: 'idx_topn', type: 'key', columns: ['brand_id', 'folder_id', 'category_id', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] },
|
||
{ key: 'uq_attr_folder', type: 'unique', columns: ['attribute_id', 'folder_id'] },
|
||
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
|
||
],
|
||
},
|
||
|
||
// ---- Bild-Modus & Feed --------------------------------------------------
|
||
{
|
||
// Ein Post ist ein Slot-Rezept, kein Freitext-Prompt – nur deshalb kopierbar.
|
||
// prompt_sent ist das Betriebsgeheimnis und darf nie an fremde Konten gehen.
|
||
id: 'posts', name: 'Posts', rowSecurity: true,
|
||
columns: [
|
||
id('brand_id', { required: true }),
|
||
id('folder_id'),
|
||
str('titel', 255),
|
||
md('user_prompt'),
|
||
jsonCol('slots', 65535), // das Rezept: Kulisse, Modelle, Licht/Kamera/Farbe, Werbetext
|
||
jsonCol('slot_summary', 65535), // die abstrahierten Chips (P19) – das sieht der Kopierer
|
||
md('prompt_sent'), // 🔒 nur eigenes Konto + Team internal
|
||
str('format', 32), // 1:1 | 4:5 | 9:16
|
||
int('bild_count', { default: 1 }),
|
||
enm('status', ['entwurf', 'generiert', 'fehler'], { default: 'entwurf' }),
|
||
enm('sichtbarkeit', ['privat', 'oeffentlich'], { default: 'privat' }), // Veröffentlichen ist ein aktiver Schritt
|
||
flt('feed_score', { default: 0 }), // gewichtete Summe + Decay, NICHT Elo
|
||
dt('feed_score_updated_at'),
|
||
dt('veroeffentlicht_at'),
|
||
id('copied_from_post_id'),
|
||
int('kopien_count', { default: 0 }), // Kopien ×1 im Feed-Score
|
||
enm('nische', NISCHEN),
|
||
],
|
||
indexes: [
|
||
{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] },
|
||
{ key: 'idx_feed', type: 'key', columns: ['sichtbarkeit', 'feed_score'], orders: ['ASC', 'DESC'] },
|
||
// Explorations-Slot: neue Posts chronologisch, gegen Rich-get-richer
|
||
{ key: 'idx_feed_neu', type: 'key', columns: ['sichtbarkeit', 'veroeffentlicht_at'], orders: ['ASC', 'DESC'] },
|
||
{ key: 'idx_copied', type: 'key', columns: ['copied_from_post_id'] },
|
||
],
|
||
},
|
||
{
|
||
// Bilderkette: nur Position und Winkel variieren. Das Text-Overlay ist ein
|
||
// eigenes Bild, keine Ebene – so lässt sich Text ohne Neugenerierung tauschen.
|
||
id: 'post_images', name: 'Post Images', rowSecurity: true,
|
||
columns: [
|
||
id('post_id', { required: true }),
|
||
id('brand_id', { required: true }),
|
||
int('position', { min: 1, default: 1 }),
|
||
enm('typ', ['motiv', 'text_overlay'], { default: 'motiv' }),
|
||
id('storage_file_id'), // → Bucket generated-images
|
||
md('prompt_sent'), // 🔒
|
||
id('asset_version_ids', { array: true }),
|
||
enm('typ_tags', TYP_TAGS, { array: true }), // P22-Tagger, Enum-Zwang
|
||
int('breite'), int('hoehe'),
|
||
],
|
||
indexes: [{ key: 'idx_post_position', type: 'key', columns: ['post_id', 'position'] }],
|
||
},
|
||
{
|
||
// m:n – ein Post darf in mehreren Ordnern liegen, eigene wie fremde
|
||
id: 'post_folders', name: 'Post ↔ Folder', rowSecurity: true,
|
||
columns: [
|
||
id('post_id', { required: true }),
|
||
id('folder_id', { required: true }),
|
||
id('brand_id', { required: true }),
|
||
bool('ist_fremd', { default: false }),
|
||
enm('quelle', ['eigen', 'feed_import', 'kopie'], { default: 'eigen' }),
|
||
],
|
||
indexes: [
|
||
{ key: 'uq_post_folder', type: 'unique', columns: ['post_id', 'folder_id'] },
|
||
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
|
||
],
|
||
},
|
||
{
|
||
// Feed-Signale – append-only. Views ×0,1 · Likes ×0,5 · Kopien ×1 · Social ×2
|
||
id: 'post_metrics', name: 'Post Metrics', rowSecurity: true,
|
||
columns: [
|
||
id('post_id', { required: true }),
|
||
dt('fetched_at'),
|
||
int('views', { default: 0 }), int('likes', { default: 0 }), int('kopien', { default: 0 }),
|
||
int('social_reichweite', { default: 0 }),
|
||
str('social_plattform', 32),
|
||
],
|
||
indexes: [{ key: 'idx_post_fetched', type: 'key', columns: ['post_id', 'fetched_at'] }],
|
||
},
|
||
{
|
||
id: 'follows', name: 'Follows', rowSecurity: true,
|
||
columns: [
|
||
id('follower_brand_id', { required: true }),
|
||
id('followed_brand_id', { required: true }),
|
||
],
|
||
indexes: [
|
||
{ key: 'uq_follow', type: 'unique', columns: ['follower_brand_id', 'followed_brand_id'] },
|
||
{ key: 'idx_follower', type: 'key', columns: ['follower_brand_id'] },
|
||
{ key: 'idx_followed', type: 'key', columns: ['followed_brand_id'] },
|
||
],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Buckets. `fileSecurity` überall an – gelesen wird nur, was die Datei-Rechte
|
||
* hergeben.
|
||
*
|
||
* `create("users")` aus demselben Grund wie bei den Tabellen: eine Datei, die
|
||
* noch nicht existiert, hat keine Rechte. Ohne Bucket-Recht zum Anlegen kann
|
||
* der Client nichts hochladen.
|
||
*
|
||
* **Ohne** `create`: `generated-videos` und `generated-images` – dort schreibt
|
||
* ausschließlich der Job-Dispatcher mit Server-Key. Dürfte der Client das,
|
||
* könnte er Bilder unterschieben, die nie durch eine Generierung gelaufen sind.
|
||
*/
|
||
const BUCKETS = [
|
||
{ id: 'uploads', name: 'Uploads (Onboarding)', permissions: userCreate },
|
||
{ id: 'asset-references', name: 'Asset-Referenzbilder', permissions: userCreate },
|
||
{ id: 'generated-videos', name: 'Generierte Videos' },
|
||
{ id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`, ...userCreate] },
|
||
{ id: 'generated-images', name: 'Generierte Bilder' },
|
||
];
|
||
|
||
/**
|
||
* Zieht fehlende Enum-Werte auf einer bestehenden Spalte nach.
|
||
*
|
||
* Der Plan hat das lange als gefährlich geführt (§2.6: „je nach Version Löschen
|
||
* und Neuanlegen der Spalte, also Datenverlust"). **Für Appwrite 1.8.1 ist das
|
||
* am 15.08.2026 gegen eine Wegwerf-Tabelle geprüft worden:** `PATCH` auf die
|
||
* Enum-Spalte liefert 200, alle Bestandswerte überleben, der neue Wert ist
|
||
* sofort nutzbar. Bei einem Server-Upgrade gehört dieser Test wiederholt.
|
||
*
|
||
* Bewusst nur **additiv**: Werte, die live existieren und im Skript fehlen,
|
||
* werden gemeldet, aber nie entfernt – sonst verlören bestehende Zeilen ihren
|
||
* Wert, und genau davor warnt der Plan zu Recht.
|
||
*/
|
||
/**
|
||
* Enum-Spalten, die **nie** automatisch nachgezogen werden.
|
||
*
|
||
* `prompt_templates.key` ist mit 22 Zeilen gefüllt und trägt die Identität
|
||
* jedes Prompts. Selbst wenn der additive PATCH technisch hält – hier ist jede
|
||
* Enum-Berührung ein Migrationsprojekt mit Prüfung, kein Skriptlauf.
|
||
*/
|
||
const ENUM_TABU = new Set(['prompt_templates.key']);
|
||
|
||
async function enumNachziehen(tableId, c, live) {
|
||
const key = c.body.key;
|
||
if (c.type !== 'enum') { skipped++; console.log(` = ${tableId}.${key} (existiert)`); return; }
|
||
|
||
if (ENUM_TABU.has(`${tableId}.${key}`)) {
|
||
const fehlt = (c.body.elements ?? []).filter((e) => !(live.elements ?? []).includes(e));
|
||
if (fehlt.length) {
|
||
warned++;
|
||
console.warn(` ! ${tableId}.${key}: [${fehlt.join(', ')}] fehlen – NICHT automatisch geändert (Tabu-Liste), bitte bewusst migrieren`);
|
||
} else { skipped++; console.log(` = ${tableId}.${key} (existiert)`); }
|
||
return;
|
||
}
|
||
|
||
const soll = c.body.elements ?? [];
|
||
const ist = live.elements ?? [];
|
||
const fehlend = soll.filter((e) => !ist.includes(e));
|
||
const zusaetzlich = ist.filter((e) => !soll.includes(e));
|
||
|
||
if (zusaetzlich.length) {
|
||
warned++;
|
||
console.warn(` ! ${tableId}.${key}: live zusätzlich [${zusaetzlich.join(', ')}] – bleibt stehen, wird nicht entfernt`);
|
||
}
|
||
if (!fehlend.length) { skipped++; console.log(` = ${tableId}.${key} (existiert)`); return; }
|
||
|
||
const body = {
|
||
elements: [...ist, ...fehlend],
|
||
required: !!c.body.required,
|
||
default: c.body.required ? null : (c.body.default ?? null),
|
||
};
|
||
await ensure(`${tableId}.${key} +[${fehlend.join(',')}]`, 'PATCH',
|
||
`/tablesdb/${DB_ID}/tables/${tableId}/columns/enum/${key}`, body);
|
||
updated++; created--; // ensure() zählt als "angelegt" – hier ist es eine Änderung
|
||
await waitForColumn(tableId, key);
|
||
}
|
||
|
||
// ---- Ablauf ---------------------------------------------------------------
|
||
async function waitForColumn(tableId, key) {
|
||
for (let i = 0; i < 30; i++) {
|
||
const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${tableId}/columns/${key}`);
|
||
if (status === 200 && json.status === 'available') return true;
|
||
if (status === 200 && json.status === 'failed') {
|
||
console.warn(` ! Spalte ${tableId}.${key}: Status "failed"`);
|
||
warned++;
|
||
return false;
|
||
}
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
console.warn(` ! Spalte ${tableId}.${key}: nicht verfügbar nach 30s`);
|
||
warned++;
|
||
return false;
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`Endpoint: ${ENDPOINT}\nProjekt: ${PROJECT}\nDB: ${DB_ID}\n`);
|
||
|
||
console.log('Team:');
|
||
await ensure('internal (Internes Team / Dev-Modus)', 'POST', '/teams', { teamId: 'internal', name: 'Internes Team (Dev)' });
|
||
|
||
console.log('Datenbank:');
|
||
await ensure(`${DB_ID}`, 'POST', '/tablesdb', { databaseId: DB_ID, name: 'BrandLoop' });
|
||
|
||
for (const t of TABLES) {
|
||
console.log(`Tabelle ${t.id}:`);
|
||
const perms = tablePermissions(t);
|
||
const fresh = await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
|
||
tableId: t.id, name: t.name,
|
||
permissions: perms,
|
||
rowSecurity: t.rowSecurity,
|
||
});
|
||
// Existierte die Tabelle schon, hat POST nur ein 409 geliefert – die Rechte
|
||
// wären dann nie angefasst worden. Deshalb hier abgleichen und angleichen:
|
||
// sonst driftet das Rechte-Modell genauso auseinander wie zuvor das Schema.
|
||
if (!fresh) {
|
||
const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}`);
|
||
const same = status === 200
|
||
&& JSON.stringify([...(json.$permissions ?? [])].sort()) === JSON.stringify([...perms].sort())
|
||
&& !!json.rowSecurity === !!t.rowSecurity;
|
||
if (!same) {
|
||
await ensure(`${t.id} Rechte`, 'PUT', `/tablesdb/${DB_ID}/tables/${t.id}`, {
|
||
name: t.name, permissions: perms, rowSecurity: t.rowSecurity,
|
||
});
|
||
updated++; created--; // ensure() zählt als "angelegt" – hier ist es eine Änderung
|
||
}
|
||
}
|
||
for (const c of t.columns) {
|
||
// Erst GET: Appwrite prüft das Zeilengrößen-Limit VOR dem Duplikat-Check,
|
||
// ein erneutes POST auf große Spalten gäbe sonst 400 statt 409.
|
||
const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.body.key}`);
|
||
if (status === 200) {
|
||
await enumNachziehen(t.id, c, json);
|
||
continue;
|
||
}
|
||
await ensure(`${t.id}.${c.body.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.type}`, c.body);
|
||
}
|
||
}
|
||
|
||
// Indizes erst, wenn alle beteiligten Spalten "available" sind
|
||
for (const t of TABLES) {
|
||
if (!t.indexes?.length) continue;
|
||
console.log(`Indizes ${t.id}:`);
|
||
const needed = new Set(t.indexes.flatMap(i => i.columns).filter(c => !c.startsWith('$')));
|
||
for (const key of needed) await waitForColumn(t.id, key);
|
||
for (const idx of t.indexes) {
|
||
const body = { key: idx.key, type: idx.type, columns: idx.columns, orders: idx.orders };
|
||
if (idx.optional) await tryEnsure(`${t.id}.${idx.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/indexes`, body);
|
||
else await ensure(`${t.id}.${idx.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/indexes`, body);
|
||
}
|
||
}
|
||
|
||
console.log('Storage-Buckets:');
|
||
for (const b of BUCKETS) {
|
||
const perms = b.permissions || [];
|
||
const fresh = await ensure(b.id, 'POST', '/storage/buckets', {
|
||
bucketId: b.id, name: b.name,
|
||
fileSecurity: true,
|
||
permissions: perms,
|
||
enabled: true,
|
||
});
|
||
// Gleiches Nachziehen wie bei den Tabellen – ein 409 lässt die Rechte sonst
|
||
// auf dem Stand von damals stehen.
|
||
if (!fresh) {
|
||
const { status, json } = await api('GET', `/storage/buckets/${b.id}`);
|
||
const same = status === 200
|
||
&& JSON.stringify([...(json.$permissions ?? [])].sort()) === JSON.stringify([...perms].sort());
|
||
if (!same) {
|
||
await ensure(`${b.id} Rechte`, 'PUT', `/storage/buckets/${b.id}`, {
|
||
name: b.name, fileSecurity: true, permissions: perms, enabled: true,
|
||
});
|
||
updated++; created--;
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`\nFertig: ${created} angelegt, ${updated} geändert, ${skipped} existierten schon, ${warned} Warnung(en).`);
|
||
}
|
||
|
||
main().catch(e => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });
|