ki integration
This commit is contained in:
266
scripts/job-dispatcher.mjs
Normal file
266
scripts/job-dispatcher.mjs
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BrandLoop – Job-Dispatcher für `bild_gen`.
|
||||
*
|
||||
* Arbeitet die Warteschlange aus `jobs` ab: baut aus dem Slot-Rezept und dem
|
||||
* Wissen des gewählten Ordners einen Prompt, lässt ein Bild erzeugen, legt es
|
||||
* im Bucket `generated-images` ab und schreibt eine `post_images`-Zeile.
|
||||
*
|
||||
* **Warum serverseitig:** Der Anbieter-Schlüssel darf nicht ins Client-Bundle –
|
||||
* aus einer Web-App ließe er sich sonst auslesen. Später wird daraus eine
|
||||
* Appwrite-Function; bis dahin läuft dasselbe Skript lokal oder per Cron.
|
||||
*
|
||||
* Aufruf:
|
||||
* node scripts/job-dispatcher.mjs # Anbieter aus BILD_ANBIETER
|
||||
* BILD_ANBIETER=stub node scripts/job-dispatcher.mjs
|
||||
*
|
||||
* Anbieter:
|
||||
* ark Seedream über BytePlus ModelArk
|
||||
* openrouter Bildmodelle über OpenRouter
|
||||
* stub erzeugt ein Platzhalterbild – zum Prüfen der Kette ohne Kosten
|
||||
*/
|
||||
import { pngErzeugen } from './lib/png.mjs';
|
||||
|
||||
const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1';
|
||||
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
|
||||
const DB = 'brandloop';
|
||||
const BUCKET = 'generated-images';
|
||||
const ANBIETER = process.env.BILD_ANBIETER || 'stub';
|
||||
const MAX_JOBS = Number(process.env.MAX_JOBS || 20);
|
||||
|
||||
const KEY = process.env.APPWRITE_API_KEY;
|
||||
if (!KEY) { console.error('APPWRITE_API_KEY fehlt.'); process.exit(1); }
|
||||
|
||||
// ---- Appwrite -------------------------------------------------------------
|
||||
const q = (o) => `queries[]=${encodeURIComponent(JSON.stringify(o))}`;
|
||||
const limit = (n) => q({ method: 'limit', values: [n] });
|
||||
|
||||
async function api(method, path, body, form) {
|
||||
const headers = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': KEY };
|
||||
if (!form) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||||
method, headers, body: form ?? (body ? JSON.stringify(body) : undefined),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
|
||||
if (res.status >= 300) throw new Error(`${method} ${path} → ${res.status} ${json.message}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
const zeilen = (tabelle, ...queries) =>
|
||||
api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows?${queries.join('&')}`).then((r) => r.rows);
|
||||
const zeile = (tabelle, id) => api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`);
|
||||
const anlegen = (tabelle, data, permissions) =>
|
||||
api('POST', `/tablesdb/${DB}/tables/${tabelle}/rows`, { rowId: 'unique()', data, permissions });
|
||||
const aendern = (tabelle, id, data) =>
|
||||
api('PATCH', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`, { data });
|
||||
|
||||
// ---- Prompt-Bau (Vorstufe von P7) ----------------------------------------
|
||||
const FORMAT_MASSE = { '1:1': [1024, 1024], '4:5': [896, 1120], '9:16': [768, 1365] };
|
||||
|
||||
/**
|
||||
* Setzt den Prompt aus vier Quellen zusammen – in der Reihenfolge, in der die
|
||||
* Prompt-Architektur sie vorsieht: Regeln, Assets, Attribute, User-Input.
|
||||
*
|
||||
* Das ist bewusst noch **nicht** P7: der echte Prompt ist versioniert und liegt
|
||||
* in `prompt_templates`. Hier steht die Verdrahtung, damit die Kette prüfbar
|
||||
* ist; der Text wird ersetzt, sobald P7 auf das Slot-System umgebaut ist.
|
||||
*/
|
||||
function promptBauen({ post, slots, assets, attribute, regeln, position }) {
|
||||
const teile = [];
|
||||
|
||||
const kulisse = assets[slots.kulisse_asset_id];
|
||||
const produkt = assets[slots.produkt_asset_id];
|
||||
const person = assets[slots.person_asset_id];
|
||||
|
||||
teile.push(post.user_prompt?.trim() || post.titel || 'Werbebild');
|
||||
if (kulisse) teile.push(`Ort: ${kulisse.name}. ${kulisse.beschreibung ?? ''}`.trim());
|
||||
if (produkt) teile.push(`Produkt, exakt wie beschrieben: ${produkt.name}. ${produkt.beschreibung ?? ''}`.trim());
|
||||
if (person) teile.push(`Person: ${person.name}. ${person.beschreibung ?? ''}`.trim());
|
||||
|
||||
if (attribute.length) {
|
||||
teile.push(`Bewährt für diese Marke: ${attribute.map((a) => a.name).join(', ')}.`);
|
||||
}
|
||||
// Nur Position und Winkel variieren über die Kette – alles andere bleibt
|
||||
// konstant, sonst ist es keine Kette, sondern sind es Einzelbilder (§15).
|
||||
const winkel = ['frontal auf Augenhöhe', 'leicht seitlich von links', 'leichte Aufsicht',
|
||||
'Detailaufnahme näher am Motiv', 'weiter gefasst, mehr Umgebung'];
|
||||
teile.push(`Bild ${position} der Serie: ${winkel[(position - 1) % winkel.length]}.`);
|
||||
|
||||
if (regeln.length) teile.push(regeln.map((r) => r.prompt_text).filter(Boolean).join(' '));
|
||||
|
||||
teile.push('Kein Text, kein Logo, kein Wasserzeichen im Bild.');
|
||||
return teile.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ---- Anbieter -------------------------------------------------------------
|
||||
async function bildErzeugen(prompt, [breite, hoehe]) {
|
||||
if (ANBIETER === 'stub') {
|
||||
// Ruhiger Verlauf mit Rasterlinien – erkennbar als Platzhalter, aber ein
|
||||
// echtes Bild, damit Upload, Anzeige und Kettenlogik geprüft werden können.
|
||||
const saat = [...prompt].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
|
||||
const h = saat % 360;
|
||||
// Viertelgröße: der Platzhalter soll die Kette prüfen, nicht Rechenzeit
|
||||
// verbrauchen. Gemeldet werden die **tatsächlichen** Maße – sonst stünde in
|
||||
// `post_images` eine Zahl, die nicht zur Datei passt.
|
||||
const [bw, bh] = [breite >> 2, hoehe >> 2];
|
||||
return {
|
||||
bytes: pngErzeugen(bw, bh, (x, y) => {
|
||||
const t = y / bh;
|
||||
const raster = x % 32 === 0 || y % 32 === 0 ? 18 : 0;
|
||||
return hsl(h, 0.22, 0.14 + t * 0.2 + raster / 255);
|
||||
}),
|
||||
typ: 'image/png',
|
||||
masse: [bw, bh],
|
||||
kosten: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (ANBIETER === 'ark') {
|
||||
const r = await fetch(`${process.env.ARK_APAC_BASE_URL}/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${process.env.ARK_APAC_API_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: process.env.ARK_IMAGE_MODEL || 'seedream-5-0-260128',
|
||||
prompt, size: `${breite}x${hoehe}`, response_format: 'url', watermark: false,
|
||||
}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${j.error.code}: ${j.error.message}`);
|
||||
const url = j.data?.[0]?.url;
|
||||
if (!url) throw new Error('Ark lieferte keine Bilddaten');
|
||||
const bild = await fetch(url);
|
||||
return { bytes: Buffer.from(await bild.arrayBuffer()), typ: 'image/jpeg', kosten: 0 };
|
||||
}
|
||||
|
||||
if (ANBIETER === 'openrouter') {
|
||||
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENROUTER_IMAGE_MODEL || 'google/gemini-3-pro-image',
|
||||
modalities: ['image', 'text'],
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(j.error.message ?? JSON.stringify(j.error));
|
||||
const daten = j.choices?.[0]?.message?.images?.[0]?.image_url?.url;
|
||||
if (!daten) throw new Error('OpenRouter lieferte keine Bilddaten');
|
||||
const bytes = daten.startsWith('data:')
|
||||
? Buffer.from(daten.split(',')[1], 'base64')
|
||||
: Buffer.from(await (await fetch(daten)).arrayBuffer());
|
||||
return { bytes, typ: 'image/png', kosten: j.usage?.cost ?? 0 };
|
||||
}
|
||||
|
||||
throw new Error(`Unbekannter Anbieter "${ANBIETER}"`);
|
||||
}
|
||||
|
||||
function hsl(h, s, l) {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
|
||||
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||
}
|
||||
|
||||
// ---- Ablauf ---------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log(`Anbieter: ${ANBIETER}\n`);
|
||||
|
||||
const jobs = (await zeilen('jobs', limit(MAX_JOBS), q({ method: 'orderAsc', attribute: '$createdAt' })))
|
||||
.filter((j) => j.typ === 'bild_gen' && j.status === 'wartend');
|
||||
|
||||
if (!jobs.length) { console.log('Keine wartenden bild_gen-Jobs.'); return; }
|
||||
console.log(`${jobs.length} wartende(r) Job(s).\n`);
|
||||
|
||||
const postCache = new Map();
|
||||
let fertig = 0, fehler = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
let refs = {};
|
||||
try { refs = JSON.parse(job.refs || '{}'); } catch { /* leer lassen */ }
|
||||
const kennung = `${refs.post_id ?? '?'}#${refs.position ?? '?'}`;
|
||||
|
||||
try {
|
||||
await aendern('jobs', job.$id, { status: 'laeuft' });
|
||||
|
||||
const post = await zeile('posts', refs.post_id);
|
||||
const brand = await zeile('brands', post.brand_id);
|
||||
const rolle = `team:${brand.team_id}`;
|
||||
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
|
||||
|
||||
let kontext = postCache.get(post.$id);
|
||||
if (!kontext) {
|
||||
const slots = JSON.parse(post.slots || '{}');
|
||||
const assets = {};
|
||||
for (const id of [slots.kulisse_asset_id, slots.produkt_asset_id, slots.person_asset_id].filter(Boolean)) {
|
||||
const a = await zeile('assets', id);
|
||||
let beschreibung = '';
|
||||
if (a.released_version_id) {
|
||||
try { beschreibung = (await zeile('asset_versions', a.released_version_id)).beschreibung_md ?? ''; } catch { /* egal */ }
|
||||
}
|
||||
assets[id] = { name: a.name, typ: a.typ, beschreibung };
|
||||
}
|
||||
// Die besten Attribute **des gewählten Ordners** – nicht die der Marke.
|
||||
// Der Ordner überschreibt die brand-weite Ebene vollständig (§9).
|
||||
const scores = post.folder_id
|
||||
? await zeilen('attribute_scores', limit(8),
|
||||
q({ method: 'equal', attribute: 'folder_id', values: [post.folder_id] }),
|
||||
q({ method: 'orderDesc', attribute: 'score' }))
|
||||
: [];
|
||||
const attribute = [];
|
||||
for (const s of scores.slice(0, 6)) {
|
||||
try { attribute.push(await zeile('attributes', s.attribute_id)); } catch { /* egal */ }
|
||||
}
|
||||
const regeln = await zeilen('rules', limit(10));
|
||||
kontext = { slots, assets, attribute, regeln };
|
||||
postCache.set(post.$id, kontext);
|
||||
}
|
||||
|
||||
const masse = FORMAT_MASSE[post.format] ?? FORMAT_MASSE['4:5'];
|
||||
const prompt = promptBauen({ post, ...kontext, position: refs.position ?? 1 });
|
||||
const { bytes, typ, kosten, masse: echteMasse } = await bildErzeugen(prompt, masse);
|
||||
const [bw, bh] = echteMasse ?? masse;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('fileId', 'unique()');
|
||||
fd.append('file', new Blob([bytes], { type: typ }), `post-${post.$id}-${refs.position}.png`);
|
||||
for (const p of rechte) fd.append('permissions[]', p);
|
||||
const datei = await api('POST', `/storage/buckets/${BUCKET}/files`, null, fd);
|
||||
|
||||
await anlegen('post_images', {
|
||||
post_id: post.$id,
|
||||
brand_id: post.brand_id,
|
||||
position: refs.position ?? 1,
|
||||
typ: 'motiv',
|
||||
storage_file_id: datei.$id,
|
||||
prompt_sent: prompt, // 🔒 nur eigenes Team – das ist das Betriebsgeheimnis
|
||||
breite: bw, hoehe: bh,
|
||||
}, rechte);
|
||||
|
||||
await aendern('jobs', job.$id, { status: 'fertig', cost_usd: kosten });
|
||||
|
||||
const bisher = await zeilen('post_images', limit(50),
|
||||
q({ method: 'equal', attribute: 'post_id', values: [post.$id] }));
|
||||
if (bisher.length >= (post.bild_count ?? 1)) {
|
||||
await aendern('posts', post.$id, { status: 'generiert' });
|
||||
}
|
||||
|
||||
fertig++;
|
||||
console.log(` ✓ ${kennung} ${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh}`);
|
||||
} catch (e) {
|
||||
fehler++;
|
||||
const meldung = String(e.message).slice(0, 500);
|
||||
console.log(` ✗ ${kennung} ${meldung}`);
|
||||
await aendern('jobs', job.$id, { status: 'fehler', error: meldung }).catch(() => {});
|
||||
if (refs.post_id) await aendern('posts', refs.post_id, { status: 'fehler' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${fertig} erledigt, ${fehler} fehlgeschlagen.`);
|
||||
if (fehler) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });
|
||||
63
scripts/lib/png.mjs
Normal file
63
scripts/lib/png.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Minimaler PNG-Encoder – nur für den Platzhalter-Anbieter des Job-Dispatchers.
|
||||
*
|
||||
* Bewusst ohne Fremdbibliothek: das Repo soll für einen Testbildgenerator keine
|
||||
* Abhängigkeit mitschleppen. `zlib` bringt Node mit, mehr braucht ein PNG nicht.
|
||||
*/
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
const CRC = (() => {
|
||||
const t = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let c = -1;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(typ, daten) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(daten.length);
|
||||
const körper = Buffer.concat([Buffer.from(typ, 'ascii'), daten]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(körper));
|
||||
return Buffer.concat([len, körper, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} breite
|
||||
* @param {number} hoehe
|
||||
* @param {(x:number,y:number)=>[number,number,number]} farbe RGB je Pixel
|
||||
*/
|
||||
export function pngErzeugen(breite, hoehe, farbe) {
|
||||
const roh = Buffer.alloc(hoehe * (breite * 3 + 1));
|
||||
let p = 0;
|
||||
for (let y = 0; y < hoehe; y++) {
|
||||
roh[p++] = 0; // Filter: none
|
||||
for (let x = 0; x < breite; x++) {
|
||||
const [r, g, b] = farbe(x, y);
|
||||
roh[p++] = r; roh[p++] = g; roh[p++] = b;
|
||||
}
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(breite, 0);
|
||||
ihdr.writeUInt32BE(hoehe, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // colour type: truecolour
|
||||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', deflateSync(roh, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
188
scripts/seed-demo.mjs
Normal file
188
scripts/seed-demo.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Legt ein Demo-Konto mit Ordner und drei Modellen (inkl. echter Bild-Uploads)
|
||||
* an – damit sich die Anzeige in der App gegen echte Daten prüfen lässt.
|
||||
*
|
||||
* Läuft als Client (Cookie-Session), nicht mit dem Server-Key: so wird
|
||||
* nebenbei belegt, dass die Tabellen-Rechte aus E2 wirklich ausreichen.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
const ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||||
const PROJECT = '6a5cee34002bb8360c34';
|
||||
const DB = 'brandloop';
|
||||
const UP = 'A:/developer/GitHub-desktop/videogen/uploads';
|
||||
|
||||
const email = process.argv[2] ?? 'demo@brandloop.test';
|
||||
const password = process.argv[3] ?? 'Demo-2026-brandloop';
|
||||
const label = 'Modaily';
|
||||
|
||||
const jar = new Map();
|
||||
async function call(method, path, body, isForm = false) {
|
||||
const headers = { 'X-Appwrite-Project': PROJECT };
|
||||
if (!isForm) headers['Content-Type'] = 'application/json';
|
||||
if (jar.size) headers.Cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||||
method, headers, body: isForm ? body : body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
for (const c of res.headers.getSetCookie?.() ?? []) {
|
||||
const [pair] = c.split(';');
|
||||
const i = pair.indexOf('=');
|
||||
if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
|
||||
}
|
||||
const t = await res.text();
|
||||
let j; try { j = JSON.parse(t); } catch { j = { message: t }; }
|
||||
if (res.status >= 300) throw new Error(`${method} ${path} -> ${res.status} ${j.message}`);
|
||||
return j;
|
||||
}
|
||||
|
||||
// --- Konto -----------------------------------------------------------------
|
||||
try {
|
||||
await call('POST', '/account', { userId: 'unique()', email, password, name: label });
|
||||
console.log('Konto angelegt:', email);
|
||||
} catch (e) {
|
||||
if (!String(e.message).includes('already exists')) throw e;
|
||||
console.log('Konto existiert bereits:', email);
|
||||
}
|
||||
await call('POST', '/account/sessions/email', { email, password });
|
||||
|
||||
const teamsVorhanden = await call('GET', '/teams');
|
||||
const team = teamsVorhanden.teams[0] ?? (await call('POST', '/teams', { teamId: 'unique()', name: label }));
|
||||
const rolle = `team:${team.$id}`;
|
||||
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
|
||||
|
||||
const brandsVorhanden = await call('GET', `/tablesdb/${DB}/tables/brands/rows`);
|
||||
const brand = brandsVorhanden.rows[0] ?? (await call('POST', `/tablesdb/${DB}/tables/brands/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { team_id: team.$id, label_name: label, status: 'trial', plan: 'trial', nische: 'beauty_kosmetik' },
|
||||
permissions: rechte,
|
||||
}));
|
||||
console.log(`Marke: ${brand.label_name} (${brand.$id}), Team ${team.$id}`);
|
||||
|
||||
// --- Kategorien + Attribute + brand-weite Scores ---------------------------
|
||||
// Das erzeugt später P2 aus dem Onboarding. Hier von Hand, damit ein Ordner
|
||||
// mit Startwert `erben` überhaupt etwas zu erben hat.
|
||||
const WISSEN = {
|
||||
location: ['badezimmer morgens', 'studio grauer hintergrund', 'kueche tageslicht'],
|
||||
licht: ['weiches seitenlicht', 'hartes sonnenlicht', 'flaches studiolicht'],
|
||||
farben: ['warme erdtoene', 'kuehles monochrom', 'kraeftiger rotakzent'],
|
||||
kamera: ['augenhoehe frontal', 'leichte aufsicht', 'detail makro'],
|
||||
};
|
||||
|
||||
const katVorhanden = await call('GET', `/tablesdb/${DB}/tables/categories/rows`);
|
||||
if (!katVorhanden.rows.length) {
|
||||
let attrCount = 0;
|
||||
for (const [kat, attribute] of Object.entries(WISSEN)) {
|
||||
const c = await call('POST', `/tablesdb/${DB}/tables/categories/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { brand_id: brand.$id, name: kat, avg_score: 5000, attribute_count: attribute.length },
|
||||
permissions: rechte,
|
||||
});
|
||||
for (const name of attribute) {
|
||||
const a = await call('POST', `/tablesdb/${DB}/tables/attributes/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: {
|
||||
brand_id: brand.$id, category_id: c.$id, name,
|
||||
slug: name.replace(/\s+/g, '-'), status: 'aktiv',
|
||||
beschreibung_md: `Aus dem Onboarding abgeleitet: ${name}.`,
|
||||
},
|
||||
permissions: rechte,
|
||||
});
|
||||
// Brand-weite Ebene: folder_id bleibt leer.
|
||||
await call('POST', `/tablesdb/${DB}/tables/attribute_scores/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: {
|
||||
brand_id: brand.$id, attribute_id: a.$id, category_id: c.$id,
|
||||
score: 5000, start_value: 5000, k_factor: 32, start_quelle: 'neutral',
|
||||
used_count: 0, wins: 0, losses: 0,
|
||||
},
|
||||
permissions: rechte,
|
||||
});
|
||||
attrCount++;
|
||||
}
|
||||
}
|
||||
console.log(`Wissen: ${Object.keys(WISSEN).length} Kategorien, ${attrCount} Attribute (brand-weit)`);
|
||||
} else {
|
||||
console.log(`Wissen: ${katVorhanden.rows.length} Kategorien vorhanden`);
|
||||
}
|
||||
|
||||
// --- Ordner ----------------------------------------------------------------
|
||||
const ordnerVorhanden = await call('GET', `/tablesdb/${DB}/tables/folders/rows`);
|
||||
if (!ordnerVorhanden.rows.length) {
|
||||
for (const o of [
|
||||
{ name: 'Sommerkampagne', zweck: 'wissens_scope', startwert_modus: 'erben' },
|
||||
{ name: 'Cleane Studioshots', zweck: 'wissens_scope', startwert_modus: 'aus_posts' },
|
||||
]) {
|
||||
const r = await call('POST', `/tablesdb/${DB}/tables/folders/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { brand_id: brand.$id, ...o, ist_default: o.name === 'Sommerkampagne', post_count: 0, signal_count: 0 },
|
||||
permissions: rechte,
|
||||
});
|
||||
// Gleiche Regel wie ordnerInitialisieren() im Client: nur `erben` kopiert
|
||||
// die brand-weite Ebene, alles andere startet leer.
|
||||
let kopiert = 0;
|
||||
if (o.startwert_modus === 'erben') {
|
||||
const q = encodeURIComponent(JSON.stringify({ method: 'isNull', attribute: 'folder_id' }));
|
||||
const l = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }));
|
||||
const quelle = await call('GET', `/tablesdb/${DB}/tables/attribute_scores/rows?queries[]=${q}&queries[]=${l}`);
|
||||
for (const z of quelle.rows) {
|
||||
await call('POST', `/tablesdb/${DB}/tables/attribute_scores/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: {
|
||||
brand_id: brand.$id, attribute_id: z.attribute_id, category_id: z.category_id,
|
||||
folder_id: r.$id, score: z.score, start_value: z.score, k_factor: 32,
|
||||
start_quelle: 'geerbt', used_count: 0, wins: 0, losses: 0,
|
||||
},
|
||||
permissions: rechte,
|
||||
});
|
||||
kopiert++;
|
||||
}
|
||||
}
|
||||
console.log(`Ordner: ${r.name} (${o.zweck}, ${o.startwert_modus}) – ${kopiert} Scores geerbt`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Ordner: ${ordnerVorhanden.rows.length} vorhanden`);
|
||||
}
|
||||
|
||||
// --- Modelle mit echten Referenzbildern ------------------------------------
|
||||
async function bildHochladen(datei) {
|
||||
const bytes = readFileSync(`${UP}/${datei}`);
|
||||
const fd = new FormData();
|
||||
fd.append('fileId', 'unique()');
|
||||
fd.append('file', new Blob([bytes], { type: 'image/jpeg' }), basename(datei));
|
||||
for (const p of rechte) fd.append('permissions[]', p);
|
||||
const f = await call('POST', '/storage/buckets/asset-references/files', fd, true);
|
||||
return f.$id;
|
||||
}
|
||||
|
||||
const MODELLE = [
|
||||
{ typ: 'produkt', name: 'Refine & Renew B3 Serum', datei: 'pasted-1784663200459-0.jpg', beschreibung: 'Weiße Pumpflasche, 30 ml, rote Typo, Aufschrift MODAILY vertikal links.' },
|
||||
{ typ: 'produkt', name: 'Age Decoder Essence', datei: 'pasted-1784643165286-0.jpg', beschreibung: 'Pipettenflasche, rosé-transparentes Glas, mattgraue Kappe, Schriftzug okolo.' },
|
||||
{ typ: 'kulisse', name: 'U-Bahn, Türbereich', datei: 'pasted-1784643111353-0.jpg', beschreibung: 'Metallische U-Bahn-Türen, Haltestangen, weiches Kunstlicht, gedämpfte Erdtöne.' },
|
||||
{ typ: 'kulisse', name: 'Halle, Metallwand', datei: 'pasted-1784663261701-0.jpg', beschreibung: 'Minimalistischer Innenraum, Stahlwand mit kühlen Reflexen, polierter Boden.' },
|
||||
];
|
||||
|
||||
const assetsVorhanden = await call('GET', `/tablesdb/${DB}/tables/assets/rows`);
|
||||
if (assetsVorhanden.rows.length) {
|
||||
console.log(`Modelle: ${assetsVorhanden.rows.length} vorhanden, kein Neuanlegen`);
|
||||
} else {
|
||||
for (const m of MODELLE) {
|
||||
const fileId = await bildHochladen(m.datei);
|
||||
const asset = await call('POST', `/tablesdb/${DB}/tables/assets/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { brand_id: brand.$id, typ: m.typ, name: m.name, ist_teilbar: false, nische: 'beauty_kosmetik' },
|
||||
permissions: rechte,
|
||||
});
|
||||
const version = await call('POST', `/tablesdb/${DB}/tables/asset_versions/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { asset_id: asset.$id, version_no: 1, status: 'freigegeben', beschreibung_md: m.beschreibung, reference_file_ids: [fileId] },
|
||||
permissions: rechte,
|
||||
});
|
||||
await call('PATCH', `/tablesdb/${DB}/tables/assets/rows/${asset.$id}`, {
|
||||
data: { released_version_id: version.$id },
|
||||
});
|
||||
console.log(`Modell: ${m.name} [${m.typ}] Bild ${fileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nFertig. Anmelden mit ${email} / ${password}`);
|
||||
@@ -40,7 +40,7 @@ function resolveApiKey() {
|
||||
}
|
||||
const API_KEY = resolveApiKey();
|
||||
|
||||
let created = 0, skipped = 0, warned = 0;
|
||||
let created = 0, skipped = 0, warned = 0, updated = 0;
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||||
@@ -81,13 +81,63 @@ const bool = (key, opts = {}) => ({ type: 'boolean', body: { key, required: fals
|
||||
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 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 = [
|
||||
{
|
||||
@@ -101,8 +151,18 @@ const TABLES = [
|
||||
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'] },
|
||||
],
|
||||
indexes: [{ key: 'idx_team', type: 'key', columns: ['team_id'] }],
|
||||
},
|
||||
{
|
||||
// 🔒 Dev-Modus: nur internes Team
|
||||
@@ -139,8 +199,12 @@ const TABLES = [
|
||||
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'] },
|
||||
],
|
||||
indexes: [{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] }],
|
||||
},
|
||||
{
|
||||
id: 'attributes', name: 'Attributes', rowSecurity: true,
|
||||
@@ -151,22 +215,21 @@ const TABLES = [
|
||||
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'),
|
||||
// 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_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'] },
|
||||
// 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -175,22 +238,40 @@ const TABLES = [
|
||||
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 }),
|
||||
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'] },
|
||||
],
|
||||
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 }),
|
||||
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'] },
|
||||
],
|
||||
indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }],
|
||||
},
|
||||
{
|
||||
id: 'asset_versions', name: 'Asset Versions', rowSecurity: true,
|
||||
@@ -216,6 +297,7 @@ const TABLES = [
|
||||
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'] }],
|
||||
},
|
||||
@@ -277,7 +359,10 @@ const TABLES = [
|
||||
id: 'jobs', name: 'Jobs', rowSecurity: true,
|
||||
columns: [
|
||||
id('brand_id', { required: true }),
|
||||
enm('typ', ['bild_gen', 'video_gen', 'tagging', 'vergleich', 'script'], { 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' }),
|
||||
@@ -301,13 +386,158 @@ const TABLES = [
|
||||
],
|
||||
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)' },
|
||||
{ id: 'asset-references', name: 'Asset-Referenzbilder' },
|
||||
{ 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}")`] },
|
||||
{ id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`, ...userCreate] },
|
||||
{ id: 'generated-images', name: 'Generierte Bilder' },
|
||||
];
|
||||
|
||||
// ---- Ablauf ---------------------------------------------------------------
|
||||
@@ -338,11 +568,27 @@ async function main() {
|
||||
|
||||
for (const t of TABLES) {
|
||||
console.log(`Tabelle ${t.id}:`);
|
||||
await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
|
||||
const perms = tablePermissions(t);
|
||||
const fresh = await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
|
||||
tableId: t.id, name: t.name,
|
||||
permissions: t.permissions || [],
|
||||
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.
|
||||
@@ -367,15 +613,29 @@ async function main() {
|
||||
|
||||
console.log('Storage-Buckets:');
|
||||
for (const b of BUCKETS) {
|
||||
await ensure(b.id, 'POST', '/storage/buckets', {
|
||||
const perms = b.permissions || [];
|
||||
const fresh = await ensure(b.id, 'POST', '/storage/buckets', {
|
||||
bucketId: b.id, name: b.name,
|
||||
fileSecurity: true,
|
||||
permissions: b.permissions || [],
|
||||
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, ${skipped} existierten schon, ${warned} Warnung(en).`);
|
||||
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); });
|
||||
|
||||
119
scripts/test-mandanten.mjs
Normal file
119
scripts/test-mandanten.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* E2-Abnahme: Mandantentrennung – geprüft über die API, nicht über das UI.
|
||||
*
|
||||
* Legt zwei echte Konten an und baut für beide Team + brands-Zeile genau so wie
|
||||
* client/src/lib/auth.ts. Danach: sieht Konto B irgendetwas von Konto A?
|
||||
*
|
||||
* Kein SDK, nur fetch mit eigenem Cookie-Speicher – so verhält sich der Aufruf
|
||||
* wie ein Browser, und der Test hängt nicht an SDK-Eigenheiten.
|
||||
*/
|
||||
const ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||||
const PROJECT = '6a5cee34002bb8360c34';
|
||||
const DB = 'brandloop';
|
||||
const KEY = process.env.APPWRITE_API_KEY;
|
||||
if (!KEY) { console.error('APPWRITE_API_KEY fehlt'); process.exit(1); }
|
||||
|
||||
const stamp = Date.now();
|
||||
let fehler = 0;
|
||||
const ok = (m) => console.log(` ok ${m}`);
|
||||
const bad = (m) => { fehler++; console.log(` FEHL ${m}`); };
|
||||
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }));
|
||||
|
||||
/** Ein Konto = ein Cookie-Speicher, so wie ein Browser-Profil. */
|
||||
function neueSitzung() {
|
||||
const jar = new Map();
|
||||
return async function call(method, path, body) {
|
||||
const headers = { 'X-Appwrite-Project': PROJECT, 'Content-Type': 'application/json' };
|
||||
if (jar.size) headers.Cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||||
method, headers, body: body ? JSON.stringify(body) : undefined, redirect: 'manual',
|
||||
});
|
||||
for (const c of res.headers.getSetCookie?.() ?? []) {
|
||||
const [pair] = c.split(';');
|
||||
const i = pair.indexOf('=');
|
||||
if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
|
||||
}
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
|
||||
return { status: res.status, json };
|
||||
};
|
||||
}
|
||||
|
||||
const admin = async (method, path, body) => {
|
||||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||||
method,
|
||||
headers: { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': 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 };
|
||||
};
|
||||
|
||||
function muss(r, was) {
|
||||
if (r.status >= 200 && r.status < 300) return r.json;
|
||||
throw new Error(`${was}: HTTP ${r.status} – ${r.json?.message}`);
|
||||
}
|
||||
|
||||
/** Spiegelt client/src/lib/auth.ts: Konto → Session → Team → brands-Zeile. */
|
||||
async function kontoAnlegen(kennung) {
|
||||
const call = neueSitzung();
|
||||
const email = `e2-${kennung}-${stamp}@brandloop.test`;
|
||||
const password = `Test-${stamp}-${kennung}!`;
|
||||
const label = `Marke ${kennung.toUpperCase()}`;
|
||||
|
||||
const user = muss(await call('POST', '/account', { userId: 'unique()', email, password, name: label }), 'account');
|
||||
muss(await call('POST', '/account/sessions/email', { email, password }), 'session');
|
||||
|
||||
const team = muss(await call('POST', '/teams', { teamId: 'unique()', name: label }), 'team');
|
||||
const rolle = `team:${team.$id}`;
|
||||
const brand = muss(await call('POST', `/tablesdb/${DB}/tables/brands/rows`, {
|
||||
rowId: 'unique()',
|
||||
data: { team_id: team.$id, label_name: label, status: 'trial', plan: 'trial' },
|
||||
permissions: [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`],
|
||||
}), 'brands-Zeile');
|
||||
|
||||
return { call, userId: user.$id, teamId: team.$id, brandId: brand.$id, label };
|
||||
}
|
||||
|
||||
console.log('Konten anlegen (wie der Client es tut):');
|
||||
const A = await kontoAnlegen('a');
|
||||
console.log(` A: user=${A.userId} team=${A.teamId} brand=${A.brandId}`);
|
||||
const B = await kontoAnlegen('b');
|
||||
console.log(` B: user=${B.userId} team=${B.teamId} brand=${B.brandId}`);
|
||||
|
||||
console.log('\nMandantentrennung:');
|
||||
|
||||
const liste = await B.call('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
|
||||
const ids = (liste.json.rows ?? []).map((r) => r.$id);
|
||||
if (ids.includes(A.brandId)) bad(`B sieht A's Zeile in der Liste`);
|
||||
else ok(`B listet ${ids.length} brands-Zeile(n), A's ist nicht dabei`);
|
||||
if (!ids.includes(B.brandId)) bad('B sieht die EIGENE Zeile nicht – Rechte zu streng');
|
||||
else ok('B sieht die eigene Zeile');
|
||||
|
||||
const direkt = await B.call('GET', `/tablesdb/${DB}/tables/brands/rows/${A.brandId}`);
|
||||
if (direkt.status < 300) bad(`B konnte A's Zeile direkt lesen (HTTP ${direkt.status})`);
|
||||
else ok(`Direktzugriff B→A abgewiesen (HTTP ${direkt.status} ${direkt.json?.type ?? ''})`);
|
||||
|
||||
const schreib = await B.call('PATCH', `/tablesdb/${DB}/tables/brands/rows/${A.brandId}`, { data: { label_name: 'gekapert' } });
|
||||
if (schreib.status < 300) bad(`B konnte A's Zeile ÄNDERN (HTTP ${schreib.status})`);
|
||||
else ok(`Schreibzugriff B→A abgewiesen (HTTP ${schreib.status} ${schreib.json?.type ?? ''})`);
|
||||
|
||||
// Gegenprobe: sieht der Server-Key beide? Sonst wäre der erste Test wertlos,
|
||||
// weil dann vielleicht schlicht nichts in der Tabelle steht.
|
||||
const alle = await admin('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
|
||||
const alleIds = (alle.json.rows ?? []).map((r) => r.$id);
|
||||
if (alleIds.includes(A.brandId) && alleIds.includes(B.brandId)) ok(`Server-Key sieht beide Zeilen (${alle.json.total} gesamt) – der erste Test ist damit aussagekräftig`);
|
||||
else bad(`Server-Key sieht nicht beide: ${JSON.stringify(alleIds)}`);
|
||||
|
||||
console.log('\nAufräumen:');
|
||||
for (const k of [A, B]) {
|
||||
await admin('DELETE', `/tablesdb/${DB}/tables/brands/rows/${k.brandId}`);
|
||||
await admin('DELETE', `/teams/${k.teamId}`);
|
||||
await admin('DELETE', `/users/${k.userId}`);
|
||||
}
|
||||
const rest = await admin('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
|
||||
console.log(` Testkonten entfernt, brands enthält jetzt ${rest.json.total} Zeile(n).`);
|
||||
|
||||
console.log(fehler ? `\n${fehler} FEHLER – Mandantentrennung nicht dicht.` : '\nMandantentrennung hält.');
|
||||
process.exit(fehler ? 1 : 0);
|
||||
Reference in New Issue
Block a user