// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine // öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes // `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der // Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. import { Query, type Models } from 'appwrite'; import { ID_PATTERN } from '../card/config'; import { apiPost, unavailableError } from '../community/api'; import { API_MESSAGES } from '../i18n/api-messages'; import { msg, t } from '../i18n/i18n'; import { IDS, tables } from './appwrite'; import { currentUserId } from './auth'; import { isRowMissing } from './errors'; /** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */ export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/; /** * Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen. * Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide. */ export const RESERVED_USERNAMES: readonly string[] = [ 'admin', 'administrator', 'root', 'system', 'support', 'moderator', 'mod', 'aircard', 'aircardios', 'aircard-ios', 'aircard.ios', 'aircard_ios', 'webklar', 'anonym', 'anonymous', 'null', 'undefined', 'team', 'staff', 'official', 'offiziell', 'hilfe', 'help', 'kontakt', 'contact', 'info', ]; const RESERVED = new Set(RESERVED_USERNAMES); /** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */ export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -'); /** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */ const LOOKUP_MAX = 100; type TableRow = Models.Row & Record; function text(value: unknown): string { return typeof value === 'string' ? value : ''; } /** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */ export function usernameKey(name: string): string { return name.toLowerCase(); } /** * Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste). * Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht). */ export function validateUsername(name: string): string | null { if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid); if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved); return null; } // ---- Namen merken und melden ------------------------------------------------------------------------------ /** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */ const names = new Map(); const listeners = new Set<(userId: string, username: string) => void>(); /** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */ function announce(userId: string, username: string): void { names.set(userId, username); // Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis. for (const fn of [...listeners]) { try { fn(userId, username); } catch (err) { console.error('Ein Hörer der Namensänderung ist gescheitert', err); } } } /** * Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils * (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück. */ export function onUsernameChange(fn: (userId: string, username: string) => void): () => void { listeners.add(fn); return () => { listeners.delete(fn); }; } /** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */ export function myUsername(): string | null { const userId = currentUserId(); return userId ? (names.get(userId) ?? null) : null; } // ---- Lesen -------------------------------------------------------------------------------------------------- /** * Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein * kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch. */ export async function getUsername(userId: string): Promise { if (!ID_PATTERN.test(userId)) return null; try { const row = await tables.getRow({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId }); return text(row.username) || null; } catch (err) { if (isRowMissing(err)) return null; throw err; } } /** * Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen * in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein * Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück. */ export async function usernamesFor(userIds: readonly string[]): Promise> { const found = new Map(); const missing: string[] = []; for (const id of new Set(userIds)) { if (!ID_PATTERN.test(id)) continue; const known = names.get(id); if (known === undefined) missing.push(id); else found.set(id, known); } try { for (let from = 0; from < missing.length; from += LOOKUP_MAX) { const ids = missing.slice(from, from + LOOKUP_MAX); const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.profiles, queries: [Query.equal('$id', ids), Query.limit(ids.length)], }); for (const row of list.rows) { const name = text(row.username); if (name === '') continue; // Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort. const current = names.get(row.$id) ?? name; names.set(row.$id, current); found.set(row.$id, current); } } } catch { // nur Beiwerk der Anzeige, siehe oben } return found; } /** * Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten * Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst. * Fehler (Netz, Server) gehen durch. */ export async function isUsernameTaken(name: string): Promise { const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.profiles, queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)], }); const own = currentUserId(); return list.rows.some((row) => row.$id !== own); } /** * Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne * Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein * Name“, und nur bei „kein Name“ wird gefragt. */ export async function loadMyUsername(): Promise { const userId = currentUserId(); if (!userId) return null; const name = await getUsername(userId); // Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name. if (name === null) return names.get(userId) ?? null; announce(userId, name); return name; } // ---- Speichern ---------------------------------------------------------------------------------------------- /** * Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der * Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es. * Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst. */ export async function saveUsername(name: string): Promise { const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist const res = await apiPost('/api/profil', { username: name }, { auth: true }); const saved = res.username; // Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen. if (typeof saved !== 'string' || saved === '') throw unavailableError(); if (userId) announce(userId, saved); return saved; } // ---- „In dieser Sitzung schon gefragt“ --------------------------------------------------------------------- /** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */ const PROMPTED_KEY = 'aircard.usernamePrompted'; /** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */ let promptedInMemory = false; /** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */ export function usernamePrompted(): boolean { if (promptedInMemory) return true; try { return sessionStorage.getItem(PROMPTED_KEY) === '1'; } catch { return false; } } /** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */ export function markUsernamePrompted(): void { promptedInMemory = true; try { sessionStorage.setItem(PROMPTED_KEY, '1'); } catch { // gesperrt oder voll: es bleibt beim Merker im Speicher } } /** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */ export function clearUsernamePrompted(): void { promptedInMemory = false; try { sessionStorage.removeItem(PROMPTED_KEY); } catch { // gesperrt: nichts zu entfernen } }