Compare commits
8
Commits
638a88f0de
...
3ec0ff4df1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec0ff4df1 | ||
|
|
b988a82765 | ||
|
|
245666d363 | ||
|
|
a7228e63d1 | ||
|
|
f3e31cdd17 | ||
|
|
3d2b87297e | ||
|
|
a1b764155a | ||
|
|
d7e7a3b186 |
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
server/public
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/*.log
|
||||||
|
coverage
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
||||||
|
|
||||||
|
# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen.
|
||||||
|
DB_PASSWORD=hier-ein-langes-zufaelliges-passwort
|
||||||
|
|
||||||
|
# Schluessel fuer 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.
|
||||||
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# --- Optional ---
|
||||||
|
|
||||||
|
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
||||||
|
# SESSION_TTL_HOURS=12
|
||||||
|
|
||||||
|
# Nur abschalten, wenn ohne HTTPS getestet wird.
|
||||||
|
# COOKIE_SECURE=true
|
||||||
|
|
||||||
|
# LOG_LEVEL=info
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vite/
|
||||||
|
# Frontend-Bundle, wird beim Build erzeugt
|
||||||
|
server/public/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
uploads/
|
||||||
|
tmp/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Local notes
|
||||||
|
notes/
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# --- Build ---
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
|
# Prisma benoetigt OpenSSL fuer seine Query-Engine.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
COPY web/package.json ./web/
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY server/prisma ./server/prisma
|
||||||
|
RUN npx prisma generate --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
COPY server ./server
|
||||||
|
COPY web ./web
|
||||||
|
|
||||||
|
# Das Frontend landet laut vite.config.ts direkt in server/public.
|
||||||
|
RUN npm run build --workspace=web \
|
||||||
|
&& npx tsc -p server/tsconfig.json
|
||||||
|
|
||||||
|
# --- Laufzeit ---
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
COPY web/package.json ./web/
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
COPY server/prisma ./server/prisma
|
||||||
|
RUN npx prisma generate --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/server/public ./server/public
|
||||||
|
COPY docker-entrypoint.sh ./
|
||||||
|
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
@@ -1,3 +1,178 @@
|
|||||||
# CC-Manager
|
# CC-Manager
|
||||||
|
|
||||||
For managing Custom Characters on a Server
|
Selbst gehostete Web-App zur Verwaltung von Custom Characters (CCs) auf einem SCP-RP-Server.
|
||||||
|
Sie führt pro Spieler ein Konto über gekaufte Ansprüche und deren Verbrauch, damit die Frage
|
||||||
|
„darf der gerade einen neuen CC?" nicht mehr von Hand abgeglichen werden muss.
|
||||||
|
|
||||||
|
## Das Prinzip
|
||||||
|
|
||||||
|
Jeder Spieler hat drei Guthaben-Töpfe:
|
||||||
|
|
||||||
|
| Topf | Wird gefüllt durch | Wird verbraucht durch |
|
||||||
|
|------|--------------------|-----------------------|
|
||||||
|
| CC-Guthaben | jedes CC-Paket (+1) | ein neuer CC |
|
||||||
|
| Redesign-Guthaben (bezahlt) | jedes CC-Paket (+1, immer inklusive) und jedes Redesign-Paket (+1) | ein Redesign |
|
||||||
|
| Kulanz-Redesign | manuell eingetragene Kulanz (+1) | ein Redesign |
|
||||||
|
|
||||||
|
Zwei Regeln sind fest verdrahtet:
|
||||||
|
|
||||||
|
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
||||||
|
in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück.
|
||||||
|
2. **Inaktive CCs kosten unterschiedlich.** Wurde ein CC nur wegen Spieler-Inaktivität vom Server
|
||||||
|
entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr,
|
||||||
|
kostet die Reaktivierung ein Redesign- oder CC-Guthaben.
|
||||||
|
|
||||||
|
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
||||||
|
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert
|
||||||
|
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
|
||||||
|
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
|
||||||
|
|
||||||
|
Ein Redesign legt einen **neuen** CC-Datensatz an und verkettet ihn mit dem alten. So bleibt die
|
||||||
|
Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig.
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
Vorausgesetzt wird Docker mit Compose-Plugin (oder DockGe).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.harlelux.de/Harlekin/CC-Manager.git
|
||||||
|
cd CC-Manager
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
In der `.env` mindestens `DB_PASSWORD` und `SESSION_SECRET` setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48 # für SESSION_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
||||||
|
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der
|
||||||
|
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
||||||
|
Seiten gesperrt.
|
||||||
|
|
||||||
|
Für DockGe: Repo-Inhalt als Stack anlegen, `.env` daneben, fertig. Datenbank-Migrationen laufen
|
||||||
|
bei jedem Containerstart automatisch mit.
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Reverse Proxy
|
||||||
|
(Nginx Proxy Manager, Traefik oder Caddy), der HTTPS terminiert und intern auf `app:3000`
|
||||||
|
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
|
||||||
|
|
||||||
|
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben — sonst landen im
|
||||||
|
Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure`
|
||||||
|
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
|
||||||
|
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
|
||||||
|
Ansprüche ab — das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
||||||
|
|
||||||
|
Wiederherstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
|
||||||
|
docker compose exec -T db psql -U ccuser -d ccmanager
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
||||||
|
|
||||||
|
1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt.
|
||||||
|
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
||||||
|
Spaltennamen werden vorausgewählt.
|
||||||
|
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
||||||
|
4. Buchen.
|
||||||
|
|
||||||
|
Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter *Verwaltung →
|
||||||
|
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
||||||
|
jederzeit ergänzen, ohne den Code anzufassen.
|
||||||
|
|
||||||
|
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut —
|
||||||
|
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
|
||||||
|
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
|
||||||
|
|
||||||
|
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
|
|
||||||
|
- **Viewer** — darf nur lesen.
|
||||||
|
- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren.
|
||||||
|
- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen.
|
||||||
|
|
||||||
|
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
||||||
|
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
Gebraucht werden Node 20+ und eine erreichbare PostgreSQL-Instanz.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp server/.env.example server/.env # DATABASE_URL auf die lokale DB zeigen lassen
|
||||||
|
npm run migrate:dev
|
||||||
|
npm run dev # API auf :3000, Frontend auf :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
||||||
|
funktionieren also wie in Produktion.
|
||||||
|
|
||||||
|
Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierungslogik, Korrekturen
|
||||||
|
und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
|
||||||
|
|
||||||
|
### Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
prisma/schema.prisma Datenmodell und Migrationen
|
||||||
|
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import
|
||||||
|
src/routes/ HTTP-Endpunkte
|
||||||
|
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
||||||
|
test/ Integrationstests gegen eine echte Postgres-Instanz
|
||||||
|
web/
|
||||||
|
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
||||||
|
src/components/ Wiederverwendete Bausteine
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend ist Fastify mit Prisma, Frontend eine React-SPA. Gebaut wird beides in ein einziges
|
||||||
|
Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
|
||||||
|
|
||||||
|
Nach Änderungen am Datenmodell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate:dev -- --name beschreibung-der-aenderung
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
||||||
|
- Sitzungen serverseitig; im Cookie steht nur ein Token, in der Datenbank dessen Hash.
|
||||||
|
Cookie ist `httpOnly`, `SameSite=Lax` und in Produktion `Secure`.
|
||||||
|
- CSRF-Schutz für alle ändernden Anfragen über ein aus der Sitzung abgeleitetes Token.
|
||||||
|
- Alle Eingaben werden validiert, alle Datenbankzugriffe laufen parametrisiert über das ORM.
|
||||||
|
- Rate-Limit auf dem Login, generische Fehlermeldung ohne Hinweis auf existierende Konten.
|
||||||
|
- Sicherheits-Header inklusive Content-Security-Policy.
|
||||||
|
- Ein gesperrtes Konto verliert seine offenen Sitzungen sofort; der letzte aktive Administrator
|
||||||
|
kann sich nicht selbst entfernen.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
GPL-3.0-or-later, siehe [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
# Alternativ ein vorgebautes Image verwenden:
|
||||||
|
# image: ghcr.io/harlekin/cc-manager:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://ccuser:${DB_PASSWORD}@db:5432/ccmanager
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
NODE_ENV: production
|
||||||
|
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
||||||
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
TRUST_PROXY: "true"
|
||||||
|
expose:
|
||||||
|
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
||||||
|
- "3000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ccuser
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ccmanager
|
||||||
|
volumes:
|
||||||
|
- cc_db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ccuser -d ccmanager"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backup:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./backups:/backups
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
# Dump beim Start und danach alle 24 Stunden, Aufbewahrung 14 Tage.
|
||||||
|
entrypoint: >
|
||||||
|
sh -c 'while true; do
|
||||||
|
pg_dump -h db -U ccuser -d ccmanager | gzip > /backups/ccmanager-$$(date +%F-%H%M).sql.gz &&
|
||||||
|
echo "Backup geschrieben: $$(date +%F-%H%M)";
|
||||||
|
find /backups -name "ccmanager-*.sql.gz" -mtime +14 -delete;
|
||||||
|
sleep 86400;
|
||||||
|
done'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cc_db:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
||||||
|
# Migrationen werden dabei uebersprungen.
|
||||||
|
echo "Wende Datenbank-Migrationen an ..."
|
||||||
|
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Generated
+5059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "cc-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Tracking von Custom Characters, Paketen und Redesigns",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n api,web -c blue,magenta \"npm:dev:api\" \"npm:dev:web\"",
|
||||||
|
"dev:api": "npm run dev --workspace=server",
|
||||||
|
"dev:web": "npm run dev --workspace=web",
|
||||||
|
"build": "npm run build --workspace=web && npm run build --workspace=server",
|
||||||
|
"start": "npm run start --workspace=server",
|
||||||
|
"migrate": "npm run migrate --workspace=server",
|
||||||
|
"migrate:dev": "npm run migrate:dev --workspace=server",
|
||||||
|
"generate": "npm run generate --workspace=server",
|
||||||
|
"typecheck": "npm run typecheck --workspace=server && npm run typecheck --workspace=web",
|
||||||
|
"test": "npm run test --workspace=server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Nur fuer die lokale Entwicklung. Als server/.env kopieren.
|
||||||
|
# Im Container kommen diese Werte aus der compose.yaml.
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
||||||
|
SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
|
||||||
|
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen.
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Erstadministrator beim ersten Start.
|
||||||
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=EntwicklungPasswort1
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@cc-manager/server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||||
|
"build": "prisma generate && tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"generate": "prisma generate",
|
||||||
|
"migrate": "prisma migrate deploy",
|
||||||
|
"migrate:dev": "prisma migrate dev",
|
||||||
|
"studio": "prisma studio",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --import tsx --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/helmet": "^13.0.1",
|
||||||
|
"@fastify/multipart": "^9.0.3",
|
||||||
|
"@fastify/rate-limit": "^10.2.2",
|
||||||
|
"@fastify/static": "^8.1.1",
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
|
"@prisma/client": "^6.5.0",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"otplib": "^13.5.0",
|
||||||
|
"prisma": "^6.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electric-sql/pglite": "^0.5.8",
|
||||||
|
"@electric-sql/pglite-socket": "^0.2.11",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CreditKind" AS ENUM ('CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CreditSource" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'KULANZ', 'ADJUSTMENT', 'CONSUME', 'REVERSAL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CharacterStatus" AS ENUM ('ACTIVE', 'INACTIVE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL DEFAULT 'EDITOR',
|
||||||
|
"totpSecret" TEXT,
|
||||||
|
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ip" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Player" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"steamId" TEXT,
|
||||||
|
"forumName" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CreditLedger" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"kind" "CreditKind" NOT NULL,
|
||||||
|
"amount" INTEGER NOT NULL,
|
||||||
|
"source" "CreditSource" NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"characterId" INTEGER,
|
||||||
|
"importId" INTEGER,
|
||||||
|
"note" TEXT,
|
||||||
|
"reversalOfId" INTEGER,
|
||||||
|
"createdById" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "CreditLedger_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Character" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"loreText" TEXT,
|
||||||
|
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"inactiveReason" "InactiveReason",
|
||||||
|
"lineageId" TEXT NOT NULL,
|
||||||
|
"predecessorId" INTEGER,
|
||||||
|
"createdById" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Import" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"filename" TEXT NOT NULL,
|
||||||
|
"status" "ImportStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"rowCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"bookedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"mapping" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"committedAt" TIMESTAMP(3),
|
||||||
|
"importedById" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "Import_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImportRow" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"importId" INTEGER NOT NULL,
|
||||||
|
"lineNo" INTEGER NOT NULL,
|
||||||
|
"externalId" TEXT,
|
||||||
|
"playerKey" TEXT,
|
||||||
|
"playerName" TEXT,
|
||||||
|
"productName" TEXT,
|
||||||
|
"purchasedAt" TIMESTAMP(3),
|
||||||
|
"target" "ProductTarget",
|
||||||
|
"resolvedPlayerId" INTEGER,
|
||||||
|
"plan" TEXT NOT NULL,
|
||||||
|
"raw" JSONB NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ImportRow_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImportedOrder" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"importId" INTEGER NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"target" "ProductTarget" NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ImportedOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductMapping" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"matchPattern" TEXT NOT NULL,
|
||||||
|
"target" "ProductTarget" NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 100,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductMapping_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"actorUserId" INTEGER,
|
||||||
|
"actorName" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"targetType" TEXT NOT NULL,
|
||||||
|
"targetId" TEXT,
|
||||||
|
"details" JSONB,
|
||||||
|
"ip" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CreditLedger_playerId_idx" ON "CreditLedger"("playerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CreditLedger_playerId_kind_idx" ON "CreditLedger"("playerId", "kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CreditLedger_groupId_idx" ON "CreditLedger"("groupId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CreditLedger_createdAt_idx" ON "CreditLedger"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Character_playerId_idx" ON "Character"("playerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Character_playerId_status_idx" ON "Character"("playerId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Character_lineageId_idx" ON "Character"("lineageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Import_status_idx" ON "Import"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ImportRow_importId_idx" ON "ImportRow"("importId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ImportedOrder_externalId_key" ON "ImportedOrder"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ImportedOrder_playerId_idx" ON "ImportedOrder"("playerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductMapping_priority_idx" ON "ProductMapping"("priority");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_reversalOfId_fkey" FOREIGN KEY ("reversalOfId") REFERENCES "CreditLedger"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Character" ADD CONSTRAINT "Character_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Character" ADD CONSTRAINT "Character_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Character" ADD CONSTRAINT "Character_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Import" ADD CONSTRAINT "Import_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImportRow" ADD CONSTRAINT "ImportRow_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
EDITOR
|
||||||
|
VIEWER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CreditKind {
|
||||||
|
CC
|
||||||
|
REDESIGN_PAID
|
||||||
|
REDESIGN_KULANZ
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CreditSource {
|
||||||
|
CC_PACKAGE
|
||||||
|
REDESIGN_PACKAGE
|
||||||
|
KULANZ
|
||||||
|
ADJUSTMENT
|
||||||
|
CONSUME
|
||||||
|
REVERSAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CharacterStatus {
|
||||||
|
ACTIVE
|
||||||
|
INACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InactiveReason {
|
||||||
|
PLAYER_INACTIVITY
|
||||||
|
REWORKED
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProductTarget {
|
||||||
|
CC_PACKAGE
|
||||||
|
REDESIGN_PACKAGE
|
||||||
|
IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImportStatus {
|
||||||
|
PENDING
|
||||||
|
COMMITTED
|
||||||
|
DISCARDED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
role Role @default(EDITOR)
|
||||||
|
totpSecret String?
|
||||||
|
totpEnabled Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
mustChangePassword Boolean @default(false)
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
auditEntries AuditLog[]
|
||||||
|
ledgerEntries CreditLedger[] @relation("LedgerCreatedBy")
|
||||||
|
charactersCreated Character[] @relation("CharacterCreatedBy")
|
||||||
|
imports Import[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tokenHash String @unique
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
ip String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Player {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
displayName String
|
||||||
|
steamId String? @unique
|
||||||
|
forumName String?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
ledger CreditLedger[]
|
||||||
|
characters Character[]
|
||||||
|
orders ImportedOrder[]
|
||||||
|
|
||||||
|
@@index([displayName])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
|
||||||
|
/// Der offene Stand eines Topfes ist die Summe der amounts je kind.
|
||||||
|
model CreditLedger {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
playerId Int
|
||||||
|
kind CreditKind
|
||||||
|
amount Int
|
||||||
|
source CreditSource
|
||||||
|
|
||||||
|
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
||||||
|
/// mit derselben groupId, damit ein Storno beide zurueckholt.
|
||||||
|
groupId String @default(uuid())
|
||||||
|
|
||||||
|
characterId Int?
|
||||||
|
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
importId Int?
|
||||||
|
import Import? @relation(fields: [importId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
note String?
|
||||||
|
|
||||||
|
/// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique
|
||||||
|
/// verhindert, dass eine Zeile zweimal storniert wird.
|
||||||
|
reversalOfId Int? @unique
|
||||||
|
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
|
reversedBy CreditLedger? @relation("LedgerReversal")
|
||||||
|
|
||||||
|
createdById Int?
|
||||||
|
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([playerId])
|
||||||
|
@@index([playerId, kind])
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Character {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
playerId Int
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
loreText String?
|
||||||
|
status CharacterStatus @default(ACTIVE)
|
||||||
|
inactiveReason InactiveReason?
|
||||||
|
|
||||||
|
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
||||||
|
lineageId String @default(uuid())
|
||||||
|
predecessorId Int?
|
||||||
|
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
|
successors Character[] @relation("CharacterLineage")
|
||||||
|
|
||||||
|
ledgerEntries CreditLedger[]
|
||||||
|
|
||||||
|
createdById Int?
|
||||||
|
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([playerId])
|
||||||
|
@@index([playerId, status])
|
||||||
|
@@index([lineageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Import {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
filename String
|
||||||
|
status ImportStatus @default(PENDING)
|
||||||
|
rowCount Int @default(0)
|
||||||
|
bookedCount Int @default(0)
|
||||||
|
mapping Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
committedAt DateTime?
|
||||||
|
|
||||||
|
importedById Int?
|
||||||
|
importedBy User? @relation(fields: [importedById], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
rows ImportRow[]
|
||||||
|
orders ImportedOrder[]
|
||||||
|
ledgerEntries CreditLedger[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ImportRow {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
importId Int
|
||||||
|
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
lineNo Int
|
||||||
|
externalId String?
|
||||||
|
playerKey String?
|
||||||
|
playerName String?
|
||||||
|
productName String?
|
||||||
|
purchasedAt DateTime?
|
||||||
|
|
||||||
|
target ProductTarget?
|
||||||
|
resolvedPlayerId Int?
|
||||||
|
|
||||||
|
/// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK,
|
||||||
|
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
||||||
|
plan String
|
||||||
|
raw Json
|
||||||
|
|
||||||
|
@@index([importId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dedup-Anker: eine bereits gebuchte Bestell-ID wird nie ein zweites Mal gebucht.
|
||||||
|
model ImportedOrder {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
externalId String @unique
|
||||||
|
importId Int
|
||||||
|
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
|
||||||
|
playerId Int
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
target ProductTarget
|
||||||
|
groupId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([playerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductMapping {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
matchPattern String
|
||||||
|
target ProductTarget
|
||||||
|
priority Int @default(100)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([priority])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
actorUserId Int?
|
||||||
|
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||||
|
/// Name wird mitgeschrieben, damit der Eintrag lesbar bleibt, wenn der Account weg ist.
|
||||||
|
actorName String?
|
||||||
|
action String
|
||||||
|
targetType String
|
||||||
|
targetId String?
|
||||||
|
details Json?
|
||||||
|
ip String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
@@index([actorUserId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { AppError } from './errors.js';
|
||||||
|
import { attachUser, blockUntilPasswordChanged, enforceCsrf } from './auth/guard.js';
|
||||||
|
import { authRoutes } from './routes/auth.routes.js';
|
||||||
|
import { playerRoutes } from './routes/players.routes.js';
|
||||||
|
import { creditRoutes } from './routes/credits.routes.js';
|
||||||
|
import { characterRoutes } from './routes/characters.routes.js';
|
||||||
|
import { importRoutes } from './routes/imports.routes.js';
|
||||||
|
import { adminRoutes } from './routes/admin.routes.js';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
|
||||||
|
const webRoot = join(here, '..', 'public');
|
||||||
|
|
||||||
|
function readStatusCode(error: unknown): number | undefined {
|
||||||
|
const value = (error as { statusCode?: unknown }).statusCode;
|
||||||
|
return typeof value === 'number' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessage(error: unknown): string | undefined {
|
||||||
|
const value = (error as { message?: unknown }).message;
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
redact: ['req.headers.cookie', 'req.headers.authorization'],
|
||||||
|
},
|
||||||
|
trustProxy: env.TRUST_PROXY,
|
||||||
|
bodyLimit: 2 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
// Der QR-Code der 2FA-Einrichtung kommt als data-URL.
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
baseUri: ["'self'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
formAction: ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
hsts: env.isProduction ? { maxAge: 31536000, includeSubDomains: true } : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cookie, { secret: env.SESSION_SECRET });
|
||||||
|
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
global: true,
|
||||||
|
max: 300,
|
||||||
|
timeWindow: '1 minute',
|
||||||
|
allowList: () => !env.isProduction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024, files: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.addHook('onRequest', attachUser);
|
||||||
|
app.addHook('onRequest', enforceCsrf);
|
||||||
|
app.addHook('preHandler', blockUntilPasswordChanged);
|
||||||
|
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
reply.code(409).send({ error: 'UNIQUE_VIOLATION', message: 'Dieser Wert ist bereits vergeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.code === 'P2025') {
|
||||||
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Datensatz nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = readStatusCode(error);
|
||||||
|
|
||||||
|
if (statusCode === 429) {
|
||||||
|
reply.code(429).send({ error: 'RATE_LIMITED', message: 'Zu viele Anfragen, bitte kurz warten' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
||||||
|
reply
|
||||||
|
.code(statusCode)
|
||||||
|
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error({ err: error }, 'Unbehandelter Fehler');
|
||||||
|
reply.code(500).send({ error: 'INTERNAL', message: 'Interner Fehler' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', async () => ({ status: 'ok' }));
|
||||||
|
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(playerRoutes);
|
||||||
|
await app.register(creditRoutes);
|
||||||
|
await app.register(characterRoutes);
|
||||||
|
await app.register(importRoutes);
|
||||||
|
await app.register(adminRoutes);
|
||||||
|
|
||||||
|
if (existsSync(webRoot)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: webRoot,
|
||||||
|
prefix: '/',
|
||||||
|
// Gehashte Assets duerfen lange liegen bleiben, index.html nie.
|
||||||
|
maxAge: '1y',
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
if (path.endsWith('index.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle nicht-API-Pfade gehen an die SPA, damit Deep-Links funktionieren.
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
if (request.url.startsWith('/api/')) {
|
||||||
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Unbekannter Endpunkt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existsSync(webRoot)) {
|
||||||
|
reply.sendFile('index.html');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.code(404).send({ error: 'NOT_FOUND', message: 'Frontend ist nicht gebaut' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
|
||||||
|
import type { Role } from '@prisma/client';
|
||||||
|
import { forbidden, unauthorized } from '../errors.js';
|
||||||
|
import { CSRF_HEADER, SESSION_COOKIE, csrfMatches, resolveSession } from './session.js';
|
||||||
|
import type { SessionUser } from './session.js';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
user?: SessionUser;
|
||||||
|
sessionToken?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK: Record<Role, number> = { 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
|
||||||
|
// weder Session noch ableitbares CSRF-Token.
|
||||||
|
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
||||||
|
|
||||||
|
export async function attachUser(request: FastifyRequest): Promise<void> {
|
||||||
|
const token = request.cookies[SESSION_COOKIE];
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const user = await resolveSession(token);
|
||||||
|
if (user) {
|
||||||
|
request.user = user;
|
||||||
|
request.sessionToken = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enforceCsrf(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||||
|
if (SAFE_METHODS.has(request.method)) return;
|
||||||
|
if (CSRF_EXEMPT.has(request.url.split('?')[0] ?? '')) return;
|
||||||
|
|
||||||
|
const token = request.cookies[SESSION_COOKIE];
|
||||||
|
if (!token) return; // Ohne Session greift ohnehin requireAuth.
|
||||||
|
|
||||||
|
const header = request.headers[CSRF_HEADER];
|
||||||
|
const presented = Array.isArray(header) ? header[0] : header;
|
||||||
|
|
||||||
|
if (!csrfMatches(token, presented)) {
|
||||||
|
throw forbidden('CSRF-Token fehlt oder passt nicht');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAuth: preHandlerHookHandler = async (request) => {
|
||||||
|
if (!request.user) throw unauthorized();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requireRole(minimum: Role): preHandlerHookHandler {
|
||||||
|
return async (request) => {
|
||||||
|
if (!request.user) throw unauthorized();
|
||||||
|
if (RANK[request.user.role] < RANK[minimum]) {
|
||||||
|
throw forbidden();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solange ein Erstpasswort nicht gewechselt ist, bleiben schreibende Wege zu.
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireEditor = requireRole('EDITOR');
|
||||||
|
export const requireAdmin = requireRole('ADMIN');
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
|
||||||
|
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
|
||||||
|
const options = {
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function hashPassword(plain: string): Promise<string> {
|
||||||
|
return hash(plain, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(digest: string, plain: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await verify(digest, plain, options);
|
||||||
|
} catch {
|
||||||
|
// Kaputter oder fremdformatiger Hash gilt als nicht passend.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
export function validatePasswordStrength(plain: string): string | null {
|
||||||
|
if (plain.length < MIN_LENGTH) {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(plain)) {
|
||||||
|
return 'Passwort muss mindestens eine Ziffer enthalten';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { Role, User } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'ccm_sid';
|
||||||
|
export const CSRF_COOKIE = 'ccm_csrf';
|
||||||
|
export const CSRF_HEADER = 'x-csrf-token';
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das CSRF-Token wird aus dem Session-Token abgeleitet statt separat gespeichert.
|
||||||
|
// Ohne Kenntnis des httpOnly-Cookies und des SESSION_SECRET ist es nicht herstellbar.
|
||||||
|
export function csrfTokenFor(sessionToken: string): string {
|
||||||
|
return createHmac('sha256', env.SESSION_SECRET).update(sessionToken).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csrfMatches(sessionToken: string, presented: string | undefined): boolean {
|
||||||
|
if (!presented) return false;
|
||||||
|
const expected = csrfTokenFor(sessionToken);
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(presented);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSessionUser(user: User): SessionUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
totpEnabled: user.totpEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(
|
||||||
|
userId: number,
|
||||||
|
ip: string | undefined,
|
||||||
|
userAgent: string | undefined,
|
||||||
|
): Promise<{ token: string; expiresAt: Date }> {
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 3600 * 1000);
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
tokenHash: hashToken(token),
|
||||||
|
userId,
|
||||||
|
expiresAt,
|
||||||
|
ip: ip ?? null,
|
||||||
|
userAgent: userAgent?.slice(0, 255) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSession(token: string): Promise<SessionUser | null> {
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { tokenHash: hashToken(token) },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
if (session.expiresAt.getTime() < Date.now()) {
|
||||||
|
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user.isActive) {
|
||||||
|
await prisma.session.deleteMany({ where: { userId: session.userId } });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht bei jedem Request schreiben, sonst erzeugt jede Anfrage einen Write.
|
||||||
|
const staleAfter = 5 * 60 * 1000;
|
||||||
|
if (Date.now() - session.lastSeenAt.getTime() > staleAfter) {
|
||||||
|
await prisma.session
|
||||||
|
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toSessionUser(session.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroySession(token: string): Promise<void> {
|
||||||
|
await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyAllSessionsFor(userId: number): Promise<void> {
|
||||||
|
await prisma.session.deleteMany({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */
|
||||||
|
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupExpiredSessions(): Promise<number> {
|
||||||
|
const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { generateSecret as createSecret, generateURI, verify } from 'otplib';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const ISSUER = 'CC-Manager';
|
||||||
|
|
||||||
|
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
|
||||||
|
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
||||||
|
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||||
|
|
||||||
|
export function generateSecret(): string {
|
||||||
|
return createSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotp(secret: string, token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await verify({
|
||||||
|
secret,
|
||||||
|
token: token.replace(/\s/g, ''),
|
||||||
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||||
|
});
|
||||||
|
return result.valid;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otpauthUrl(username: string, secret: string): string {
|
||||||
|
return generateURI({ issuer: ISSUER, label: username, secret });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function qrDataUrl(otpauth: string): Promise<string> {
|
||||||
|
return QRCode.toDataURL(otpauth, { margin: 1, width: 240 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { ProductTarget } from '@prisma/client';
|
||||||
|
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
|
||||||
|
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
||||||
|
const DEFAULT_MAPPINGS = [
|
||||||
|
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
|
||||||
|
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
|
||||||
|
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
|
||||||
|
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BootstrapResult {
|
||||||
|
createdAdmin: { username: string; password: string } | null;
|
||||||
|
createdMappings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt beim allerersten Start einen Administrator an. Ohne gesetztes
|
||||||
|
* Bootstrap-Passwort wird eines erzeugt und einmalig ins Log geschrieben.
|
||||||
|
*/
|
||||||
|
export async function bootstrap(log: {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
}): Promise<BootstrapResult> {
|
||||||
|
const result: BootstrapResult = { createdAdmin: null, createdMappings: 0 };
|
||||||
|
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
if (userCount === 0) {
|
||||||
|
const password = env.BOOTSTRAP_ADMIN_PASSWORD ?? randomBytes(12).toString('base64url');
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: env.BOOTSTRAP_ADMIN_USER,
|
||||||
|
passwordHash: await hashPassword(password),
|
||||||
|
role: 'ADMIN',
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
result.createdAdmin = { username: env.BOOTSTRAP_ADMIN_USER, password };
|
||||||
|
|
||||||
|
log.warn('─'.repeat(60));
|
||||||
|
log.warn(`Administrator angelegt: ${env.BOOTSTRAP_ADMIN_USER}`);
|
||||||
|
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
||||||
|
log.warn(`Einmal-Passwort: ${password}`);
|
||||||
|
}
|
||||||
|
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
|
||||||
|
log.warn('─'.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingCount = await prisma.productMapping.count();
|
||||||
|
if (mappingCount === 0) {
|
||||||
|
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
||||||
|
result.createdMappings = created.count;
|
||||||
|
log.info(`${created.count} Produkt-Mappings als Startwerte angelegt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Tx = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
|
||||||
|
|
||||||
|
export async function disconnect(): Promise<void> {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
|
||||||
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL fehlt'),
|
||||||
|
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET muss mindestens 32 Zeichen lang sein'),
|
||||||
|
|
||||||
|
// Lebensdauer einer Session in Stunden.
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().int().positive().default(12),
|
||||||
|
|
||||||
|
// Wird beim ersten Start angelegt, solange noch kein Account existiert.
|
||||||
|
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.
|
||||||
|
TRUST_PROXY: z
|
||||||
|
.string()
|
||||||
|
.default('true')
|
||||||
|
.transform((v) => v === 'true' || v === '1'),
|
||||||
|
|
||||||
|
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
|
||||||
|
COOKIE_SECURE: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||||
|
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = parsed.data;
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
...raw,
|
||||||
|
isProduction: raw.NODE_ENV === 'production',
|
||||||
|
// In Produktion ohne expliziten Wert: sichere Cookies erzwingen.
|
||||||
|
cookieSecure: raw.COOKIE_SECURE ?? raw.NODE_ENV === 'production',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Env = typeof env;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly statusCode: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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') =>
|
||||||
|
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);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { bootstrap } from './bootstrap.js';
|
||||||
|
import { cleanupExpiredSessions } from './auth/session.js';
|
||||||
|
import { prisma } from './db.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error({ err: error }, 'Keine Verbindung zur Datenbank');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await bootstrap({
|
||||||
|
info: (msg) => app.log.info(msg),
|
||||||
|
warn: (msg) => app.log.warn(msg),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = setInterval(() => {
|
||||||
|
cleanupExpiredSessions()
|
||||||
|
.then((count) => {
|
||||||
|
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||||
|
})
|
||||||
|
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
||||||
|
}, SESSION_CLEANUP_INTERVAL_MS);
|
||||||
|
cleanup.unref();
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
app.log.info(`${signal} empfangen, fahre herunter`);
|
||||||
|
clearInterval(cleanup);
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Start fehlgeschlagen:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ProductTarget, Role, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { requireAdmin } from '../auth/guard.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
|
||||||
|
import { destroyAllSessionsFor } from '../auth/session.js';
|
||||||
|
import { recordAudit } from '../services/audit.js';
|
||||||
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
totpEnabled: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
|
||||||
|
async function assertNotLastAdmin(userId: number): Promise<void> {
|
||||||
|
const target = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!target || target.role !== Role.ADMIN || !target.isActive) return;
|
||||||
|
|
||||||
|
const activeAdmins = await prisma.user.count({ where: { role: Role.ADMIN, isActive: true } });
|
||||||
|
if (activeAdmins <= 1) {
|
||||||
|
throw conflict('LAST_ADMIN', 'Der letzte aktive Administrator kann nicht entfernt werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.addHook('preHandler', requireAdmin);
|
||||||
|
|
||||||
|
// --- Nutzerverwaltung ---
|
||||||
|
|
||||||
|
app.get('/api/admin/users', async () => {
|
||||||
|
const items = await prisma.user.findMany({
|
||||||
|
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/users', async (request, reply) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-zA-Z0-9._-]+$/, 'Erlaubt sind Buchstaben, Ziffern, Punkt, Strich, Unterstrich'),
|
||||||
|
password: z.string().min(1),
|
||||||
|
role: z.nativeEnum(Role).default(Role.EDITOR),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(body.password);
|
||||||
|
if (weak) throw badRequest('WEAK_PASSWORD', weak);
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({
|
||||||
|
where: { username: body.username },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (exists) throw conflict('USERNAME_TAKEN', 'Dieser Benutzername ist vergeben');
|
||||||
|
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: body.username,
|
||||||
|
passwordHash: await hashPassword(body.password),
|
||||||
|
role: body.role,
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.user_create',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { username: created.username, role: created.role },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/users/:id', async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
role: z.nativeEnum(Role).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) throw notFound('Nutzer');
|
||||||
|
|
||||||
|
const losesAdmin =
|
||||||
|
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
|
||||||
|
if (losesAdmin) await assertNotLastAdmin(id);
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.role !== undefined ? { role: body.role } : {}),
|
||||||
|
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||||
|
},
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein gesperrter Account verliert seine offenen Sitzungen sofort.
|
||||||
|
if (body.isActive === false) await destroyAllSessionsFor(id);
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.user_update',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: id,
|
||||||
|
details: { username: updated.username, role: updated.role, isActive: updated.isActive },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/users/:id/reset-password', async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(z.object({ newPassword: z.string().min(1) }), request.body);
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(body.newPassword);
|
||||||
|
if (weak) throw badRequest('WEAK_PASSWORD', weak);
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) throw notFound('Nutzer');
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await hashPassword(body.newPassword),
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await destroyAllSessionsFor(id);
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.user_reset_password',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: id,
|
||||||
|
details: { username: target.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) throw notFound('Nutzer');
|
||||||
|
await assertNotLastAdmin(id);
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.user_delete',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: id,
|
||||||
|
details: { username: target.username },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Produkt-Mappings ---
|
||||||
|
|
||||||
|
app.get('/api/admin/product-mappings', async () => {
|
||||||
|
const items = await prisma.productMapping.findMany({
|
||||||
|
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/product-mappings', async (request, reply) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
matchPattern: z.string().trim().min(1).max(200),
|
||||||
|
target: z.nativeEnum(ProductTarget),
|
||||||
|
priority: z.number().int().min(0).max(9999).default(100),
|
||||||
|
note: z.string().trim().max(300).optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await prisma.productMapping.create({
|
||||||
|
data: {
|
||||||
|
matchPattern: body.matchPattern,
|
||||||
|
target: body.target,
|
||||||
|
priority: body.priority,
|
||||||
|
note: body.note ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.mapping_create',
|
||||||
|
targetType: 'product_mapping',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { matchPattern: created.matchPattern, target: created.target },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/product-mappings/:id', async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
matchPattern: z.string().trim().min(1).max(200).optional(),
|
||||||
|
target: z.nativeEnum(ProductTarget).optional(),
|
||||||
|
priority: z.number().int().min(0).max(9999).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
note: z.string().trim().max(300).nullable().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = await prisma.productMapping.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw notFound('Mapping');
|
||||||
|
|
||||||
|
const updated = await prisma.productMapping.update({ where: { id }, data: body });
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.mapping_update',
|
||||||
|
targetType: 'product_mapping',
|
||||||
|
targetId: id,
|
||||||
|
details: { matchPattern: updated.matchPattern, target: updated.target },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/product-mappings/:id', async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const existing = await prisma.productMapping.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw notFound('Mapping');
|
||||||
|
|
||||||
|
await prisma.productMapping.delete({ where: { id } });
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'admin.mapping_delete',
|
||||||
|
targetType: 'product_mapping',
|
||||||
|
targetId: id,
|
||||||
|
details: { matchPattern: existing.matchPattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Audit-Log ---
|
||||||
|
|
||||||
|
app.get('/api/admin/audit', async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({
|
||||||
|
action: z.string().trim().max(80).optional(),
|
||||||
|
targetType: z.string().trim().max(40).optional(),
|
||||||
|
targetId: z.string().trim().max(40).optional(),
|
||||||
|
take: z.coerce.number().int().min(1).max(200).default(100),
|
||||||
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
|
}),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
|
||||||
|
const where: Prisma.AuditLogWhereInput = {};
|
||||||
|
if (query.action) where.action = { contains: query.action, mode: 'insensitive' };
|
||||||
|
if (query.targetType) where.targetType = query.targetType;
|
||||||
|
if (query.targetId) where.targetId = query.targetId;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: query.take,
|
||||||
|
skip: query.skip,
|
||||||
|
include: { actor: { select: { username: true } } },
|
||||||
|
}),
|
||||||
|
prisma.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/stats', async () => {
|
||||||
|
const [players, characters, activeCharacters, users, imports] = await Promise.all([
|
||||||
|
prisma.player.count(),
|
||||||
|
prisma.character.count(),
|
||||||
|
prisma.character.count({ where: { status: 'ACTIVE' } }),
|
||||||
|
prisma.user.count({ where: { isActive: true } }),
|
||||||
|
prisma.import.count({ where: { status: 'COMMITTED' } }),
|
||||||
|
]);
|
||||||
|
return { players, characters, activeCharacters, users, imports };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import { badRequest, unauthorized } from '../errors.js';
|
||||||
|
import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js';
|
||||||
|
import {
|
||||||
|
CSRF_COOKIE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
createSession,
|
||||||
|
csrfTokenFor,
|
||||||
|
destroyAllSessionsFor,
|
||||||
|
destroyOtherSessionsFor,
|
||||||
|
destroySession,
|
||||||
|
} from '../auth/session.js';
|
||||||
|
import { requireAuth } from '../auth/guard.js';
|
||||||
|
import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js';
|
||||||
|
import { recordAudit } from '../services/audit.js';
|
||||||
|
import { contextOf, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert,
|
||||||
|
// damit kein halb angemeldeter Zustand in der Datenbank liegt.
|
||||||
|
function signChallenge(userId: number): string {
|
||||||
|
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
||||||
|
const payload = `${userId}.${expiresAt}`;
|
||||||
|
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
|
||||||
|
return `${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyChallenge(token: string): number | null {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const [rawUserId, rawExpiry, signature] = parts as [string, string, string];
|
||||||
|
|
||||||
|
const expected = createHmac('sha256', env.SESSION_SECRET)
|
||||||
|
.update(`${rawUserId}.${rawExpiry}`)
|
||||||
|
.digest('base64url');
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(signature);
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||||
|
|
||||||
|
const expiresAt = Number(rawExpiry);
|
||||||
|
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
|
||||||
|
|
||||||
|
const userId = Number(rawUserId);
|
||||||
|
return Number.isInteger(userId) ? userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
username: z.string().trim().min(1).max(64),
|
||||||
|
password: z.string().min(1).max(256),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totpSchema = z.object({
|
||||||
|
challenge: z.string().min(1),
|
||||||
|
code: z.string().trim().min(6).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const loginLimit = {
|
||||||
|
config: {
|
||||||
|
rateLimit: {
|
||||||
|
max: 10,
|
||||||
|
timeWindow: '5 minutes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void {
|
||||||
|
reply.setCookie(SESSION_COOKIE, token, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.cookieSecure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
||||||
|
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: false,
|
||||||
|
secure: env.cookieSecure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/auth/login', loginLimit, async (request, reply) => {
|
||||||
|
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.
|
||||||
|
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
const passwordOk = await verifyPassword(digest, body.password);
|
||||||
|
|
||||||
|
if (!user || !passwordOk || !user.isActive) {
|
||||||
|
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
|
||||||
|
action: 'auth.login_failed',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user?.id ?? null,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
throw unauthorized('Benutzername oder Passwort ist falsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.totpEnabled && user.totpSecret) {
|
||||||
|
return { totpRequired: true, challenge: signChallenge(user.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, expiresAt } = await createSession(
|
||||||
|
user.id,
|
||||||
|
request.ip,
|
||||||
|
request.headers['user-agent'],
|
||||||
|
);
|
||||||
|
issueSession(reply, token, expiresAt);
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||||
|
await recordAudit(
|
||||||
|
{ auditLog: prisma.auditLog },
|
||||||
|
{ actorId: user.id, actorName: user.username, ip: request.ip },
|
||||||
|
{ action: 'auth.login', targetType: 'user', targetId: user.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totpRequired: false,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
totpEnabled: user.totpEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login/totp', loginLimit, async (request, reply) => {
|
||||||
|
const body = parseWith(totpSchema, request.body);
|
||||||
|
|
||||||
|
const userId = verifyChallenge(body.challenge);
|
||||||
|
if (userId === null) {
|
||||||
|
throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user || !user.isActive || !user.totpSecret) throw unauthorized();
|
||||||
|
|
||||||
|
if (!(await verifyTotp(user.totpSecret, body.code))) {
|
||||||
|
await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, {
|
||||||
|
action: 'auth.totp_failed',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
throw unauthorized('Der Code stimmt nicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, expiresAt } = await createSession(
|
||||||
|
user.id,
|
||||||
|
request.ip,
|
||||||
|
request.headers['user-agent'],
|
||||||
|
);
|
||||||
|
issueSession(reply, token, expiresAt);
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||||
|
await recordAudit(
|
||||||
|
{ auditLog: prisma.auditLog },
|
||||||
|
{ actorId: user.id, actorName: user.username, ip: request.ip },
|
||||||
|
{ action: 'auth.login', targetType: 'user', targetId: user.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
totpEnabled: user.totpEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
|
if (request.sessionToken) {
|
||||||
|
await destroySession(request.sessionToken);
|
||||||
|
}
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
reply.clearCookie(CSRF_COOKIE, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', async (request) => {
|
||||||
|
if (!request.user) return { user: null };
|
||||||
|
return { user: request.user };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Eigener Account ---
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/account/password',
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (request) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(1),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||||
|
if (!user) throw unauthorized();
|
||||||
|
|
||||||
|
if (!(await verifyPassword(user.passwordHash, body.currentPassword))) {
|
||||||
|
throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const weak = validatePasswordStrength(body.newPassword);
|
||||||
|
if (weak) throw badRequest('WEAK_PASSWORD', weak);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await hashPassword(body.newPassword),
|
||||||
|
mustChangePassword: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Andere Sitzungen beenden, die eigene bleibt bestehen.
|
||||||
|
if (request.sessionToken) {
|
||||||
|
await destroyOtherSessionsFor(user.id, request.sessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'account.password_changed',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const secret = generateSecret();
|
||||||
|
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: request.user!.id },
|
||||||
|
data: { totpSecret: secret, totpEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = otpauthUrl(request.user!.username, secret);
|
||||||
|
return { secret, otpauth: url, qr: await qrDataUrl(url) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||||
|
if (!user?.totpSecret) {
|
||||||
|
throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten');
|
||||||
|
}
|
||||||
|
if (!(await verifyTotp(user.totpSecret, body.code))) {
|
||||||
|
throw badRequest('WRONG_CODE', 'Der Code stimmt nicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } });
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'account.totp_enabled',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const body = parseWith(z.object({ password: z.string().min(1) }), request.body);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||||
|
if (!user) throw unauthorized();
|
||||||
|
if (!(await verifyPassword(user.passwordHash, body.password))) {
|
||||||
|
throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { totpEnabled: false, totpSecret: null },
|
||||||
|
});
|
||||||
|
await recordAudit(prisma, contextOf(request), {
|
||||||
|
action: 'account.totp_disabled',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => {
|
||||||
|
await destroyAllSessionsFor(request.user!.id);
|
||||||
|
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
reply.clearCookie(CSRF_COOKIE, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CreditKind, InactiveReason } from '@prisma/client';
|
||||||
|
import { requireEditor } from '../auth/guard.js';
|
||||||
|
import {
|
||||||
|
createCharacter,
|
||||||
|
deactivateCharacter,
|
||||||
|
reactivateFree,
|
||||||
|
reactivatePaid,
|
||||||
|
redesignCharacter,
|
||||||
|
revertCharacter,
|
||||||
|
updateCharacter,
|
||||||
|
} from '../services/characters.js';
|
||||||
|
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
||||||
|
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
||||||
|
|
||||||
|
export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post('/api/players/:id/characters', { preHandler: requireEditor }, async (request, reply) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(160),
|
||||||
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
const result = await createCharacter(id, body, contextOf(request));
|
||||||
|
reply.code(201);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/characters/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(160).optional(),
|
||||||
|
loreText: z.string().trim().max(5000).nullable().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return updateCharacter(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/redesign', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(160),
|
||||||
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
|
useKind: redesignKind,
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return redesignCharacter(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
reason: z.enum([InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER]),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return deactivateCharacter(id, body.reason, contextOf(request), body.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/reactivate-free', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return reactivateFree(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/reactivate-paid', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({ useKind: reviveKind, note: optionalNote }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return revertCharacter(id, body.reason, contextOf(request));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CreditKind } from '@prisma/client';
|
||||||
|
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
||||||
|
import {
|
||||||
|
bookAdjustment,
|
||||||
|
bookCcPackage,
|
||||||
|
bookKulanz,
|
||||||
|
bookRedesignPackage,
|
||||||
|
reverseGroup,
|
||||||
|
} from '../services/credits.js';
|
||||||
|
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const noteBody = z.object({ note: optionalNote });
|
||||||
|
|
||||||
|
export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
|
return bookCcPackage(id, contextOf(request), { note: body.note });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/redesign-package',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
|
return bookRedesignPackage(id, contextOf(request), { note: body.note });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
|
return bookKulanz(id, contextOf(request), { note: body.note });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return bookAdjustment(id, body.kind, body.amount, body.note, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
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) }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reverseGroup(id, body.reason, contextOf(request));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
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.
|
||||||
|
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const path = issue.path.join('.');
|
||||||
|
return path ? `${path}: ${issue.message}` : issue.message;
|
||||||
|
})
|
||||||
|
.join('; ');
|
||||||
|
throw badRequest('VALIDATION_FAILED', message);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextOf(request: FastifyRequest): ActionContext {
|
||||||
|
if (!request.user) throw unauthorized();
|
||||||
|
return {
|
||||||
|
actorId: request.user.id,
|
||||||
|
actorName: request.user.username,
|
||||||
|
ip: request.ip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const idParam = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
|
||||||
|
export const optionalNote = z.string().trim().max(500).optional();
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAdmin } from '../auth/guard.js';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
|
import {
|
||||||
|
applyMapping,
|
||||||
|
commitImport,
|
||||||
|
createImport,
|
||||||
|
discardImport,
|
||||||
|
getImport,
|
||||||
|
listImports,
|
||||||
|
parseUpload,
|
||||||
|
} from '../services/imports.js';
|
||||||
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const mappingBody = z.object({
|
||||||
|
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
|
||||||
|
playerName: z.string().optional(),
|
||||||
|
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
|
||||||
|
orderId: z.string().optional(),
|
||||||
|
purchasedAt: z.string().optional(),
|
||||||
|
createMissingPlayers: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// Der Import veraendert Guthaben in grosser 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');
|
||||||
|
|
||||||
|
const content = await file.toBuffer();
|
||||||
|
const parsed = parseUpload(file.filename, content);
|
||||||
|
const importId = await createImport(file.filename, parsed, contextOf(request));
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
return {
|
||||||
|
importId,
|
||||||
|
columns: parsed.columns,
|
||||||
|
rowCount: parsed.rows.length,
|
||||||
|
sample: parsed.rows.slice(0, 5),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
|
||||||
|
return { items: await listImports() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return getImport(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(mappingBody, request.body);
|
||||||
|
const summary = await applyMapping(id, body, contextOf(request));
|
||||||
|
const record = await getImport(id);
|
||||||
|
return { summary, import: record };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return commitImport(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
await discardImport(id, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CreditKind } from '@prisma/client';
|
||||||
|
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
|
import { listLedger } from '../services/credits.js';
|
||||||
|
import {
|
||||||
|
createPlayer,
|
||||||
|
deletePlayer,
|
||||||
|
getPlayerDetail,
|
||||||
|
searchPlayers,
|
||||||
|
updatePlayer,
|
||||||
|
} from '../services/players.js';
|
||||||
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
const playerBody = z.object({
|
||||||
|
displayName: z.string().trim().min(1).max(120),
|
||||||
|
steamId: z.string().trim().max(64).nullish(),
|
||||||
|
forumName: z.string().trim().max(120).nullish(),
|
||||||
|
notes: z.string().trim().max(2000).nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/players', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({
|
||||||
|
q: z.string().trim().max(120).default(''),
|
||||||
|
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
|
}),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return searchPlayers(query.q, query.take, query.skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return getPlayerDetail(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/players/:id/ledger', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({
|
||||||
|
kind: z.nativeEnum(CreditKind).optional(),
|
||||||
|
take: z.coerce.number().int().min(1).max(500).default(100),
|
||||||
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
|
}),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return listLedger({ playerId: id, ...query });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/players', { preHandler: requireEditor }, async (request, reply) => {
|
||||||
|
const body = parseWith(playerBody, request.body);
|
||||||
|
const created = await createPlayer(body, contextOf(request));
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(playerBody.partial(), request.body);
|
||||||
|
return updatePlayer(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
await deletePlayer(id, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
|
||||||
|
export interface ActionContext {
|
||||||
|
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
|
||||||
|
actorId: number | null;
|
||||||
|
actorName: string;
|
||||||
|
ip?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId?: string | number | null;
|
||||||
|
details?: Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client = Pick<typeof prisma, 'auditLog'>;
|
||||||
|
|
||||||
|
export async function recordAudit(
|
||||||
|
client: Client,
|
||||||
|
ctx: ActionContext,
|
||||||
|
entry: AuditEntry,
|
||||||
|
): Promise<void> {
|
||||||
|
await client.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorUserId: ctx.actorId,
|
||||||
|
actorName: ctx.actorName,
|
||||||
|
action: entry.action,
|
||||||
|
targetType: entry.targetType,
|
||||||
|
targetId: entry.targetId == null ? null : String(entry.targetId),
|
||||||
|
details: entry.details ?? undefined,
|
||||||
|
ip: ctx.ip ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
CharacterStatus,
|
||||||
|
CreditKind,
|
||||||
|
CreditSource,
|
||||||
|
InactiveReason,
|
||||||
|
type Character,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { getBalances, type Balances } from './credits.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
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.
|
||||||
|
overdrawn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
|
||||||
|
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
||||||
|
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
|
||||||
|
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
||||||
|
|
||||||
|
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
||||||
|
export type ReviveKind = (typeof REVIVE_KINDS)[number];
|
||||||
|
|
||||||
|
function isOverdrawn(balances: Balances): boolean {
|
||||||
|
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCharacter(id: number): Promise<Character> {
|
||||||
|
const character = await prisma.character.findUnique({ where: { id } });
|
||||||
|
if (!character) throw notFound('CC');
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
|
||||||
|
export async function createCharacter(
|
||||||
|
playerId: number,
|
||||||
|
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.character.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
name: input.name,
|
||||||
|
loreText: input.loreText ?? null,
|
||||||
|
status: CharacterStatus.ACTIVE,
|
||||||
|
lineageId: randomUUID(),
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind: CreditKind.CC,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: created.id,
|
||||||
|
note: input.note ?? `Neuer CC: ${created.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.create',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { playerId, name: created.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function redesignCharacter(
|
||||||
|
characterId: number,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
loreText?: string | undefined;
|
||||||
|
useKind: RedesignKind;
|
||||||
|
note?: string | undefined;
|
||||||
|
},
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult & { predecessor: Character }> {
|
||||||
|
const old = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (old.status !== CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
|
||||||
|
}
|
||||||
|
if (!REDESIGN_KINDS.includes(input.useKind)) {
|
||||||
|
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const predecessor = await tx.character.update({
|
||||||
|
where: { id: old.id },
|
||||||
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await tx.character.create({
|
||||||
|
data: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
name: input.name,
|
||||||
|
loreText: input.loreText ?? null,
|
||||||
|
status: CharacterStatus.ACTIVE,
|
||||||
|
lineageId: old.lineageId,
|
||||||
|
predecessorId: old.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
kind: input.useKind,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: created.id,
|
||||||
|
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.redesign',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: created.id,
|
||||||
|
details: {
|
||||||
|
playerId: old.playerId,
|
||||||
|
predecessorId: old.id,
|
||||||
|
from: old.name,
|
||||||
|
to: created.name,
|
||||||
|
useKind: input.useKind,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created, predecessor };
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(old.playerId);
|
||||||
|
return {
|
||||||
|
character: result.created,
|
||||||
|
predecessor: result.predecessor,
|
||||||
|
balances,
|
||||||
|
overdrawn: isOverdrawn(balances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
|
||||||
|
export async function deactivateCharacter(
|
||||||
|
characterId: number,
|
||||||
|
reason: InactiveReason,
|
||||||
|
ctx: ActionContext,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
if (existing.status === CharacterStatus.INACTIVE) {
|
||||||
|
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
|
||||||
|
}
|
||||||
|
if (reason === InactiveReason.REWORKED) {
|
||||||
|
throw badRequest(
|
||||||
|
'USE_REDESIGN',
|
||||||
|
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: reason },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.deactivate',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { reason, note: note ?? null, name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen
|
||||||
|
* Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert
|
||||||
|
* weiter und kostet deshalb kein Guthaben.
|
||||||
|
*/
|
||||||
|
export async function reactivateFree(
|
||||||
|
characterId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (existing.status === CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||||
|
}
|
||||||
|
if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) {
|
||||||
|
throw conflict(
|
||||||
|
'NOT_FREE',
|
||||||
|
'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.reactivate_free',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { playerId: updated.playerId, name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren
|
||||||
|
* (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder
|
||||||
|
* ein CC-Guthaben, der Nutzer entscheidet welches.
|
||||||
|
*/
|
||||||
|
export async function reactivatePaid(
|
||||||
|
characterId: number,
|
||||||
|
useKind: ReviveKind,
|
||||||
|
ctx: ActionContext,
|
||||||
|
note?: string,
|
||||||
|
): Promise<CharacterResult> {
|
||||||
|
const existing = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
if (existing.status === CharacterStatus.ACTIVE) {
|
||||||
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||||
|
}
|
||||||
|
if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) {
|
||||||
|
throw conflict(
|
||||||
|
'IS_FREE',
|
||||||
|
'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!REVIVE_KINDS.includes(useKind)) {
|
||||||
|
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = await prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: updated.playerId,
|
||||||
|
kind: useKind,
|
||||||
|
amount: -1,
|
||||||
|
source: CreditSource.CONSUME,
|
||||||
|
groupId: randomUUID(),
|
||||||
|
characterId: updated.id,
|
||||||
|
note: note ?? `Reaktivierung: ${updated.name}`,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.reactivate_paid',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: {
|
||||||
|
playerId: updated.playerId,
|
||||||
|
name: updated.name,
|
||||||
|
useKind,
|
||||||
|
previousReason: existing.inactiveReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
const balances = await getBalances(character.playerId);
|
||||||
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCharacter(
|
||||||
|
characterId: number,
|
||||||
|
input: { name?: string; loreText?: string | null },
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<Character> {
|
||||||
|
await loadCharacter(characterId);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.character.update({
|
||||||
|
where: { id: characterId },
|
||||||
|
data: {
|
||||||
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
|
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.update',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: { name: updated.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
|
||||||
|
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
|
||||||
|
* kehrt in den aktiven Zustand zurueck.
|
||||||
|
*/
|
||||||
|
export async function revertCharacter(
|
||||||
|
characterId: number,
|
||||||
|
reason: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
|
||||||
|
const existing = await prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
include: { successors: { select: { id: true } }, predecessor: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw notFound('CC');
|
||||||
|
|
||||||
|
if (existing.successors.length > 0) {
|
||||||
|
throw conflict(
|
||||||
|
'HAS_SUCCESSORS',
|
||||||
|
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await prisma.creditLedger.findMany({
|
||||||
|
where: { characterId, source: CreditSource.CONSUME },
|
||||||
|
include: { reversedBy: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
const open = consumed.filter((entry) => entry.reversedBy === null);
|
||||||
|
|
||||||
|
const predecessor = existing.predecessor;
|
||||||
|
const restorePredecessor =
|
||||||
|
predecessor !== null &&
|
||||||
|
predecessor.status === CharacterStatus.INACTIVE &&
|
||||||
|
predecessor.inactiveReason === InactiveReason.REWORKED;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const entry of open) {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: entry.playerId,
|
||||||
|
kind: entry.kind,
|
||||||
|
amount: -entry.amount,
|
||||||
|
source: CreditSource.REVERSAL,
|
||||||
|
groupId: entry.groupId,
|
||||||
|
note: `Ruecknahme "${existing.name}": ${reason}`,
|
||||||
|
reversalOfId: entry.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restorePredecessor && predecessor) {
|
||||||
|
await tx.character.update({
|
||||||
|
where: { id: predecessor.id },
|
||||||
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.character.delete({ where: { id: characterId } });
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'character.revert',
|
||||||
|
targetType: 'character',
|
||||||
|
targetId: characterId,
|
||||||
|
details: {
|
||||||
|
playerId: existing.playerId,
|
||||||
|
name: existing.name,
|
||||||
|
reason,
|
||||||
|
reversedLedgerIds: open.map((e) => e.id),
|
||||||
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances: await getBalances(existing.playerId),
|
||||||
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCharacters(playerId: number) {
|
||||||
|
return prisma.character.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: {
|
||||||
|
predecessor: { select: { id: true, name: true } },
|
||||||
|
successors: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { CreditKind, CreditSource, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export interface Balances {
|
||||||
|
CC: number;
|
||||||
|
REDESIGN_PAID: number;
|
||||||
|
REDESIGN_KULANZ: number;
|
||||||
|
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
||||||
|
redesignTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 };
|
||||||
|
|
||||||
|
function assemble(sums: Partial<Record<CreditKind, number>>): Balances {
|
||||||
|
const cc = sums.CC ?? 0;
|
||||||
|
const paid = sums.REDESIGN_PAID ?? 0;
|
||||||
|
const kulanz = sums.REDESIGN_KULANZ ?? 0;
|
||||||
|
return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalances(playerId: number): Promise<Balances> {
|
||||||
|
const rows = await prisma.creditLedger.groupBy({
|
||||||
|
by: ['kind'],
|
||||||
|
where: { playerId },
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sums: Partial<Record<CreditKind, number>> = {};
|
||||||
|
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
|
||||||
|
return assemble(sums);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
|
||||||
|
const result = new Map<number, Balances>();
|
||||||
|
if (playerIds.length === 0) return result;
|
||||||
|
|
||||||
|
const rows = await prisma.creditLedger.groupBy({
|
||||||
|
by: ['playerId', 'kind'],
|
||||||
|
where: { playerId: { in: playerIds } },
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = new Map<number, Partial<Record<CreditKind, number>>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const entry = grouped.get(row.playerId) ?? {};
|
||||||
|
entry[row.kind] = row._sum.amount ?? 0;
|
||||||
|
grouped.set(row.playerId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of playerIds) {
|
||||||
|
const sums = grouped.get(id);
|
||||||
|
result.set(id, sums ? assemble(sums) : { ...EMPTY });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requirePlayer(playerId: number): Promise<void> {
|
||||||
|
const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||||
|
if (!exists) throw notFound('Spieler');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageRowParams {
|
||||||
|
playerId: number;
|
||||||
|
groupId: string;
|
||||||
|
note?: string | null;
|
||||||
|
importId?: number | null;
|
||||||
|
actorId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function buildPackageRows(
|
||||||
|
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||||
|
params: PackageRowParams,
|
||||||
|
): Prisma.CreditLedgerCreateManyInput[] {
|
||||||
|
const base = {
|
||||||
|
playerId: params.playerId,
|
||||||
|
groupId: params.groupId,
|
||||||
|
note: params.note ?? null,
|
||||||
|
importId: params.importId ?? null,
|
||||||
|
createdById: params.actorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target === 'REDESIGN_PACKAGE') {
|
||||||
|
return [
|
||||||
|
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE },
|
||||||
|
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function bookCcPackage(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = options.groupId ?? randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows('CC_PACKAGE', {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
importId: options.importId ?? null,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.cc_package',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bookRedesignPackage(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = options.groupId ?? randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows('REDESIGN_PACKAGE', {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
importId: options.importId ?? null,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.redesign_package',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bookKulanz(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
options: { note?: string | undefined } = {},
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind: CreditKind.REDESIGN_KULANZ,
|
||||||
|
amount: 1,
|
||||||
|
source: CreditSource.KULANZ,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.kulanz',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
||||||
|
export async function bookAdjustment(
|
||||||
|
playerId: number,
|
||||||
|
kind: CreditKind,
|
||||||
|
amount: number,
|
||||||
|
note: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ groupId: string; balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
if (!Number.isInteger(amount) || amount === 0) {
|
||||||
|
throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind,
|
||||||
|
amount,
|
||||||
|
source: CreditSource.ADJUSTMENT,
|
||||||
|
groupId,
|
||||||
|
note,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.adjustment',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, kind, amount, note },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
||||||
|
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||||
|
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
||||||
|
*/
|
||||||
|
export async function reverseGroup(
|
||||||
|
ledgerId: number,
|
||||||
|
reason: string,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances; reversed: number }> {
|
||||||
|
const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } });
|
||||||
|
if (!anchor) throw notFound('Buchung');
|
||||||
|
|
||||||
|
if (anchor.source === CreditSource.CONSUME) {
|
||||||
|
throw conflict(
|
||||||
|
'CONSUME_NOT_REVERSIBLE',
|
||||||
|
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (anchor.source === CreditSource.REVERSAL) {
|
||||||
|
throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await prisma.creditLedger.findMany({
|
||||||
|
where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } },
|
||||||
|
include: { reversedBy: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = entries.filter((e) => e.reversedBy === null);
|
||||||
|
if (open.length === 0) {
|
||||||
|
throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const entry of open) {
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: entry.playerId,
|
||||||
|
kind: entry.kind,
|
||||||
|
amount: -entry.amount,
|
||||||
|
source: CreditSource.REVERSAL,
|
||||||
|
groupId: entry.groupId,
|
||||||
|
note: reason,
|
||||||
|
reversalOfId: entry.id,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.reverse',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: anchor.playerId,
|
||||||
|
details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { balances: await getBalances(anchor.playerId), reversed: open.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerFilter {
|
||||||
|
playerId: number;
|
||||||
|
kind?: CreditKind;
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLedger(filter: LedgerFilter) {
|
||||||
|
const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId };
|
||||||
|
if (filter.kind) where.kind = filter.kind;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.creditLedger.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: filter.take ?? 100,
|
||||||
|
skip: filter.skip ?? 0,
|
||||||
|
include: {
|
||||||
|
createdBy: { select: { username: true } },
|
||||||
|
character: { select: { id: true, name: true } },
|
||||||
|
reversedBy: { select: { id: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.creditLedger.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
import { ImportStatus, ProductTarget, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { buildPackageRows } from './credits.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export const PLANS = {
|
||||||
|
BOOK: 'BOOK',
|
||||||
|
CREATE_PLAYER_AND_BOOK: 'CREATE_PLAYER_AND_BOOK',
|
||||||
|
SKIP_DUPLICATE: 'SKIP_DUPLICATE',
|
||||||
|
SKIP_IGNORED: 'SKIP_IGNORED',
|
||||||
|
SKIP_UNMAPPED: 'SKIP_UNMAPPED',
|
||||||
|
SKIP_NO_PLAYER: 'SKIP_NO_PLAYER',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Plan = (typeof PLANS)[keyof typeof PLANS];
|
||||||
|
|
||||||
|
export interface ColumnMapping {
|
||||||
|
playerKey: string;
|
||||||
|
playerName?: string | undefined;
|
||||||
|
product: string;
|
||||||
|
orderId?: string | undefined;
|
||||||
|
purchasedAt?: string | undefined;
|
||||||
|
createMissingPlayers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedFile {
|
||||||
|
columns: string[];
|
||||||
|
rows: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ROWS = 20000;
|
||||||
|
|
||||||
|
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
||||||
|
function detectDelimiter(sample: string): string {
|
||||||
|
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||||
|
const candidates = [',', ';', '\t', '|'];
|
||||||
|
let best = ',';
|
||||||
|
let bestCount = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const count = firstLine.split(candidate).length - 1;
|
||||||
|
if (count > bestCount) {
|
||||||
|
best = candidate;
|
||||||
|
bestCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow(row: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
out[key] = '';
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
out[key] = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
out[key] = String(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||||
|
const text = content.toString('utf8').replace(/^/, '');
|
||||||
|
if (text.trim().length === 0) throw badRequest('EMPTY_FILE', 'Die Datei ist leer');
|
||||||
|
|
||||||
|
const isJson = filename.toLowerCase().endsWith('.json') || /^\s*[[{]/.test(text);
|
||||||
|
let rows: Record<string, string>[];
|
||||||
|
|
||||||
|
if (isJson) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: typeof data === 'object' && data !== null
|
||||||
|
? Object.values(data).find((v): v is unknown[] => Array.isArray(v))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
||||||
|
}
|
||||||
|
rows = list
|
||||||
|
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||||
|
.map(normalizeRow);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsed = parse(text, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
relax_column_count: true,
|
||||||
|
delimiter: detectDelimiter(text),
|
||||||
|
bom: true,
|
||||||
|
}) as Record<string, unknown>[];
|
||||||
|
rows = parsed.map(normalizeRow);
|
||||||
|
} catch {
|
||||||
|
throw badRequest('INVALID_CSV', 'Die CSV-Datei konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
||||||
|
if (rows.length > MAX_ROWS) {
|
||||||
|
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
||||||
|
return { columns, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImport(
|
||||||
|
filename: string,
|
||||||
|
parsed: ParsedFile,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<number> {
|
||||||
|
const created = await prisma.import.create({
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
status: ImportStatus.PENDING,
|
||||||
|
rowCount: parsed.rows.length,
|
||||||
|
importedById: ctx.actorId,
|
||||||
|
rows: {
|
||||||
|
create: parsed.rows.map((raw, index) => ({
|
||||||
|
lineNo: index + 1,
|
||||||
|
plan: PLANS.SKIP_UNMAPPED,
|
||||||
|
raw: raw as Prisma.InputJsonValue,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.upload',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { filename, rowCount: parsed.rows.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTarget(
|
||||||
|
productName: string,
|
||||||
|
mappings: { matchPattern: string; target: ProductTarget }[],
|
||||||
|
): ProductTarget | null {
|
||||||
|
const haystack = productName.toLowerCase();
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (haystack.includes(mapping.matchPattern.toLowerCase())) return mapping.target;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: string | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
||||||
|
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
||||||
|
*/
|
||||||
|
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||||
|
const record = await prisma.import.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
include: { rows: { orderBy: { lineNo: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status !== ImportStatus.PENDING) {
|
||||||
|
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const productMappings = await prisma.productMapping.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { matchPattern: true, target: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawRows = record.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
lineNo: row.lineNo,
|
||||||
|
raw: row.raw as Record<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const playerKeys = [
|
||||||
|
...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const players = await prisma.player.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||||
|
},
|
||||||
|
select: { id: true, steamId: true, displayName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
|
||||||
|
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
||||||
|
|
||||||
|
const orderIds = mapping.orderId
|
||||||
|
? [...new Set(rawRows.map((r) => (r.raw[mapping.orderId as string] ?? '').trim()).filter(Boolean))]
|
||||||
|
: [];
|
||||||
|
const alreadyBooked = new Set(
|
||||||
|
orderIds.length > 0
|
||||||
|
? (
|
||||||
|
await prisma.importedOrder.findMany({
|
||||||
|
where: { externalId: { in: orderIds } },
|
||||||
|
select: { externalId: true },
|
||||||
|
})
|
||||||
|
).map((o) => o.externalId)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Faengt Dubletten innerhalb derselben Datei ab.
|
||||||
|
const seenInFile = new Set<string>();
|
||||||
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
|
const summary: Record<Plan, number> = {
|
||||||
|
BOOK: 0,
|
||||||
|
CREATE_PLAYER_AND_BOOK: 0,
|
||||||
|
SKIP_DUPLICATE: 0,
|
||||||
|
SKIP_IGNORED: 0,
|
||||||
|
SKIP_UNMAPPED: 0,
|
||||||
|
SKIP_NO_PLAYER: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rawRows) {
|
||||||
|
const playerKey = (row.raw[mapping.playerKey] ?? '').trim();
|
||||||
|
const playerName = mapping.playerName ? (row.raw[mapping.playerName] ?? '').trim() : '';
|
||||||
|
const productName = (row.raw[mapping.product] ?? '').trim();
|
||||||
|
const externalId = mapping.orderId ? (row.raw[mapping.orderId] ?? '').trim() : '';
|
||||||
|
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
|
||||||
|
|
||||||
|
const target = productName ? matchTarget(productName, productMappings) : null;
|
||||||
|
const resolvedPlayerId =
|
||||||
|
bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
let plan: Plan;
|
||||||
|
if (target === null) {
|
||||||
|
plan = PLANS.SKIP_UNMAPPED;
|
||||||
|
} else if (target === ProductTarget.IGNORE) {
|
||||||
|
plan = PLANS.SKIP_IGNORED;
|
||||||
|
} else if (externalId && (alreadyBooked.has(externalId) || seenInFile.has(externalId))) {
|
||||||
|
plan = PLANS.SKIP_DUPLICATE;
|
||||||
|
} else if (resolvedPlayerId !== null) {
|
||||||
|
plan = PLANS.BOOK;
|
||||||
|
} else if (mapping.createMissingPlayers && (playerKey || playerName)) {
|
||||||
|
plan = PLANS.CREATE_PLAYER_AND_BOOK;
|
||||||
|
} else {
|
||||||
|
plan = PLANS.SKIP_NO_PLAYER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalId && (plan === PLANS.BOOK || plan === PLANS.CREATE_PLAYER_AND_BOOK)) {
|
||||||
|
seenInFile.add(externalId);
|
||||||
|
}
|
||||||
|
summary[plan] += 1;
|
||||||
|
|
||||||
|
updates.push(
|
||||||
|
prisma.importRow.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
playerKey: playerKey || null,
|
||||||
|
playerName: playerName || null,
|
||||||
|
productName: productName || null,
|
||||||
|
externalId: externalId || null,
|
||||||
|
purchasedAt,
|
||||||
|
target,
|
||||||
|
resolvedPlayerId,
|
||||||
|
plan,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
...updates,
|
||||||
|
prisma.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: { mapping: mapping as unknown as Prisma.InputJsonValue },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.preview',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: { mapping: mapping as unknown as Prisma.InputJsonValue, summary },
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImport(importId: number, includeRows = true) {
|
||||||
|
const record = await prisma.import.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
include: {
|
||||||
|
importedBy: { select: { username: true } },
|
||||||
|
...(includeRows ? { rows: { orderBy: { lineNo: 'asc' as const } } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuehrt 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 },
|
||||||
|
include: { rows: { orderBy: { lineNo: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status !== ImportStatus.PENDING) {
|
||||||
|
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookable = record.rows.filter(
|
||||||
|
(row) => row.plan === PLANS.BOOK || row.plan === PLANS.CREATE_PLAYER_AND_BOOK,
|
||||||
|
);
|
||||||
|
if (bookable.length === 0) {
|
||||||
|
throw conflict('NOTHING_TO_BOOK', 'Es gibt keine Zeilen zum Buchen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
||||||
|
const createdPlayers = new Map<string, number>();
|
||||||
|
let booked = 0;
|
||||||
|
|
||||||
|
for (const row of bookable) {
|
||||||
|
const key = (row.playerKey ?? '').trim();
|
||||||
|
let playerId = row.resolvedPlayerId;
|
||||||
|
|
||||||
|
if (playerId === null) {
|
||||||
|
const cacheKey = key.toLowerCase();
|
||||||
|
const cached = createdPlayers.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
playerId = cached;
|
||||||
|
} else {
|
||||||
|
const created = await tx.player.create({
|
||||||
|
data: {
|
||||||
|
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
|
||||||
|
steamId: key || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdPlayers.set(cacheKey, created.id);
|
||||||
|
playerId = created.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows(target, {
|
||||||
|
playerId,
|
||||||
|
groupId,
|
||||||
|
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
||||||
|
importId: record.id,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.externalId) {
|
||||||
|
await tx.importedOrder.create({
|
||||||
|
data: {
|
||||||
|
externalId: row.externalId,
|
||||||
|
importId: record.id,
|
||||||
|
playerId,
|
||||||
|
target: row.target ?? ProductTarget.CC_PACKAGE,
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
booked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tx.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: {
|
||||||
|
status: ImportStatus.COMMITTED,
|
||||||
|
committedAt: new Date(),
|
||||||
|
bookedCount: booked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'import.commit',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: {
|
||||||
|
filename: record.filename,
|
||||||
|
booked,
|
||||||
|
createdPlayers: createdPlayers.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { booked, createdPlayers: createdPlayers.size, import: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
|
||||||
|
const record = await prisma.import.findUnique({ where: { id: importId } });
|
||||||
|
if (!record) throw notFound('Import');
|
||||||
|
if (record.status === ImportStatus.COMMITTED) {
|
||||||
|
throw conflict('ALREADY_COMMITTED', 'Ein gebuchter Import kann nicht verworfen werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.import.update({
|
||||||
|
where: { id: importId },
|
||||||
|
data: { status: ImportStatus.DISCARDED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.discard',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: importId,
|
||||||
|
details: { filename: record.filename },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listImports(take = 50) {
|
||||||
|
return prisma.import.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take,
|
||||||
|
include: { importedBy: { select: { username: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { conflict, notFound } from '../errors.js';
|
||||||
|
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
||||||
|
import { listCharacters } from './characters.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
export interface PlayerInput {
|
||||||
|
displayName: string;
|
||||||
|
steamId?: string | null;
|
||||||
|
forumName?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const where: Prisma.PlayerWhereInput =
|
||||||
|
trimmed.length === 0
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
||||||
|
{ steamId: { contains: trimmed, mode: 'insensitive' } },
|
||||||
|
{ forumName: { contains: trimmed, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [players, total] = await Promise.all([
|
||||||
|
prisma.player.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
include: { _count: { select: { characters: true } } },
|
||||||
|
}),
|
||||||
|
prisma.player.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const balances = await getBalancesForMany(players.map((p) => p.id));
|
||||||
|
const activeCounts = await prisma.character.groupBy({
|
||||||
|
by: ['playerId'],
|
||||||
|
where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
items: players.map((player) => ({
|
||||||
|
id: player.id,
|
||||||
|
displayName: player.displayName,
|
||||||
|
steamId: player.steamId,
|
||||||
|
forumName: player.forumName,
|
||||||
|
characterCount: player._count.characters,
|
||||||
|
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
|
||||||
|
balances: balances.get(player.id) as Balances,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayerDetail(playerId: number) {
|
||||||
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const [balances, characters, ledger] = await Promise.all([
|
||||||
|
getBalances(playerId),
|
||||||
|
listCharacters(playerId),
|
||||||
|
listLedger({ playerId, take: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
|
||||||
|
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
|
||||||
|
if (existing && existing.id !== exceptId) {
|
||||||
|
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||||
|
const steamId = input.steamId?.trim() || null;
|
||||||
|
if (steamId) await assertSteamIdFree(steamId);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.player.create({
|
||||||
|
data: {
|
||||||
|
displayName: input.displayName.trim(),
|
||||||
|
steamId,
|
||||||
|
forumName: input.forumName?.trim() || null,
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'player.create',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: created.id,
|
||||||
|
details: { displayName: created.displayName, steamId: created.steamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
|
||||||
|
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null;
|
||||||
|
if (steamId) await assertSteamIdFree(steamId, playerId);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.player.update({
|
||||||
|
where: { id: playerId },
|
||||||
|
data: {
|
||||||
|
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
|
||||||
|
...(steamId !== undefined ? { steamId } : {}),
|
||||||
|
...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}),
|
||||||
|
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'player.update',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { displayName: updated.displayName, steamId: updated.steamId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||||
|
const existing = await prisma.player.findUnique({
|
||||||
|
where: { id: playerId },
|
||||||
|
include: { _count: { select: { characters: true, ledger: true } } },
|
||||||
|
});
|
||||||
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.player.delete({ where: { id: playerId } });
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'player.delete',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: {
|
||||||
|
displayName: existing.displayName,
|
||||||
|
steamId: existing.steamId,
|
||||||
|
removedCharacters: existing._count.characters,
|
||||||
|
removedLedgerRows: existing._count.ledger,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { PGlite } from '@electric-sql/pglite';
|
||||||
|
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql');
|
||||||
|
|
||||||
|
export interface TestDatabase {
|
||||||
|
url: string;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen
|
||||||
|
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
|
||||||
|
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
||||||
|
*/
|
||||||
|
export async function startTestDatabase(port: number): Promise<TestDatabase> {
|
||||||
|
const db = await PGlite.create();
|
||||||
|
await db.exec(readFileSync(MIGRATION, 'utf8'));
|
||||||
|
|
||||||
|
const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' });
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
||||||
|
// gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht.
|
||||||
|
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
|
||||||
|
stop: async () => {
|
||||||
|
await server.stop();
|
||||||
|
await db.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
const PASSWORD = 'TestPasswort123';
|
||||||
|
|
||||||
|
interface Credentials {
|
||||||
|
session: string;
|
||||||
|
csrf: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username: string, password = PASSWORD): Promise<Credentials> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: { username, password },
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`);
|
||||||
|
|
||||||
|
const session = response.cookies.find((c) => c.name === 'ccm_sid');
|
||||||
|
const csrf = response.cookies.find((c) => c.name === 'ccm_csrf');
|
||||||
|
assert.ok(session && csrf, 'Sitzungs-Cookies fehlen');
|
||||||
|
|
||||||
|
return { session: session.value, csrf: csrf.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(credentials: Credentials, withCsrf = true) {
|
||||||
|
return {
|
||||||
|
cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`,
|
||||||
|
...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54330);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.COOKIE_SECURE = 'false';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
const { hashPassword } = await import('../src/auth/password.js');
|
||||||
|
const { buildApp } = await import('../src/app.js');
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(PASSWORD);
|
||||||
|
await db.prisma.user.createMany({
|
||||||
|
data: [
|
||||||
|
{ username: 'chef', passwordHash, role: 'ADMIN' },
|
||||||
|
{ username: 'team', passwordHash, role: 'EDITOR' },
|
||||||
|
{ username: 'gast', passwordHash, role: 'VIEWER' },
|
||||||
|
{ username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true },
|
||||||
|
{ username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
app = await buildApp();
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await app.close();
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anmeldung', () => {
|
||||||
|
it('weist ein falsches Passwort ab', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: { username: 'chef', password: 'falsch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verraet nicht, ob es den Benutzernamen gibt', async () => {
|
||||||
|
const unknownUser = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: { username: 'gibtesnicht', password: 'falsch' },
|
||||||
|
});
|
||||||
|
const wrongPassword = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: { username: 'chef', password: 'falsch' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(unknownUser.statusCode, wrongPassword.statusCode);
|
||||||
|
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst ein gesperrtes Konto nicht herein', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: { username: 'gesperrt', password: PASSWORD },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt bei Erfolg beide Cookies', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
assert.ok(credentials.session.length > 20);
|
||||||
|
assert.ok(credentials.csrf.length > 20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSRF-Schutz', () => {
|
||||||
|
it('blockt eine schreibende Anfrage ohne Token', async () => {
|
||||||
|
const credentials = await login('team');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials, false),
|
||||||
|
payload: { displayName: 'Ohne Token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.equal(response.json().error, 'FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockt einen gefaelschten Token', async () => {
|
||||||
|
const credentials = await login('team');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) },
|
||||||
|
payload: { displayName: 'Falscher Token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst die Anfrage mit passendem Token durch', async () => {
|
||||||
|
const credentials = await login('team');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { displayName: 'Mit Token' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst lesende Anfragen ohne Token zu', async () => {
|
||||||
|
const credentials = await login('gast');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials, false),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rollen', () => {
|
||||||
|
it('ein Viewer darf lesen, aber nichts anlegen', async () => {
|
||||||
|
const credentials = await login('gast');
|
||||||
|
|
||||||
|
const read = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
const write = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { displayName: 'Verboten' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(read.statusCode, 200);
|
||||||
|
assert.equal(write.statusCode, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Editor kommt nicht in den Admin-Bereich', async () => {
|
||||||
|
const credentials = await login('team');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/users',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Admin kommt in den Admin-Bereich', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/users',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.ok(Array.isArray(response.json().items));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Anmeldung ist alles zu', async () => {
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/players' });
|
||||||
|
assert.equal(response.statusCode, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Erstpasswort', () => {
|
||||||
|
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
||||||
|
const credentials = await login('neu');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { displayName: 'Zu frueh' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 403);
|
||||||
|
assert.match(response.json().message, /Passwort/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erlaubt genau den Passwortwechsel', async () => {
|
||||||
|
const credentials = await login('neu');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/account/password',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
|
||||||
|
const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } });
|
||||||
|
assert.equal(user.mustChangePassword, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Guthaben ueber die API', () => {
|
||||||
|
it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => {
|
||||||
|
const credentials = await login('team');
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/players',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { displayName: 'API-Spieler' },
|
||||||
|
});
|
||||||
|
const playerId = created.json().id;
|
||||||
|
|
||||||
|
const booked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/players/${playerId}/credits/cc-package`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(booked.statusCode, 200);
|
||||||
|
assert.deepEqual(booked.json().balances, {
|
||||||
|
CC: 1,
|
||||||
|
REDESIGN_PAID: 1,
|
||||||
|
REDESIGN_KULANZ: 0,
|
||||||
|
redesignTotal: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine unbekannte API-Route liefert sauberes JSON', async () => {
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 404);
|
||||||
|
assert.equal(response.json().error, 'NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Imports = typeof import('../src/services/imports.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let imports: Imports;
|
||||||
|
let credits: Credits;
|
||||||
|
let db: Db;
|
||||||
|
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-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',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const MAPPING = {
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
playerName: 'Spielername',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
purchasedAt: 'Kaufdatum',
|
||||||
|
createMissingPlayers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54332);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
imports = await import('../src/services/imports.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'importeur', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
|
||||||
|
await db.prisma.productMapping.createMany({
|
||||||
|
data: [
|
||||||
|
{ matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
|
||||||
|
{ matchPattern: 'redesign', target: 'REDESIGN_PACKAGE', priority: 20 },
|
||||||
|
{ matchPattern: 'spielerfarbe', target: 'IGNORE', priority: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Datei einlesen', () => {
|
||||||
|
it('erkennt Semikolon als Trennzeichen', () => {
|
||||||
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(parsed.rows.length, 4);
|
||||||
|
assert.deepEqual(parsed.columns, [
|
||||||
|
'Bestellnummer',
|
||||||
|
'Steam-ID',
|
||||||
|
'Spielername',
|
||||||
|
'Produkt',
|
||||||
|
'Kaufdatum',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest auch JSON-Listen', () => {
|
||||||
|
const json = JSON.stringify([{ order: 'B-1', product: 'CC-Paket', user: '765' }]);
|
||||||
|
const parsed = imports.parseUpload('export.json', Buffer.from(json, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(parsed.rows.length, 1);
|
||||||
|
assert.equal(parsed.rows[0]?.product, 'CC-Paket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine leere Datei ab', () => {
|
||||||
|
assert.throws(() => imports.parseUpload('leer.csv', Buffer.from('', 'utf8')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vorschau und Buchung', () => {
|
||||||
|
it('ordnet jede Zeile ihrem Plan zu', async () => {
|
||||||
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('export.csv', parsed, ctx);
|
||||||
|
|
||||||
|
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.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
||||||
|
assert.equal(summary.SKIP_DUPLICATE, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bucht CC-Pakete mit beiden Guthaben und legt Spieler an', async () => {
|
||||||
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('lauf1.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
const result = await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
assert.equal(result.booked, 3);
|
||||||
|
assert.equal(result.createdPlayers, 3);
|
||||||
|
|
||||||
|
const rabe = await db.prisma.player.findUniqueOrThrow({
|
||||||
|
where: { steamId: '76561198000000001' },
|
||||||
|
});
|
||||||
|
const balances = await credits.getBalances(rabe.id);
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 1, 'CC-Paket gibt einen CC');
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 1, 'und das inklusive Redesign');
|
||||||
|
|
||||||
|
const fuchs = await db.prisma.player.findUniqueOrThrow({
|
||||||
|
where: { steamId: '76561198000000002' },
|
||||||
|
});
|
||||||
|
const fuchsBalances = await credits.getBalances(fuchs.id);
|
||||||
|
|
||||||
|
assert.equal(fuchsBalances.CC, 0, 'ein Redesign-Paket gibt keinen CC');
|
||||||
|
assert.equal(fuchsBalances.REDESIGN_PAID, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bucht dieselbe Bestell-ID kein zweites Mal', async () => {
|
||||||
|
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('lauf2.csv', parsed, ctx);
|
||||||
|
|
||||||
|
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
assert.equal(summary.SKIP_DUPLICATE, 3, 'alle bereits gebuchten Bestellungen');
|
||||||
|
assert.equal(summary.BOOK, 0);
|
||||||
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 0);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => imports.commitImport(importId, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_BOOK',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt Dubletten auch innerhalb einer Datei', async () => {
|
||||||
|
const doubled = [
|
||||||
|
'Bestellnummer;Steam-ID;Produkt',
|
||||||
|
'C-1;76561198000000009;CC-Paket',
|
||||||
|
'C-1;76561198000000009;CC-Paket',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const parsed = imports.parseUpload('doppelt.csv', Buffer.from(doubled, 'utf8'));
|
||||||
|
const importId = await imports.createImport('doppelt.csv', parsed, ctx);
|
||||||
|
|
||||||
|
const summary = await imports.applyMapping(
|
||||||
|
importId,
|
||||||
|
{
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
createMissingPlayers: true,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 1);
|
||||||
|
assert.equal(summary.SKIP_DUPLICATE, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ueberspringt 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);
|
||||||
|
|
||||||
|
const summary = await imports.applyMapping(
|
||||||
|
importId,
|
||||||
|
{
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
createMissingPlayers: false,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein abgeschlossener Import laesst 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);
|
||||||
|
await imports.applyMapping(
|
||||||
|
importId,
|
||||||
|
{ playerKey: 'Steam-ID', product: 'Produkt', orderId: 'Bestellnummer', createMissingPlayers: false },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => imports.commitImport(importId, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOT_PENDING',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
// Die Module lesen beim Laden die Umgebung, deshalb werden sie erst nach dem
|
||||||
|
// Start der Testdatenbank dynamisch importiert.
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let credits: Credits;
|
||||||
|
let characters: Characters;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
async function freshPlayer(name: string): Promise<number> {
|
||||||
|
const player = await db.prisma.player.create({ data: { displayName: name } });
|
||||||
|
return player.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54329);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'tester', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Guthaben-Toepfe', () => {
|
||||||
|
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
||||||
|
const playerId = await freshPlayer('Paket-Spieler');
|
||||||
|
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 1);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 1);
|
||||||
|
assert.equal(balances.REDESIGN_KULANZ, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Redesign-Paket erzeugt nur Redesign-Guthaben', async () => {
|
||||||
|
const playerId = await freshPlayer('Redesign-Spieler');
|
||||||
|
const { balances } = await credits.bookRedesignPackage(playerId, ctx);
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 0);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => {
|
||||||
|
const playerId = await freshPlayer('Kulanz-Spieler');
|
||||||
|
const { balances } = await credits.bookKulanz(playerId, ctx);
|
||||||
|
|
||||||
|
assert.equal(balances.REDESIGN_KULANZ, 1);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 0);
|
||||||
|
assert.equal(balances.redesignTotal, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
||||||
|
const playerId = await freshPlayer('Minus-Spieler');
|
||||||
|
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
||||||
|
|
||||||
|
assert.equal(result.balances.CC, -1);
|
||||||
|
assert.equal(result.overdrawn, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CC-Lebenszyklus', () => {
|
||||||
|
it('ein neuer CC verbraucht genau ein CC-Guthaben', async () => {
|
||||||
|
const playerId = await freshPlayer('Ersteller');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
|
const result = await characters.createCharacter(playerId, { name: 'Dr. Bright' }, ctx);
|
||||||
|
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
assert.equal(result.balances.CC, 0);
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 1, 'das inklusive Redesign bleibt erhalten');
|
||||||
|
assert.equal(result.overdrawn, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Redesign setzt den alten CC auf REWORKED und verkettet den neuen', async () => {
|
||||||
|
const playerId = await freshPlayer('Redesigner');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
|
|
||||||
|
const result = await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.predecessor.status, 'INACTIVE');
|
||||||
|
assert.equal(result.predecessor.inactiveReason, 'REWORKED');
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
assert.equal(result.character.predecessorId, first.character.id);
|
||||||
|
assert.equal(
|
||||||
|
result.character.lineageId,
|
||||||
|
first.character.lineageId,
|
||||||
|
'die Kette behaelt ihre lineageId',
|
||||||
|
);
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Redesign kann auch aus dem Kulanz-Topf bezahlt werden', async () => {
|
||||||
|
const playerId = await freshPlayer('Kulanz-Redesigner');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await credits.bookKulanz(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
|
|
||||||
|
const result = await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'Neu', useKind: 'REDESIGN_KULANZ' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.balances.REDESIGN_KULANZ, 0);
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 1, 'der bezahlte Topf bleibt unangetastet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reaktivierung', () => {
|
||||||
|
it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => {
|
||||||
|
const playerId = await freshPlayer('Inaktiver');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
||||||
|
|
||||||
|
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
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.inactiveReason, null);
|
||||||
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein reworkter CC kommt NICHT gratis zurueck', async () => {
|
||||||
|
const playerId = await freshPlayer('Rework-Spieler');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateFree(first.character.id, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => {
|
||||||
|
const playerId = await freshPlayer('Wiederbeleber');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = await credits.getBalances(playerId);
|
||||||
|
const result = await characters.reactivatePaid(first.character.id, 'CC', ctx);
|
||||||
|
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
assert.equal(result.balances.CC, before.CC - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine bezahlte Reaktivierung wird bei Gratis-Faellen verweigert', async () => {
|
||||||
|
const playerId = await freshPlayer('Gratis-Fall');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
||||||
|
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'IS_FREE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Korrekturen', () => {
|
||||||
|
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => {
|
||||||
|
const playerId = await freshPlayer('Storno-Spieler');
|
||||||
|
const booked = await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
|
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
||||||
|
where: { groupId: booked.groupId },
|
||||||
|
});
|
||||||
|
const result = await credits.reverseGroup(entry.id, 'Doppelt erfasst', ctx);
|
||||||
|
|
||||||
|
assert.equal(result.reversed, 2, 'beide Zeilen des Pakets');
|
||||||
|
assert.equal(result.balances.CC, 0);
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dieselbe Buchung laesst 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({
|
||||||
|
where: { groupId: booked.groupId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await credits.reverseGroup(entry.id, 'Erste Korrektur', ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => credits.reverseGroup(entry.id, 'Nochmal', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'ALREADY_REVERSED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Verbrauchsbuchung wird nicht direkt storniert', async () => {
|
||||||
|
const playerId = await freshPlayer('Verbrauch-Storno');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const created = await characters.createCharacter(playerId, { name: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const consume = await db.prisma.creditLedger.findFirstOrThrow({
|
||||||
|
where: { characterId: created.character.id, source: 'CONSUME' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => credits.reverseGroup(consume.id, 'Versuch', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'CONSUME_NOT_REVERSIBLE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => {
|
||||||
|
const playerId = await freshPlayer('Ruecknehmer');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
||||||
|
const redesigned = await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'Versehen', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await characters.revertCharacter(
|
||||||
|
redesigned.character.id,
|
||||||
|
'Falscher Spieler',
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.restoredPredecessorId, first.character.id);
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 1, 'das Redesign-Guthaben ist wieder da');
|
||||||
|
|
||||||
|
const restored = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: first.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(restored.status, 'ACTIVE');
|
||||||
|
assert.equal(restored.inactiveReason, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => {
|
||||||
|
const playerId = await freshPlayer('Ketten-Spieler');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await credits.bookRedesignPackage(playerId, ctx);
|
||||||
|
const first = await characters.createCharacter(playerId, { name: 'A' }, ctx);
|
||||||
|
const second = await characters.redesignCharacter(
|
||||||
|
first.character.id,
|
||||||
|
{ name: 'B', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
second.character.id,
|
||||||
|
{ name: 'C', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.revertCharacter(second.character.id, 'Zu spaet', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>CC-Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@cc-manager/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^6.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import { useSession } from './lib/session';
|
||||||
|
import type { Role } from './lib/types';
|
||||||
|
import Account from './pages/Account';
|
||||||
|
import Admin from './pages/Admin';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Import from './pages/Import';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import PlayerDetail from './pages/PlayerDetail';
|
||||||
|
|
||||||
|
function Protected({ children, minimum }: { children: React.ReactNode; minimum?: Role }) {
|
||||||
|
const { user, loading, can } = useSession();
|
||||||
|
|
||||||
|
if (loading) return <div className="boot">Lade …</div>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
// Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln.
|
||||||
|
if (user.mustChangePassword) return <Navigate to="/account" replace />;
|
||||||
|
if (minimum && !can(minimum)) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, loading } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={loading ? <div className="boot">Lade …</div> : user ? <Navigate to="/" replace /> : <Login />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={
|
||||||
|
loading ? (
|
||||||
|
<div className="boot">Lade …</div>
|
||||||
|
) : !user ? (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
) : (
|
||||||
|
<Layout>
|
||||||
|
<Account />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Layout>
|
||||||
|
<Dashboard />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/players/:id"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Layout>
|
||||||
|
<PlayerDetail />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/import"
|
||||||
|
element={
|
||||||
|
<Protected minimum="ADMIN">
|
||||||
|
<Layout>
|
||||||
|
<Import />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<Protected minimum="ADMIN">
|
||||||
|
<Layout>
|
||||||
|
<Admin />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Balances } from '../lib/types';
|
||||||
|
|
||||||
|
function Card({ label, value, hint }: { label: string; value: number; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
|
||||||
|
<span className="balance-label">{label}</span>
|
||||||
|
<span className="balance-value">{value}</span>
|
||||||
|
{hint && <span className="balance-hint">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceCards({ balances }: { balances: Balances }) {
|
||||||
|
const canCreate = balances.CC >= 1;
|
||||||
|
const canRedesign = balances.redesignTotal >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="balances" aria-label="Guthaben">
|
||||||
|
<div className="balance-grid">
|
||||||
|
<Card label="CC-Guthaben" value={balances.CC} />
|
||||||
|
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} />
|
||||||
|
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="eligibility">
|
||||||
|
<span className={canCreate ? 'ok' : 'no'}>
|
||||||
|
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
|
||||||
|
</span>
|
||||||
|
<span className={canRedesign ? 'ok' : 'no'}>
|
||||||
|
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
|
||||||
|
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && (
|
||||||
|
<p className="overdrawn-hint">
|
||||||
|
Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht
|
||||||
|
erfasstes Paket hin.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const { user, logout, can } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-inner">
|
||||||
|
<NavLink to="/" className="brand">
|
||||||
|
CC-Manager
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<nav className="nav">
|
||||||
|
<NavLink to="/" end>
|
||||||
|
Spieler
|
||||||
|
</NavLink>
|
||||||
|
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
|
||||||
|
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar-right">
|
||||||
|
<NavLink to="/account" className="user-chip">
|
||||||
|
{user?.username}
|
||||||
|
<span className="role-tag">{user?.role}</span>
|
||||||
|
</NavLink>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ title, onClose, children, footer }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div
|
||||||
|
className="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-head">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && <div className="modal-foot">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
interface NoticeProps {
|
||||||
|
kind: 'error' | 'success' | 'info' | 'warn';
|
||||||
|
children: React.ReactNode;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notice({ kind, children, onDismiss }: NoticeProps) {
|
||||||
|
return (
|
||||||
|
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
|
||||||
|
<span>{children}</span>
|
||||||
|
{onDismiss && (
|
||||||
|
<button type="button" className="notice-close" onClick={onDismiss} aria-label="Ausblenden">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
const CSRF_COOKIE = 'ccm_csrf';
|
||||||
|
const CSRF_HEADER = 'x-csrf-token';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name: string): string | undefined {
|
||||||
|
const match = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((entry) => entry.startsWith(`${name}=`));
|
||||||
|
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
body?: unknown;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const method = options.method ?? 'GET';
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (options.body !== undefined) {
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
}
|
||||||
|
if (method !== 'GET') {
|
||||||
|
const token = readCookie(CSRF_COOKIE);
|
||||||
|
if (token) headers[CSRF_HEADER] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
...(options.signal ? { signal: options.signal } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = text ? safeParse(text) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = payload as { error?: string; message?: string } | null;
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
error?.error ?? 'UNKNOWN',
|
||||||
|
error?.message ?? `Fehler ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||||
|
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const token = readCookie(CSRF_COOKIE);
|
||||||
|
if (token) headers[CSRF_HEADER] = token;
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = text ? safeParse(text) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = payload as { error?: string; message?: string } | null;
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
error?.error ?? 'UNKNOWN',
|
||||||
|
error?.message ?? `Fehler ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { message: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { CreditKind, CreditSource, InactiveReason, ImportPlan } from './types';
|
||||||
|
|
||||||
|
export const KIND_LABEL: Record<CreditKind, string> = {
|
||||||
|
CC: 'CC-Guthaben',
|
||||||
|
REDESIGN_PAID: 'Redesign (bezahlt)',
|
||||||
|
REDESIGN_KULANZ: 'Redesign (Kulanz)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
||||||
|
CC_PACKAGE: 'CC-Paket',
|
||||||
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||||
|
KULANZ: 'Kulanz',
|
||||||
|
ADJUSTMENT: 'Korrektur',
|
||||||
|
CONSUME: 'Verbrauch',
|
||||||
|
REVERSAL: 'Storno',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||||
|
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
|
||||||
|
REWORKED: 'Redesignt',
|
||||||
|
OTHER: 'Sonstiger Grund',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
||||||
|
BOOK: 'Wird gebucht',
|
||||||
|
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
||||||
|
SKIP_DUPLICATE: 'Uebersprungen: schon gebucht',
|
||||||
|
SKIP_IGNORED: 'Uebersprungen: ignoriertes Produkt',
|
||||||
|
SKIP_UNMAPPED: 'Uebersprungen: kein Mapping',
|
||||||
|
SKIP_NO_PLAYER: 'Uebersprungen: Spieler unbekannt',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(amount: number): string {
|
||||||
|
return amount > 0 ? `+${amount}` : String(amount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
import type { Role, SessionUser } from './types';
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
user: SessionUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
setUser: (user: SessionUser | null) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
can: (minimum: Role) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionState | null>(null);
|
||||||
|
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api<{ user: SessionUser | null }>('/api/auth/me');
|
||||||
|
setUser(result.user);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api('/api/auth/logout', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const can = useCallback(
|
||||||
|
(minimum: Role) => (user ? RANK[user.role] >= RANK[minimum] : false),
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<SessionState>(
|
||||||
|
() => ({ user, loading, setUser, refresh, logout, can }),
|
||||||
|
[user, loading, refresh, logout, can],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): SessionState {
|
||||||
|
const context = useContext(SessionContext);
|
||||||
|
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER';
|
||||||
|
export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||||
|
export type CreditSource =
|
||||||
|
| 'CC_PACKAGE'
|
||||||
|
| 'REDESIGN_PACKAGE'
|
||||||
|
| 'KULANZ'
|
||||||
|
| 'ADJUSTMENT'
|
||||||
|
| 'CONSUME'
|
||||||
|
| 'REVERSAL';
|
||||||
|
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||||
|
export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER';
|
||||||
|
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Balances {
|
||||||
|
CC: number;
|
||||||
|
REDESIGN_PAID: number;
|
||||||
|
REDESIGN_KULANZ: number;
|
||||||
|
redesignTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerSummary {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
steamId: string | null;
|
||||||
|
forumName: string | null;
|
||||||
|
characterCount: number;
|
||||||
|
activeCharacterCount: number;
|
||||||
|
balances: Balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
steamId: string | null;
|
||||||
|
forumName: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterRef {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
id: number;
|
||||||
|
playerId: number;
|
||||||
|
name: string;
|
||||||
|
loreText: string | null;
|
||||||
|
status: CharacterStatus;
|
||||||
|
inactiveReason: InactiveReason | null;
|
||||||
|
lineageId: string;
|
||||||
|
predecessorId: number | null;
|
||||||
|
predecessor: CharacterRef | null;
|
||||||
|
successors: CharacterRef[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerEntry {
|
||||||
|
id: number;
|
||||||
|
playerId: number;
|
||||||
|
kind: CreditKind;
|
||||||
|
amount: number;
|
||||||
|
source: CreditSource;
|
||||||
|
groupId: string;
|
||||||
|
note: string | null;
|
||||||
|
characterId: number | null;
|
||||||
|
character: CharacterRef | null;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: { username: string } | null;
|
||||||
|
reversedBy: { id: number } | null;
|
||||||
|
reversalOfId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerDetail {
|
||||||
|
player: Player;
|
||||||
|
balances: Balances;
|
||||||
|
characters: Character[];
|
||||||
|
ledger: LedgerEntry[];
|
||||||
|
ledgerTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: Role;
|
||||||
|
isActive: boolean;
|
||||||
|
totpEnabled: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductMapping {
|
||||||
|
id: number;
|
||||||
|
matchPattern: string;
|
||||||
|
target: ProductTarget;
|
||||||
|
priority: number;
|
||||||
|
isActive: boolean;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
actorUserId: number | null;
|
||||||
|
actorName: string | null;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetId: string | null;
|
||||||
|
details: unknown;
|
||||||
|
ip: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actor: { username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportPlan =
|
||||||
|
| 'BOOK'
|
||||||
|
| 'CREATE_PLAYER_AND_BOOK'
|
||||||
|
| 'SKIP_DUPLICATE'
|
||||||
|
| 'SKIP_IGNORED'
|
||||||
|
| 'SKIP_UNMAPPED'
|
||||||
|
| 'SKIP_NO_PLAYER';
|
||||||
|
|
||||||
|
export interface ImportRow {
|
||||||
|
id: number;
|
||||||
|
lineNo: number;
|
||||||
|
externalId: string | null;
|
||||||
|
playerKey: string | null;
|
||||||
|
playerName: string | null;
|
||||||
|
productName: string | null;
|
||||||
|
purchasedAt: string | null;
|
||||||
|
target: ProductTarget | null;
|
||||||
|
resolvedPlayerId: number | null;
|
||||||
|
plan: ImportPlan;
|
||||||
|
raw: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRecord {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
status: 'PENDING' | 'COMMITTED' | 'DISCARDED';
|
||||||
|
rowCount: number;
|
||||||
|
bookedCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
committedAt: string | null;
|
||||||
|
importedBy: { username: string } | null;
|
||||||
|
rows?: ImportRow[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import { SessionProvider } from './lib/session';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) throw new Error('Wurzelelement fehlt');
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<SessionProvider>
|
||||||
|
<App />
|
||||||
|
</SessionProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
|
||||||
|
export default function Account() {
|
||||||
|
const { user, refresh } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [totpSetup, setTotpSetup] = useState<{ qr: string; secret: string } | null>(null);
|
||||||
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
|
||||||
|
const changePassword = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (next !== repeat) {
|
||||||
|
setError('Die beiden neuen Passwoerter stimmen nicht ueberein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/account/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { currentPassword: current, newPassword: next },
|
||||||
|
});
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
await refresh();
|
||||||
|
setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.');
|
||||||
|
if (user?.mustChangePassword) navigate('/', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTotp = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api<{ qr: string; secret: string }>('/api/account/totp/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
setTotpSetup(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Einrichtung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enableTotp = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/account/totp/enable', { method: 'POST', body: { code: totpCode } });
|
||||||
|
setTotpSetup(null);
|
||||||
|
setTotpCode('');
|
||||||
|
await refresh();
|
||||||
|
setFlash('Zwei-Faktor-Anmeldung ist aktiv.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Code wurde nicht akzeptiert');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableTotp = async () => {
|
||||||
|
const password = window.prompt('Zum Deaktivieren bitte das Passwort eingeben:');
|
||||||
|
if (!password) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api('/api/account/totp/disable', { method: 'POST', body: { password } });
|
||||||
|
await refresh();
|
||||||
|
setFlash('Zwei-Faktor-Anmeldung wurde deaktiviert.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Deaktivieren fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page narrow">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Mein Konto</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.mustChangePassword && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
|
||||||
|
uebrigen Seiten sind bis dahin gesperrt.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Passwort aendern</h2>
|
||||||
|
<form onSubmit={changePassword}>
|
||||||
|
<label htmlFor="current">Aktuelles Passwort</label>
|
||||||
|
<input
|
||||||
|
id="current"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="next">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
id="next"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(event) => setNext(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="repeat">Neues Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
id="repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Passwort aendern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Zwei-Faktor-Anmeldung</h2>
|
||||||
|
|
||||||
|
{user?.totpEnabled ? (
|
||||||
|
<>
|
||||||
|
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
|
||||||
|
Deaktivieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : totpSetup ? (
|
||||||
|
<form onSubmit={enableTotp}>
|
||||||
|
<p className="muted">
|
||||||
|
QR-Code in der Authenticator-App scannen und den angezeigten Code eintragen.
|
||||||
|
</p>
|
||||||
|
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
|
||||||
|
<p className="muted small">
|
||||||
|
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
|
||||||
|
<code className="mono">{totpSetup.secret}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="totp">Code aus der App</label>
|
||||||
|
<input
|
||||||
|
id="totp"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(event) => setTotpCode(event.target.value)}
|
||||||
|
maxLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setTotpSetup(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="muted">
|
||||||
|
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn" onClick={() => void startTotp()}>
|
||||||
|
Einrichten
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { formatDate } from '../lib/format';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import type { AdminUser, AuditEntry, ProductMapping, ProductTarget, Role } from '../lib/types';
|
||||||
|
|
||||||
|
type Tab = 'users' | 'mappings' | 'audit';
|
||||||
|
|
||||||
|
const TARGET_LABEL: Record<ProductTarget, string> = {
|
||||||
|
CC_PACKAGE: 'CC-Paket',
|
||||||
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||||
|
IGNORE: 'Ignorieren',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const [tab, setTab] = useState<Tab>('users');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setFlash(message);
|
||||||
|
window.setTimeout(() => setFlash(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Verwaltung</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'users' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('users')}
|
||||||
|
>
|
||||||
|
Nutzer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'mappings' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('mappings')}
|
||||||
|
>
|
||||||
|
Produkt-Mappings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'audit' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('audit')}
|
||||||
|
>
|
||||||
|
Audit-Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
|
||||||
|
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
|
||||||
|
{tab === 'audit' && <AuditTab onError={setError} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProps {
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab({ onError, onDone }: TabProps) {
|
||||||
|
const { user: me } = useSession();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api<{ items: AdminUser[] }>('/api/admin/users');
|
||||||
|
setUsers(result.items);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const act = async (action: () => Promise<unknown>, message: string) => {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await load();
|
||||||
|
onDone(message);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPassword = (target: AdminUser) => {
|
||||||
|
const password = window.prompt(
|
||||||
|
`Neues Passwort fuer "${target.username}" (min. 12 Zeichen, Gross/Klein, Ziffer):`,
|
||||||
|
);
|
||||||
|
if (!password) return;
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api(`/api/admin/users/${target.id}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { newPassword: password },
|
||||||
|
}),
|
||||||
|
`Passwort fuer "${target.username}" gesetzt. Muss beim naechsten Login geaendert werden.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<div className="block-head">
|
||||||
|
<h2>Nutzer</h2>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
Nutzer anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Notice kind="info">
|
||||||
|
Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzername</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>2FA</th>
|
||||||
|
<th>Letzter Login</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((row) => (
|
||||||
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
||||||
|
<td>
|
||||||
|
<strong>{row.username}</strong>
|
||||||
|
{row.id === me?.id && <span className="badge">du</span>}
|
||||||
|
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
disabled={row.id === me?.id}
|
||||||
|
onChange={(event) =>
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api(`/api/admin/users/${row.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role: event.target.value as Role },
|
||||||
|
}),
|
||||||
|
`Rolle von "${row.username}" geaendert.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="VIEWER">Viewer</option>
|
||||||
|
<option value="EDITOR">Editor</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>{row.isActive ? 'aktiv' : 'gesperrt'}</td>
|
||||||
|
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
|
||||||
|
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => resetPassword(row)}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</button>
|
||||||
|
{row.id !== me?.id && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() =>
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api(`/api/admin/users/${row.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { isActive: !row.isActive },
|
||||||
|
}),
|
||||||
|
row.isActive
|
||||||
|
? `"${row.username}" gesperrt.`
|
||||||
|
: `"${row.username}" entsperrt.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.isActive ? 'Sperren' : 'Entsperren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(`Konto "${row.username}" wirklich loeschen?`)) return;
|
||||||
|
void act(
|
||||||
|
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
||||||
|
`"${row.username}" geloescht.`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateUserModal
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(username) => {
|
||||||
|
setCreating(false);
|
||||||
|
void load();
|
||||||
|
onDone(`Konto "${username}" angelegt.`);
|
||||||
|
}}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (username: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState<Role>('EDITOR');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api('/api/admin/users', { method: 'POST', body: { username, password, role } });
|
||||||
|
onCreated(username);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Nutzer anlegen" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label htmlFor="new-username">Benutzername</label>
|
||||||
|
<input
|
||||||
|
id="new-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="new-password">Erstpasswort</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
||||||
|
geaendert werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="new-role">Rolle</label>
|
||||||
|
<select
|
||||||
|
id="new-role"
|
||||||
|
value={role}
|
||||||
|
onChange={(event) => setRole(event.target.value as Role)}
|
||||||
|
>
|
||||||
|
<option value="VIEWER">Viewer — nur lesen</option>
|
||||||
|
<option value="EDITOR">Editor — Spieler, Guthaben, CCs</option>
|
||||||
|
<option value="ADMIN">Admin — alles inkl. Import und Nutzer</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Lege an …' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MappingsTab({ onError, onDone }: TabProps) {
|
||||||
|
const [items, setItems] = useState<ProductMapping[]>([]);
|
||||||
|
const [pattern, setPattern] = useState('');
|
||||||
|
const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api<{ items: ProductMapping[] }>('/api/admin/product-mappings');
|
||||||
|
setItems(result.items);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const add = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/api/admin/product-mappings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { matchPattern: pattern, target },
|
||||||
|
});
|
||||||
|
setPattern('');
|
||||||
|
await load();
|
||||||
|
onDone('Mapping angelegt.');
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (row: ProductMapping) => {
|
||||||
|
if (!window.confirm(`Mapping "${row.matchPattern}" loeschen?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
|
||||||
|
await load();
|
||||||
|
onDone('Mapping geloescht.');
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Produkt-Mappings</h2>
|
||||||
|
<Notice kind="info">
|
||||||
|
Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und
|
||||||
|
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<form className="inline-form" onSubmit={add}>
|
||||||
|
<input
|
||||||
|
placeholder="Muster, z. B. cc-paket"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(event) => setPattern(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select value={target} onChange={(event) => setTarget(event.target.value as ProductTarget)}>
|
||||||
|
<option value="CC_PACKAGE">CC-Paket</option>
|
||||||
|
<option value="REDESIGN_PACKAGE">Redesign-Paket</option>
|
||||||
|
<option value="IGNORE">Ignorieren</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Muster</th>
|
||||||
|
<th>Bedeutung</th>
|
||||||
|
<th className="num">Prioritaet</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => (
|
||||||
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
||||||
|
<td className="mono">{row.matchPattern}</td>
|
||||||
|
<td>{TARGET_LABEL[row.target]}</td>
|
||||||
|
<td className="num muted">{row.priority}</td>
|
||||||
|
<td className="muted">{row.isActive ? 'ja' : 'nein'}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-danger"
|
||||||
|
onClick={() => void remove(row)}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty">
|
||||||
|
Noch keine Mappings.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditTab({ onError }: { onError: (message: string) => void }) {
|
||||||
|
const [items, setItems] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ take: '150' });
|
||||||
|
if (filter) params.set('action', filter);
|
||||||
|
const result = await api<{ items: AuditEntry[]; total: number }>(
|
||||||
|
`/api/admin/audit?${params}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
setItems(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [filter, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Audit-Log</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
placeholder="Nach Aktion filtern, z. B. credit. oder character."
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="table-wrap scroll">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Wer</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th>Ziel</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((entry) => (
|
||||||
|
<tr key={entry.id}>
|
||||||
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||||
|
<td>{entry.actor?.username ?? entry.actorName ?? '—'}</td>
|
||||||
|
<td className="mono">{entry.action}</td>
|
||||||
|
<td className="muted">
|
||||||
|
{entry.targetType}
|
||||||
|
{entry.targetId ? ` #${entry.targetId}` : ''}
|
||||||
|
</td>
|
||||||
|
<td className="muted small details">
|
||||||
|
{entry.details ? JSON.stringify(entry.details) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty">
|
||||||
|
Keine Eintraege.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > items.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{items.length} von {total} Eintraegen angezeigt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import type { PlayerSummary } from '../lib/types';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
total: number;
|
||||||
|
items: PlayerSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Counter({ value, title }: { value: number; title: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`mini-counter${value < 0 ? ' is-negative' : ''}`} title={title}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { can } = useSession();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (search: string, signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: search, take: '100' });
|
||||||
|
const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {});
|
||||||
|
setResult(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => void load(query, controller.signal), 250);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [query, load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Spieler</h1>
|
||||||
|
{can('EDITOR') && (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
Spieler anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Name, SteamID oder Forum-Name suchen …"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Spieler</th>
|
||||||
|
<th>SteamID</th>
|
||||||
|
<th className="num">CC</th>
|
||||||
|
<th className="num">Redesign</th>
|
||||||
|
<th className="num">Kulanz</th>
|
||||||
|
<th className="num">Aktive CCs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.map((player) => (
|
||||||
|
<tr key={player.id}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/players/${player.id}`} className="link-strong">
|
||||||
|
{player.displayName}
|
||||||
|
</Link>
|
||||||
|
{player.forumName && <span className="muted"> · {player.forumName}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="mono muted">{player.steamId ?? '—'}</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.CC} title="CC-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.REDESIGN_PAID} title="Redesign-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.REDESIGN_KULANZ} title="Kulanz-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num muted">
|
||||||
|
{player.activeCharacterCount} / {player.characterCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && result.items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="empty">
|
||||||
|
{query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="muted">Lade …</p>}
|
||||||
|
{!loading && result.total > result.items.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreatePlayerModal
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreating(false);
|
||||||
|
void load(query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [steamId, setSteamId] = useState('');
|
||||||
|
const [forumName, setForumName] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api('/api/players', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
displayName,
|
||||||
|
steamId: steamId.trim() || null,
|
||||||
|
forumName: forumName.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onCreated();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Spieler anlegen" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
<label htmlFor="displayName">Anzeigename</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="steamId">SteamID (optional)</label>
|
||||||
|
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} />
|
||||||
|
|
||||||
|
<label htmlFor="forumName">Forum-Name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="forumName"
|
||||||
|
value={forumName}
|
||||||
|
onChange={(event) => setForumName(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Lege an …' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api, uploadFile } from '../lib/api';
|
||||||
|
import { PLAN_LABEL, formatDate } from '../lib/format';
|
||||||
|
import type { ImportPlan, ImportRecord } from '../lib/types';
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
importId: number;
|
||||||
|
columns: string[];
|
||||||
|
rowCount: number;
|
||||||
|
sample: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MappingForm {
|
||||||
|
playerKey: string;
|
||||||
|
playerName: string;
|
||||||
|
product: string;
|
||||||
|
orderId: string;
|
||||||
|
purchasedAt: string;
|
||||||
|
createMissingPlayers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_MAPPING: MappingForm = {
|
||||||
|
playerKey: '',
|
||||||
|
playerName: '',
|
||||||
|
product: '',
|
||||||
|
orderId: '',
|
||||||
|
purchasedAt: '',
|
||||||
|
createMissingPlayers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt.
|
||||||
|
const GUESSES: Record<keyof Omit<MappingForm, 'createMissingPlayers'>, string[]> = {
|
||||||
|
playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'],
|
||||||
|
playerName: ['name', 'username', 'displayname', 'spieler'],
|
||||||
|
product: ['product', 'produkt', 'item', 'artikel', 'package', 'paket'],
|
||||||
|
orderId: ['order', 'bestell', 'transaction', 'id', 'purchase'],
|
||||||
|
purchasedAt: ['date', 'datum', 'created', 'time', 'kauf'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function guessMapping(columns: string[]): MappingForm {
|
||||||
|
const result = { ...EMPTY_MAPPING };
|
||||||
|
for (const [field, needles] of Object.entries(GUESSES) as [
|
||||||
|
keyof typeof GUESSES,
|
||||||
|
string[],
|
||||||
|
][]) {
|
||||||
|
const hit = columns.find((column) =>
|
||||||
|
needles.some((needle) => column.toLowerCase().includes(needle)),
|
||||||
|
);
|
||||||
|
if (hit) result[field] = hit;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Import() {
|
||||||
|
const [upload, setUpload] = useState<UploadResult | null>(null);
|
||||||
|
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
summary: Record<ImportPlan, number>;
|
||||||
|
import: ImportRecord;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setUpload(null);
|
||||||
|
setPreview(null);
|
||||||
|
setMapping(EMPTY_MAPPING);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setPreview(null);
|
||||||
|
try {
|
||||||
|
const result = await uploadFile<UploadResult>('/api/imports', file);
|
||||||
|
setUpload(result);
|
||||||
|
setMapping(guessMapping(result.columns));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Upload fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!upload) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
playerKey: mapping.playerKey,
|
||||||
|
product: mapping.product,
|
||||||
|
createMissingPlayers: mapping.createMissingPlayers,
|
||||||
|
...(mapping.playerName ? { playerName: mapping.playerName } : {}),
|
||||||
|
...(mapping.orderId ? { orderId: mapping.orderId } : {}),
|
||||||
|
...(mapping.purchasedAt ? { purchasedAt: mapping.purchasedAt } : {}),
|
||||||
|
};
|
||||||
|
const result = await api<{ summary: Record<ImportPlan, number>; import: ImportRecord }>(
|
||||||
|
`/api/imports/${upload.importId}/mapping`,
|
||||||
|
{ method: 'POST', body },
|
||||||
|
);
|
||||||
|
setPreview(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Vorschau fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!upload) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await api<{ booked: number; createdPlayers: number }>(
|
||||||
|
`/api/imports/${upload.importId}/commit`,
|
||||||
|
{ method: 'POST', body: {} },
|
||||||
|
);
|
||||||
|
setFlash(
|
||||||
|
`${result.booked} Buchungen angelegt` +
|
||||||
|
(result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'),
|
||||||
|
);
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = async () => {
|
||||||
|
if (!upload) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/imports/${upload.importId}/discard`, { method: 'POST', body: {} });
|
||||||
|
} finally {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookable = preview
|
||||||
|
? preview.summary.BOOK + preview.summary.CREATE_PLAYER_AND_BOOK
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Import</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
{!upload && (
|
||||||
|
<section className="block">
|
||||||
|
<Notice kind="info">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<label className="file-drop">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json,.tsv,.txt"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void handleUpload(file);
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>{busy ? 'Lade hoch …' : 'Datei auswaehlen'}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upload && !preview && (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Spalten zuordnen</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{upload.rowCount} Zeilen aus <strong>{upload.columns.length}</strong> Spalten gelesen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handlePreview}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<MappingSelect
|
||||||
|
label="Spieler-Kennung (Pflicht)"
|
||||||
|
value={mapping.playerKey}
|
||||||
|
columns={upload.columns}
|
||||||
|
required
|
||||||
|
onChange={(value) => setMapping({ ...mapping, playerKey: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Produktname (Pflicht)"
|
||||||
|
value={mapping.product}
|
||||||
|
columns={upload.columns}
|
||||||
|
required
|
||||||
|
onChange={(value) => setMapping({ ...mapping, product: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Anzeigename"
|
||||||
|
value={mapping.playerName}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, playerName: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Bestell-ID (fuer Dedup)"
|
||||||
|
value={mapping.orderId}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, orderId: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Kaufdatum"
|
||||||
|
value={mapping.purchasedAt}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, purchasedAt: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mapping.createMissingPlayers}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMapping({ ...mapping, createMissingPlayers: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Unbekannte Spieler automatisch anlegen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!mapping.orderId && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Berechne …' : 'Vorschau erzeugen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Beispielzeilen</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{upload.columns.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{upload.sample.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{upload.columns.map((column) => (
|
||||||
|
<td key={column} className="muted">
|
||||||
|
{row[column] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Vorschau</h2>
|
||||||
|
|
||||||
|
<div className="summary-grid">
|
||||||
|
{(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan}
|
||||||
|
className={`summary-card${
|
||||||
|
plan === 'BOOK' || plan === 'CREATE_PLAYER_AND_BOOK' ? ' is-active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="summary-value">{preview.summary[plan] ?? 0}</span>
|
||||||
|
<span className="summary-label">{PLAN_LABEL[plan]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookable === 0 ? (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings
|
||||||
|
in der Verwaltung.
|
||||||
|
</Notice>
|
||||||
|
) : (
|
||||||
|
<Notice kind="info">
|
||||||
|
{bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein
|
||||||
|
CC-Guthaben <em>und</em> ein bezahltes Redesign-Guthaben.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-wrap scroll">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Spieler</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Bestell-ID</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.import.rows ?? []).map((row) => (
|
||||||
|
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
|
||||||
|
<td className="muted">{row.lineNo}</td>
|
||||||
|
<td>
|
||||||
|
{row.playerName || row.playerKey || '—'}
|
||||||
|
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
|
||||||
|
<span className="badge badge-new">neu</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{row.productName ?? '—'}</td>
|
||||||
|
<td className="mono muted">{row.externalId ?? '—'}</td>
|
||||||
|
<td className="muted">{formatDate(row.purchasedAt)}</td>
|
||||||
|
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
|
||||||
|
{PLAN_LABEL[row.plan]}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
|
||||||
|
Zuordnung aendern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || bookable === 0}
|
||||||
|
onClick={() => void handleCommit()}
|
||||||
|
>
|
||||||
|
{busy ? 'Buche …' : `${bookable} Zeilen buchen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MappingSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
columns,
|
||||||
|
required,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
columns: string[];
|
||||||
|
required?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{label}</label>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
|
||||||
|
<option value="">— nicht zuordnen —</option>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<option key={column} value={column}>
|
||||||
|
{column}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import type { SessionUser } from '../lib/types';
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
totpRequired: boolean;
|
||||||
|
challenge?: string;
|
||||||
|
user?: SessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { setUser } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [challenge, setChallenge] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const finish = (user: SessionUser) => {
|
||||||
|
setUser(user);
|
||||||
|
navigate(user.mustChangePassword ? '/account' : '/', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPassword = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await api<LoginResponse>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.totpRequired && result.challenge) {
|
||||||
|
setChallenge(result.challenge);
|
||||||
|
} else if (result.user) {
|
||||||
|
finish(result.user);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTotp = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await api<{ user: SessionUser }>('/api/auth/login/totp', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { challenge, code },
|
||||||
|
});
|
||||||
|
finish(result.user);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen');
|
||||||
|
if (err instanceof ApiError && err.code === 'UNAUTHORIZED' && err.message.includes('abgelaufen')) {
|
||||||
|
setChallenge(null);
|
||||||
|
setCode('');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>CC-Manager</h1>
|
||||||
|
<p className="login-sub">
|
||||||
|
{challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
{challenge ? (
|
||||||
|
<form onSubmit={submitTotp}>
|
||||||
|
<label htmlFor="code">Code aus der Authenticator-App</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
maxLength={10}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? 'Pruefe …' : 'Bestaetigen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-block"
|
||||||
|
onClick={() => {
|
||||||
|
setChallenge(null);
|
||||||
|
setCode('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submitPassword}>
|
||||||
|
<label htmlFor="username">Benutzername</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? 'Melde an …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import BalanceCards from '../components/BalanceCards';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import Notice from '../components/Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
|
||||||
|
import { useSession } from '../lib/session';
|
||||||
|
import type { Character, CreditKind, LedgerEntry, PlayerDetail as Detail } from '../lib/types';
|
||||||
|
|
||||||
|
type DialogState =
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'newCharacter' }
|
||||||
|
| { type: 'redesign'; character: Character }
|
||||||
|
| { type: 'deactivate'; character: Character }
|
||||||
|
| { type: 'reactivatePaid'; character: Character }
|
||||||
|
| { type: 'revert'; character: Character }
|
||||||
|
| { type: 'reverse'; entry: LedgerEntry };
|
||||||
|
|
||||||
|
export default function PlayerDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const playerId = Number(id);
|
||||||
|
const { can } = useSession();
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<Detail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDetail(await api<Detail>(`/api/players/${playerId}`));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [playerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<unknown>, message: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await load();
|
||||||
|
setDialog({ type: 'none' });
|
||||||
|
setFlash(message);
|
||||||
|
window.setTimeout(() => setFlash(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <p className="muted">Lade …</p>;
|
||||||
|
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
|
||||||
|
|
||||||
|
const editor = can('EDITOR');
|
||||||
|
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
|
||||||
|
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Link to="/" className="back-link">
|
||||||
|
← Alle Spieler
|
||||||
|
</Link>
|
||||||
|
<h1>{detail.player.displayName}</h1>
|
||||||
|
<p className="muted mono">
|
||||||
|
{detail.player.steamId ?? 'keine SteamID'}
|
||||||
|
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
<BalanceCards balances={detail.balances} />
|
||||||
|
|
||||||
|
{editor && (
|
||||||
|
<section className="action-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
|
||||||
|
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ CC-Paket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/players/${playerId}/credits/redesign-package`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
'Redesign-Paket eingetragen: +1 Redesign.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Redesign-Paket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
|
||||||
|
'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Kulanz
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDialog({ type: 'newCharacter' })}
|
||||||
|
>
|
||||||
|
Neuer CC
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Aktive CCs ({active.length})</h2>
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<p className="muted">Keine aktiven CCs.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cc-list">
|
||||||
|
{active.map((character) => (
|
||||||
|
<li key={character.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<strong>{character.name}</strong>
|
||||||
|
{character.predecessor && (
|
||||||
|
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
|
||||||
|
)}
|
||||||
|
{character.loreText && <p className="lore">{character.loreText}</p>}
|
||||||
|
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{editor && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => setDialog({ type: 'redesign', character })}
|
||||||
|
>
|
||||||
|
Redesign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-ghost"
|
||||||
|
onClick={() => setDialog({ type: 'deactivate', character })}
|
||||||
|
>
|
||||||
|
Deaktivieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-danger"
|
||||||
|
onClick={() => setDialog({ type: 'revert', character })}
|
||||||
|
>
|
||||||
|
Zuruecknehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Inaktive CCs ({inactive.length})</h2>
|
||||||
|
{inactive.length === 0 ? (
|
||||||
|
<p className="muted">Keine inaktiven CCs.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cc-list">
|
||||||
|
{inactive.map((character) => {
|
||||||
|
const free = character.inactiveReason === 'PLAYER_INACTIVITY';
|
||||||
|
return (
|
||||||
|
<li key={character.id} className="cc-item is-inactive">
|
||||||
|
<div>
|
||||||
|
<strong>{character.name}</strong>
|
||||||
|
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
|
||||||
|
{character.inactiveReason
|
||||||
|
? REASON_LABEL[character.inactiveReason]
|
||||||
|
: 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
{character.successors.length > 0 && (
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="muted small">
|
||||||
|
{free
|
||||||
|
? 'Kommt gratis zurueck — kostet kein Guthaben.'
|
||||||
|
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{editor && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
{free ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${character.id}/reactivate-free`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
`"${character.name}" ist wieder aktiv — kostenlos.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Gratis reaktivieren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => setDialog({ type: 'reactivatePaid', character })}
|
||||||
|
>
|
||||||
|
Bezahlt reaktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Guthaben-Historie</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Topf</th>
|
||||||
|
<th className="num">Menge</th>
|
||||||
|
<th>Anlass</th>
|
||||||
|
<th>Notiz</th>
|
||||||
|
<th>Von</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detail.ledger.map((entry) => (
|
||||||
|
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
||||||
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||||
|
<td>{KIND_LABEL[entry.kind]}</td>
|
||||||
|
<td className={`num ${entry.amount < 0 ? 'neg' : 'pos'}`}>
|
||||||
|
{formatAmount(entry.amount)}
|
||||||
|
</td>
|
||||||
|
<td>{SOURCE_LABEL[entry.source]}</td>
|
||||||
|
<td className="muted small">
|
||||||
|
{entry.note ?? '—'}
|
||||||
|
{entry.character && ` (${entry.character.name})`}
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
||||||
|
<td className="num">
|
||||||
|
{editor &&
|
||||||
|
!entry.reversedBy &&
|
||||||
|
entry.source !== 'CONSUME' &&
|
||||||
|
entry.source !== 'REVERSAL' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => setDialog({ type: 'reverse', entry })}
|
||||||
|
>
|
||||||
|
Storno
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entry.reversedBy && <span className="muted small">storniert</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{detail.ledger.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="empty">
|
||||||
|
Noch keine Buchungen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{detail.ledgerTotal > detail.ledger.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{dialog.type === 'newCharacter' && (
|
||||||
|
<CharacterFormModal
|
||||||
|
title="Neuen CC anlegen"
|
||||||
|
submitLabel="Anlegen"
|
||||||
|
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafuer nicht einsetzbar."
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/players/${playerId}/characters`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: values,
|
||||||
|
}),
|
||||||
|
`CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'redesign' && (
|
||||||
|
<CharacterFormModal
|
||||||
|
title={`Redesign von "${dialog.character.name}"`}
|
||||||
|
submitLabel="Redesign durchfuehren"
|
||||||
|
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
||||||
|
busy={busy}
|
||||||
|
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
|
||||||
|
balances={detail.balances}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}/redesign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: values,
|
||||||
|
}),
|
||||||
|
`Redesign gebucht: "${dialog.character.name}" → "${values.name}".`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'deactivate' && (
|
||||||
|
<DeactivateModal
|
||||||
|
character={dialog.character}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(reason) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}/deactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
`"${dialog.character.name}" ist jetzt inaktiv.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'reactivatePaid' && (
|
||||||
|
<KindChoiceModal
|
||||||
|
title={`"${dialog.character.name}" reaktivieren`}
|
||||||
|
hint="Dieser CC existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
|
||||||
|
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
||||||
|
balances={detail.balances}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(useKind) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}/reactivate-paid`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { useKind },
|
||||||
|
}),
|
||||||
|
`"${dialog.character.name}" ist wieder aktiv.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'revert' && (
|
||||||
|
<ReasonModal
|
||||||
|
title={`"${dialog.character.name}" zuruecknehmen`}
|
||||||
|
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
|
||||||
|
confirmLabel="Zuruecknehmen"
|
||||||
|
danger
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(reason) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}/revert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
`"${dialog.character.name}" wurde zurueckgenommen.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'reverse' && (
|
||||||
|
<ReasonModal
|
||||||
|
title="Buchung stornieren"
|
||||||
|
hint="Es wird eine Gegenbuchung erzeugt. Bei einem CC-Paket betrifft das beide Zeilen."
|
||||||
|
confirmLabel="Stornieren"
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(reason) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/credits/${dialog.entry.id}/reverse`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
'Buchung storniert.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterValues {
|
||||||
|
name: string;
|
||||||
|
loreText?: string;
|
||||||
|
useKind?: CreditKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharacterFormModal({
|
||||||
|
title,
|
||||||
|
submitLabel,
|
||||||
|
hint,
|
||||||
|
busy,
|
||||||
|
withKind,
|
||||||
|
balances,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
submitLabel: string;
|
||||||
|
hint: string;
|
||||||
|
busy: boolean;
|
||||||
|
withKind?: CreditKind[];
|
||||||
|
balances?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: CharacterValues) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [loreText, setLoreText] = useState('');
|
||||||
|
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
|
||||||
|
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
name,
|
||||||
|
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
||||||
|
...(withKind ? { useKind } : {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">{hint}</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="cc-name">Name des CCs</label>
|
||||||
|
<input
|
||||||
|
id="cc-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="cc-lore">Lore / Notiz (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="cc-lore"
|
||||||
|
rows={3}
|
||||||
|
value={loreText}
|
||||||
|
onChange={(event) => setLoreText(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{withKind && balances && (
|
||||||
|
<>
|
||||||
|
<label>Welches Guthaben soll verbraucht werden?</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
{withKind.map((kind) => (
|
||||||
|
<label key={kind} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
value={kind}
|
||||||
|
checked={useKind === kind}
|
||||||
|
onChange={() => setUseKind(kind)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{KIND_LABEL[kind]}
|
||||||
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
|
{' '}
|
||||||
|
({balances[kind]} verfuegbar)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindChoiceModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
kinds,
|
||||||
|
balances,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
kinds: CreditKind[];
|
||||||
|
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (kind: CreditKind) => void;
|
||||||
|
}) {
|
||||||
|
const [useKind, setUseKind] = useState<CreditKind>(kinds[0] ?? 'CC');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(useKind);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">{hint}</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
{kinds.map((kind) => (
|
||||||
|
<label key={kind} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
value={kind}
|
||||||
|
checked={useKind === kind}
|
||||||
|
onChange={() => setUseKind(kind)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{KIND_LABEL[kind]}
|
||||||
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
|
{' '}
|
||||||
|
({balances[kind]} verfuegbar)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Bestaetigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeactivateModal({
|
||||||
|
character,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER') => void;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(reason);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">
|
||||||
|
Der Grund entscheidet, ob die spaetere Reaktivierung kostenlos ist.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reason"
|
||||||
|
checked={reason === 'PLAYER_INACTIVITY'}
|
||||||
|
onChange={() => setReason('PLAYER_INACTIVITY')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Spieler-Inaktivitaet</strong>
|
||||||
|
<span className="muted"> — vom Server geputzt, kommt gratis zurueck</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reason"
|
||||||
|
checked={reason === 'OTHER'}
|
||||||
|
onChange={() => setReason('OTHER')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Sonstiger Grund</strong>
|
||||||
|
<span className="muted"> — Reaktivierung kostet ein Guthaben</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Deaktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasonModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
confirmLabel,
|
||||||
|
danger,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
danger?: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(reason);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="reason">Begruendung</label>
|
||||||
|
<input
|
||||||
|
id="reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="z. B. versehentlich doppelt eingetragen"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? 'Speichere …' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #12141a;
|
||||||
|
--bg-raised: #1a1d26;
|
||||||
|
--bg-input: #21252f;
|
||||||
|
--border: #2c3140;
|
||||||
|
--border-strong: #3a4154;
|
||||||
|
--text: #e6e8ee;
|
||||||
|
--text-muted: #9099ad;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--accent-hover: #6d99ff;
|
||||||
|
--positive: #4ac28a;
|
||||||
|
--negative: #f2626b;
|
||||||
|
--warn: #e0a34a;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Rahmen --- */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav a:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav a.active {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.user-chip:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.75rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.narrow {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bausteine --- */
|
||||||
|
|
||||||
|
.block {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.block-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.pos {
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.neg {
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.link-strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Guthaben --- */
|
||||||
|
|
||||||
|
.balances {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.is-negative {
|
||||||
|
border-color: var(--negative);
|
||||||
|
background: color-mix(in srgb, var(--negative) 8%, var(--bg-raised));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-value {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.is-negative .balance-value {
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eligibility {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.eligibility .ok {
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.eligibility .no {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdrawn-hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-counter {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.9rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mini-counter.is-negative {
|
||||||
|
background: color-mix(in srgb, var(--negative) 20%, transparent);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CC-Listen --- */
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-item.is-inactive {
|
||||||
|
opacity: 0.85;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lore {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.badge-free {
|
||||||
|
border-color: var(--positive);
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.badge-paid {
|
||||||
|
border-color: var(--warn);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.badge-new {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabellen --- */
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.table-wrap.scroll {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
.table.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-reversed td {
|
||||||
|
opacity: 0.45;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.is-skipped td {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
max-width: 40ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Formulare --- */
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.85rem 0 0.3rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='password'],
|
||||||
|
input[type='search'],
|
||||||
|
input:not([type]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.inline-form input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.inline-form select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox,
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input,
|
||||||
|
.radio input {
|
||||||
|
width: auto;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border: 2px dashed var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.file-drop:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.file-drop input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--negative);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--negative) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn-tiny {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hinweise --- */
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-error {
|
||||||
|
background: color-mix(in srgb, var(--negative) 12%, transparent);
|
||||||
|
border-color: var(--negative);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.notice-success {
|
||||||
|
background: color-mix(in srgb, var(--positive) 12%, transparent);
|
||||||
|
border-color: var(--positive);
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.notice-warn {
|
||||||
|
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||||
|
border-color: var(--warn);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.notice-info {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal --- */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 0.5rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tab.is-active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Import-Zusammenfassung --- */
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.summary-card.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Login --- */
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 370px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Schmale Viewports --- */
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.balance-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.topbar-inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.cc-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.cc-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
// Landet im Server-Image neben dem kompilierten Backend.
|
||||||
|
outDir: '../server/public',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user