Files
videogen/scripts/setup-appwrite.mjs

382 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
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: 18 }, (_, i) => `P${i + 1}`);
const INTERNAL = 'team:internal';
const internalOnly = [
`read("${INTERNAL}")`, `create("${INTERNAL}")`,
`update("${INTERNAL}")`, `delete("${INTERNAL}")`,
];
// ---- 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' }),
],
indexes: [{ key: 'idx_team', type: 'key', columns: ['team_id'] }],
},
{
// 🔒 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 }),
],
indexes: [{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] }],
},
{
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
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
int('used_count', { default: 0 }), int('wins', { default: 0 }), int('losses', { default: 0 }),
dt('last_used_at'),
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_topn', type: 'key', columns: ['brand_id', 'category_id', 'status', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] },
{ 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'] },
],
},
{
// 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'], { 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
],
indexes: [{ key: 'idx_attr_created', type: 'key', columns: ['attribute_id', '$createdAt'] }],
},
{
id: 'assets', name: 'Assets', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
enm('typ', ['gesicht', 'produkt', 'logo', 'sonstiges'], { required: true }),
str('name', 255, { required: true }),
id('released_version_id'), // nur diese Version wird in Videos verwendet
],
indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }],
},
{
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 }), // → Bucket asset-references
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 }),
],
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'], { required: true }),
str('prompt_template_key', 16), // welcher Prompt …
int('prompt_template_version'), // … in welcher Version lief
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'] }],
},
];
const BUCKETS = [
{ id: 'uploads', name: 'Uploads (Onboarding)' },
{ id: 'asset-references', name: 'Asset-Referenzbilder' },
{ id: 'generated-videos', name: 'Generierte Videos' },
{ id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`] },
];
// ---- 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}:`);
await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
tableId: t.id, name: t.name,
permissions: t.permissions || [],
rowSecurity: t.rowSecurity,
});
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 } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.body.key}`);
if (status === 200) { skipped++; console.log(` = ${t.id}.${c.body.key} (existiert)`); 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) {
await ensure(b.id, 'POST', '/storage/buckets', {
bucketId: b.id, name: b.name,
fileSecurity: true,
permissions: b.permissions || [],
enabled: true,
});
}
console.log(`\nFertig: ${created} angelegt, ${skipped} existierten schon, ${warned} Warnung(en).`);
}
main().catch(e => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });