diff --git a/.env.example b/.env.example index bca0a96..1dfb981 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,32 @@ # Kopie als .env neben die compose.yaml legen und Werte ersetzen. -# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen. +# Passwort des Datenbank-Nutzers. Lang und zufällig wählen. # Achtung: Ein $ im Wert wird von Docker Compose als Variable gedeutet und # verschluckt. Entweder $ vermeiden oder als $$ schreiben. -DB_PASSWORD=hier-ein-langes-zufaelliges-passwort +DB_PASSWORD=hier-ein-langes-zufälliges-passwort -# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen. +# Schlüssel für Sitzungen und CSRF-Token, mindestens 32 Zeichen. # Erzeugen z. B. mit: openssl rand -base64 48 SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen # Erster Administrator, wird nur angelegt solange noch kein Konto existiert. # Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig -# ins Container-Log. In beiden Faellen ist ein Wechsel beim ersten Login Pflicht. +# ins Container-Log. In beiden Fällen ist ein Wechsel beim ersten Login Pflicht. BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_PASSWORD= # --- Optional --- -# Name des Docker-Netzwerks, in dem der Reverse Proxy laeuft. Der Proxy legt es +# Name des Docker-Netzwerks, in dem der Reverse Proxy läuft. Der Proxy legt es # an; bei Nginx Proxy Manager heisst es meist nginx_default. # PROXY_NETWORK=nginx_default -# Adresse der GExtension-Instanz fuer den Paket-Abgleich. Der Parser ist auf -# deren Seitenaufbau gebaut; ein anderes Shop-System laesst sich damit nicht +# Adresse der GExtension-Instanz für den Paket-Abgleich. Der Parser ist auf +# deren Seitenaufbau gebaut; ein anderes Shop-System lässt sich damit nicht # auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird. # GEXTENSION_BASE_URL=https://gextension.senseigaming.de -# Aufbewahrung der Audit-Eintraege in Tagen (Standard: 365, 0 = nie loeschen). +# Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen). # AUDIT_RETENTION_DAYS=365 # Gueltigkeit einer Sitzung in Stunden (Standard: 12). diff --git a/Dockerfile b/Dockerfile index 337069f..75e2cdc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # --- Build --- FROM node:22-slim AS build -# Prisma benoetigt OpenSSL fuer seine Query-Engine. +# Prisma benötigt OpenSSL für seine Query-Engine. RUN apt-get update && apt-get install -y --no-install-recommends openssl \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index e8ba2f6..25fb8d8 100644 --- a/README.md +++ b/README.md @@ -223,9 +223,11 @@ Image: Fastify liefert die API und die fertigen Frontend-Dateien aus. Nach Änderungen am Datenmodell: ```bash -npm run migrate:dev -- --name beschreibung-der-aenderung +npm run migrate:dev -- --name kurze-beschreibung ``` +Der Name wird zum Verzeichnisnamen der Migration — dort also besser bei ASCII bleiben. + ## Sicherheit - Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen. diff --git a/compose.yaml b/compose.yaml index d5d920a..591a929 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,7 +12,7 @@ services: BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} TRUST_PROXY: "true" - # Nur noetig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll. + # Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll. GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de} AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} expose: @@ -66,7 +66,7 @@ networks: # # Ohne Reverse Proxy in Docker: diesen Block und die networks-Liste beim # app-Service entfernen und stattdessen einen Port mappen (ports: "3000:3000"). - # Dann aber selbst fuer HTTPS sorgen - die App gehoert nicht ungeschuetzt ins Netz. + # Dann aber selbst für HTTPS sorgen - die App gehört nicht ungeschuetzt ins Netz. proxy: external: true name: ${PROXY_NETWORK:-nginx_default} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 09d6198..2d31be8 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,7 +2,7 @@ set -e # Schema aktuell halten, bevor die App startet. Bereits angewandte -# Migrationen werden dabei uebersprungen. +# Migrationen werden dabei übersprungen. echo "Wende Datenbank-Migrationen an ..." npx prisma migrate deploy --schema server/prisma/schema.prisma diff --git a/server/.env.example b/server/.env.example index 8ac53a7..ce31044 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,4 +1,4 @@ -# Nur fuer die lokale Entwicklung. Als server/.env kopieren. +# Nur für die lokale Entwicklung. Als server/.env kopieren. # Im Container kommen diese Werte aus der compose.yaml. DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager @@ -7,7 +7,7 @@ SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen NODE_ENV=development PORT=3000 -# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen. +# Ohne HTTPS müssen die Cookies unsicher ausgeliefert werden dürfen. COOKIE_SECURE=false # Erstadministrator beim ersten Start. diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 32c750b..1574913 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -96,7 +96,7 @@ model Player { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - /// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich, + /// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich, /// wird nie automatisch mit dem Ledger verrechnet. gextensionUsage Int? gextensionSyncedAt DateTime? @@ -110,7 +110,7 @@ model Player { } /// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner -/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht, +/// Guthaben-Art sicher zuordnen lässt. Wird nie automatisch gebucht, /// sondern im CC-Manager von Hand entschieden. model UnmatchedPackage { id Int @id @default(autoincrement()) @@ -146,7 +146,7 @@ model CreditLedger { source CreditSource /// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen - /// mit derselben groupId, damit ein Storno beide zurueckholt. + /// mit derselben groupId, damit ein Storno beide zurückholt. groupId String @default(uuid()) characterId Int? @@ -157,7 +157,7 @@ model CreditLedger { note String? - /// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique + /// Storno erfolgt als Gegenbuchung, nicht als Löschung. Das @unique /// verhindert, dass eine Zeile zweimal storniert wird. reversalOfId Int? @unique reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -181,7 +181,7 @@ model Character { loreText String? status CharacterStatus @default(ACTIVE) inactiveReason InactiveReason? - /// Klartext zum Grund, etwa "Einheit bis Maerz entfernt". Nur zur Anzeige. + /// Klartext zum Grund, etwa "Einheit bis März entfernt". Nur zur Anzeige. inactiveNote String? /// Alle CCs einer Redesign-Kette teilen sich die lineageId. @@ -237,7 +237,7 @@ model ImportRow { target ProductTarget? resolvedPlayerId Int? - /// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK, + /// Was beim Buchen passieren würde: BOOK, CREATE_PLAYER_AND_BOOK, /// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER plan String raw Json diff --git a/server/src/app.ts b/server/src/app.ts index a532a0f..02fc324 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -106,7 +106,7 @@ export async function buildApp(): Promise { if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) { reply .code(statusCode) - .send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' }); + .send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' }); return; } @@ -128,17 +128,17 @@ export async function buildApp(): Promise { await app.register(fastifyStatic, { root: webRoot, prefix: '/', - // maxAge wuerde den Header fuer *alle* Dateien setzen und dabei auch die + // maxAge würde den Header für *alle* Dateien setzen und dabei auch die // index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden, - // sonst zeigt der Browser nach einem Update monatelang die alte Oberflaeche. + // sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche. // Deshalb hier abschalten und beides unten selbst setzen. cacheControl: false, setHeaders: (res, path) => { if (path.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); } else { - // Assets tragen einen Inhalts-Hash im Namen: aendert sich der Inhalt, - // aendert sich der Dateiname. Die duerfen beliebig lange liegen. + // Assets tragen einen Inhalts-Hash im Namen: ändert sich der Inhalt, + // ändert sich der Dateiname. Die dürfen beliebig lange liegen. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } }, diff --git a/server/src/auth/guard.ts b/server/src/auth/guard.ts index 20c9c74..9f43568 100644 --- a/server/src/auth/guard.ts +++ b/server/src/auth/guard.ts @@ -15,7 +15,7 @@ const RANK: Record = { VIEWER: 1, EDITOR: 2, ADMIN: 3 }; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); -// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es +// Ohne diese Ausnahme könnte sich niemand anmelden: vor dem Login gibt es // weder Session noch ableitbares CSRF-Token. const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']); @@ -63,7 +63,7 @@ export const blockUntilPasswordChanged: preHandlerHookHandler = async (request) if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) { const path = request.url.split('?')[0] ?? ''; if (path !== '/api/account/password' && path !== '/api/auth/logout') { - throw forbidden('Bitte zuerst das Passwort aendern'); + throw forbidden('Bitte zuerst das Passwort ändern'); } } }; diff --git a/server/src/auth/password.ts b/server/src/auth/password.ts index 8236069..441d32d 100644 --- a/server/src/auth/password.ts +++ b/server/src/auth/password.ts @@ -1,6 +1,6 @@ import { hash, verify } from '@node-rs/argon2'; -// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1. +// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1. const options = { memoryCost: 19456, timeCost: 2, @@ -27,7 +27,7 @@ export function validatePasswordStrength(plain: string): string | null { return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`; } if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) { - return 'Passwort muss Gross- und Kleinbuchstaben enthalten'; + return 'Passwort muss Groß- und Kleinbuchstaben enthalten'; } if (!/[0-9]/.test(plain)) { return 'Passwort muss mindestens eine Ziffer enthalten'; diff --git a/server/src/auth/session.ts b/server/src/auth/session.ts index a40defd..68005da 100644 --- a/server/src/auth/session.ts +++ b/server/src/auth/session.ts @@ -102,7 +102,7 @@ export async function destroyAllSessionsFor(userId: number): Promise { await prisma.session.deleteMany({ where: { userId } }); } -/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */ +/** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */ export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise { await prisma.session.deleteMany({ where: { userId, NOT: { tokenHash: hashToken(keepToken) } }, diff --git a/server/src/auth/totp.ts b/server/src/auth/totp.ts index 706de62..f38cb05 100644 --- a/server/src/auth/totp.ts +++ b/server/src/auth/totp.ts @@ -3,7 +3,7 @@ import QRCode from 'qrcode'; const ISSUER = 'CC-Manager'; -// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht +// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht // abweichende Uhren zwischen Server und Authenticator-App ab. const EPOCH_TOLERANCE_SECONDS = 30; diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 1b1495b..e4ee3b8 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -4,10 +4,10 @@ import { prisma } from './db.js'; import { env } from './env.js'; import { hashPassword } from './auth/password.js'; -// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich +// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich // anpassbar, sobald die echten Produktnamen bekannt sind. -// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide -// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht +// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide +// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht // das Redesign-Muster vorn. const DEFAULT_MAPPINGS = [ { matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 }, @@ -52,7 +52,7 @@ export async function bootstrap(log: { if (!env.BOOTSTRAP_ADMIN_PASSWORD) { log.warn(`Einmal-Passwort: ${password}`); } - log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.'); + log.warn('Das Passwort muss bei der ersten Anmeldung geändert werden.'); log.warn('─'.repeat(60)); } diff --git a/server/src/env.ts b/server/src/env.ts index c9ded68..e4a40fd 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -15,23 +15,23 @@ const schema = z.object({ BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'), BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(), - // Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen. + // Hinter einem Reverse Proxy nötig, damit Client-IPs im Audit-Log stimmen. TRUST_PROXY: z .string() .default('true') .transform((v) => v === 'true' || v === '1'), - // Muss in Produktion true sein (Cookies nur ueber HTTPS). + // Muss in Produktion true sein (Cookies nur über HTTPS). COOKIE_SECURE: z .string() .optional() .transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')), // Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau - // zugeschnitten; eine voellig andere Software liesse sich damit nicht lesen. + // zugeschnitten; eine völlig andere Software ließe sich damit nicht lesen. GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'), - // Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab. + // Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab. // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), @@ -42,7 +42,7 @@ const parsed = schema.safeParse(process.env); if (!parsed.success) { const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n'); - console.error(`Konfiguration unvollstaendig:\n${issues}`); + console.error(`Konfiguration unvollständig:\n${issues}`); process.exit(1); } diff --git a/server/src/errors.ts b/server/src/errors.ts index 88e5955..4df88d0 100644 --- a/server/src/errors.ts +++ b/server/src/errors.ts @@ -12,7 +12,7 @@ export class AppError extends Error { export const badRequest = (code: string, message: string) => new AppError(400, code, message); export const unauthorized = (message = 'Nicht angemeldet') => new AppError(401, 'UNAUTHORIZED', message); -export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') => +export const forbidden = (message = 'Keine Berechtigung für diese Aktion') => new AppError(403, 'FORBIDDEN', message); export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`); export const conflict = (code: string, message: string) => new AppError(409, code, message); diff --git a/server/src/index.ts b/server/src/index.ts index 7274972..973d63e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -27,13 +27,13 @@ async function main(): Promise { .then((count) => { if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`); }) - .catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen')); + .catch((error) => app.log.warn({ err: error }, 'Aufräumen der Sitzungen fehlgeschlagen')); cleanupOldAuditEntries() .then((count) => { - if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`); + if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`); }) - .catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen')); + .catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen')); }, CLEANUP_INTERVAL_MS); cleanup.unref(); diff --git a/server/src/lib/gextension.ts b/server/src/lib/gextension.ts index f5f048b..798248a 100644 --- a/server/src/lib/gextension.ts +++ b/server/src/lib/gextension.ts @@ -4,9 +4,9 @@ import { normalizeSteamId } from './steamid.js'; /** * Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die - * Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON + * Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON * gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster - * gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag + * gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag * und den Storno-Status. */ @@ -91,11 +91,11 @@ export type Classification = | { kind: 'unclear' } | { kind: 'ignore' }; -// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete. +// Nur der SCP-RP-Bereich führt CC-relevante Pakete. const RELEVANT_BUNDLE = 'scp-rp'; -// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner -// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten +// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner +// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten // zu werden. const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i; @@ -104,8 +104,8 @@ const USAGE_TIMES = /\|\s*(\d+)\s*mal/i; /** * Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist - * bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als - * Redesign zaehlen, nicht als CC-Paket. + * bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als + * Redesign zählen, nicht als CC-Paket. */ export function classifyPackage(pkg: ScrapedPackage): Classification { if (pkg.revoked) return { kind: 'ignore' }; @@ -132,11 +132,11 @@ export function classifyPackage(pkg: ScrapedPackage): Classification { return { kind: 'ignore' }; } -// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension +// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft GExtension // bei einer anderen Community unter eigener Domain, reicht ein anderer Wert. export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de'; -// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde. +// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde. const MIN_REQUEST_GAP_MS = 1000; let lastRequestAt = 0; @@ -156,7 +156,7 @@ export async function fetchProfile( options: FetchOptions = {}, ): Promise { const steamId64 = normalizeSteamId(steamId); - if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`); + if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gültige SteamID: ${steamId}`); const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const timeoutMs = options.timeoutMs ?? 15000; @@ -171,7 +171,7 @@ export async function fetchProfile( signal: controller.signal, headers: { // Ehrliche Kennzeichnung statt Browser-Tarnung. - 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)', + 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)', accept: 'text/html', }, }); @@ -183,7 +183,7 @@ export async function fetchProfile( return parseProfile(await response.text(), steamId64); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils'); + throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils'); } throw error; } finally { diff --git a/server/src/lib/steamid.ts b/server/src/lib/steamid.ts index fe2c19e..1ab891e 100644 --- a/server/src/lib/steamid.ts +++ b/server/src/lib/steamid.ts @@ -16,7 +16,7 @@ const STEAM3 = /^\[?U:1:(\d+)\]?$/i; const STEAM64 = /^\d{17}$/; /** - * Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert + * Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert * nach keiner bekannten Steam-Schreibweise aussieht. */ export function normalizeSteamId(input: string): string | null { @@ -37,14 +37,14 @@ export function normalizeSteamId(input: string): string | null { if (STEAM64.test(value)) { const numeric = BigInt(value); - // Alles unterhalb des Offsets waere keine Nutzer-ID. + // Alles unterhalb des Offsets wäre keine Nutzer-ID. return numeric > BASE ? numeric.toString() : null; } return null; } -/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */ +/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */ export function toLegacySteamId(steamId64: string): string | null { if (!STEAM64.test(steamId64)) return null; diff --git a/server/src/routes/admin.routes.ts b/server/src/routes/admin.routes.ts index 64533e9..3ffbe3d 100644 --- a/server/src/routes/admin.routes.ts +++ b/server/src/routes/admin.routes.ts @@ -160,7 +160,7 @@ export async function adminRoutes(app: FastifyInstance): Promise { app.delete('/api/admin/users/:id', async (request) => { const { id } = parseWith(idParam, request.params); if (id === request.user?.id) { - throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden'); + throw conflict('SELF_DELETE', 'Der eigene Account kann nicht gelöscht werden'); } const target = await prisma.user.findUnique({ where: { id } }); diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index dec7dd6..580c907 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -77,7 +77,7 @@ export async function authRoutes(app: FastifyInstance): Promise { sameSite: 'lax', expires: expiresAt, }); - // Muss fuer das Double-Submit-Verfahren lesbar sein. + // Muss für das Double-Submit-Verfahren lesbar sein. reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), { path: '/', httpOnly: false, @@ -91,7 +91,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const body = parseWith(loginSchema, request.body); const user = await prisma.user.findUnique({ where: { username: body.username } }); - // Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet. + // Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät. const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const passwordOk = await verifyPassword(digest, body.password); @@ -250,7 +250,7 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => { const secret = generateSecret(); - // Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus. + // Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus. await prisma.user.update({ where: { id: request.user!.id }, data: { totpSecret: secret, totpEnabled: false }, diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 90e982d..4894bda 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -87,7 +87,7 @@ export async function characterRoutes(app: FastifyInstance): Promise { app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith( - z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }), + z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }), request.body, ); return revertCharacter(id, body.reason, contextOf(request)); diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index b897d95..9226fdc 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -42,7 +42,7 @@ export async function creditRoutes(app: FastifyInstance): Promise { z.object({ kind: z.nativeEnum(CreditKind), amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'), - note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500), + note: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500), }), request.body, ); @@ -52,7 +52,7 @@ export async function creditRoutes(app: FastifyInstance): Promise { app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith( - z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }), + z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }), request.body, ); return reverseGroup(id, body.reason, contextOf(request)); diff --git a/server/src/routes/helpers.ts b/server/src/routes/helpers.ts index 102af36..7390d49 100644 --- a/server/src/routes/helpers.ts +++ b/server/src/routes/helpers.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { badRequest, unauthorized } from '../errors.js'; import type { ActionContext } from '../services/audit.js'; -// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt. +// z.output statt z.input: nur so zählen .default()-Werte als gesetzt. export function parseWith(schema: S, input: unknown): z.output { const result = schema.safeParse(input); if (!result.success) { diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts index cc85334..65205c9 100644 --- a/server/src/routes/imports.routes.ts +++ b/server/src/routes/imports.routes.ts @@ -14,16 +14,16 @@ import { import { contextOf, idParam, parseWith } from './helpers.js'; const mappingBody = z.object({ - playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'), + playerKey: z.string().min(1, 'Spalte für die Spieler-Kennung fehlt'), playerName: z.string().optional(), - product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'), + product: z.string().min(1, 'Spalte für den Produktnamen fehlt'), orderId: z.string().optional(), purchasedAt: z.string().optional(), createMissingPlayers: z.boolean().default(false), }); export async function importRoutes(app: FastifyInstance): Promise { - // Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten. + // Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten. app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => { const file = await request.file(); if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen'); diff --git a/server/src/routes/scraper.routes.ts b/server/src/routes/scraper.routes.ts index 4912de6..9b768c0 100644 --- a/server/src/routes/scraper.routes.ts +++ b/server/src/routes/scraper.routes.ts @@ -15,7 +15,7 @@ import { contextOf, idParam, parseWith } from './helpers.js'; export async function scraperRoutes(app: FastifyInstance): Promise { /** * Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die - * bekannten: der Abgleich soll vorhandene Konten ergaenzen, nicht fremde + * bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde * Profile auf Vorrat einsammeln. */ app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => { diff --git a/server/src/services/audit.ts b/server/src/services/audit.ts index eebe6c7..c03d782 100644 --- a/server/src/services/audit.ts +++ b/server/src/services/audit.ts @@ -3,7 +3,7 @@ import { prisma } from '../db.js'; import { env } from '../env.js'; export interface ActionContext { - // Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen + // Beide null für Vorgänge ohne bekanntes Konto, etwa einen fehlgeschlagenen // Login auf einen Benutzernamen, den es gar nicht gibt. actorId: number | null; actorName: string | null; @@ -38,9 +38,9 @@ export async function recordAudit( } /** - * Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die - * Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer - * bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll. + * Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die + * Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für + * bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll. */ export async function cleanupOldAuditEntries(): Promise { if (env.AUDIT_RETENTION_DAYS === 0) return 0; diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index d87a22a..d715a17 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -15,13 +15,13 @@ export interface CharacterResult { character: Character; balances: Balances; // Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird - // trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht. + // trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht. overdrawn: boolean; } -// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe. +// Für ein Redesign zählen nur die beiden Redesign-Töpfe. const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const; -// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden. +// Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden. const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const; export type RedesignKind = (typeof REDESIGN_KINDS)[number]; @@ -37,7 +37,7 @@ async function loadCharacter(id: number): Promise { return character; } -/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */ +/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */ export async function createCharacter( playerId: number, input: { name: string; loreText?: string | undefined; note?: string | undefined }, @@ -87,8 +87,8 @@ export async function createCharacter( /** * Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue - * entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter - * automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt. + * entsteht als eigener Datensatz in derselben Kette. Damit greift später + * automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt. */ export async function redesignCharacter( characterId: number, @@ -165,7 +165,7 @@ export async function redesignCharacter( }; } -/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */ +/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */ export async function deactivateCharacter( characterId: number, reason: InactiveReason, @@ -179,7 +179,7 @@ export async function deactivateCharacter( if (reason === InactiveReason.REWORKED) { throw badRequest( 'USE_REDESIGN', - 'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung', + 'Ein Rework entsteht über die Redesign-Aktion, nicht über eine Deaktivierung', ); } @@ -208,9 +208,9 @@ export async function deactivateCharacter( } /** - * Gratis-Reaktivierung. Moeglich, solange der CC nicht redesignt wurde. Der - * Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivitaet - * oder eine zeitweise entfernte Einheit: der CC existiert unveraendert weiter + * Gratis-Reaktivierung. Möglich, solange der CC nicht redesignt wurde. Der + * Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivität + * oder eine zeitweise entfernte Einheit: der CC existiert unverändert weiter * und kostet deshalb kein Guthaben. */ export async function reactivateFree( @@ -250,7 +250,7 @@ export async function reactivateFree( } /** - * Bezahlte Reaktivierung, ausschliesslich fuer redesignte CCs: deren Original + * Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original * existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein * Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches. */ @@ -268,11 +268,11 @@ export async function reactivatePaid( if (existing.inactiveReason !== InactiveReason.REWORKED) { throw conflict( 'IS_FREE', - 'Dieser CC existiert unveraendert weiter und kommt gratis zurueck. Eine Guthaben-Buchung waere hier falsch.', + 'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.', ); } if (!REVIVE_KINDS.includes(useKind)) { - throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung'); + throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung'); } const character = await prisma.$transaction(async (tx) => { @@ -341,9 +341,9 @@ export async function updateCharacter( } /** - * Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen - * werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger - * kehrt in den aktiven Zustand zurueck. + * Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen + * werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger + * kehrt in den aktiven Zustand zurück. */ export async function revertCharacter( characterId: number, @@ -359,7 +359,7 @@ export async function revertCharacter( if (existing.successors.length > 0) { throw conflict( 'HAS_SUCCESSORS', - 'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.', + 'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.', ); } @@ -384,7 +384,7 @@ export async function revertCharacter( amount: -entry.amount, source: CreditSource.REVERSAL, groupId: entry.groupId, - note: `Ruecknahme "${existing.name}": ${reason}`, + note: `Rücknahme "${existing.name}": ${reason}`, reversalOfId: entry.id, createdById: ctx.actorId, }, diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 16ed729..22837fb 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -8,7 +8,7 @@ export interface Balances { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number; - // Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen. + // Für ein Redesign zählen beide Redesign-Töpfe zusammen. redesignTotal: number; } @@ -72,8 +72,8 @@ export interface PackageRowParams { /** * Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket - * immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle - * Eingabe und Import nicht auseinanderlaufen koennen. + * immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle + * Eingabe und Import nicht auseinanderlaufen können. */ export function buildPackageRows( target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE', @@ -102,7 +102,7 @@ export function buildPackageRows( /** * Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes * Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie - * gemeinsam zurueckholt. + * gemeinsam zurückholt. */ export async function bookCcPackage( playerId: number, @@ -196,7 +196,7 @@ export async function bookKulanz( return { groupId, balances: await getBalances(playerId) }; } -/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */ +/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */ export async function bookAdjustment( playerId: number, kind: CreditKind, @@ -236,9 +236,9 @@ export async function bookAdjustment( } /** - * Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie - * bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden - * ueber die zugehoerige CC-Aktion zurueckgenommen. + * Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie + * bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden + * über die zugehörige CC-Aktion zurückgenommen. */ export async function reverseGroup( ledgerId: number, @@ -251,7 +251,7 @@ export async function reverseGroup( if (anchor.source === CreditSource.CONSUME) { throw conflict( 'CONSUME_NOT_REVERSIBLE', - 'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert', + 'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert', ); } if (anchor.source === CreditSource.REVERSAL) { diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index 252df19..9ace8fb 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -34,7 +34,7 @@ export interface ParsedFile { const MAX_ROWS = 20000; -// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma. +// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma. function detectDelimiter(sample: string): string { const firstLine = sample.split(/\r?\n/, 1)[0] ?? ''; const candidates = [',', ';', '\t', '|']; @@ -78,7 +78,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile { } catch { throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden'); } - // Manche Exporte verpacken die Liste in ein Huellobjekt. + // Manche Exporte verpacken die Liste in ein Hüllobjekt. const list = Array.isArray(data) ? data : typeof data === 'object' && data !== null @@ -86,7 +86,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile { : undefined; if (!Array.isArray(list)) { - throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen'); + throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen'); } rows = list .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) @@ -107,7 +107,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile { } } - if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen'); + if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen'); if (rows.length > MAX_ROWS) { throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`); } @@ -165,9 +165,9 @@ function parseDate(value: string | undefined): Date | null { } /** - * Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich - * zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse, - * Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft, + * Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsächlich + * zugeordnet wurden. Ein Shop-Export enthält oft deutlich mehr - Mailadresse, + * Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Läuft, * sobald ein Import abgeschlossen oder verworfen ist. */ async function pruneRawRows( @@ -204,8 +204,8 @@ async function pruneRawRows( } /** - * Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der - * Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt. + * Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der + * Bestätigung angezeigt und beim Commit unverändert ausgeführt. */ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) { const record = await prisma.import.findUnique({ @@ -229,7 +229,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx raw: row.raw as Record, })); - // Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt. + // Die Datei kann eine andere Schreibweise liefern als die Datenbank führt. const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean); const playerKeys = [ ...new Set(rawKeys.flatMap((key) => { @@ -262,7 +262,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx : [], ); - // Faengt Dubletten innerhalb derselben Datei ab. + // Fängt Dubletten innerhalb derselben Datei ab. const seenInFile = new Set(); const updates: Prisma.PrismaPromise[] = []; const summary: Record = { @@ -356,7 +356,7 @@ export async function getImport(importId: number, includeRows = true) { return record; } -/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */ +/** Führt den zuvor angezeigten Plan aus. Alles oder nichts. */ export async function commitImport(importId: number, ctx: ActionContext) { const record = await prisma.import.findUnique({ where: { id: importId }, @@ -375,7 +375,7 @@ export async function commitImport(importId: number, ctx: ActionContext) { } const result = await prisma.$transaction(async (tx) => { - // Mehrere Zeilen koennen denselben neuen Spieler betreffen. + // Mehrere Zeilen können denselben neuen Spieler betreffen. const createdPlayers = new Map(); let booked = 0; diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 030aebd..fef762a 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -90,7 +90,7 @@ export async function getPlayerDetail(playerId: number) { /** * Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe - * Spieler zwei Datensaetze an, sobald eine Quelle STEAM_0:... liefert und die + * Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die * andere die 64-Bit-Zahl. */ function canonicalSteamId(input: string | null | undefined): string | null { @@ -169,10 +169,10 @@ export async function updatePlayer(playerId: number, input: Partial } /** - * Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den - * Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt, - * damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt. - * Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt. + * Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den + * Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt, + * damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt. + * Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt. */ export async function deletePlayer(playerId: number, ctx: ActionContext): Promise { const existing = await prisma.player.findUnique({ @@ -198,7 +198,7 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis }, }); - // Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt. + // Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt. await tx.auditLog.updateMany({ where: { targetType: 'player', targetId: String(playerId) }, data: { details: { redacted: true } }, @@ -210,8 +210,8 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis action: 'player.delete', targetType: 'player', targetId: playerId, - // Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen, - // nicht die geloeschten Daten konservieren. + // Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen, + // nicht die gelöschten Daten konservieren. details: { removedCharacters: existing._count.characters, removedLedgerRows: existing._count.ledger, diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index 128c94c..a5f0e60 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -36,8 +36,8 @@ export interface ScrapeSummary { const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`; /** - * Holt die Profile der uebergebenen Spieler und legt daraus einen Import an, - * der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts + * Holt die Profile der übergebenen Spieler und legt daraus einen Import an, + * der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts * automatisch gebucht. */ export async function scrapePlayers( @@ -170,7 +170,7 @@ export async function scrapePlayers( return summary; } -// --- Handpruefung der unklaren Eintraege --- +// --- Handprüfung der unklaren Einträge --- export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE'; @@ -182,7 +182,7 @@ export async function listUnmatched(onlyOpen = true) { }); } -/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */ +/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */ export async function unmatchedOverview() { const open = await prisma.unmatchedPackage.findMany({ where: { resolved: false }, @@ -208,7 +208,7 @@ export async function unmatchedOverview() { } /** - * Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht + * Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht * sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch * ein Redesign mitbringt. */ diff --git a/server/test/harness.ts b/server/test/harness.ts index ed7a500..c8190f8 100644 --- a/server/test/harness.ts +++ b/server/test/harness.ts @@ -13,7 +13,7 @@ export interface TestDatabase { } /** - * Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen + * Startet eine Postgres-Instanz im Speicher und stellt sie über einen lokalen * Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet. * So laufen die Tests ohne Docker oder installiertes Postgres. */ @@ -26,7 +26,7 @@ export async function startTestDatabase(port: number): Promise { return { // connection_limit=1, weil die Testinstanz immer nur eine Verbindung - // gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht. + // gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht. url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`, stop: async () => { await server.stop(); diff --git a/server/test/http.test.ts b/server/test/http.test.ts index ed3af19..9331a0e 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -81,7 +81,7 @@ describe('Anmeldung', () => { assert.equal(response.statusCode, 401); }); - it('verraet nicht, ob es den Benutzernamen gibt', async () => { + it('verrät nicht, ob es den Benutzernamen gibt', async () => { const unknownUser = await app.inject({ method: 'POST', url: '/api/auth/login', @@ -97,7 +97,7 @@ describe('Anmeldung', () => { assert.equal(unknownUser.json().message, wrongPassword.json().message); }); - it('laesst ein gesperrtes Konto nicht herein', async () => { + it('lässt ein gesperrtes Konto nicht herein', async () => { const response = await app.inject({ method: 'POST', url: '/api/auth/login', @@ -129,7 +129,7 @@ describe('CSRF-Schutz', () => { assert.equal(response.json().error, 'FORBIDDEN'); }); - it('blockt einen gefaelschten Token', async () => { + it('blockt einen gefälschten Token', async () => { const credentials = await login('team'); const response = await app.inject({ @@ -142,7 +142,7 @@ describe('CSRF-Schutz', () => { assert.equal(response.statusCode, 403); }); - it('laesst die Anfrage mit passendem Token durch', async () => { + it('lässt die Anfrage mit passendem Token durch', async () => { const credentials = await login('team'); const response = await app.inject({ @@ -155,7 +155,7 @@ describe('CSRF-Schutz', () => { assert.equal(response.statusCode, 201); }); - it('laesst lesende Anfragen ohne Token zu', async () => { + it('lässt lesende Anfragen ohne Token zu', async () => { const credentials = await login('gast'); const response = await app.inject({ @@ -227,7 +227,7 @@ describe('Erstpasswort', () => { method: 'POST', url: '/api/players', headers: authHeaders(credentials), - payload: { displayName: 'Zu frueh' }, + payload: { displayName: 'Zu früh' }, }); assert.equal(response.statusCode, 403); @@ -251,8 +251,8 @@ describe('Erstpasswort', () => { }); }); -describe('Guthaben ueber die API', () => { - it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => { +describe('Guthaben über die API', () => { + it('ein CC-Paket schlägt in beiden Töpfen auf', async () => { const credentials = await login('team'); const created = await app.inject({ @@ -287,11 +287,11 @@ describe('Guthaben ueber die API', () => { }); }); -describe('Auslieferung der Oberflaeche', () => { +describe('Auslieferung der Oberfläche', () => { // Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die // index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die - // alte Oberflaeche - ohne dass irgendwo ein Fehler auftaucht. - it('laesst die index.html nicht zwischenspeichern', async () => { + // alte Oberfläche - ohne dass irgendwo ein Fehler auftaucht. + it('lässt die index.html nicht zwischenspeichern', async () => { const response = await app.inject({ method: 'GET', url: '/' }); assert.equal(response.statusCode, 200); @@ -309,7 +309,7 @@ describe('Auslieferung der Oberflaeche', () => { assert.match(response.headers['cache-control'] as string, /no-cache/); }); - it('laesst gehashte Assets lange liegen', async () => { + it('lässt gehashte Assets lange liegen', async () => { const page = await app.inject({ method: 'GET', url: '/' }); const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0]; assert.ok(asset, 'die Seite verweist auf ein Asset'); diff --git a/server/test/imports.test.ts b/server/test/imports.test.ts index cfcb904..962ba32 100644 --- a/server/test/imports.test.ts +++ b/server/test/imports.test.ts @@ -14,7 +14,7 @@ let ctx: { actorId: number; actorName: string }; const CSV = [ 'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum', - 'A-1001;76561198000000001;Rabe;CC-Paket Gross;2025-01-15', + 'A-1001;76561198000000001;Rabe;CC-Paket Groß;2025-01-15', 'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16', 'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17', 'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18', @@ -92,7 +92,7 @@ describe('Vorschau und Buchung', () => { const summary = await imports.applyMapping(importId, MAPPING, ctx); - assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gueltigem Produkt'); + assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt'); assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert'); assert.equal(summary.SKIP_DUPLICATE, 0); }); @@ -165,7 +165,7 @@ describe('Vorschau und Buchung', () => { assert.equal(summary.SKIP_DUPLICATE, 1); }); - it('ueberspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => { + it('überspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => { const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n'); const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8')); const importId = await imports.createImport('fremd.csv', parsed, ctx); @@ -184,7 +184,7 @@ describe('Vorschau und Buchung', () => { assert.equal(summary.SKIP_NO_PLAYER, 1); }); - it('ein abgeschlossener Import laesst sich nicht erneut buchen', async () => { + it('ein abgeschlossener Import lässt sich nicht erneut buchen', async () => { const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n'); const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8')); const importId = await imports.createImport('nochmal.csv', parsed, ctx); diff --git a/server/test/privacy.test.ts b/server/test/privacy.test.ts index 81926d1..1fca9ba 100644 --- a/server/test/privacy.test.ts +++ b/server/test/privacy.test.ts @@ -18,7 +18,7 @@ let audit: Audit; let db: Db; let ctx: { actorId: number; actorName: string }; -// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht. +// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht. const CSV = [ 'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart', 'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal', @@ -45,7 +45,7 @@ before(async () => { audit = await import('../src/services/audit.js'); const user = await db.prisma.user.create({ - data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' }, + data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' }, }); ctx = { actorId: user.id, actorName: user.username }; @@ -80,7 +80,7 @@ describe('Datenminimierung beim Import', () => { assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt'); }); - it('raeumt auch einen verworfenen Import auf', async () => { + it('räumt auch einen verworfenen Import auf', async () => { const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8')); const importId = await imports.createImport('shop2.csv', parsed, ctx); await imports.applyMapping(importId, MAPPING, ctx); @@ -116,7 +116,7 @@ describe('Fehlgeschlagene Anmeldung', () => { }); }); -describe('Loeschung eines Spielers', () => { +describe('Löschung eines Spielers', () => { it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => { // Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik. const csv = [ @@ -152,7 +152,7 @@ describe('Loeschung eines Spielers', () => { const auditEntries = await db.prisma.auditLog.findMany({ where: { targetType: 'player', targetId: String(player.id) }, }); - assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar'); + assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar'); for (const entry of auditEntries) { const details = JSON.stringify(entry.details ?? {}); assert.ok( @@ -164,7 +164,7 @@ describe('Loeschung eines Spielers', () => { }); describe('Aufbewahrung des Audit-Logs', () => { - it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => { + it('löscht Einträge jenseits der Frist, neuere bleiben', async () => { const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000); const veraltet = await db.prisma.auditLog.create({ diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts index b3b3b0b..76bb4d7 100644 --- a/server/test/rules.test.ts +++ b/server/test/rules.test.ts @@ -40,7 +40,7 @@ after(async () => { await database.stop(); }); -describe('Guthaben-Toepfe', () => { +describe('Guthaben-Töpfe', () => { it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => { const playerId = await freshPlayer('Paket-Spieler'); const { balances } = await credits.bookCcPackage(playerId, ctx); @@ -58,7 +58,7 @@ describe('Guthaben-Toepfe', () => { assert.equal(balances.REDESIGN_PAID, 1); }); - it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => { + it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => { const playerId = await freshPlayer('Kulanz-Spieler'); const { balances } = await credits.bookKulanz(playerId, ctx); @@ -67,7 +67,7 @@ describe('Guthaben-Toepfe', () => { assert.equal(balances.redesignTotal, 1); }); - it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => { + it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => { const playerId = await freshPlayer('Minus-Spieler'); const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx); @@ -107,7 +107,7 @@ describe('CC-Lebenszyklus', () => { assert.equal( result.character.lineageId, first.character.lineageId, - 'die Kette behaelt ihre lineageId', + 'die Kette behält ihre lineageId', ); assert.equal(result.balances.REDESIGN_PAID, 0); }); @@ -130,7 +130,7 @@ describe('CC-Lebenszyklus', () => { }); describe('Reaktivierung', () => { - it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => { + it('ein wegen Inaktivität entfernter CC kommt gratis zurück', async () => { const playerId = await freshPlayer('Inaktiver'); await credits.bookCcPackage(playerId, ctx); const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx); @@ -144,7 +144,7 @@ describe('Reaktivierung', () => { assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt'); }); - it('ein reworkter CC kommt NICHT gratis zurueck', async () => { + it('ein reworkter CC kommt NICHT gratis zurück', async () => { const playerId = await freshPlayer('Rework-Spieler'); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx); @@ -160,7 +160,7 @@ describe('Reaktivierung', () => { ); }); - it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => { + it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => { const playerId = await freshPlayer('Wiederbeleber'); await credits.bookCcPackage(playerId, ctx); await credits.bookCcPackage(playerId, ctx); @@ -178,7 +178,7 @@ describe('Reaktivierung', () => { assert.equal(result.balances.CC, before.CC - 1); }); - it('auch ein aus anderem Grund entfernter CC kommt gratis zurueck', async () => { + it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => { const playerId = await freshPlayer('Einheit-weg'); await credits.bookCcPackage(playerId, ctx); const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx); @@ -187,19 +187,19 @@ describe('Reaktivierung', () => { created.character.id, 'OTHER', ctx, - 'Einheit bis Maerz entfernt', + 'Einheit bis März entfernt', ); const deactivated = await db.prisma.character.findUniqueOrThrow({ where: { id: created.character.id }, }); - assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz entfernt', 'der Grund bleibt lesbar'); + assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar'); const before = await credits.getBalances(playerId); const result = await characters.reactivateFree(created.character.id, ctx); assert.equal(result.character.status, 'ACTIVE'); - assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurueckholen geloescht'); + assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht'); assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt'); }); @@ -222,7 +222,7 @@ describe('Reaktivierung', () => { }); describe('Korrekturen', () => { - it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => { + it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => { const playerId = await freshPlayer('Storno-Spieler'); const booked = await credits.bookCcPackage(playerId, ctx); @@ -236,7 +236,7 @@ describe('Korrekturen', () => { assert.equal(result.balances.REDESIGN_PAID, 0); }); - it('dieselbe Buchung laesst sich kein zweites Mal stornieren', async () => { + it('dieselbe Buchung lässt sich kein zweites Mal stornieren', async () => { const playerId = await freshPlayer('Doppel-Storno'); const booked = await credits.bookKulanz(playerId, ctx); const entry = await db.prisma.creditLedger.findFirstOrThrow({ @@ -266,8 +266,8 @@ describe('Korrekturen', () => { ); }); - it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => { - const playerId = await freshPlayer('Ruecknehmer'); + it('eine Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => { + const playerId = await freshPlayer('Rücknehmer'); await credits.bookCcPackage(playerId, ctx); const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx); const redesigned = await characters.redesignCharacter( @@ -292,7 +292,7 @@ describe('Korrekturen', () => { assert.equal(restored.inactiveReason, null); }); - it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => { + it('eine Rücknahme mitten in der Kette wird abgelehnt', async () => { const playerId = await freshPlayer('Ketten-Spieler'); await credits.bookCcPackage(playerId, ctx); await credits.bookRedesignPackage(playerId, ctx); @@ -309,7 +309,7 @@ describe('Korrekturen', () => { ); await assert.rejects( - () => characters.revertCharacter(second.character.id, 'Zu spaet', ctx), + () => characters.revertCharacter(second.character.id, 'Zu spät', ctx), (error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS', ); }); diff --git a/server/test/scraper.test.ts b/server/test/scraper.test.ts index b85c049..f82a59d 100644 --- a/server/test/scraper.test.ts +++ b/server/test/scraper.test.ts @@ -39,7 +39,7 @@ describe('Profil auslesen', () => { assert.equal(cc.purchasedAt?.getDate(), 6); }); - it('erkennt stornierte Kaeufe', () => { + it('erkennt stornierte Käufe', () => { const revoked = profile.packages.find((p) => p.packageBoughtId === 9007); assert.equal(revoked?.revoked, true); }); @@ -54,7 +54,7 @@ describe('Profil auslesen', () => { 'donations_data[9004] = {kaputt"id"', ); const result = parseProfile(broken, '76561198000000999'); - assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg'); + assert.equal(result.packages.length, 7, 'nur die kaputte Zeile fällt weg'); }); }); @@ -73,21 +73,21 @@ describe('Pakete zuordnen', () => { }); }); - it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => { + it('zählt "Custom Character Redesign" als Redesign, nicht als CC', () => { assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), { kind: 'package', target: 'REDESIGN_PACKAGE', }); }); - it('liest den Verbrauchszaehler samt Anzahl', () => { + it('liest den Verbrauchszähler samt Anzahl', () => { const result = classifyPackage( pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }), ); assert.deepEqual(result, { kind: 'usage_counter', times: 3 }); }); - it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => { + it('ignoriert Merch und Ränge trotz SCP-RP-Tag', () => { assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore'); assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore'); assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore'); @@ -105,7 +105,7 @@ describe('Pakete zuordnen', () => { assert.equal(result.kind, 'ignore'); }); - it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => { + it('schiebt unklare CC-Titel in die Handprüfung statt zu raten', () => { assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear'); }); }); @@ -116,13 +116,13 @@ describe('SteamID-Schreibweisen', () => { const modern = '[U:1:39735271]'; const sixtyFour = '76561198000000999'; - it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => { + it('führt alle drei Formate auf dieselbe ID zusammen', () => { assert.equal(normalizeSteamId(legacy), sixtyFour); assert.equal(normalizeSteamId(modern), sixtyFour); assert.equal(normalizeSteamId(sixtyFour), sixtyFour); }); - it('rechnet zurueck in die Legacy-Form', () => { + it('rechnet zurück in die Legacy-Form', () => { assert.equal(toLegacySteamId(sixtyFour), legacy); }); diff --git a/web/src/components/BalanceCards.tsx b/web/src/components/BalanceCards.tsx index 38beed9..6356e56 100644 --- a/web/src/components/BalanceCards.tsx +++ b/web/src/components/BalanceCards.tsx @@ -24,11 +24,11 @@ export default function BalanceCards({ balances }: { balances: Balances }) {
- {canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'} + {canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'möglich' : 'nicht möglich'} - {canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'} - {canRedesign && ` (${balances.redesignTotal} verfuegbar)`} + {canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'} + {canRedesign && ` (${balances.redesignTotal} verfügbar)`}
diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 01a8dd7..554e823 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -28,7 +28,7 @@ export default function Modal({ title, onClose, children, footer }: ModalProps) >

{title}

-
diff --git a/web/src/components/UnmatchedBox.tsx b/web/src/components/UnmatchedBox.tsx index d83d961..a3ec7fc 100644 --- a/web/src/components/UnmatchedBox.tsx +++ b/web/src/components/UnmatchedBox.tsx @@ -47,10 +47,10 @@ export default function UnmatchedBox({ return (
-

Ungeklaerte Eintraege ({items.length})

+

Ungeklärte Einträge ({items.length})

Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign, - liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du + ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du sie hier einordnest. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 35c4d36..ca4b63b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -62,7 +62,7 @@ export async function api(path: string, options: RequestOptions = {}): Promis return payload as T; } -/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */ +/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */ export async function uploadFile(path: string, file: File): Promise { const form = new FormData(); form.append('file', file); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index a0734aa..66b08f6 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -16,7 +16,7 @@ export const SOURCE_LABEL: Record = { }; export const REASON_LABEL: Record = { - PLAYER_INACTIVITY: 'Spieler-Inaktivitaet', + PLAYER_INACTIVITY: 'Spieler-Inaktivität', REWORKED: 'Redesignt', OTHER: 'Anderer Grund', }; diff --git a/web/src/lib/session.tsx b/web/src/lib/session.tsx index b45ffca..f004ee3 100644 --- a/web/src/lib/session.tsx +++ b/web/src/lib/session.tsx @@ -58,6 +58,6 @@ export function SessionProvider({ children }: { children: ReactNode }) { export function useSession(): SessionState { const context = useContext(SessionContext); - if (!context) throw new Error('useSession benoetigt einen SessionProvider'); + if (!context) throw new Error('useSession benötigt einen SessionProvider'); return context; } diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx index 5acfb9a..705fd6d 100644 --- a/web/src/pages/Account.tsx +++ b/web/src/pages/Account.tsx @@ -23,7 +23,7 @@ export default function Account() { setError(null); if (next !== repeat) { - setError('Die beiden neuen Passwoerter stimmen nicht ueberein'); + setError('Die beiden neuen Passwörter stimmen nicht überein'); return; } @@ -37,10 +37,10 @@ export default function Account() { setNext(''); setRepeat(''); await refresh(); - setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.'); + setFlash('Passwort geändert. Andere Sitzungen wurden beendet.'); if (user?.mustChangePassword) navigate('/', { replace: true }); } catch (err) { - setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen'); + setError(err instanceof ApiError ? err.message : 'Änderung fehlgeschlagen'); } finally { setBusy(false); } @@ -97,8 +97,8 @@ export default function Account() { {user?.mustChangePassword && ( - Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die - uebrigen Seiten sind bis dahin gesperrt. + Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern — die + übrigen Seiten sind bis dahin gesperrt. )} @@ -106,7 +106,7 @@ export default function Account() { {flash && setFlash(null)}>{flash}}
-

Passwort aendern

+

Passwort ändern

- Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer. + Mindestens 12 Zeichen, Groß- und Kleinbuchstaben sowie eine Ziffer.

@@ -142,7 +142,7 @@ export default function Account() { />
@@ -152,7 +152,7 @@ export default function Account() { {user?.totpEnabled ? ( <> - Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv. + Zwei-Faktor-Anmeldung ist für dieses Konto aktiv. @@ -164,7 +164,7 @@ export default function Account() {

QR-Code zur Einrichtung

- Falls Scannen nicht geht, Schluessel manuell eintragen:{' '} + Falls Scannen nicht geht, Schlüssel manuell eintragen:{' '} {totpSetup.secret}

@@ -190,7 +190,7 @@ export default function Account() { ) : ( <>

- Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen. + Schützt das Konto zusätzlich mit einem Einmalcode. Für Administratoren empfohlen.

)} @@ -280,8 +280,8 @@ function CreateUserModal({ required />

- Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login - geaendert werden. + Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login + geändert werden.

@@ -342,13 +342,13 @@ function MappingsTab({ onError, onDone }: TabProps) { }; const remove = async (row: ProductMapping) => { - if (!window.confirm(`Mapping "${row.matchPattern}" loeschen?`)) return; + if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return; try { await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' }); await load(); - onDone('Mapping geloescht.'); + onDone('Mapping gelöscht.'); } catch (err) { - onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen'); + onError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen'); } }; @@ -356,8 +356,8 @@ function MappingsTab({ onError, onDone }: TabProps) {

Produkt-Mappings

- Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und - Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt. + Beim Import wird der Produktname gegen diese Muster geprüft (Teiltreffer, Groß- und + Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Priorität gewinnt.
@@ -373,7 +373,7 @@ function MappingsTab({ onError, onDone }: TabProps) {
@@ -383,7 +383,7 @@ function MappingsTab({ onError, onDone }: TabProps) { Muster Bedeutung - Prioritaet + Priorität Aktiv @@ -401,7 +401,7 @@ function MappingsTab({ onError, onDone }: TabProps) { className="btn btn-tiny btn-danger" onClick={() => void remove(row)} > - Loeschen + Löschen @@ -489,7 +489,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) { {items.length === 0 && ( - Keine Eintraege. + Keine Einträge. )} @@ -499,7 +499,7 @@ function AuditTab({ onError }: { onError: (message: string) => void }) { {total > items.length && (

- {items.length} von {total} Eintraegen angezeigt. + {items.length} von {total} Einträgen angezeigt.

)}
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index cad3b3e..37ef463 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -49,7 +49,7 @@ export default function Dashboard() { try { setFlagged(await api('/api/scrape/unmatched/overview')); } catch { - // Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stoeren. + // Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stören. } }, []); @@ -62,9 +62,9 @@ export default function Dashboard() { setSyncResult(null); try { const result = await api('/api/scrape/run', { method: 'POST', body: {} }); - const parts = [`${result.scannedPlayers} Profile geprueft`]; - if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestaetigung`); - if (result.unclear > 0) parts.push(`${result.unclear} ungeklaert`); + const parts = [`${result.scannedPlayers} Profile geprüft`]; + if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete zur Bestätigung`); + if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`); if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`); setSyncResult(parts.join(', ') + '.'); @@ -76,7 +76,7 @@ export default function Dashboard() { } }; - // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest. + // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst. useEffect(() => { const controller = new AbortController(); const timer = setTimeout(() => void load(query, controller.signal), 250); @@ -119,7 +119,7 @@ export default function Dashboard() { {flagged.total > 0 && ( - {flagged.total} ungeklaerte {flagged.total === 1 ? 'Eintrag' : 'Eintraege'} bei{' '} + {flagged.total} ungeklärte {flagged.total === 1 ? 'Eintrag' : 'Einträge'} bei{' '} {flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}: {' '} {flagged.players.map((entry, index) => ( @@ -251,7 +251,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea placeholder="76561198... oder STEAM_0:1:..." />

- Beide Schreibweisen sind moeglich, auch [U:1:...]. + Beide Schreibweisen sind möglich, auch [U:1:...]. Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.

diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx index 75eadc6..d55ded0 100644 --- a/web/src/pages/Import.tsx +++ b/web/src/pages/Import.tsx @@ -29,7 +29,7 @@ const EMPTY_MAPPING: MappingForm = { createMissingPlayers: false, }; -// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt. +// Trifft die üblichen Spaltennamen, damit das Formular meist schon passt. const GUESSES: Record, string[]> = { playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'], playerName: ['name', 'username', 'displayname', 'spieler'], @@ -158,8 +158,8 @@ export default function Import() {
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau - gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs - werden automatisch uebersprungen. + gezeigt — gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs + werden automatisch übersprungen.
)} @@ -207,7 +207,7 @@ export default function Import() { onChange={(value) => setMapping({ ...mapping, playerName: value })} /> setMapping({ ...mapping, orderId: value })} @@ -233,7 +233,7 @@ export default function Import() { {!mapping.orderId && ( - Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen. + Ohne Bestell-ID kann ein zweiter Import dieselben Käufe erneut buchen. )} @@ -293,7 +293,7 @@ export default function Import() { {bookable === 0 ? ( - Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings + Keine Zeile würde gebucht. Prüfe die Spaltenzuordnung oder die Produkt-Mappings in der Verwaltung. ) : ( @@ -342,7 +342,7 @@ export default function Import() { Verwerfen ) : ( diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index fffceff..49d7976 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -215,7 +215,7 @@ export default function PlayerDetail() { className="btn btn-small btn-danger" onClick={() => setDialog({ type: 'revert', character })} > - Zuruecknehmen + Zurücknehmen )} @@ -254,7 +254,7 @@ export default function PlayerDetail() { )}

{free - ? 'Kommt gratis zurueck — kostet kein Guthaben.' + ? 'Kommt gratis zurück — kostet kein Guthaben.' : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}

@@ -363,7 +363,7 @@ export default function PlayerDetail() { setDialog({ type: 'none' })} onSubmit={(values) => @@ -382,7 +382,7 @@ export default function PlayerDetail() { {dialog.type === 'redesign' && ( setDialog({ type: 'none' })} @@ -455,7 +455,7 @@ export default function PlayerDetail() { method: 'POST', body: { reason }, }), - `"${dialog.character.name}" wurde zurueckgenommen.`, + `"${dialog.character.name}" wurde zurückgenommen.`, ) } /> @@ -562,7 +562,7 @@ function CharacterFormModal({ {KIND_LABEL[kind]} {' '} - ({balances[kind]} verfuegbar) + ({balances[kind]} verfügbar) @@ -627,7 +627,7 @@ function KindChoiceModal({ {KIND_LABEL[kind]} {' '} - ({balances[kind]} verfuegbar) + ({balances[kind]} verfügbar) @@ -639,7 +639,7 @@ function KindChoiceModal({ Abbrechen @@ -670,8 +670,8 @@ function DeactivateModal({ }} > - Der CC bleibt unveraendert und kommt spaeter in beiden Faellen gratis zurueck. Nur ein - Redesign macht ihn kostenpflichtig — das laeuft aber ueber die Redesign-Aktion. + Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein + Redesign macht ihn kostenpflichtig — das läuft aber über die Redesign-Aktion.
@@ -683,8 +683,8 @@ function DeactivateModal({ onChange={() => setReason('PLAYER_INACTIVITY')} /> - Spieler-Inaktivitaet - — vom Server geputzt, weil der Spieler laenger weg war + Spieler-Inaktivität + — vom Server geputzt, weil der Spieler länger weg war
@@ -708,7 +708,7 @@ function DeactivateModal({ id="deactivate-note" value={note} onChange={(event) => setNote(event.target.value)} - placeholder="z. B. Einheit bis Maerz entfernt" + placeholder="z. B. Einheit bis März entfernt" />
@@ -753,7 +753,7 @@ function ReasonModal({ > {hint} - +