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:
1 parent
fc9e5f5577
commit
3051b3b3f9
10 files changed
+1491
-21
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user