Files
videogen/scripts/setup-appwrite.mjs
2026-08-15 13:43:52 +02:00

642 lines
28 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, 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 }), // → 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 }),
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
], { 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'] }],
},
// ---- 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' },
];
// ---- 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 } = 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) {
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); });