feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler

- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
  Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
  myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
  uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
  aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
  Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
  alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
  in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
  apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
  sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
  genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
  Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
  i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:59:08 +00:00
1 parent fc9e5f5577
commit 3051b3b3f9
10 files changed
+1491 -21

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
// Die Community-Logos lesen: Tabelle `logos` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen), dazu die eigenen Herzen
// (`logo_likes`, jeder liest nur seine) und die Meldungen (`logo_reports`, nur Admins). Schreiben (Hochladen, Herz, Melden,
// Löschen, Ausblenden) läuft über den API-Dienst, siehe logo-api.ts. Aufbau wie community/gallery.ts.
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
export interface CommunityLogo {
id: string;
name: string;
displayName: string;
fileId: string;
ownerId: string;
/** Einfarbig: nur die Deckkraft zählt, das Logo nimmt die Farbe der Karte an (hell/dunkel). */
mono: boolean;
likeCount: number;
hidden: boolean;
reportCount: number;
createdAt: string;
}
export interface LogoReport {
id: string;
logoId: string;
reason: string;
createdAt: string;
}
/** Beliebt: meiste Herzen zuerst, bei Gleichstand die neuesten; Neu: neueste zuerst. */
export type LogoSort = 'popular' | 'new';
/** Ansicht der Logos: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
export type LogoFilter = 'all' | 'reported' | 'hidden';
export interface LogoListOptions {
sort: LogoSort;
filter?: LogoFilter;
cursor?: string;
}
export const LOGO_PAGE_SIZE = 24;
/** Obergrenze für die Meldungen eines Logos und die Herzen einer Seite (eine Seite hat 24 Logos). */
const LIST_MAX = 100;
type Row = Record<string, unknown>;
/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */
type TableRow = Models.Row & Row;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function count(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
/** Eine Zeile der Tabelle `logos` als Logo; fehlende oder falsch typisierte Felder werden leer. */
export function toLogo(row: Row): CommunityLogo {
return {
id: text(row.$id),
name: text(row.name),
displayName: text(row.displayName),
fileId: text(row.fileId),
ownerId: text(row.ownerId),
mono: row.mono === true,
likeCount: count(row.likeCount),
hidden: row.hidden === true,
reportCount: count(row.reportCount),
createdAt: text(row.$createdAt),
};
}
/**
* Abfragen für eine Seite der Logos. Öffentlich immer nur sichtbare; Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon
* ausgeblendete), „Ausgeblendet“ = hidden. Beliebt sortiert nach Herzen, dann nach Alter (beides absteigend), Neu nur nach Alter.
* Optional: Cursor (die ID des letzten Logos der Seite davor).
*/
export function logoQueries(opts: LogoListOptions): string[] {
const base =
opts.filter === 'reported'
? Query.greaterThan('reportCount', 0)
: opts.filter === 'hidden'
? Query.equal('hidden', true)
: Query.equal('hidden', false);
const order = opts.sort === 'popular' ? [Query.orderDesc('likeCount'), Query.orderDesc('$createdAt')] : [Query.orderDesc('$createdAt')];
const queries = [base, ...order, Query.limit(LOGO_PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
return queries;
}
/** Eine Seite der Logos. `nextCursor` ist die ID des letzten Logos, solange die Seite voll war, sonst null. */
export async function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }> {
const list = await tables.listRows<TableRow>({ databaseId: IDS.database, tableId: IDS.logos, queries: logoQueries(opts) });
const logos = list.rows.map((row) => toLogo(row));
return { logos, nextCursor: logos.length === LOGO_PAGE_SIZE ? logos[logos.length - 1].id : null };
}
/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
function isRowMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/**
* Ein Logo per ID. Gibt es das Logo nicht oder ist es für mich ausgeblendet oder gelöscht (Appwrite antwortet beides mit 404),
* ist das Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
*/
export async function getLogo(id: string): Promise<CommunityLogo | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toLogo(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.logos, rowId: id }));
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/** Von diesen Logos (einer Seite, höchstens 100) die, die ich mit einem Herz markiert habe; Gäste und Nutzer ohne Herzen: leer. */
export async function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>> {
const hearts = new Set<string>();
if (!userId || logoIds.length === 0) return hearts;
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.logoLikes,
queries: [Query.equal('userId', userId), Query.equal('logoId', [...logoIds]), Query.limit(LIST_MAX)],
});
for (const row of list.rows) {
const logoId = text(row.logoId);
if (logoId) hearts.add(logoId);
}
return hearts;
}
/** Admin: die Meldungen eines Logos, neueste zuerst. Der Melder-Hash bleibt außen vor. */
export async function listLogoReports(logoId: string): Promise<LogoReport[]> {
if (!ID_PATTERN.test(logoId)) return [];
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.logoReports,
queries: [Query.equal('logoId', logoId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
});
return list.rows.map((row) => ({
id: text(row.$id),
logoId: text(row.logoId),
reason: text(row.reason),
createdAt: text(row.$createdAt),
}));
}
/** Lädt die PNG-Datei eines Logos aus dem Logo-Bucket. */
export async function loadLogoBlob(logo: CommunityLogo): Promise<Blob> {
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
if (!ID_PATTERN.test(logo.fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
return fetchFile(IDS.logoBucket, logo.fileId);
}