feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)

- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:40:44 +00:00
1 parent a65c075004
commit 19c8151c7e
15 files changed
+980 -53

No files matched your search

+14 -4
View File
@@ -3,9 +3,9 @@
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und
* `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets
* `card-images`, `gallery-images` und `logo-images` an.
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`,
* `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die
* Buckets `card-images`, `gallery-images` und `logo-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
@@ -41,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes).
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
@@ -137,6 +137,16 @@ export const DESIRED = {
],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
},
{
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
id: 'profiles',
name: 'Profiles',
permissions: [],
rowSecurity: true,
columns: [str('username', 20, true), str('usernameKey', 20, true)],
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
},
],
buckets: [
{
+111 -10
View File
@@ -270,9 +270,9 @@ describe('DESIRED', () => {
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => {
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports, profiles; Buckets card-images, gallery-images, logo-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']);
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports', 'profiles']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']);
});
@@ -393,6 +393,16 @@ describe('DESIRED', () => {
assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]);
});
test('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => {
const t = table('profiles');
assert.equal(t.name, 'Profiles');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]);
// unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden
assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]);
});
test('logo-images entspricht Logo-Spec §8', () => {
assert.deepEqual(bucket('logo-images'), {
id: 'logo-images',
@@ -622,6 +632,19 @@ describe('compareIndex', () => {
});
});
describe('compareIndex (unique)', () => {
const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0];
const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' });
test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => {
assert.deepEqual(compareIndex(ok(), usernameKey), []);
// ein normaler Index verhindert keine doppelten Namen: das muss auffallen
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']);
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']);
});
});
describe('compareBucket', () => {
const CARD_IMAGES = DESIRED.buckets[0];
const ok = () => ({
@@ -741,6 +764,10 @@ describe('main gegen Fake-Server', () => {
'Spalte logo_reports.reason',
'Spalte logo_reports.reporterHash',
'Index logo_reports.logo',
'Tabelle profiles',
'Spalte profiles.username',
'Spalte profiles.usernameKey',
'Index profiles.username_key',
'Bucket card-images',
'Bucket gallery-images',
'Bucket logo-images',
@@ -748,6 +775,8 @@ describe('main gegen Fake-Server', () => {
// Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images)
const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o));
const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o));
// Erweiterung um die Benutzernamen (profiles)
const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
@@ -805,6 +834,11 @@ describe('main gegen Fake-Server', () => {
}],
str('logo_reports', 'reporterHash', 64, true),
[`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
[T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }],
str('profiles', 'username', 20, true),
str('profiles', 'usernameKey', 20, true),
// unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen)
[`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
@@ -822,15 +856,16 @@ describe('main gegen Fake-Server', () => {
}],
];
const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? '');
const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles';
test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => {
test('--check auf leerem Projekt: Exit 1, alle 55 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 51);
assert.equal(EXPECTED_OBJECTS.length, 55);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n'));
assert.ok(lines.includes('UNVOLLSTÄNDIG: 55 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
@@ -859,8 +894,8 @@ describe('main gegen Fake-Server', () => {
assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
assert.equal(fake.state.columns.size, 31);
assert.equal(fake.state.indexes.size, 10);
assert.equal(fake.state.columns.size, 33);
assert.equal(fake.state.indexes.size, 11);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
@@ -868,6 +903,7 @@ describe('main gegen Fake-Server', () => {
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [
'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery',
'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo',
'profiles.username_key',
]);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
@@ -920,7 +956,7 @@ describe('main gegen Fake-Server', () => {
dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] });
const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o));
assert.equal(LOGO_OBJECTS.length, 23);
assert.equal(kept.length, 28);
assert.equal(kept.length, 32);
const requestsBefore = fake.state.requests.length;
const postsBefore = fake.posts().length;
@@ -947,8 +983,8 @@ describe('main gegen Fake-Server', () => {
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
// Alles Bestehende wurde nur gelesen
assert.equal(fake.otherWrites().length, 0);
assert.equal(fake.state.columns.size, 31);
assert.equal(fake.state.indexes.size, 10);
assert.equal(fake.state.columns.size, 33);
assert.equal(fake.state.indexes.size, 11);
// danach ist das Schema vollständig
const again = await run(fake, { check: true });
@@ -956,6 +992,42 @@ describe('main gegen Fake-Server', () => {
});
});
test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Stand vor den Benutzernamen nachstellen: alles außer profiles
dropFromFake(fake, { tables: ['profiles'] });
const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o));
assert.equal(PROFILE_OBJECTS.length, 4);
assert.equal(kept.length, 51);
const requestsBefore = fake.state.requests.length;
const postsBefore = fake.posts().length;
// --check: genau die vier neuen Objekte fehlen, nichts wird angelegt
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0);
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 4 fehlend.'), check.lines.join('\n'));
assert.equal(fake.posts().length, postsBefore);
// Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind)
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isProfilePost));
assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] });
assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique');
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
assert.equal(fake.otherWrites().length, 0);
assert.equal((await run(fake, { check: true })).code, 0);
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -1118,6 +1190,33 @@ describe('main gegen Fake-Server', () => {
});
});
test('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.tables.get('aircard/profiles').$permissions = ['read("any")'];
fake.state.tables.get('aircard/profiles').rowSecurity = false;
fake.state.columns.get('aircard/profiles/username').size = 30;
fake.state.columns.get('aircard/profiles/usernameKey').required = false;
fake.state.indexes.get('aircard/profiles/username_key').type = 'key';
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: profiles.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true',
'ABWEICHUNG: profiles.username.size ist 30, erwartet 20',
'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true',
'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique',
]);
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
assert.ok(!lines.includes('OK: Schema vollständig'));
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -1303,6 +1402,8 @@ describe('CLI', () => {
assert.match(stdout, /FEHLT: Tabelle logos/);
assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/);
assert.match(stdout, /FEHLT: Tabelle logo_reports/);
assert.match(stdout, /FEHLT: Tabelle profiles/);
assert.match(stdout, /FEHLT: Index profiles\.username_key/);
assert.match(stdout, /FEHLT: Bucket logo-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
+6
View File
@@ -35,6 +35,12 @@ export const API_MESSAGES: Readonly<Record<string, string>> = {
tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'),
badHeart: msg('Ungültige Angabe zum Herz.'),
badMono: msg('Ungültige Angabe zu „einfarbig“.'),
usernameInvalid: msg(
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
),
usernameReserved: msg('Dieser Benutzername ist nicht erlaubt.'),
usernameTaken: msg('Dieser Benutzername ist schon vergeben.'),
usernameRequired: msg('Bitte wähle zuerst einen Benutzernamen.'),
tooLarge: msg('Die Anfrage ist zu groß.'),
jsonOnly: msg('Nur application/json wird unterstützt.'),
unavailable: msg('Der Dienst ist gerade nicht erreichbar.'),
+5
View File
@@ -361,6 +361,11 @@ export const EN: Readonly<Record<string, string>> = {
'You’ve already uploaded 30 logos. Delete one to upload a new one.',
'Ungültige Angabe zum Herz.': 'Invalid heart value.',
'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.',
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.':
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
'Dieser Benutzername ist nicht erlaubt.': 'This username is not allowed.',
'Dieser Benutzername ist schon vergeben.': 'This username is already taken.',
'Bitte wähle zuerst einen Benutzernamen.': 'Please choose a username first.',
'Die Anfrage ist zu groß.': 'The request is too large.',
'Nur application/json wird unterstützt.': 'Only application/json is supported.',
'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',