feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder

Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum,
Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert.
DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index,
Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt
Community (JWT an, Label admin, Key aircard-api) ergänzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:50:54 +00:00
1 parent 25ff496548
commit 9e4f4ffacc
3 files changed
+803 -127

No files matched your search

+210 -68
View File
@@ -2,8 +2,9 @@
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
* Indizes) und die Buckets `card-images` und `gallery-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
@@ -13,8 +14,9 @@
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
*
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
* Abweichungen werden gemeldet, nie geändert.
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
*
* Ausgabe: eine Zeile je Objekt –
@@ -34,30 +36,81 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
// Soll-Zustand
// ---------------------------------------------------------------------------
const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
table: {
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [
{ key: 'name', size: 80, required: true },
{ key: 'config', size: 2000, required: true },
{ key: 'imageFileId', size: 36, required: true },
{ key: 'previewFileId', size: 36, required: true },
],
},
bucket: {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
indexes: [],
},
{
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
id: 'gallery',
name: 'Gallery',
permissions: [],
rowSecurity: true,
columns: [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
],
indexes: [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'reports',
name: 'Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
},
],
buckets: [
{
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
{
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
},
],
};
// ---------------------------------------------------------------------------
@@ -94,11 +147,35 @@ export function compareTable(actual, desired) {
return out;
}
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
/**
* Vergleich einer Spalte, je nach Typ:
* string → type, size, required
* boolean → type, required, default
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
* Außerdem immer `status` (erwartet `available`).
*/
export function compareColumn(actual, desired) {
const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
}
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
out.push(mismatch('default', actual.default, desired.default));
}
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
export function compareIndex(actual, desired) {
const out = [];
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
@@ -197,7 +274,9 @@ async function createObject(ctx, path, body, what) {
// ---------------------------------------------------------------------------
const DB_ID = DESIRED.database.id;
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
export async function ensureDatabase(ctx) {
@@ -217,13 +296,12 @@ export async function ensureDatabase(ctx) {
}
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
export async function ensureTable(ctx, parentExists) {
const t = DESIRED.table;
const label = `Tabelle ${t.id}`;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
export async function ensureTable(ctx, table, parentExists) {
const label = `Tabelle ${table.id}`;
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
if (actual) {
const deviations = compareTable(actual, t);
if (deviations.length) ctx.deviations(t.id, deviations);
const deviations = compareTable(actual, table);
if (deviations.length) ctx.deviations(table.id, deviations);
else ctx.ok(label);
return true;
}
@@ -234,31 +312,39 @@ export async function ensureTable(ctx, parentExists) {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
label,
);
ctx.created(label);
return true;
}
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
const CHILD_KINDS = {
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
};
/**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
*/
export async function waitAvailable(ctx, tableId, keys) {
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
const { noun, plural, path } = CHILD_KINDS[kind];
const deadline = Date.now() + ctx.timeoutMs;
const done = new Map();
let pending = [...keys];
for (;;) {
const still = [];
for (const key of pending) {
const label = `Spalte ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(key), label);
const label = `${noun} ${tableId}.${key}`;
const actual = await getObject(ctx, path(tableId, key), label);
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
if (actual.status === 'available') {
done.set(key, actual);
} else if (actual.status === 'failed' || actual.status === 'stuck') {
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
} else {
still.push(key);
}
@@ -266,7 +352,7 @@ export async function waitAvailable(ctx, tableId, keys) {
if (!still.length) return done;
if (Date.now() >= deadline) {
throw new SetupError(
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
);
}
pending = still;
@@ -274,49 +360,102 @@ export async function waitAvailable(ctx, tableId, keys) {
}
}
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
function columnRequest(table, col) {
const path = `${tablePath(table.id)}/columns/${col.type}`;
switch (col.type) {
case 'string':
return { path, body: { key: col.key, size: col.size, required: col.required } };
case 'boolean':
return { path, body: { key: col.key, required: col.required, default: col.default } };
case 'integer':
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
case 'enum':
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
default:
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
}
}
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
* (`actual` ist dort gesetzt, wo das Objekt existiert).
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
*/
export async function ensureColumns(ctx, parentExists) {
const t = DESIRED.table;
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
const { noun, path } = CHILD_KINDS[kind];
const entries = [];
for (const col of t.columns) {
const label = `Spalte ${t.id}.${col.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
for (const item of items) {
const label = `${noun} ${table.id}.${item.key}`;
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
if (actual) {
entries.push({ col, label, actual, created: false });
} else if (ctx.check) {
entries.push({ item, label, actual, created: false });
} else if (ctx.check || !canCreate) {
ctx.missing(label);
} else {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
const { path: createPath, body } = request(item);
await createObject(ctx, createPath, body, label);
ctx.created(label);
entries.push({ col, label, actual: null, created: true });
entries.push({ item, label, actual: null, created: true });
}
}
if (!ctx.check) {
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
if (waiting.length) {
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
for (const e of waiting) e.actual = ready.get(e.col.key);
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
for (const e of waiting) e.actual = ready.get(e.item.key);
}
}
for (const e of entries) {
const deviations = compareColumn(e.actual, e.col);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
const deviations = compare(e.actual, e.item);
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
else if (!e.created) ctx.ok(e.label);
}
return entries;
}
export async function ensureBucket(ctx) {
const b = DESIRED.bucket;
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
* und `available` sind (Voraussetzung für die Indizes).
*/
export async function ensureColumns(ctx, table, parentExists) {
const entries = await ensureChildren(ctx, table, parentExists, {
kind: 'column',
items: table.columns,
request: (col) => columnRequest(table, col),
compare: compareColumn,
});
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
}
/**
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
*/
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
const indexes = table.indexes ?? [];
if (!indexes.length) return;
await ensureChildren(ctx, table, parentExists, {
kind: 'index',
items: indexes,
request: (idx) => ({
path: `${tablePath(table.id)}/indexes`,
body: { key: idx.key, type: idx.type, columns: idx.columns },
}),
compare: compareIndex,
canCreate: columnsReady,
});
}
export async function ensureBucket(ctx, bucket) {
const b = bucket;
const label = `Bucket ${b.id}`;
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
if (actual) {
@@ -391,9 +530,12 @@ export async function main({
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
try {
const databaseExists = await ensureDatabase(ctx);
const tableExists = await ensureTable(ctx, databaseExists);
await ensureColumns(ctx, tableExists);
await ensureBucket(ctx);
for (const table of DESIRED.tables) {
const tableExists = await ensureTable(ctx, table, databaseExists);
const columnsReady = await ensureColumns(ctx, table, tableExists);
await ensureIndexes(ctx, table, tableExists, columnsReady);
}
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
} catch (err) {
if (!(err instanceof SetupError)) throw err;
log(`ABBRUCH: ${err.message}`);