Compare commits
42
Commits
3ec0ff4df1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93ff2fb0e | ||
|
|
367f1aace4 | ||
|
|
fee4a4f73a | ||
|
|
1f4034b397 | ||
|
|
9b024d343d | ||
|
|
823439f28d | ||
|
|
2515dcbed7 | ||
|
|
34819d44cc | ||
|
|
9b93844be2 | ||
|
|
ae5c67eea6 | ||
|
|
e57bfbabc7 | ||
|
|
f0213fe4bc | ||
|
|
46e339fdc4 | ||
|
|
1b643cb758 | ||
|
|
f7b48a9891 | ||
|
|
2d1c008c0a | ||
|
|
2b78a972bc | ||
|
|
fe89032c03 | ||
|
|
2527f6a240 | ||
|
|
c82391e73f | ||
|
|
6094e4eaef | ||
|
|
db37d3388a | ||
|
|
029d380abf | ||
|
|
dbde5b7d11 | ||
|
|
293d882cb4 | ||
|
|
e65fdfc0ad | ||
|
|
15d683c59e | ||
|
|
e78a57e35e | ||
|
|
3a55ef1b2f | ||
|
|
6c50d32d2b | ||
|
|
3344592c6e | ||
|
|
bebb89c10e | ||
|
|
751f8bfe8e | ||
|
|
664a0fd5ee | ||
|
|
6b49b34eef | ||
|
|
724f68644b | ||
|
|
b9364151b0 | ||
|
|
f19e596ee4 | ||
|
|
7876e29b90 | ||
|
|
c242e73c38 | ||
|
|
bc86e2df26 | ||
|
|
f775b0a594 |
+34
-4
@@ -1,20 +1,50 @@
|
|||||||
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
||||||
|
|
||||||
# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen.
|
# Passwort des Datenbank-Nutzers. Lang und zufällig wählen.
|
||||||
DB_PASSWORD=hier-ein-langes-zufaelliges-passwort
|
# Achtung: Ein $ im Wert wird von Docker Compose als Variable gedeutet und
|
||||||
|
# verschluckt. Entweder $ vermeiden oder als $$ schreiben.
|
||||||
|
DB_PASSWORD=hier-ein-langes-zufälliges-passwort
|
||||||
|
|
||||||
# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
# Schlüssel für Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
||||||
# Erzeugen z. B. mit: openssl rand -base64 48
|
# Erzeugen z. B. mit: openssl rand -base64 48
|
||||||
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
|
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
|
||||||
|
|
||||||
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
|
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
|
||||||
# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig
|
# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig
|
||||||
# ins Container-Log. In beiden Faellen ist ein Wechsel beim ersten Login Pflicht.
|
# ins Container-Log. In beiden Fällen ist ein Wechsel beim ersten Login Pflicht.
|
||||||
BOOTSTRAP_ADMIN_USER=admin
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=
|
BOOTSTRAP_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# --- Optional ---
|
# --- Optional ---
|
||||||
|
|
||||||
|
# Name des Docker-Netzwerks, in dem der Reverse Proxy läuft. Der Proxy legt es
|
||||||
|
# an; bei Nginx Proxy Manager heisst es meist nginx_default.
|
||||||
|
# PROXY_NETWORK=nginx_default
|
||||||
|
|
||||||
|
# Adresse der GExtension-Instanz für den Paket-Abgleich. Der Parser ist auf
|
||||||
|
# deren Seitenaufbau gebaut; ein anderes Shop-System lässt sich damit nicht
|
||||||
|
# auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird.
|
||||||
|
# GEXTENSION_BASE_URL=https://gextension.senseigaming.de
|
||||||
|
|
||||||
|
# Adresse des Forums für den Antrags-Abgleich. Der Parser ist auf WoltLab
|
||||||
|
# (Burning Board) zugeschnitten.
|
||||||
|
# FORUM_BASE_URL=https://senseigaming.de/forum
|
||||||
|
|
||||||
|
# Der Zugang aus der Erstinstallation ist geschützt: weder Rolle noch Zustand
|
||||||
|
# lassen sich ändern, sein Passwort nicht von aussen zurücksetzen, und löschen
|
||||||
|
# lässt er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg in die
|
||||||
|
# Anwendung offen, wenn sich jemand die Rechte entzieht. Sein eigenes Passwort
|
||||||
|
# ändert er weiterhin selbst unter "Konto". Auf true gesetzt gilt er wie jeder
|
||||||
|
# andere Zugang.
|
||||||
|
# BOOTSTRAP_ADMIN_EDITABLE=false
|
||||||
|
|
||||||
|
# Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter
|
||||||
|
# Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum
|
||||||
|
# ab welcher Stunde. Hier steht dazu nichts mehr.
|
||||||
|
|
||||||
|
# Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen).
|
||||||
|
# AUDIT_RETENTION_DAYS=365
|
||||||
|
|
||||||
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
# Gueltigkeit einer Sitzung in Stunden (Standard: 12).
|
||||||
# SESSION_TTL_HOURS=12
|
# SESSION_TTL_HOURS=12
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# --- Build ---
|
# --- Build ---
|
||||||
FROM node:22-slim AS build
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
# Prisma benoetigt OpenSSL fuer seine Query-Engine.
|
# Prisma benötigt OpenSSL für seine Query-Engine.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,228 @@ Jeder Spieler hat drei Guthaben-Töpfe:
|
|||||||
Zwei Regeln sind fest verdrahtet:
|
Zwei Regeln sind fest verdrahtet:
|
||||||
|
|
||||||
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
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.
|
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
|
2. **Der Grund entscheidet über die Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||||
entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr,
|
gratis zurück - gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||||
kostet die Reaktivierung ein Redesign- oder CC-Guthaben.
|
Wurde er redesignt oder entzogen, kostet die Rückkehr ein Redesign- oder CC-Guthaben. Ist er
|
||||||
|
im Rollenspiel gestorben, entscheidet darüber ausschließlich ein Administrator, mit Begründung.
|
||||||
|
|
||||||
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
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
|
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
|
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.
|
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
|
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.
|
Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig: Will man später
|
||||||
|
A zurück, ist A ein redesignter CC und kostet entsprechend.
|
||||||
|
|
||||||
|
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
|
||||||
|
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie. Vier Gründe stehen
|
||||||
|
zur Wahl:
|
||||||
|
|
||||||
|
| Grund | Rückkehr |
|
||||||
|
|---|---|
|
||||||
|
| Spieler-Inaktivität | gratis |
|
||||||
|
| Anderer Grund (z. B. Einheit zeitweise entfernt) | gratis |
|
||||||
|
| Entzogen | kostet ein Guthaben |
|
||||||
|
| Im Rollenspiel gestorben | nur durch einen Administrator, mit Begründung |
|
||||||
|
|
||||||
|
## Ketten: ein Platz, viele Redesigns
|
||||||
|
|
||||||
|
Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte: erster CC, Redesign,
|
||||||
|
noch ein Redesign. Auf der Spielerseite steht je Platz eine Spalte, **von unten nach oben**
|
||||||
|
gelesen - unten der erste CC, darüber seine Redesigns, die Pfeile zeigen in Richtung des
|
||||||
|
jüngsten Eintrags.
|
||||||
|
|
||||||
|
Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später auf, weil sein Antrag in
|
||||||
|
einem anderen Board lag oder weil der Spieler es für den Beitritt zu einem Gruppen-CC benutzt
|
||||||
|
hat, dessen Antrag jemand anders geschrieben hat. Dann steht in der Kette schon „Redesign 3 →
|
||||||
|
Redesign 5", und Redesign 4 fehlt.
|
||||||
|
|
||||||
|
**Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen.** Beim Ziehen erscheinen
|
||||||
|
zwischen den Einträgen Ablagestellen - auch mitten in einer Kette und in jeder anderen Kette
|
||||||
|
desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo er hingehört.
|
||||||
|
*Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in
|
||||||
|
diese Kette gehörte.
|
||||||
|
|
||||||
|
**Die Stelle in der Kette bestimmt, was ein Eintrag kostet**, und die Rechnung ist schlicht:
|
||||||
|
|
||||||
|
> So viele **CC-Pakete** wie Ketten. So viele **Redesigns** wie Einträge minus Ketten.
|
||||||
|
|
||||||
|
Drei Ketten mit zusammen sechzehn Einträgen sind also drei Pakete und dreizehn Redesigns. Anders
|
||||||
|
gesagt: Wer keinen Vorgänger hat, ist der Anfang einer Kette und damit ein gekaufter CC; wer
|
||||||
|
einen hat, ist dessen Redesign. In welcher Linie ein Eintrag geführt wird, spielt dabei keine
|
||||||
|
Rolle - die Kette ist die Kette. Wandert ein Eintrag, wandert seine Bedeutung mit: Ein eigenständiger CC, den
|
||||||
|
du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthaben kommt zurück.
|
||||||
|
Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und
|
||||||
|
kostet wieder ein CC-Paket.
|
||||||
|
|
||||||
|
Daraus folgt auch der Fall mit dem fremden Gruppen-CC: Wer aus einem herausredesignt, dessen
|
||||||
|
eigener Strang hat einen Vorgänger - den Gruppen-CC. Also ist sein erstes Glied kein Kauf,
|
||||||
|
sondern ein Redesign. Bezahlt wurde der Platz beim Beitritt, und zwar einmal.
|
||||||
|
|
||||||
|
Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt
|
||||||
|
sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an.
|
||||||
|
Zwischen bezahltem Redesign und Kulanz wird dabei **nicht** gewechselt: Das ist eine
|
||||||
|
Entscheidung, keine Folge der Reihenfolge, und steht unter *Bezahlart*. Einträge ganz ohne
|
||||||
|
Buchung bleiben unangetastet.
|
||||||
|
|
||||||
|
Eines folgt daraus von selbst: **Wer einen Nachfolger hat, wurde durch ihn ersetzt.** Anders ist
|
||||||
|
eine Kette nicht zu lesen. Deshalb trägt jedes Glied außer dem obersten den Vermerk *Redesignt*,
|
||||||
|
und wer ans Ende rückt, verliert ihn wieder - er stammte ja von einem Nachfolger, der jetzt
|
||||||
|
woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadurch wieder aktiv,
|
||||||
|
muss er gedeckt sein wie jeder andere.
|
||||||
|
|
||||||
|
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen -
|
||||||
|
die Thread-Nummer genügt, die volle Adresse geht auch. Es gibt zwei Ausnahmen von dieser Regel, beide bewusst zu treffen:
|
||||||
|
|
||||||
|
- Ein **Redesign aus dem CC-Topf**. Möglich, aber nicht der Regelfall - es verbraucht einen
|
||||||
|
ganzen Platz statt eines Redesigns. Im Dialog steht das dabei.
|
||||||
|
- Eine **von Hand geänderte Bezahlart** über den Knopf *Bezahlart*.
|
||||||
|
|
||||||
|
Beide werden als bewusste Entscheidung vermerkt, und das Ausrichten an den Ketten lässt sie
|
||||||
|
stehen. Sonst würde jede Ausnahme beim nächsten Umsortieren wieder eingeebnet.
|
||||||
|
|
||||||
|
Stimmt ein Bestand nicht mehr - etwa weil er aus einer Zeit stammt, in der eine Buchung blieb,
|
||||||
|
wo sie beim Anlegen entstand -, richtet **Buchungen nachrechnen** auf der Spielerseite alles
|
||||||
|
nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine
|
||||||
|
Gegenbuchung, und zwischen bezahltem Redesign und Kulanz wird nichts gewechselt.
|
||||||
|
|
||||||
|
Über *Bezahlart* wird umgestellt, woraus ein Eintrag
|
||||||
|
bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; umgebucht wird über
|
||||||
|
eine Gegenbuchung, in der Historie bleibt beides sichtbar.
|
||||||
|
|
||||||
|
Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kette einreiht, stellt
|
||||||
|
damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es
|
||||||
|
sein eigener Platz in seiner eigenen Kette. Einreihen darf, wer an beidem beteiligt war.
|
||||||
|
|
||||||
|
**Was einem Spieler gehört, steht immer in seinen Ketten** - auch wenn seine Mitgliedschaft
|
||||||
|
daran beendet wurde. Es bleibt sein CC und seine Geschichte. Unter *Früher genutzt* landen nur
|
||||||
|
CCs, die jemand anderem gehören und zu keinem seiner Plätze mehr führen.
|
||||||
|
|
||||||
|
Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich
|
||||||
|
der Spieler herausredesignt hat. Er trägt das Kennzeichen *war dabei* samt Zeitraum und
|
||||||
|
Besitzer, ist aber **genauso bedienbar wie jeder andere Eintrag**: ziehen, bearbeiten,
|
||||||
|
Bezahlart, Verweise. Beim Nacherfassen steht erst am Ende fest, welches Redesign wohin führte -
|
||||||
|
da muss sich auch einordnen lassen, was einem nicht gehört. Frühere Mitgliedschaften, die zu
|
||||||
|
keinem seiner Plätze führen, stehen darunter unter *Früher genutzt*.
|
||||||
|
|
||||||
|
### Verweise an einem CC
|
||||||
|
|
||||||
|
An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Erstellers, ein Modell,
|
||||||
|
eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der
|
||||||
|
Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*.
|
||||||
|
|
||||||
|
## Guthaben von Hand eintragen
|
||||||
|
|
||||||
|
Auf der Spielerseite stehen drei Paare: **+ CC-Guthaben**, **+ Redesign-Paket**, **+ Kulanz** -
|
||||||
|
und daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt.
|
||||||
|
Für den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen.
|
||||||
|
|
||||||
|
**Von Hand vergeben heißt nur der Platz.** Ein im Shop gekauftes Paket bringt ein Redesign mit,
|
||||||
|
und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand einen Platz
|
||||||
|
bekommt, ohne dafür gekauft zu haben. Wer ein volles Paket nachtragen will, klickt zusätzlich
|
||||||
|
auf *+ Redesign-Paket*. In der Historie steht *CC-Guthaben (von Hand)*, damit zu sehen ist, dass
|
||||||
|
kein Kauf dahintersteht.
|
||||||
|
|
||||||
|
Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem
|
||||||
|
CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign.
|
||||||
|
**Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und
|
||||||
|
werden dort storniert, wo dieser Zusammenhang sichtbar ist.
|
||||||
|
|
||||||
|
Steht der **bezahlte Redesign-Topf im Minus, während Kulanz übrig ist**, erscheint *Minus mit
|
||||||
|
Kulanz ausgleichen*. Es bucht so viele Übergänge von „bezahlt" auf „Kulanz" um, wie beide Seiten
|
||||||
|
hergeben - die ältesten zuerst. Unterm Strich ändert sich nichts, nur die Verteilung auf die
|
||||||
|
Töpfe. Das ist derselbe Vorgang wie ein Dutzend Mal *Bezahlart* zu drücken, nur in einem Zug.
|
||||||
|
|
||||||
|
In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest
|
||||||
|
sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt.
|
||||||
|
|
||||||
|
## Gruppen-CCs
|
||||||
|
|
||||||
|
Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen
|
||||||
|
eigenen CC: **der Platz kostet ein eigenes Guthaben** - entweder aus dem CC-Paket oder über ein
|
||||||
|
Redesign, je nachdem, worüber der Spieler hinzukommt.
|
||||||
|
|
||||||
|
### CC-Pakete sind Plätze
|
||||||
|
|
||||||
|
Ein CC-Paket ist ein **Platz für einen gleichzeitig aktiven CC**. Zwei Pakete sind zwei Plätze,
|
||||||
|
und so weiter. Ein Redesign **tauscht** einen CC aus, es schafft keinen zusätzlichen Platz: fünf
|
||||||
|
Millionen Redesigns auf ein Paket bleiben ein gleichzeitig aktiver CC.
|
||||||
|
|
||||||
|
Geprüft wird das an einer Stelle, für alle Wege zugleich - erschaffen, redesignen, reaktivieren,
|
||||||
|
zurückholen, einem Gruppen-CC beitreten. Jeder aktive CC braucht eine eigene Buchung im CC-Topf.
|
||||||
|
Wer einen Platz belegen will, der nicht gedeckt ist, bekommt eine Absage mit dem Hinweis, welcher
|
||||||
|
CC dafür weichen müsste.
|
||||||
|
|
||||||
|
Ein **überzogenes Konto** bleibt davon unberührt: Einen CC einzutragen, bevor das Paket verbucht
|
||||||
|
ist, geht weiter - die Buchung ist ja da, nur das Guthaben fehlt noch, und das Minus steht
|
||||||
|
sichtbar auf der Spielerseite. Der Unterschied ist, ob ein Platz gebucht wurde oder aus dem
|
||||||
|
Nichts entsteht.
|
||||||
|
|
||||||
|
Über *Nutzer aufnehmen* auf der Spielerseite kommt jemand dazu; das Guthaben wird von seinem
|
||||||
|
Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird über das ✕ an seinem
|
||||||
|
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
|
||||||
|
den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg.
|
||||||
|
|
||||||
|
Welcher Weg hineinführt, macht einen Unterschied:
|
||||||
|
|
||||||
|
| Bezahlt über | Was passiert |
|
||||||
|
| --- | --- |
|
||||||
|
| **CC-Paket** | Ein Platz kommt hinzu. Bestehende CCs des Spielers bleiben unberührt. |
|
||||||
|
| **Redesign** (bezahlt oder Kulanz) | Der Platz **ersetzt** einen eigenen CC, genau wie jedes andere Redesign. Welcher aufgegeben wird, muss angegeben werden. |
|
||||||
|
|
||||||
|
Das ist dieselbe Regel wie oben, nur an einem Gruppen-CC: über ein Redesign entsteht kein
|
||||||
|
zusätzlicher Platz. Der aufgegebene CC muss dem Spieler gehören, **aktiv sein muss er nicht**: auch ein
|
||||||
|
längst stillgelegter CC lässt sich redesignen. Ist er noch aktiv, wird er dabei inaktiv gesetzt
|
||||||
|
(Grund *Redesignt*); war er es schon nicht mehr, bleibt er, wie er ist. Wer gar keinen eigenen CC
|
||||||
|
hat, kann nicht über ein Redesign beitreten - dafür braucht es ein CC-Paket.
|
||||||
|
|
||||||
|
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
|
||||||
|
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
|
||||||
|
|
||||||
|
### Nachtragen, was längst passiert ist
|
||||||
|
|
||||||
|
Beim Nacherfassen kommt heraus, dass ein Spieler schon vor drei Redesigns bei einem fremden
|
||||||
|
Gruppen-CC dabei war. Dafür gilt: **An einem stillgelegten CC wird nachgetragen, nicht
|
||||||
|
beigetreten.** Der Knopf heißt dort *Nutzer nachtragen*, und die Guthaben-Angabe ist
|
||||||
|
freigestellt - die Buchung kann längst im Konto stehen. Ein Zeitraum lässt sich mitgeben.
|
||||||
|
|
||||||
|
Wer weiter unten in derselben Kette schon steht, kommt auch an einem **laufenden** CC ohne neue
|
||||||
|
Buchung dazu: Sein Platz wurde einmal bezahlt, und ein Redesign tauscht ihn aus, statt einen
|
||||||
|
zweiten zu verlangen. Genau das tut ein Redesign im laufenden Betrieb auch - es nimmt die Gruppe
|
||||||
|
mit. Für alle anderen bleibt die Angabe Pflicht.
|
||||||
|
|
||||||
|
Damit das nicht vier Klicks für vier Kettenglieder bedeutet, trägt ein Aufruf den Spieler ab
|
||||||
|
einem Glied in die **ganze weitere Kette** ein. Gebucht wird dabei genau einmal, beim Einstieg.
|
||||||
|
|
||||||
|
### Einzelner Nutzer oder ganze Gruppe
|
||||||
|
|
||||||
|
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist:
|
||||||
|
|
||||||
|
| Knopf | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Anteil redesignen** | Dieser Spieler steigt aus und bekommt einen eigenen CC. Die Gruppe läuft unberührt weiter. |
|
||||||
|
| **Gruppe redesignen** | Der ganze CC wandert in einen neuen; alle bisherigen Nutzer sind auch dort dabei. |
|
||||||
|
| **Nur diesen Nutzer** | Dieser Platz fällt weg - geputzt, entzogen oder im Rollenspiel gestorben. Der CC bleibt bestehen. |
|
||||||
|
| **Deaktivieren** | Der ganze CC wird inaktiv, für alle. |
|
||||||
|
|
||||||
|
Ein Ausstieg per Redesign ist ein Tausch wie jeder andere: Der Platz in der Gruppe geht weg, der
|
||||||
|
eigene CC kommt an seine Stelle - **es kommt keiner hinzu**. Wohin jemand gewechselt ist, steht
|
||||||
|
danach bei den früheren Nutzern („jetzt …").
|
||||||
|
|
||||||
|
Einzelne Nutzer können **unabhängig voneinander** ausfallen. Bei einem Trio kann einer im
|
||||||
|
Rollenspiel sterben, während die beiden anderen weiterspielen; ebenso können alle nacheinander
|
||||||
|
wegfallen. Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC: geputzt kommt
|
||||||
|
gratis zurück, entzogen kostet ein Guthaben, und über einen Gestorbenen entscheidet ein
|
||||||
|
Administrator mit Begründung. Wer ausgefallen ist, sieht seinen CC weiterhin auf seiner
|
||||||
|
Spielerseite - sonst gäbe es nichts, worüber er zurückkehren könnte.
|
||||||
|
|
||||||
|
**Ein freiwilliger Austritt ist etwas anderes.** Wer über das ✕ ausgetragen wird, hat keinen
|
||||||
|
Anspruch auf Rückkehr; ein Wiedereintritt ist ein neuer Beitritt und kostet ein Guthaben.
|
||||||
|
|
||||||
|
Ein Redesign nimmt die Gruppe mit: Wer zum Zeitpunkt des Redesigns dabei war, ist es danach auch.
|
||||||
|
Bezahlt wird es von dem, der es beantragt.
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
@@ -54,7 +264,7 @@ docker compose logs -f app
|
|||||||
```
|
```
|
||||||
|
|
||||||
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
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
|
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
|
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
||||||
Seiten gesperrt.
|
Seiten gesperrt.
|
||||||
|
|
||||||
@@ -67,7 +277,25 @@ Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Rever
|
|||||||
(Nginx Proxy Manager, Traefik oder Caddy), der HTTPS terminiert und intern auf `app:3000`
|
(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.
|
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
|
Damit der Proxy die App erreicht, hängt der `app`-Service zusätzlich im Netzwerk des Proxys.
|
||||||
|
Die `compose.yaml` erwartet es unter dem Namen `nginx_default` - so heißt es bei Nginx Proxy
|
||||||
|
Manager. Heißt es bei dir anders, setz `PROXY_NETWORK` in der `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
PROXY_NETWORK=mein-proxy-netz
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Netzwerk muss existieren, bevor der Stack startet; angelegt wird es vom Proxy. Betreibst du
|
||||||
|
gar keinen Proxy in Docker, entferne den `networks`-Block am Ende der `compose.yaml` samt der
|
||||||
|
`networks`-Liste beim `app`-Service und mappe stattdessen einen Port - dann aber bitte HTTPS
|
||||||
|
anderweitig davorsetzen.
|
||||||
|
|
||||||
|
> **Zu `$` in der `.env`:** Docker Compose deutet `$name` als Variable und ersetzt es durch
|
||||||
|
> nichts, wenn es die Variable nicht gibt. Ein Passwort mit `$` kommt dadurch gekürzt in der App
|
||||||
|
> an - ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder `$` vermeiden oder
|
||||||
|
> als `$$` schreiben.
|
||||||
|
|
||||||
|
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`
|
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.
|
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
||||||
|
|
||||||
@@ -75,7 +303,7 @@ gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abs
|
|||||||
|
|
||||||
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
|
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
|
`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.
|
Ansprüche ab - das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
||||||
|
|
||||||
Wiederherstellen:
|
Wiederherstellen:
|
||||||
|
|
||||||
@@ -88,7 +316,7 @@ gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
|
|||||||
|
|
||||||
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
||||||
|
|
||||||
1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt.
|
1. Datei hochladen - Spalten und Beispielzeilen werden angezeigt.
|
||||||
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
||||||
Spaltennamen werden vorausgewählt.
|
Spaltennamen werden vorausgewählt.
|
||||||
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
||||||
@@ -98,19 +326,240 @@ Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter
|
|||||||
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
||||||
jederzeit ergänzen, ohne den Code anzufassen.
|
jederzeit ergänzen, ohne den Code anzufassen.
|
||||||
|
|
||||||
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut —
|
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.
|
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.
|
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.
|
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
|
||||||
|
|
||||||
|
## GExtension-Abgleich
|
||||||
|
|
||||||
|
> **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
|
||||||
|
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine
|
||||||
|
> andere GExtension-Instanz, genügt `GEXTENSION_BASE_URL` in der `.env`. Nutzt ihr ein
|
||||||
|
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** - dafür müsste
|
||||||
|
> `server/src/lib/gextension.ts` angepasst werden. Der Rest der App ist davon unabhängig und
|
||||||
|
> läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
|
||||||
|
> eintragen.
|
||||||
|
|
||||||
|
Der Abgleich holt die gekauften Pakete eines Spielers direkt aus seinem GExtension-Profil,
|
||||||
|
damit sie nicht abgetippt werden müssen.
|
||||||
|
|
||||||
|
**Was er tut:** Für jeden Spieler, der im CC-Manager erfasst ist *und* eine SteamID hinterlegt
|
||||||
|
hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das
|
||||||
|
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) - jeweils als
|
||||||
|
Admin.
|
||||||
|
|
||||||
|
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie
|
||||||
|
abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach
|
||||||
|
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein - eindeutige Treffer
|
||||||
|
landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.
|
||||||
|
|
||||||
|
Erkannt wird anhand von Titel *und* Bereich: Nur der `SCP-RP`-Bereich führt CC-Pakete, und ein
|
||||||
|
Titel mit „Redesign" zählt als Redesign, auch wenn „Custom Character" darin vorkommt.
|
||||||
|
Stornierte Käufe werden übersprungen. Doppelbuchungen verhindert die Bestell-ID von GExtension.
|
||||||
|
|
||||||
|
**Ungeklärte Einträge:** Klingt ein Titel nach CC oder Redesign, passt aber in kein Muster (etwa
|
||||||
|
eine einmalige Sonderaktion), wird *nicht geraten*. Der Eintrag landet in einer Handprüfung:
|
||||||
|
sichtbar als Hinweis auf der Übersicht und als Box auf der Spielerseite, wo ein Admin ihn als
|
||||||
|
CC-Paket, gekauftes Redesign, Kulanz oder „nichts davon" einordnet. Bei CC-Paket entsteht
|
||||||
|
automatisch auch das inklusive Redesign-Guthaben.
|
||||||
|
|
||||||
|
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim
|
||||||
|
Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe zum Abgleich mit
|
||||||
|
dem eigenen Ledger, mehr nicht.
|
||||||
|
|
||||||
|
Der Abgleich läuft **im Hintergrund**, mit Fortschrittsbalken und einem Protokoll, das Profil
|
||||||
|
für Profil mitschreibt - sowohl beim Abgleich aller Spieler als auch beim einzelnen. Er hält eine
|
||||||
|
Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
|
||||||
|
Weist GExtension eine Anfrage wegen zu vieler Zugriffe ab, wird gewartet und erneut versucht,
|
||||||
|
statt den Spieler zu überspringen; das Tempo bleibt für den Rest des Laufs gedrosselt.
|
||||||
|
Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der
|
||||||
|
sauberere Weg, falls er zu haben ist.
|
||||||
|
|
||||||
|
## Forums-Anträge
|
||||||
|
|
||||||
|
> Ebenfalls auf eine bestimmte Instanz zugeschnitten: gelesen wird ein **WoltLab-Forum**
|
||||||
|
> (Burning Board) unter `senseigaming.de/forum`, konfigurierbar über `FORUM_BASE_URL`. Ein
|
||||||
|
> anderes Forensystem bräuchte einen eigenen Parser in `server/src/lib/forum.ts`.
|
||||||
|
|
||||||
|
Während der GExtension-Abgleich die **Käufe** liefert, steht der **Verbrauch** im Forum: Wurde
|
||||||
|
ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest
|
||||||
|
die App die drei CC-Boards aus und sammelt die Threads.
|
||||||
|
|
||||||
|
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet -
|
||||||
|
Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im
|
||||||
|
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
||||||
|
|
||||||
|
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
||||||
|
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
||||||
|
CC kehrt zurück - gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
||||||
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
|
||||||
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||||
|
|
||||||
|
Gelesen wird dabei ausdrücklich der **Ersteller**, nicht der letzte Antwortende. Wer sein Konto
|
||||||
|
gelöscht hat, steht in der Liste ohne Profil-Link da - wer dann einfach den ersten Profil-Link
|
||||||
|
der Zeile nimmt, erwischt den letzten Antwortenden und ordnet den Antrag dem Falschen zu.
|
||||||
|
Ändert sich der Ersteller eines bereits bekannten Threads, wird seine Zuordnung neu bestimmt;
|
||||||
|
eine von Hand gesetzte bleibt dabei stehen.
|
||||||
|
|
||||||
|
Aus dem Abgleich kommt nebenbei die Adresse des Forumsprofils mit; auf der Spielerseite führt
|
||||||
|
der Forum-Name dann direkt dorthin. Die SteamID verlinkt entsprechend auf das GExtension-Profil.
|
||||||
|
|
||||||
|
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler -
|
||||||
|
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
||||||
|
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der Ersteller nirgends,
|
||||||
|
landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der
|
||||||
|
Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu.
|
||||||
|
|
||||||
|
Darüber steht *Ersteller ohne Spieler*: dieselben Anträge, aber nach Ersteller zusammengefasst.
|
||||||
|
Wer dreiundzwanzig Anträge gestellt hat, fällt dort auf, während er zwischen fünfhundert
|
||||||
|
Einzelanträgen untergeht. Ein Klick auf *Spieler anlegen* öffnet das Formular mit bereits
|
||||||
|
eingetragenem Forum-Namen; danach wandern seine Anträge von selbst in die Arbeitsliste.
|
||||||
|
|
||||||
|
Beide Listen lassen sich durchsuchen und wahlweise mit dem neuesten oder dem ältesten Antrag
|
||||||
|
zuerst anzeigen; die untere blättert seitenweise.
|
||||||
|
|
||||||
|
Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder
|
||||||
|
ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste.
|
||||||
|
Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt.
|
||||||
|
|
||||||
|
Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein
|
||||||
|
Klick auf den CC-Namen direkt zum Forumsbeitrag.
|
||||||
|
|
||||||
|
### Einen Spieler an einem Stück abarbeiten
|
||||||
|
|
||||||
|
Auf der Spielerseite stehen seine Anträge unter *Forums-Anträge*, ältester zuerst, jeder als Link
|
||||||
|
ins Forum. Offene lassen sich dort direkt entscheiden - dieselbe Entscheidung wie auf der
|
||||||
|
Anträge-Seite, nur ohne den Spieler dort erst suchen zu müssen. Danach steht der Antrag in der
|
||||||
|
Anträge-Liste nicht mehr offen.
|
||||||
|
|
||||||
|
Genauso mit den Käufen: Liegen Pakete dieses Spielers noch in einer Import-Vorschau, erscheinen
|
||||||
|
sie unter *Offene Pakete aus dem Abgleich* und können einzeln oder am Stück gebucht werden. Was
|
||||||
|
hier gebucht ist, steht in keiner Vorschau mehr zur Buchung an - auch nicht, wenn dieselbe
|
||||||
|
Bestellung in einem zweiten Import liegt. Wer ein Paket gar nicht buchen will, legt es weg; es
|
||||||
|
verschwindet dann aus der Vorschau, ohne das Konto zu berühren.
|
||||||
|
|
||||||
|
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
|
||||||
|
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
|
||||||
|
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen.
|
||||||
|
|
||||||
|
Trägst du einen Antrag von Hand ein - über *Neuer CC* oder *Redesign* auf der Spielerseite -
|
||||||
|
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
||||||
|
nächsten Abgleich nicht erneut auf.
|
||||||
|
|
||||||
|
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird - daran hängt die
|
||||||
|
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
||||||
|
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
|
||||||
|
das Signal zu unzuverlässig.
|
||||||
|
|
||||||
|
### Was von allein läuft
|
||||||
|
|
||||||
|
Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss. Eingestellt
|
||||||
|
wird das unter *Verwaltung → Zeitplan*, je Lauf einzeln:
|
||||||
|
|
||||||
|
| Einstellung | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| **läuft automatisch** | Aus heißt: nur noch von Hand, über den Knopf auf der jeweiligen Seite. |
|
||||||
|
| **Abstand** | In Minuten, mindestens 15 und höchstens eine Woche. Daneben steht, was das in Stunden bedeutet. |
|
||||||
|
| **Ruhende Konten** | Nur beim Käufe-Abgleich: der seltenere Takt für Spieler, bei denen lange nichts gekauft wurde. |
|
||||||
|
| **Ruhend ab** | Nach wie vielen Tagen ohne Kauf jemand als ruhend gilt. |
|
||||||
|
| **Frühestens ab** | Nur beim Forumslauf: eine Stunde, vor der nicht gelaufen wird. Mit einem täglichen Abstand liegt der Lauf damit verlässlich nachts. |
|
||||||
|
|
||||||
|
Ab Werk: Käufe alle zwei Stunden, ruhende Konten alle sechs, ruhend nach drei Jahren; das Forum
|
||||||
|
einmal täglich, frühestens ab vier Uhr.
|
||||||
|
|
||||||
|
Der seltenere Takt für ruhende Konten spart den Großteil der Anfragen: Wer seit Jahren nichts
|
||||||
|
kauft, fängt selten von einer Stunde auf die andere damit an, und es sind mit der Zeit die
|
||||||
|
meisten Spieler. Maßgeblich ist das Kaufdatum aus dem Shop, nicht der Zeitpunkt der Eingabe hier.
|
||||||
|
|
||||||
|
**Gebucht wird dabei nichts von allein.** Neue Pakete landen in der Import-Vorschau, neue Anträge
|
||||||
|
in der Liste - entschieden wird weiterhin von Hand. Auf der Übersicht steht, wie viele Pakete auf
|
||||||
|
eine Buchung warten und bei wem.
|
||||||
|
|
||||||
|
Darunter steht, wann zuletzt gelaufen wurde und was dabei herauskam. Änderungen greifen sofort,
|
||||||
|
ohne Neustart; der Stand der Läufe steht in der Datenbank, ein Neustart stößt also nicht alles
|
||||||
|
erneut an.
|
||||||
|
|
||||||
|
### Wie lange ein Abgleich dauert
|
||||||
|
|
||||||
|
Das Archiv allein umfasst über zwanzig Seiten. Zwischen den Abrufen liegt gut eine Sekunde, ein
|
||||||
|
voller Lauf braucht also rund eine halbe Minute. Bremst das Forum den Zugriff aus, wird die
|
||||||
|
angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite
|
||||||
|
erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles.
|
||||||
|
|
||||||
|
Deshalb läuft der Abgleich **im Hintergrund**, mit einem Fortschrittsbalken und einem Protokoll,
|
||||||
|
das Seite für Seite mitschreibt. Gelesen wird zuerst von jedem Board die erste Seite - erst
|
||||||
|
danach steht der Umfang fest, und der Balken zeigt einen ehrlichen Anteil statt einer Schätzung.
|
||||||
|
Der Lauf geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten,
|
||||||
|
was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht
|
||||||
|
hat.
|
||||||
|
|
||||||
|
## Die Spielerliste eingrenzen
|
||||||
|
|
||||||
|
Über der Tabelle stehen zwei Auswahlfelder. Eingegrenzt wird auf Spieler mit aktivem CC, auf
|
||||||
|
solche mit ausschließlich stillgelegten, auf die Beteiligten eines Gruppen-CCs, auf Spieler ganz
|
||||||
|
ohne CC oder auf die offenen Problemfälle. Sortiert wird nach Namen, nach der Zahl der
|
||||||
|
CC-Einträge oder nach dem Zeitpunkt der Anlage.
|
||||||
|
|
||||||
|
Maßgeblich ist überall die **Mitgliedschaft**, nicht wer den CC angelegt hat: Wer einem
|
||||||
|
Gruppen-CC angehört, zählt dazu, auch ohne ihn erstellt zu haben.
|
||||||
|
|
||||||
|
## Problemfälle
|
||||||
|
|
||||||
|
Nicht jeder Fall lässt sich beim Eintragen sauber klären: ein CC-Antrag, zu dem sich kein Paket
|
||||||
|
finden lässt. Ein Spieler ohne GExtension-Eintrag. Angaben, die einander widersprechen.
|
||||||
|
|
||||||
|
Solche Spieler werden trotzdem angelegt und dabei **als Problemfall vorgemerkt**, mit einer
|
||||||
|
Notiz, was offen ist. Der Vermerk lässt sich auch nachträglich setzen, über *Als Problem merken*
|
||||||
|
auf der Spielerseite, und ebenso wieder abräumen.
|
||||||
|
|
||||||
|
Die Markierung ist bewusst **folgenlos**: an Guthaben, CCs und Buchungen ändert sie nichts, sie
|
||||||
|
sperrt nichts und verhindert nichts. Sie ist ein Merkzettel mit Griff. Der Gedanke dahinter:
|
||||||
|
geklärt wird, wenn es darauf ankommt - und wenn der Spieler nie wiederkommt, kommt es nie darauf
|
||||||
|
an. Bis dahin geht die Spur wenigstens nicht verloren.
|
||||||
|
|
||||||
|
Auf der Übersicht tragen betroffene Spieler ein Kennzeichen, und über *Nur offene Problemfälle*
|
||||||
|
lässt sich die Liste darauf eingrenzen.
|
||||||
|
|
||||||
|
## Umzug auf einen anderen Server
|
||||||
|
|
||||||
|
Unter *Import* steht für Administratoren **Alles sichern und zurückholen**: der gesamte Bestand
|
||||||
|
als eine einzelne Tabelle - Spieler, Guthaben, CCs samt Ketten, frühere Konten, Mitgliedschaften
|
||||||
|
und Verweise. Gedacht für den Wechsel des Servers oder den Umstieg auf ein anderes Programm.
|
||||||
|
|
||||||
|
Weil verschiedene Satzarten in einer Datei stehen, sagt die erste Spalte `satzart`, worum es in
|
||||||
|
der Zeile geht (`SPIELER`, `KONTO`, `GUTHABEN`, `CC`, `MITGLIED`, `VERWEIS`); die übrigen Spalten
|
||||||
|
bedeuten je nach Satzart etwas anderes. Der Spieler steht in jeder Zeile, damit jede Zeile für
|
||||||
|
sich verständlich bleibt und eine Umsortierung in der Tabelle den Bestand nicht zerreißt.
|
||||||
|
|
||||||
|
Beim Einspielen wird **ergänzt, nicht überschrieben**: Was es schon gibt, bleibt unangetastet.
|
||||||
|
Erkannt wird ein Spieler an seiner SteamID, ersatzweise am Anzeigenamen; eine Buchung an
|
||||||
|
Spieler, Art, Menge, Anlass, Zeitpunkt und Notiz. Zweimal dieselbe Datei einzuspielen ändert
|
||||||
|
deshalb nichts. Zeilen, die sich nicht zuordnen lassen, werden mit Zeilennummer gemeldet, ohne
|
||||||
|
den Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten.
|
||||||
|
|
||||||
## Rollen
|
## Rollen
|
||||||
|
|
||||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
|
|
||||||
- **Viewer** — darf nur lesen.
|
Der Zugang aus der **Erstinstallation** ist geschützt: Weder seine Rolle noch sein Zustand
|
||||||
- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren.
|
lassen sich ändern, sein Passwort nicht von außen zurücksetzen, und löschen lässt er sich nicht -
|
||||||
- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen.
|
auch nicht durch sich selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand aus
|
||||||
|
Versehen die Rechte entzieht oder ein Konto abhandenkommt. Sein eigenes Passwort ändert er
|
||||||
|
weiterhin selbst, unter *Konto*. Wer das nicht will, setzt `BOOTSTRAP_ADMIN_EDITABLE=true`; dann
|
||||||
|
gilt er wie jeder andere Zugang.
|
||||||
|
|
||||||
|
- **Viewer** - darf nur lesen.
|
||||||
|
- **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||||
|
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
||||||
|
über Anträge.
|
||||||
|
- **Admin** - zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
||||||
|
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
||||||
|
|
||||||
|
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
||||||
|
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
||||||
|
vorbehalten - ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
||||||
|
|
||||||
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
||||||
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||||
@@ -129,7 +578,7 @@ npm run dev # API auf :3000, Frontend auf :5173
|
|||||||
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
||||||
funktionieren also wie in Produktion.
|
funktionieren also wie in Produktion.
|
||||||
|
|
||||||
Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher:
|
Die Tests brauchen **keine** laufende Datenbank - sie starten eine Postgres-Instanz im Speicher:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
@@ -143,9 +592,10 @@ und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
|
|||||||
```
|
```
|
||||||
server/
|
server/
|
||||||
prisma/schema.prisma Datenmodell und Migrationen
|
prisma/schema.prisma Datenmodell und Migrationen
|
||||||
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import
|
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import, Abgleich
|
||||||
src/routes/ HTTP-Endpunkte
|
src/routes/ HTTP-Endpunkte
|
||||||
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
||||||
|
src/lib/ SteamID-Umrechnung, GExtension-Parser
|
||||||
test/ Integrationstests gegen eine echte Postgres-Instanz
|
test/ Integrationstests gegen eine echte Postgres-Instanz
|
||||||
web/
|
web/
|
||||||
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
||||||
@@ -158,9 +608,11 @@ Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
|
|||||||
Nach Änderungen am Datenmodell:
|
Nach Änderungen am Datenmodell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run migrate:dev -- --name beschreibung-der-aenderung
|
npm run migrate:dev -- --name kurze-beschreibung
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Der Name wird zum Verzeichnisnamen der Migration - dort also besser bei ASCII bleiben.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
||||||
|
|||||||
@@ -11,13 +11,26 @@ services:
|
|||||||
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
||||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
# Dieser Zugang bleibt unantastbar, solange das hier nicht "true" ist.
|
||||||
|
BOOTSTRAP_ADMIN_EDITABLE: ${BOOTSTRAP_ADMIN_EDITABLE:-false}
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: "true"
|
||||||
|
# Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
||||||
|
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
||||||
|
FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum}
|
||||||
|
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
||||||
|
# Die Zeitzone bestimmt, wann "nachts um vier" ist - ohne sie liefe der
|
||||||
|
# Forumslauf nach UTC und damit im Sommer zwei Stunden daneben. Der
|
||||||
|
# Takt selbst steht in der Anwendung unter Verwaltung -> Zeitplan.
|
||||||
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
expose:
|
expose:
|
||||||
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
||||||
- "3000"
|
- "3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- default # App zur Datenbank
|
||||||
|
- proxy # Reverse Proxy zur App
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -53,5 +66,17 @@ services:
|
|||||||
sleep 86400;
|
sleep 86400;
|
||||||
done'
|
done'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Das Netzwerk des Reverse Proxy. Es muss bereits existieren - der Proxy
|
||||||
|
# legt es an, bei Nginx Proxy Manager heisst es meist nginx_default.
|
||||||
|
# Anderer Name? Dann PROXY_NETWORK in der .env setzen.
|
||||||
|
#
|
||||||
|
# Ohne Reverse Proxy in Docker: diesen Block und die networks-Liste beim
|
||||||
|
# app-Service entfernen und stattdessen einen Port mappen (ports: "3000:3000").
|
||||||
|
# Dann aber selbst für HTTPS sorgen - die App gehört nicht ungeschuetzt ins Netz.
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
name: ${PROXY_NETWORK:-nginx_default}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
cc_db:
|
cc_db:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
||||||
# Migrationen werden dabei uebersprungen.
|
# Migrationen werden dabei übersprungen.
|
||||||
echo "Wende Datenbank-Migrationen an ..."
|
echo "Wende Datenbank-Migrationen an ..."
|
||||||
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 785 KiB |
Generated
+7
@@ -2584,6 +2584,12 @@
|
|||||||
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csv-stringify": {
|
||||||
|
"version": "6.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz",
|
||||||
|
"integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -5024,6 +5030,7 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
|
"csv-stringify": "^6.8.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"prisma": "^6.5.0",
|
"prisma": "^6.5.0",
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Nur fuer die lokale Entwicklung. Als server/.env kopieren.
|
# Nur für die lokale Entwicklung. Als server/.env kopieren.
|
||||||
# Im Container kommen diese Werte aus der compose.yaml.
|
# Im Container kommen diese Werte aus der compose.yaml.
|
||||||
|
|
||||||
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
||||||
@@ -7,7 +7,7 @@ SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen.
|
# Ohne HTTPS müssen die Cookies unsicher ausgeliefert werden dürfen.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# Erstadministrator beim ersten Start.
|
# Erstadministrator beim ersten Start.
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,7 @@
|
|||||||
"migrate": "prisma migrate deploy",
|
"migrate": "prisma migrate deploy",
|
||||||
"migrate:dev": "prisma migrate dev",
|
"migrate:dev": "prisma migrate dev",
|
||||||
"studio": "prisma studio",
|
"studio": "prisma studio",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
||||||
"test": "node --import tsx --test test/*.test.ts"
|
"test": "node --import tsx --test test/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
|
"csv-stringify": "^6.8.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
"prisma": "^6.5.0",
|
"prisma": "^6.5.0",
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
|
|||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
|
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ForumBoard" AS ENUM ('CUSTOM_CHARACTERS', 'AKTIVE_CCS', 'CC_ARCHIV');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ForumClassification" AS ENUM ('NEW_CC', 'REDESIGN', 'REJECTED', 'IGNORE');
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
|
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
|
||||||
|
|
||||||
@@ -62,10 +68,31 @@ CREATE TABLE "Player" (
|
|||||||
"notes" TEXT,
|
"notes" TEXT,
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"gextensionUsage" INTEGER,
|
||||||
|
"gextensionSyncedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UnmatchedPackage" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"playerId" INTEGER,
|
||||||
|
"steamId" TEXT NOT NULL,
|
||||||
|
"rawTitle" TEXT NOT NULL,
|
||||||
|
"rawTag" TEXT NOT NULL,
|
||||||
|
"amount" TEXT,
|
||||||
|
"purchasedAt" TIMESTAMP(3),
|
||||||
|
"scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"resolved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"resolvedAs" TEXT,
|
||||||
|
"resolvedById" INTEGER,
|
||||||
|
"resolvedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE "CreditLedger" (
|
CREATE TABLE "CreditLedger" (
|
||||||
"id" SERIAL NOT NULL,
|
"id" SERIAL NOT NULL,
|
||||||
@@ -92,6 +119,7 @@ CREATE TABLE "Character" (
|
|||||||
"loreText" TEXT,
|
"loreText" TEXT,
|
||||||
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
|
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
"inactiveReason" "InactiveReason",
|
"inactiveReason" "InactiveReason",
|
||||||
|
"inactiveNote" TEXT,
|
||||||
"lineageId" TEXT NOT NULL,
|
"lineageId" TEXT NOT NULL,
|
||||||
"predecessorId" INTEGER,
|
"predecessorId" INTEGER,
|
||||||
"createdById" INTEGER,
|
"createdById" INTEGER,
|
||||||
@@ -176,6 +204,38 @@ CREATE TABLE "AuditLog" (
|
|||||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ForumThread" (
|
||||||
|
"threadId" INTEGER NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"authorName" TEXT,
|
||||||
|
"authorForumId" INTEGER,
|
||||||
|
"playerId" INTEGER,
|
||||||
|
"currentBoard" "ForumBoard" NOT NULL,
|
||||||
|
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastBoardChangeAt" TIMESTAMP(3),
|
||||||
|
"classification" "ForumClassification",
|
||||||
|
"needsClassification" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"classifiedById" INTEGER,
|
||||||
|
"classifiedAt" TIMESTAMP(3),
|
||||||
|
"linkedCharacterId" INTEGER,
|
||||||
|
"boardChangeNotice" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("threadId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ForumThreadBoard" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"threadId" INTEGER NOT NULL,
|
||||||
|
"board" "ForumBoard" NOT NULL,
|
||||||
|
"observedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ForumThreadBoard_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
|
||||||
@@ -194,6 +254,15 @@ CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
|
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId");
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
|
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
|
||||||
|
|
||||||
@@ -242,9 +311,24 @@ CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "tar
|
|||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
|
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ForumThread_needsClassification_idx" ON "ForumThread"("needsClassification");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ForumThread_playerId_idx" ON "ForumThread"("playerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ForumThread_currentBoard_idx" ON "ForumThread"("currentBoard");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ForumThreadBoard_threadId_idx" ON "ForumThreadBoard"("threadId");
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -284,3 +368,12 @@ ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN
|
|||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_linkedCharacterId_fkey" FOREIGN KEY ("linkedCharacterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ForumThreadBoard" ADD CONSTRAINT "ForumThreadBoard_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ForumThread"("threadId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- Ergaenzt alles, was nach der ersten Fassung dazugekommen ist.
|
||||||
|
--
|
||||||
|
-- Diese Migration ist bewusst durchgehend mit IF NOT EXISTS geschrieben: die
|
||||||
|
-- erste Migration wurde zwischenzeitlich nachtraeglich erweitert, weshalb
|
||||||
|
-- bestehende Installationen je nach Zeitpunkt der Einrichtung einen anderen
|
||||||
|
-- Stand haben. So laeuft sie auf einer frischen wie auf einer aelteren
|
||||||
|
-- Datenbank durch und legt nur an, was tatsaechlich fehlt.
|
||||||
|
|
||||||
|
-- --- Enums ---
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "ForumBoard" AS ENUM ('CUSTOM_CHARACTERS', 'AKTIVE_CCS', 'CC_ARCHIV');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "ForumClassification" AS ENUM ('NEW_CC', 'REDESIGN', 'REJECTED', 'IGNORE');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- --- Klartext-Grund einer Deaktivierung ---
|
||||||
|
|
||||||
|
ALTER TABLE "Character" ADD COLUMN IF NOT EXISTS "inactiveNote" TEXT;
|
||||||
|
|
||||||
|
-- --- GExtension-Abgleich ---
|
||||||
|
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "gextensionUsage" INTEGER;
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "gextensionSyncedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "UnmatchedPackage" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"playerId" INTEGER,
|
||||||
|
"steamId" TEXT NOT NULL,
|
||||||
|
"rawTitle" TEXT NOT NULL,
|
||||||
|
"rawTag" TEXT NOT NULL,
|
||||||
|
"amount" TEXT,
|
||||||
|
"purchasedAt" TIMESTAMP(3),
|
||||||
|
"scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"resolved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"resolvedAs" TEXT,
|
||||||
|
"resolvedById" INTEGER,
|
||||||
|
"resolvedAt" TIMESTAMP(3),
|
||||||
|
CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved");
|
||||||
|
CREATE INDEX IF NOT EXISTS "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey"
|
||||||
|
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- --- Forums-Antraege ---
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "ForumThread" (
|
||||||
|
"threadId" INTEGER NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"authorName" TEXT,
|
||||||
|
"authorForumId" INTEGER,
|
||||||
|
"playerId" INTEGER,
|
||||||
|
"currentBoard" "ForumBoard" NOT NULL,
|
||||||
|
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastBoardChangeAt" TIMESTAMP(3),
|
||||||
|
"classification" "ForumClassification",
|
||||||
|
"needsClassification" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"classifiedById" INTEGER,
|
||||||
|
"classifiedAt" TIMESTAMP(3),
|
||||||
|
"linkedCharacterId" INTEGER,
|
||||||
|
"boardChangeNotice" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("threadId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "ForumThread_needsClassification_idx" ON "ForumThread"("needsClassification");
|
||||||
|
CREATE INDEX IF NOT EXISTS "ForumThread_playerId_idx" ON "ForumThread"("playerId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "ForumThread_currentBoard_idx" ON "ForumThread"("currentBoard");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_playerId_fkey"
|
||||||
|
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_linkedCharacterId_fkey"
|
||||||
|
FOREIGN KEY ("linkedCharacterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "ForumThreadBoard" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"threadId" INTEGER NOT NULL,
|
||||||
|
"board" "ForumBoard" NOT NULL,
|
||||||
|
"observedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "ForumThreadBoard_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "ForumThreadBoard_threadId_idx" ON "ForumThreadBoard"("threadId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "ForumThreadBoard" ADD CONSTRAINT "ForumThreadBoard_threadId_fkey"
|
||||||
|
FOREIGN KEY ("threadId") REFERENCES "ForumThread"("threadId") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Reaktivierung als eigene Entscheidung, Labels und Thread-Verknuepfung.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE "ForumClassification" ADD VALUE IF NOT EXISTS 'REACTIVATION';
|
||||||
|
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- Labels des Threads, nur zur Anzeige als Kontext.
|
||||||
|
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "labels" TEXT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Frühere Steam- und Forumskonten eines Spielers.
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "AliasType" AS ENUM ('STEAM_ID', 'FORUM_NAME');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "PlayerAlias" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"type" "AliasType" NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PlayerAlias_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "PlayerAlias_type_value_key" ON "PlayerAlias"("type", "value");
|
||||||
|
CREATE INDEX IF NOT EXISTS "PlayerAlias_playerId_idx" ON "PlayerAlias"("playerId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "PlayerAlias" ADD CONSTRAINT "PlayerAlias_playerId_fkey"
|
||||||
|
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Gründe ohne Anspruch auf kostenlose Rückkehr, und der Tod im Rollenspiel.
|
||||||
|
|
||||||
|
ALTER TYPE "InactiveReason" ADD VALUE IF NOT EXISTS 'NO_FREE_CLAIM';
|
||||||
|
ALTER TYPE "InactiveReason" ADD VALUE IF NOT EXISTS 'DEAD';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Mehrere Spieler können sich einen CC teilen.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "CharacterMember" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"characterId" INTEGER NOT NULL,
|
||||||
|
"playerId" INTEGER NOT NULL,
|
||||||
|
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"leftAt" TIMESTAMP(3),
|
||||||
|
"paidWith" "CreditKind",
|
||||||
|
"note" TEXT,
|
||||||
|
"createdById" INTEGER,
|
||||||
|
CONSTRAINT "CharacterMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "CharacterMember_characterId_playerId_key"
|
||||||
|
ON "CharacterMember"("characterId", "playerId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_playerId_idx" ON "CharacterMember"("playerId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_characterId_idx" ON "CharacterMember"("characterId");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "CharacterMember" ADD CONSTRAINT "CharacterMember_characterId_fkey"
|
||||||
|
FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "CharacterMember" ADD CONSTRAINT "CharacterMember_playerId_fkey"
|
||||||
|
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- Bestehende CCs bekommen ihren Ersteller als erstes Mitglied. Ohne das
|
||||||
|
-- taucht nach dem Update kein CC mehr beim Spieler auf, da die Zugehörigkeit
|
||||||
|
-- ab jetzt über diese Tabelle läuft.
|
||||||
|
INSERT INTO "CharacterMember" ("characterId", "playerId", "joinedAt", "createdById")
|
||||||
|
SELECT c."id", c."playerId", c."createdAt", c."createdById"
|
||||||
|
FROM "Character" c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CharacterMember" m
|
||||||
|
WHERE m."characterId" = c."id" AND m."playerId" = c."playerId"
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Spieler, deren Lage sich gerade nicht klaeren laesst, werden trotzdem
|
||||||
|
-- angelegt und als Problemfall markiert.
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "isProblem" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemNote" TEXT;
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemSince" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Player_isProblem_idx" ON "Player"("isProblem");
|
||||||
|
|
||||||
|
-- Wer ueber ein Redesign in einen Gruppen-CC kommt, gibt dafuer einen eigenen
|
||||||
|
-- CC auf. Welcher das war, haengt am Beitritt.
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "predecessorId" INTEGER;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_predecessorId_idx" ON "CharacterMember"("predecessorId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "CharacterMember"
|
||||||
|
ADD CONSTRAINT "CharacterMember_predecessorId_fkey"
|
||||||
|
FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Wann die Bewegung tatsaechlich stattfand. Beim Import ist das der Kauf im
|
||||||
|
-- Shop, nicht der Zeitpunkt, an dem jemand hier auf "Buchen" geklickt hat.
|
||||||
|
ALTER TABLE "CreditLedger" ADD COLUMN IF NOT EXISTS "occurredAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- Bestand: erst einmal der Eintragungszeitpunkt, ...
|
||||||
|
UPDATE "CreditLedger" SET "occurredAt" = "createdAt" WHERE "occurredAt" IS NULL;
|
||||||
|
|
||||||
|
-- ... und fuer bereits gebuchte Importe das echte Kaufdatum nachtragen. Der
|
||||||
|
-- Weg fuehrt ueber die Bestellung: sie verbindet die Buchung mit der Zeile,
|
||||||
|
-- in der das Kaufdatum steht.
|
||||||
|
UPDATE "CreditLedger" cl
|
||||||
|
SET "occurredAt" = r."purchasedAt"
|
||||||
|
FROM "ImportedOrder" o
|
||||||
|
JOIN "ImportRow" r
|
||||||
|
ON r."externalId" = o."externalId"
|
||||||
|
AND r."importId" = o."importId"
|
||||||
|
WHERE cl."groupId" = o."groupId"
|
||||||
|
AND r."purchasedAt" IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "CreditLedger" ALTER COLUMN "occurredAt" SET NOT NULL;
|
||||||
|
ALTER TABLE "CreditLedger" ALTER COLUMN "occurredAt" SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CreditLedger_occurredAt_idx" ON "CreditLedger"("occurredAt");
|
||||||
|
|
||||||
|
-- Wann ein Antrag gestellt wurde, steht in der Boarduebersicht. Bestehende
|
||||||
|
-- Threads bekommen den Wert beim naechsten Abgleich nachgetragen.
|
||||||
|
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "postedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- Stand der wiederkehrenden Laeufe. Im Code steht der Zeitplan, hier steht,
|
||||||
|
-- wann zuletzt gelaufen wurde - sonst stiesse jeder Neustart alles neu an.
|
||||||
|
CREATE TABLE IF NOT EXISTS "ScheduledRun" (
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"lastRunAt" TIMESTAMP(3),
|
||||||
|
"lastStatus" TEXT,
|
||||||
|
"lastNote" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "ScheduledRun_pkey" PRIMARY KEY ("name")
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Bei einem Gruppen-CC kann ein einzelner Spieler ausfallen, waehrend die
|
||||||
|
-- uebrigen weiterspielen: geputzt, entzogen oder im Rollenspiel gestorben.
|
||||||
|
-- Der Grund haengt deshalb auch an der Mitgliedschaft, nicht nur am CC.
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveReason" "InactiveReason";
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveNote" TEXT;
|
||||||
|
|
||||||
|
-- Wer seinen Anteil am Gruppen-CC redesignt, steigt damit aus und bekommt
|
||||||
|
-- einen eigenen CC. Wohin er gewechselt ist, bleibt nachvollziehbar.
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "successorId" INTEGER;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_successorId_idx" ON "CharacterMember"("successorId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "CharacterMember"
|
||||||
|
ADD CONSTRAINT "CharacterMember_successorId_fkey"
|
||||||
|
FOREIGN KEY ("successorId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Verweise an einem CC: Addon des Erstellers, Modell, Absprachen.
|
||||||
|
CREATE TABLE IF NOT EXISTS "CharacterLink" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"characterId" INTEGER NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"createdById" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "CharacterLink_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterLink_characterId_idx" ON "CharacterLink"("characterId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "CharacterLink"
|
||||||
|
ADD CONSTRAINT "CharacterLink_characterId_fkey"
|
||||||
|
FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Platz in der Redesign-Kette. Bisher ergab sich die Reihenfolge allein aus
|
||||||
|
-- der Verkettung; zum Umsortieren braucht es eine Zahl, die sich aendern
|
||||||
|
-- laesst, ohne die ganze Kette neu zu haengen.
|
||||||
|
ALTER TABLE "Character" ADD COLUMN IF NOT EXISTS "chainPosition" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Character_lineageId_chainPosition_idx"
|
||||||
|
ON "Character"("lineageId", "chainPosition");
|
||||||
|
|
||||||
|
-- Bestand: die vorhandene Verkettung in Positionen uebersetzen. Der Anfang
|
||||||
|
-- jeder Kette ist der CC ohne Vorgaenger, danach geht es Glied fuer Glied
|
||||||
|
-- weiter - in Zehnerschritten, damit spaeter etwas dazwischen passt.
|
||||||
|
WITH RECURSIVE kette AS (
|
||||||
|
SELECT "id", "lineageId", 0 AS pos
|
||||||
|
FROM "Character"
|
||||||
|
WHERE "predecessorId" IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT c."id", c."lineageId", k.pos + 10
|
||||||
|
FROM "Character" c
|
||||||
|
JOIN kette k ON c."predecessorId" = k."id"
|
||||||
|
)
|
||||||
|
UPDATE "Character" c
|
||||||
|
SET "chainPosition" = kette.pos
|
||||||
|
FROM kette
|
||||||
|
WHERE c."id" = kette."id";
|
||||||
|
|
||||||
|
-- Profil-Adressen aus dem Forum. Sie werden uebernommen statt gebaut: die
|
||||||
|
-- Profile liegen nicht unter dem Forums-Pfad, sondern eine Ebene darueber.
|
||||||
|
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "authorProfileUrl" TEXT;
|
||||||
|
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "forumProfileUrl" TEXT;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Der Zeitplan wird einstellbar. Bisher standen Takt und Schalter in der
|
||||||
|
-- Umgebung und liessen sich nur mit einem Neustart aendern.
|
||||||
|
CREATE TABLE IF NOT EXISTS "ScheduleSetting" (
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"intervalMinutes" INTEGER NOT NULL DEFAULT 120,
|
||||||
|
"idleIntervalMinutes" INTEGER,
|
||||||
|
"idleAfterDays" INTEGER,
|
||||||
|
"onlyAfterHour" INTEGER,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "ScheduleSetting_pkey" PRIMARY KEY ("name")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Die bisherigen Werte als Ausgangspunkt: Kaeufe alle zwei Stunden, ruhende
|
||||||
|
-- Konten alle sechs, ruhend ab drei Jahren; das Forum einmal taeglich,
|
||||||
|
-- fruehestens ab vier Uhr.
|
||||||
|
INSERT INTO "ScheduleSetting"
|
||||||
|
("name", "enabled", "intervalMinutes", "idleIntervalMinutes", "idleAfterDays", "onlyAfterHour")
|
||||||
|
VALUES
|
||||||
|
('gextension', true, 120, 360, 1095, NULL),
|
||||||
|
('forum', true, 1440, NULL, NULL, 4)
|
||||||
|
ON CONFLICT ("name") DO NOTHING;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Der Zugang aus der Erstinstallation laesst sich schuetzen, damit sich
|
||||||
|
-- niemand - auch er selbst nicht - den letzten Weg in die Anwendung verbaut.
|
||||||
|
-- Welcher Zugang das ist, traegt der Start nach: er kennt den Namen aus
|
||||||
|
-- BOOTSTRAP_ADMIN_USER, die Migration nicht.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "isProtected" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Ein von Hand vergebener CC-Platz, ohne das Redesign, das in einem gekauften
|
||||||
|
-- Paket steckt. Fuer Einzelfaelle; der Abgleich mit dem Shop bleibt, wie er
|
||||||
|
-- ist - dort ist ein Paket weiterhin beides.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CC_GRANT';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Eine von Hand gewaehlte Bezahlart soll bleiben. Ohne diese Markierung
|
||||||
|
-- wuerde das Ausrichten der Buchungen an den Ketten jede Ausnahme beim
|
||||||
|
-- naechsten Umsortieren wieder einebnen.
|
||||||
|
ALTER TABLE "CreditLedger" ADD COLUMN IF NOT EXISTS "fundingPinned" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Ein CC-Guthaben, das fuer ein Redesign eingesetzt wird, wird umgewandelt
|
||||||
|
-- statt als Ausnahme gebucht. Der neue Wert steht allein in dieser Migration:
|
||||||
|
-- Postgres laesst einen frisch angelegten Enum-Wert in derselben Transaktion
|
||||||
|
-- noch nicht verwenden.
|
||||||
|
ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CONVERSION' BEFORE 'CONSUME';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Ob ein Eintrag ein Platz oder ein Redesign ist, entscheidet seine Stelle in
|
||||||
|
-- der Kette - nicht eine Markierung an der Buchung. Wer sein CC-Guthaben fuer
|
||||||
|
-- ein Redesign einsetzt, wandelt es um, statt eine Ausnahme zu buchen.
|
||||||
|
ALTER TABLE "CreditLedger" DROP COLUMN IF EXISTS "fundingPinned";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen ist
|
||||||
|
-- er der gekaufte Platz, fuer den anderen ein Redesign seines vorherigen CCs.
|
||||||
|
-- Deshalb wandert die Liste vom CC an die Mitgliedschaft.
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "lineageId" TEXT;
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "chainPosition" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Wer den CC angelegt hat, bringt seine bisherige Kette mit.
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = c."lineageId", "chainPosition" = c."chainPosition"
|
||||||
|
FROM "Character" c
|
||||||
|
WHERE c.id = m."characterId" AND c."playerId" = m."playerId" AND m."lineageId" IS NULL;
|
||||||
|
|
||||||
|
-- Die uebrigen haengen an einem Eintrag, der schon zugeordnet ist: entweder am
|
||||||
|
-- CC, den sie dafuer aufgegeben haben, oder an einem frueheren Glied derselben
|
||||||
|
-- Kette, in der sie mitgezogen wurden. Beides kann mehrere Stufen tief gehen,
|
||||||
|
-- deshalb die Schleife.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
ueber_vorgaenger INTEGER;
|
||||||
|
ueber_kette INTEGER;
|
||||||
|
geaendert INTEGER := 1;
|
||||||
|
BEGIN
|
||||||
|
WHILE geaendert > 0 LOOP
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = v."lineageId", "chainPosition" = v."chainPosition" + 10
|
||||||
|
FROM "CharacterMember" v
|
||||||
|
WHERE v."characterId" = m."predecessorId"
|
||||||
|
AND v."playerId" = m."playerId"
|
||||||
|
AND v."lineageId" IS NOT NULL
|
||||||
|
AND m."lineageId" IS NULL;
|
||||||
|
GET DIAGNOSTICS ueber_vorgaenger = ROW_COUNT;
|
||||||
|
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = q."lineageId",
|
||||||
|
"chainPosition" = q."chainPosition" + (q."cc_pos" - q."von_pos")
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (offen.id)
|
||||||
|
offen.id AS mitglied_id,
|
||||||
|
v."lineageId",
|
||||||
|
v."chainPosition",
|
||||||
|
cm."chainPosition" AS cc_pos,
|
||||||
|
cv."chainPosition" AS von_pos
|
||||||
|
FROM "CharacterMember" offen
|
||||||
|
JOIN "Character" cm ON cm.id = offen."characterId"
|
||||||
|
JOIN "Character" cv
|
||||||
|
ON cv."lineageId" = cm."lineageId" AND cv."chainPosition" < cm."chainPosition"
|
||||||
|
JOIN "CharacterMember" v ON v."characterId" = cv.id AND v."playerId" = offen."playerId"
|
||||||
|
WHERE offen."lineageId" IS NULL AND v."lineageId" IS NOT NULL
|
||||||
|
ORDER BY offen.id, cv."chainPosition" DESC
|
||||||
|
) q
|
||||||
|
WHERE m.id = q.mitglied_id;
|
||||||
|
GET DIAGNOSTICS ueber_kette = ROW_COUNT;
|
||||||
|
|
||||||
|
geaendert := ueber_vorgaenger + ueber_kette;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Alles Uebrige beginnt eine eigene Liste: ein Platz, der fuer sich steht.
|
||||||
|
UPDATE "CharacterMember"
|
||||||
|
SET "lineageId" = gen_random_uuid()::text, "chainPosition" = 0
|
||||||
|
WHERE "lineageId" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_lineageId_idx" ON "CharacterMember"("lineageId");
|
||||||
+300
-3
@@ -21,10 +21,18 @@ enum CreditKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum CreditSource {
|
enum CreditSource {
|
||||||
|
/// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit.
|
||||||
CC_PACKAGE
|
CC_PACKAGE
|
||||||
REDESIGN_PACKAGE
|
REDESIGN_PACKAGE
|
||||||
|
/// Ein von Hand vergebener Platz, ohne das Redesign aus einem Paket.
|
||||||
|
/// Für Einzelfälle, in denen jemand einen CC bekommt, ohne dafür gekauft
|
||||||
|
/// zu haben - der Abgleich mit dem Shop bleibt davon unberührt.
|
||||||
|
CC_GRANT
|
||||||
KULANZ
|
KULANZ
|
||||||
ADJUSTMENT
|
ADJUSTMENT
|
||||||
|
/// Ein CC-Guthaben, das in ein Redesign-Guthaben umgewandelt wurde.
|
||||||
|
/// Immer ein Paar: eine Zeile ab im CC-Topf, eine Zeile zu im Redesign-Topf.
|
||||||
|
CONVERSION
|
||||||
CONSUME
|
CONSUME
|
||||||
REVERSAL
|
REVERSAL
|
||||||
}
|
}
|
||||||
@@ -35,9 +43,16 @@ enum CharacterStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum InactiveReason {
|
enum InactiveReason {
|
||||||
|
/// Vom Server geputzt, weil der Spieler länger weg war. Kommt gratis zurück.
|
||||||
PLAYER_INACTIVITY
|
PLAYER_INACTIVITY
|
||||||
|
/// Durch ein Redesign ersetzt; das Original existiert nicht mehr.
|
||||||
REWORKED
|
REWORKED
|
||||||
|
/// Sonstiger Grund ohne Folgen, etwa eine zeitweise entfernte Einheit.
|
||||||
OTHER
|
OTHER
|
||||||
|
/// Entzogen ohne Anspruch auf kostenlose Rückkehr.
|
||||||
|
NO_FREE_CLAIM
|
||||||
|
/// Im Rollenspiel gestorben. Rückkehr nur durch einen Administrator.
|
||||||
|
DEAD
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProductTarget {
|
enum ProductTarget {
|
||||||
@@ -46,6 +61,26 @@ enum ProductTarget {
|
|||||||
IGNORE
|
IGNORE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ForumBoard {
|
||||||
|
CUSTOM_CHARACTERS
|
||||||
|
AKTIVE_CCS
|
||||||
|
CC_ARCHIV
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ForumClassification {
|
||||||
|
NEW_CC
|
||||||
|
REDESIGN
|
||||||
|
/// Ein inaktiver CC kehrt zurueck. Ohne Redesign kostet das nichts.
|
||||||
|
REACTIVATION
|
||||||
|
REJECTED
|
||||||
|
IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AliasType {
|
||||||
|
STEAM_ID
|
||||||
|
FORUM_NAME
|
||||||
|
}
|
||||||
|
|
||||||
enum ImportStatus {
|
enum ImportStatus {
|
||||||
PENDING
|
PENDING
|
||||||
COMMITTED
|
COMMITTED
|
||||||
@@ -61,6 +96,13 @@ model User {
|
|||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
mustChangePassword Boolean @default(false)
|
mustChangePassword Boolean @default(false)
|
||||||
|
|
||||||
|
/// Der Zugang aus der Erstinstallation. Solange er geschützt ist, lassen
|
||||||
|
/// sich weder seine Rolle noch sein Zustand ändern, sein Passwort nicht von
|
||||||
|
/// aussen zurücksetzen und er selbst nicht löschen - auch nicht durch ihn
|
||||||
|
/// selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand
|
||||||
|
/// aus Versehen oder mit Absicht die Rechte entzieht.
|
||||||
|
isProtected Boolean @default(false)
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -93,14 +135,60 @@ model Player {
|
|||||||
steamId String? @unique
|
steamId String? @unique
|
||||||
forumName String?
|
forumName String?
|
||||||
notes String?
|
notes String?
|
||||||
|
/// Sein Forumsprofil. Kommt aus dem Abgleich und überlebt eine spätere
|
||||||
|
/// Namensänderung, weil die Nummer darin steht und nicht der Name.
|
||||||
|
forumProfileUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// Ein Fall, der sich gerade nicht sauber klären lässt: ein Antrag ohne
|
||||||
|
/// auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
|
||||||
|
/// Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht
|
||||||
|
/// verloren geht - geklärt wird, wenn es darauf ankommt.
|
||||||
|
isProblem Boolean @default(false)
|
||||||
|
problemNote String?
|
||||||
|
problemSince DateTime?
|
||||||
|
|
||||||
|
/// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich,
|
||||||
|
/// wird nie automatisch mit dem Ledger verrechnet.
|
||||||
|
gextensionUsage Int?
|
||||||
|
gextensionSyncedAt DateTime?
|
||||||
|
|
||||||
ledger CreditLedger[]
|
ledger CreditLedger[]
|
||||||
characters Character[]
|
characters Character[]
|
||||||
orders ImportedOrder[]
|
orders ImportedOrder[]
|
||||||
|
unmatched UnmatchedPackage[]
|
||||||
|
forumThreads ForumThread[]
|
||||||
|
aliases PlayerAlias[]
|
||||||
|
memberships CharacterMember[]
|
||||||
|
|
||||||
@@index([displayName])
|
@@index([displayName])
|
||||||
|
@@index([isProblem])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
|
||||||
|
/// Guthaben-Art sicher zuordnen lässt. Wird nie automatisch gebucht,
|
||||||
|
/// sondern im CC-Manager von Hand entschieden.
|
||||||
|
model UnmatchedPackage {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
externalId String @unique
|
||||||
|
playerId Int?
|
||||||
|
player Player? @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
steamId String
|
||||||
|
rawTitle String
|
||||||
|
rawTag String
|
||||||
|
amount String?
|
||||||
|
purchasedAt DateTime?
|
||||||
|
scrapedAt DateTime @default(now())
|
||||||
|
|
||||||
|
resolved Boolean @default(false)
|
||||||
|
/// CC_PACKAGE | REDESIGN_PACKAGE | REDESIGN_KULANZ | IGNORE
|
||||||
|
resolvedAs String?
|
||||||
|
resolvedById Int?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
|
||||||
|
@@index([resolved])
|
||||||
|
@@index([playerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
|
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
|
||||||
@@ -115,7 +203,7 @@ model CreditLedger {
|
|||||||
source CreditSource
|
source CreditSource
|
||||||
|
|
||||||
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
||||||
/// mit derselben groupId, damit ein Storno beide zurueckholt.
|
/// mit derselben groupId, damit ein Storno beide zurückholt.
|
||||||
groupId String @default(uuid())
|
groupId String @default(uuid())
|
||||||
|
|
||||||
characterId Int?
|
characterId Int?
|
||||||
@@ -126,7 +214,7 @@ model CreditLedger {
|
|||||||
|
|
||||||
note String?
|
note String?
|
||||||
|
|
||||||
/// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique
|
/// Storno erfolgt als Gegenbuchung, nicht als Löschung. Das @unique
|
||||||
/// verhindert, dass eine Zeile zweimal storniert wird.
|
/// verhindert, dass eine Zeile zweimal storniert wird.
|
||||||
reversalOfId Int? @unique
|
reversalOfId Int? @unique
|
||||||
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
@@ -136,10 +224,17 @@ model CreditLedger {
|
|||||||
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// Wann die Bewegung tatsächlich stattfand - beim Import das Kaufdatum aus
|
||||||
|
/// dem Shop, sonst der Zeitpunkt der Eingabe. Danach richtet sich die
|
||||||
|
/// Anzeige: wann jemand gekauft hat, ist die Frage, nicht wann es jemand
|
||||||
|
/// hier eingetippt hat.
|
||||||
|
occurredAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([playerId])
|
@@index([playerId])
|
||||||
@@index([playerId, kind])
|
@@index([playerId, kind])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([occurredAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Character {
|
model Character {
|
||||||
@@ -150,14 +245,27 @@ model Character {
|
|||||||
loreText String?
|
loreText String?
|
||||||
status CharacterStatus @default(ACTIVE)
|
status CharacterStatus @default(ACTIVE)
|
||||||
inactiveReason InactiveReason?
|
inactiveReason InactiveReason?
|
||||||
|
/// Klartext zum Grund, etwa "Einheit bis März entfernt". Nur zur Anzeige.
|
||||||
|
inactiveNote String?
|
||||||
|
|
||||||
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
||||||
lineageId String @default(uuid())
|
lineageId String @default(uuid())
|
||||||
|
/// Platz in der Kette, aufsteigend vom ersten CC zum jüngsten Redesign.
|
||||||
|
/// Lücken sind erlaubt und nützlich: ein später gefundenes Redesign lässt
|
||||||
|
/// sich dazwischenschieben, ohne die übrigen anzufassen.
|
||||||
|
chainPosition Int @default(0)
|
||||||
predecessorId Int?
|
predecessorId Int?
|
||||||
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||||
successors Character[] @relation("CharacterLineage")
|
successors Character[] @relation("CharacterLineage")
|
||||||
|
|
||||||
ledgerEntries CreditLedger[]
|
ledgerEntries CreditLedger[]
|
||||||
|
forumThreads ForumThread[]
|
||||||
|
links CharacterLink[]
|
||||||
|
members CharacterMember[]
|
||||||
|
/// Beitritte, für die dieser CC aufgegeben wurde.
|
||||||
|
givenUpFor CharacterMember[] @relation("MemberPredecessor")
|
||||||
|
/// Austritte, aus denen dieser CC hervorgegangen ist.
|
||||||
|
grewOutOf CharacterMember[] @relation("MemberSuccessor")
|
||||||
|
|
||||||
createdById Int?
|
createdById Int?
|
||||||
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
@@ -167,6 +275,7 @@ model Character {
|
|||||||
@@index([playerId])
|
@@index([playerId])
|
||||||
@@index([playerId, status])
|
@@index([playerId, status])
|
||||||
@@index([lineageId])
|
@@index([lineageId])
|
||||||
|
@@index([lineageId, chainPosition])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Import {
|
model Import {
|
||||||
@@ -204,7 +313,7 @@ model ImportRow {
|
|||||||
target ProductTarget?
|
target ProductTarget?
|
||||||
resolvedPlayerId Int?
|
resolvedPlayerId Int?
|
||||||
|
|
||||||
/// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK,
|
/// Was beim Buchen passieren würde: BOOK, CREATE_PLAYER_AND_BOOK,
|
||||||
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
||||||
plan String
|
plan String
|
||||||
raw Json
|
raw Json
|
||||||
@@ -257,3 +366,191 @@ model AuditLog {
|
|||||||
@@index([targetType, targetId])
|
@@index([targetType, targetId])
|
||||||
@@index([actorUserId])
|
@@index([actorUserId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein CC-Antrag im Forum. Die Thread-ID bleibt stabil, auch wenn der Thread
|
||||||
|
/// in ein anderes Board verschoben wird - deshalb ist sie der Schlüssel und
|
||||||
|
/// nicht die Board-Zugehörigkeit.
|
||||||
|
model ForumThread {
|
||||||
|
threadId Int @id
|
||||||
|
title String
|
||||||
|
url String
|
||||||
|
|
||||||
|
authorName String?
|
||||||
|
/// Überlebt Namensänderungen im Forum, anders als der Anzeigename.
|
||||||
|
authorForumId Int?
|
||||||
|
/// Das Profil des Erstellers, wie das Forum selbst darauf verweist.
|
||||||
|
authorProfileUrl String?
|
||||||
|
/// Labels des Threads, kommagetrennt. Reiner Kontext für die Entscheidung.
|
||||||
|
labels String?
|
||||||
|
|
||||||
|
playerId Int?
|
||||||
|
player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
currentBoard ForumBoard
|
||||||
|
/// Wann der Antrag im Forum gestellt wurde. Anders als firstSeenAt sagt das
|
||||||
|
/// etwas über den Antrag aus und nicht darüber, wann wir ihn bemerkt haben.
|
||||||
|
postedAt DateTime?
|
||||||
|
firstSeenAt DateTime @default(now())
|
||||||
|
lastSeenAt DateTime @default(now())
|
||||||
|
lastBoardChangeAt DateTime?
|
||||||
|
|
||||||
|
/// Bleibt leer, bis ein Mensch entschieden hat. Es gibt bewusst keine
|
||||||
|
/// Vorbelegung aus Titel oder Board - beides ist nicht verlässlich genug.
|
||||||
|
classification ForumClassification?
|
||||||
|
needsClassification Boolean @default(true)
|
||||||
|
classifiedById Int?
|
||||||
|
classifiedAt DateTime?
|
||||||
|
|
||||||
|
linkedCharacterId Int?
|
||||||
|
linkedCharacter Character? @relation(fields: [linkedCharacterId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Ein bereits entschiedener Thread ist in ein anderes Board gewandert.
|
||||||
|
/// Reine Gedächtnisstütze, löst nie eine Buchung aus.
|
||||||
|
boardChangeNotice Boolean @default(false)
|
||||||
|
|
||||||
|
history ForumThreadBoard[]
|
||||||
|
|
||||||
|
@@index([needsClassification])
|
||||||
|
@@index([playerId])
|
||||||
|
@@index([currentBoard])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wann lag ein Thread in welchem Board. Für Streitfälle nachvollziehbar.
|
||||||
|
model ForumThreadBoard {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
threadId Int
|
||||||
|
thread ForumThread @relation(fields: [threadId], references: [threadId], onDelete: Cascade)
|
||||||
|
board ForumBoard
|
||||||
|
observedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([threadId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frühere Steam- oder Forumskonten eines Spielers. Wer den Account wechselt,
|
||||||
|
/// soll seine Ansprüche behalten - Abgleich und Import prüfen deshalb neben
|
||||||
|
/// den Hauptangaben auch diese Einträge.
|
||||||
|
model PlayerAlias {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
playerId Int
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
type AliasType
|
||||||
|
value String
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// Dieselbe Kennung darf nicht zwei Spielern gehören.
|
||||||
|
@@unique([type, value])
|
||||||
|
@@index([playerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wer einen CC nutzt. Die meisten CCs haben genau ein Mitglied, manche teilen
|
||||||
|
/// sich mehrere Spieler ("Gruppen-CC"). Für jeden gelten dieselben Regeln: der
|
||||||
|
/// Platz kostet ein eigenes Guthaben, und wer aussteigt bekommt nichts zurück.
|
||||||
|
model CharacterMember {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
characterId Int
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
playerId Int
|
||||||
|
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
/// Gesetzt, sobald der Spieler den CC nicht mehr nutzt - ob freiwillig
|
||||||
|
/// ausgetreten, geputzt oder im Rollenspiel gestorben. Der Eintrag bleibt
|
||||||
|
/// stehen, damit nachvollziehbar ist, wer den CC wann genutzt hat.
|
||||||
|
leftAt DateTime?
|
||||||
|
|
||||||
|
/// Warum er nicht mehr dabei ist. Leer bei einem einfachen Austritt; bei
|
||||||
|
/// einem Gruppen-CC kann ein einzelner Spieler sterben oder geputzt werden,
|
||||||
|
/// während die übrigen weiterspielen. Die Rückkehr folgt denselben Regeln
|
||||||
|
/// wie bei einem eigenen CC.
|
||||||
|
inactiveReason InactiveReason?
|
||||||
|
inactiveNote String?
|
||||||
|
|
||||||
|
/// Womit der Platz bezahlt wurde. Leer beim Ersteller, dessen Buchung am
|
||||||
|
/// CC selbst hängt.
|
||||||
|
paidWith CreditKind?
|
||||||
|
note String?
|
||||||
|
|
||||||
|
/// Die Liste dieses Spielers, in der der CC für ihn steht, und seine
|
||||||
|
/// Stelle darin.
|
||||||
|
///
|
||||||
|
/// Ein Gruppen-CC wird für jeden Nutzer einzeln gerechnet: Für den einen
|
||||||
|
/// kann er der gekaufte Platz sein, für den anderen ein Redesign seines
|
||||||
|
/// vorherigen CCs. Deshalb hängt die Kette am Nutzer, nicht am CC.
|
||||||
|
lineageId String @default(uuid())
|
||||||
|
chainPosition Int @default(0)
|
||||||
|
|
||||||
|
/// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur
|
||||||
|
/// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe
|
||||||
|
/// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
||||||
|
predecessorId Int?
|
||||||
|
predecessor Character? @relation("MemberPredecessor", fields: [predecessorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
/// Der CC, in den der Spieler von hier aus gewechselt ist. Gesetzt, wenn er
|
||||||
|
/// seinen Anteil am Gruppen-CC redesignt hat und damit ausgestiegen ist -
|
||||||
|
/// die Gruppe läuft dabei unberührt weiter.
|
||||||
|
successorId Int?
|
||||||
|
successor Character? @relation("MemberSuccessor", fields: [successorId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
createdById Int?
|
||||||
|
|
||||||
|
/// Ein Spieler kann demselben CC nicht zweimal angehören.
|
||||||
|
@@unique([characterId, playerId])
|
||||||
|
@@index([playerId])
|
||||||
|
@@index([characterId])
|
||||||
|
@@index([lineageId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wann ein wiederkehrender Lauf zuletzt lief. Der Zeitplan steht im Code,
|
||||||
|
/// der Stand in der Datenbank - sonst würde jeder Neustart des Dienstes alles
|
||||||
|
/// erneut anstoßen, und bei mehreren Neustarts am Tag liefe der Abgleich
|
||||||
|
/// häufiger als gewollt.
|
||||||
|
model ScheduledRun {
|
||||||
|
name String @id
|
||||||
|
lastRunAt DateTime?
|
||||||
|
/// OK oder FAILED, mit einer kurzen Notiz für die Fehlersuche.
|
||||||
|
lastStatus String?
|
||||||
|
lastNote String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beliebige Verweise an einem CC: das Steam-Addon des Erstellers, ein
|
||||||
|
/// Modell, ein Absprache-Dokument. Der Name wird zum Link, damit in der Liste
|
||||||
|
/// steht, worum es geht, und nicht eine nackte Adresse.
|
||||||
|
model CharacterLink {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
characterId Int
|
||||||
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
label String
|
||||||
|
url String
|
||||||
|
|
||||||
|
createdById Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([characterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was der Zeitplan tun soll. Steht in der Datenbank statt in der Umgebung,
|
||||||
|
/// damit sich der Takt ändern lässt, ohne den Dienst neu zu starten.
|
||||||
|
model ScheduleSetting {
|
||||||
|
/// "gextension" oder "forum".
|
||||||
|
name String @id
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
/// Abstand zwischen zwei Läufen, in Minuten.
|
||||||
|
intervalMinutes Int @default(120)
|
||||||
|
|
||||||
|
/// Nur beim Käufe-Abgleich: der seltenere Takt für Konten, bei denen lange
|
||||||
|
/// nichts gekauft wurde, und ab wann ein Konto als ruhend gilt.
|
||||||
|
idleIntervalMinutes Int?
|
||||||
|
idleAfterDays Int?
|
||||||
|
|
||||||
|
/// Nur beim Forumslauf: frühestens ab dieser Stunde. Damit lässt sich ein
|
||||||
|
/// täglicher Lauf in die Nacht legen, statt ihn irgendwann am Tag
|
||||||
|
/// loszuschicken.
|
||||||
|
onlyAfterHour Int?
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
+36
-7
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import cookie from '@fastify/cookie';
|
import cookie from '@fastify/cookie';
|
||||||
@@ -17,6 +17,8 @@ import { creditRoutes } from './routes/credits.routes.js';
|
|||||||
import { characterRoutes } from './routes/characters.routes.js';
|
import { characterRoutes } from './routes/characters.routes.js';
|
||||||
import { importRoutes } from './routes/imports.routes.js';
|
import { importRoutes } from './routes/imports.routes.js';
|
||||||
import { adminRoutes } from './routes/admin.routes.js';
|
import { adminRoutes } from './routes/admin.routes.js';
|
||||||
|
import { scraperRoutes } from './routes/scraper.routes.js';
|
||||||
|
import { forumRoutes } from './routes/forum.routes.js';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
|
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
|
||||||
@@ -78,6 +80,20 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
app.addHook('onRequest', enforceCsrf);
|
app.addHook('onRequest', enforceCsrf);
|
||||||
app.addHook('preHandler', blockUntilPasswordChanged);
|
app.addHook('preHandler', blockUntilPasswordChanged);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antworten der Schnittstelle gehören nicht in den Browser-Cache.
|
||||||
|
*
|
||||||
|
* Ohne Angabe darf ein Browser selbst entscheiden, wie lange er eine
|
||||||
|
* GET-Antwort aufhebt. Dann steht nach dem Anlegen eines CCs die alte
|
||||||
|
* Liste auf dem Schirm, und erst ein Neuladen bringt sie in Ordnung.
|
||||||
|
* Nebenbei bleiben so keine Spielerdaten im Cache liegen.
|
||||||
|
*/
|
||||||
|
app.addHook('onSend', async (request, reply) => {
|
||||||
|
if (request.url.startsWith('/api/')) {
|
||||||
|
reply.header('cache-control', 'no-store');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
||||||
@@ -105,7 +121,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
||||||
reply
|
reply
|
||||||
.code(statusCode)
|
.code(statusCode)
|
||||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +137,29 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(characterRoutes);
|
await app.register(characterRoutes);
|
||||||
await app.register(importRoutes);
|
await app.register(importRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
|
await app.register(scraperRoutes);
|
||||||
|
await app.register(forumRoutes);
|
||||||
|
|
||||||
if (existsSync(webRoot)) {
|
if (existsSync(webRoot)) {
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: webRoot,
|
root: webRoot,
|
||||||
prefix: '/',
|
prefix: '/',
|
||||||
// Gehashte Assets duerfen lange liegen bleiben, index.html nie.
|
// maxAge würde den Header für *alle* Dateien setzen und dabei auch die
|
||||||
maxAge: '1y',
|
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
||||||
setHeaders: (res, path) => {
|
// sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche.
|
||||||
if (path.endsWith('index.html')) {
|
// Deshalb hier abschalten und beides unten selbst setzen.
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
cacheControl: false,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||||
|
} else if (filePath.includes(`${sep}assets${sep}`)) {
|
||||||
|
// Nur die gebauten Assets tragen einen Inhalts-Hash im Namen: ändert
|
||||||
|
// sich der Inhalt, ändert sich der Dateiname. Die dürfen liegen bleiben.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
} else {
|
||||||
|
// Alles andere - Bilder, Favicon - behält den Namen bei neuem Inhalt.
|
||||||
|
// Ein Tag mit Rückfrage, damit ein Austausch auch ankommt.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
|
|||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
|
// Ohne diese Ausnahme könnte sich niemand anmelden: vor dem Login gibt es
|
||||||
// weder Session noch ableitbares CSRF-Token.
|
// weder Session noch ableitbares CSRF-Token.
|
||||||
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export const blockUntilPasswordChanged: preHandlerHookHandler = async (request)
|
|||||||
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
||||||
const path = request.url.split('?')[0] ?? '';
|
const path = request.url.split('?')[0] ?? '';
|
||||||
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
||||||
throw forbidden('Bitte zuerst das Passwort aendern');
|
throw forbidden('Bitte zuerst das Passwort ändern');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { hash, verify } from '@node-rs/argon2';
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
|
||||||
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
|
// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1.
|
||||||
const options = {
|
const options = {
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
@@ -27,7 +27,7 @@ export function validatePasswordStrength(plain: string): string | null {
|
|||||||
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
||||||
}
|
}
|
||||||
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
||||||
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
|
return 'Passwort muss Groß- und Kleinbuchstaben enthalten';
|
||||||
}
|
}
|
||||||
if (!/[0-9]/.test(plain)) {
|
if (!/[0-9]/.test(plain)) {
|
||||||
return 'Passwort muss mindestens eine Ziffer enthalten';
|
return 'Passwort muss mindestens eine Ziffer enthalten';
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function destroyAllSessionsFor(userId: number): Promise<void> {
|
|||||||
await prisma.session.deleteMany({ where: { userId } });
|
await prisma.session.deleteMany({ where: { userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */
|
/** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */
|
||||||
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
||||||
await prisma.session.deleteMany({
|
await prisma.session.deleteMany({
|
||||||
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import QRCode from 'qrcode';
|
|||||||
|
|
||||||
const ISSUER = 'CC-Manager';
|
const ISSUER = 'CC-Manager';
|
||||||
|
|
||||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
|
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht
|
||||||
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
||||||
const EPOCH_TOLERANCE_SECONDS = 30;
|
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -4,13 +4,17 @@ import { prisma } from './db.js';
|
|||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { hashPassword } from './auth/password.js';
|
import { hashPassword } from './auth/password.js';
|
||||||
|
|
||||||
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||||
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
||||||
|
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide
|
||||||
|
// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht
|
||||||
|
// das Redesign-Muster vorn.
|
||||||
const DEFAULT_MAPPINGS = [
|
const DEFAULT_MAPPINGS = [
|
||||||
|
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
|
||||||
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
|
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
|
||||||
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
|
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
|
||||||
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
|
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
|
||||||
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 },
|
{ matchPattern: 'custom charakter', target: ProductTarget.CC_PACKAGE, priority: 13 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface BootstrapResult {
|
export interface BootstrapResult {
|
||||||
@@ -38,6 +42,7 @@ export async function bootstrap(log: {
|
|||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
|
isProtected: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,10 +53,18 @@ export async function bootstrap(log: {
|
|||||||
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
||||||
log.warn(`Einmal-Passwort: ${password}`);
|
log.warn(`Einmal-Passwort: ${password}`);
|
||||||
}
|
}
|
||||||
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
|
log.warn('Das Passwort muss bei der ersten Anmeldung geändert werden.');
|
||||||
log.warn('─'.repeat(60));
|
log.warn('─'.repeat(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bestehende Installationen kennen die Markierung noch nicht. Welcher
|
||||||
|
// Zugang der erste war, weiss nur der Start: der Name steht in der
|
||||||
|
// Umgebung, nicht in der Datenbank.
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { username: env.BOOTSTRAP_ADMIN_USER, isProtected: false },
|
||||||
|
data: { isProtected: true },
|
||||||
|
});
|
||||||
|
|
||||||
const mappingCount = await prisma.productMapping.count();
|
const mappingCount = await prisma.productMapping.count();
|
||||||
if (mappingCount === 0) {
|
if (mappingCount === 0) {
|
||||||
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
||||||
|
|||||||
+26
-3
@@ -15,18 +15,41 @@ const schema = z.object({
|
|||||||
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
||||||
|
|
||||||
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
|
// Hinter einem Reverse Proxy nötig, damit Client-IPs im Audit-Log stimmen.
|
||||||
TRUST_PROXY: z
|
TRUST_PROXY: z
|
||||||
.string()
|
.string()
|
||||||
.default('true')
|
.default('true')
|
||||||
.transform((v) => v === 'true' || v === '1'),
|
.transform((v) => v === 'true' || v === '1'),
|
||||||
|
|
||||||
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
|
// Muss in Produktion true sein (Cookies nur über HTTPS).
|
||||||
COOKIE_SECURE: z
|
COOKIE_SECURE: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||||
|
|
||||||
|
// Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau
|
||||||
|
// zugeschnitten; eine völlig andere Software ließe sich damit nicht lesen.
|
||||||
|
GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'),
|
||||||
|
|
||||||
|
// Basis-Adresse des Forums für den Antrags-Abgleich. Der Parser ist auf
|
||||||
|
// WoltLab (Burning Board) zugeschnitten.
|
||||||
|
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
|
||||||
|
|
||||||
|
// Der Zugang aus der Erstinstallation ist ab Werk geschützt: weder Rolle
|
||||||
|
// noch Zustand lassen sich ändern, sein Passwort nicht von aussen
|
||||||
|
// zurücksetzen, und löschen lässt er sich nicht - auch nicht durch sich
|
||||||
|
// selbst. So bleibt ein Weg in die Anwendung offen, wenn sich jemand aus
|
||||||
|
// Versehen die Rechte entzieht. Auf "true" gesetzt, gilt er wie jeder
|
||||||
|
// andere Zugang.
|
||||||
|
BOOTSTRAP_ADMIN_EDITABLE: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
|
||||||
|
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||||
|
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||||
|
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||||
|
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +57,7 @@ const parsed = schema.safeParse(process.env);
|
|||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||||
console.error(`Konfiguration unvollstaendig:\n${issues}`);
|
console.error(`Konfiguration unvollständig:\n${issues}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class AppError extends Error {
|
|||||||
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
||||||
export const unauthorized = (message = 'Nicht angemeldet') =>
|
export const unauthorized = (message = 'Nicht angemeldet') =>
|
||||||
new AppError(401, 'UNAUTHORIZED', message);
|
new AppError(401, 'UNAUTHORIZED', message);
|
||||||
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
|
export const forbidden = (message = 'Keine Berechtigung für diese Aktion') =>
|
||||||
new AppError(403, 'FORBIDDEN', message);
|
new AppError(403, 'FORBIDDEN', message);
|
||||||
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
|
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);
|
export const conflict = (code: string, message: string) => new AppError(409, code, message);
|
||||||
|
|||||||
+29
-3
@@ -1,10 +1,13 @@
|
|||||||
import { buildApp } from './app.js';
|
import { buildApp } from './app.js';
|
||||||
import { bootstrap } from './bootstrap.js';
|
import { bootstrap } from './bootstrap.js';
|
||||||
import { cleanupExpiredSessions } from './auth/session.js';
|
import { cleanupExpiredSessions } from './auth/session.js';
|
||||||
|
import { cleanupOldAuditEntries } from './services/audit.js';
|
||||||
|
import { richteBuchungenAus } from './services/characters.js';
|
||||||
|
import { startSchedule } from './services/schedule.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
|
||||||
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
@@ -21,18 +24,41 @@ async function main(): Promise<void> {
|
|||||||
warn: (msg) => app.log.warn(msg),
|
warn: (msg) => app.log.warn(msg),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Am Anfang einer Kette steht ein gekaufter Platz, darüber die Redesigns.
|
||||||
|
// Der Lauf gleicht Buchungen an, die dem noch widersprechen - und tut
|
||||||
|
// nichts, wenn alles passt.
|
||||||
|
try {
|
||||||
|
const angeglichen = await richteBuchungenAus();
|
||||||
|
if (angeglichen > 0) app.log.info(`${angeglichen} Buchungen an die Ketten angeglichen`);
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error }, 'Angleichen der Buchungen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = setInterval(() => {
|
const cleanup = setInterval(() => {
|
||||||
cleanupExpiredSessions()
|
cleanupExpiredSessions()
|
||||||
.then((count) => {
|
.then((count) => {
|
||||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||||
})
|
})
|
||||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
.catch((error) => app.log.warn({ err: error }, 'Aufräumen der Sitzungen fehlgeschlagen'));
|
||||||
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
||||||
|
cleanupOldAuditEntries()
|
||||||
|
.then((count) => {
|
||||||
|
if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`);
|
||||||
|
})
|
||||||
|
.catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen'));
|
||||||
|
}, CLEANUP_INTERVAL_MS);
|
||||||
cleanup.unref();
|
cleanup.unref();
|
||||||
|
|
||||||
|
const stopSchedule = startSchedule({
|
||||||
|
info: (msg) => app.log.info(msg),
|
||||||
|
warn: (msg) => app.log.warn(msg),
|
||||||
|
debug: (msg) => app.log.debug(msg),
|
||||||
|
});
|
||||||
|
|
||||||
const shutdown = async (signal: string): Promise<void> => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
app.log.info(`${signal} empfangen, fahre herunter`);
|
app.log.info(`${signal} empfangen, fahre herunter`);
|
||||||
clearInterval(cleanup);
|
clearInterval(cleanup);
|
||||||
|
stopSchedule();
|
||||||
await app.close();
|
await app.close();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Liest Thread-Listen eines WoltLab-Forums (Burning Board).
|
||||||
|
*
|
||||||
|
* Gelesen wird ausschließlich die Übersichtsseite: sie enthält neben Titel und
|
||||||
|
* Thread-ID auch den Ersteller, sodass die Threads nicht einzeln geöffnet
|
||||||
|
* werden müssen. Das hält einen Lauf bei drei Anfragen statt einer je Thread.
|
||||||
|
*
|
||||||
|
* Wichtig: Eine Boardseite enthält neben der Thread-Liste auch Unterforen und
|
||||||
|
* globale Kästen ("letzte Beiträge"). Beides trägt ebenfalls Thread-Links, ist
|
||||||
|
* aber kein Thread dieses Boards - deshalb wird strikt entlang der
|
||||||
|
* tabularListRow-Einträge gelesen und nicht über alle Links im Dokument.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ForumThread {
|
||||||
|
threadId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
authorName: string | null;
|
||||||
|
/// Stabiler als der Name: Forumsnamen ändern sich, die ID nicht.
|
||||||
|
authorForumId: number | null;
|
||||||
|
/// Sein Profil, so wie das Forum selbst darauf verweist. Übernommen statt
|
||||||
|
/// zusammengebaut: die Profile liegen nicht unter dem Forums-Pfad, sondern
|
||||||
|
/// eine Ebene darüber, und das lässt sich nicht verlässlich erraten.
|
||||||
|
authorProfileUrl: string | null;
|
||||||
|
/// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück.
|
||||||
|
isMovedStub: boolean;
|
||||||
|
/// Labels des Threads, etwa "Wichtig" oder "SCP-RP".
|
||||||
|
labels: string[];
|
||||||
|
/// Wann der Antrag gestellt wurde, laut Forum.
|
||||||
|
postedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs,
|
||||||
|
/// Regelwerke, Bewerbungsphasen. Sie tragen diese Labels und sind keine Anträge.
|
||||||
|
export const IGNORED_LABELS = ['wichtig', 'information'];
|
||||||
|
|
||||||
|
export function hasIgnoredLabel(thread: ForumThread): boolean {
|
||||||
|
return thread.labels.some((label) => IGNORED_LABELS.includes(label.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_SPLIT = /<li class="tabularListRow">/;
|
||||||
|
|
||||||
|
const TOPIC_LINK =
|
||||||
|
/<a\s+href="([^"]*thread\/(\d+)-[^"]*)"[^>]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/;
|
||||||
|
const USER_LINK =
|
||||||
|
/<a\s+href="([^"]*user\/(\d+)-[^"]*)"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\s*([^<]{1,80})/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Zelle mit dem Ersteller. Dort steht er verlässlich - mit Profillink,
|
||||||
|
* wenn es das Konto noch gibt, sonst als blosser Text.
|
||||||
|
*/
|
||||||
|
const AUTHOR_CELL = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab hier beginnt die Spalte mit dem **letzten Beitrag**.
|
||||||
|
*
|
||||||
|
* Dort steht ein anderer Name, und wer ihn erwischt, ordnet den Antrag dem
|
||||||
|
* falschen Spieler zu. Das passiert genau dann, wenn der Ersteller sein Konto
|
||||||
|
* geloescht hat: er hat dann keinen Profillink, der letzte Antwortende schon.
|
||||||
|
* Deshalb wird die Zeile vorher abgeschnitten, statt sich auf die Reihenfolge
|
||||||
|
* der Treffer zu verlassen.
|
||||||
|
*/
|
||||||
|
const LAST_POST_COLUMN = /<li class="columnLastPost"/;
|
||||||
|
|
||||||
|
// Ein zurückgelassener Verweis ist kein eigener Thread.
|
||||||
|
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
|
||||||
|
|
||||||
|
// Der Zeitpunkt des ersten Beitrags - das Datum des Antrags. Die Spalte
|
||||||
|
// daneben trägt den letzten Beitrag, der ist hier ohne Belang.
|
||||||
|
const POSTED_AT =
|
||||||
|
/<li class="messageGroupTime">\s*<woltlab-core-date-time[^>]*\sdate="([^"]+)"/;
|
||||||
|
|
||||||
|
// Labels hängen als eigene Links über dem Titel, nicht als span.
|
||||||
|
const LABEL = /<a[^>]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g;
|
||||||
|
|
||||||
|
function decode(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/�?39;/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBoard(html: string): ForumThread[] {
|
||||||
|
const threads: ForumThread[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
|
||||||
|
for (const block of html.split(ROW_SPLIT).slice(1)) {
|
||||||
|
// Nur bis zum Ende der Zeile schauen, sonst greift der nächste Eintrag rein.
|
||||||
|
const topic = TOPIC_LINK.exec(block);
|
||||||
|
if (!topic) continue;
|
||||||
|
|
||||||
|
const threadId = Number(topic[2]);
|
||||||
|
if (!Number.isInteger(threadId) || seen.has(threadId)) continue;
|
||||||
|
seen.add(threadId);
|
||||||
|
|
||||||
|
// Alles ab der Spalte "letzter Beitrag" gehört nicht mehr zum Ersteller.
|
||||||
|
const kopf = block.split(LAST_POST_COLUMN)[0] ?? block;
|
||||||
|
|
||||||
|
// Zuerst die Zelle, die den Ersteller führt. Erst wenn es sie nicht gibt,
|
||||||
|
// wird weiter oben gesucht - etwa im Avatar, der ebenfalls auf ihn zeigt.
|
||||||
|
const zelle = AUTHOR_CELL.exec(kopf);
|
||||||
|
const user = USER_LINK.exec(zelle?.[1] ?? kopf);
|
||||||
|
|
||||||
|
let authorName = user ? decode(user[3] ?? '') || null : null;
|
||||||
|
if (authorName === null && zelle) {
|
||||||
|
authorName = decode((zelle[1] ?? '').replace(/<[^>]*>/g, ' ')) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LABEL.lastIndex = 0;
|
||||||
|
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
|
||||||
|
|
||||||
|
const zeit = POSTED_AT.exec(block);
|
||||||
|
const postedAt = zeit ? new Date(zeit[1] as string) : null;
|
||||||
|
|
||||||
|
threads.push({
|
||||||
|
threadId,
|
||||||
|
title: decode(topic[3] ?? ''),
|
||||||
|
url: decode(topic[1] ?? ''),
|
||||||
|
authorName,
|
||||||
|
authorForumId: user ? Number(user[2]) : null,
|
||||||
|
authorProfileUrl: user ? decode(user[1] ?? '') || null : null,
|
||||||
|
isMovedStub: MOVED_MARKER.test(block),
|
||||||
|
labels,
|
||||||
|
postedAt: postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzahl der Seiten eines Boards.
|
||||||
|
*
|
||||||
|
* WoltLab führt die Gesamtzahl in einem eigenen Element, nicht in den Links:
|
||||||
|
* die Blätterleiste zeigt nur die Nachbarseiten. Wer allein die pageNo-Links
|
||||||
|
* auswertet, hält ein Board mit 25 Seiten für zweiseitig.
|
||||||
|
*/
|
||||||
|
export function parsePageCount(html: string): number {
|
||||||
|
const element = /<woltlab-core-pagination[^>]*\scount="(\d+)"/.exec(html);
|
||||||
|
if (element) return Math.max(1, Number(element[1]));
|
||||||
|
|
||||||
|
// Ältere Fassungen ohne das Element: wenigstens die Links auswerten.
|
||||||
|
const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1]));
|
||||||
|
return numbers.length > 0 ? Math.max(1, ...numbers) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht die Thread-ID aus einer Forums-URL. Angenommen werden der volle Link,
|
||||||
|
* der Pfad oder die blanke Nummer - beim Eintippen von Hand landet mal das
|
||||||
|
* eine, mal das andere im Feld.
|
||||||
|
*/
|
||||||
|
export function parseThreadId(input: string): number | null {
|
||||||
|
const value = input.trim();
|
||||||
|
if (value.length === 0) return null;
|
||||||
|
|
||||||
|
const fromUrl = /thread\/(\d+)/.exec(value);
|
||||||
|
if (fromUrl) return Number(fromUrl[1]);
|
||||||
|
|
||||||
|
return /^\d+$/.test(value) ? Number(value) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { ProductTarget } from '@prisma/client';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
|
import { isRateLimited, retryAfterMs, sleep } from './ratelimit.js';
|
||||||
|
import { normalizeSteamId } from './steamid.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
|
||||||
|
* Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||||
|
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
|
||||||
|
* gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag
|
||||||
|
* und den Storno-Status.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScrapedPackage {
|
||||||
|
packageBoughtId: number;
|
||||||
|
packageName: string;
|
||||||
|
serverBundle: string;
|
||||||
|
purchasedAt: Date | null;
|
||||||
|
amount: string;
|
||||||
|
gateway: string;
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrapedProfile {
|
||||||
|
steamId64: string;
|
||||||
|
nickname: string | null;
|
||||||
|
packages: ScrapedPackage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONATION_ENTRY = /donations_data\[(\d+)\]\s*=\s*(\{.*?\});/gs;
|
||||||
|
|
||||||
|
// "06.10.2025 20:54"
|
||||||
|
const GERMAN_DATE = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?$/;
|
||||||
|
|
||||||
|
function parseGermanDate(value: unknown): Date | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const match = GERMAN_DATE.exec(value.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, day, month, year, hour, minute] = match;
|
||||||
|
const parsed = new Date(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1,
|
||||||
|
Number(day),
|
||||||
|
Number(hour ?? 0),
|
||||||
|
Number(minute ?? 0),
|
||||||
|
);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProfile(html: string, steamId64: string): ScrapedProfile {
|
||||||
|
const packages: ScrapedPackage[] = [];
|
||||||
|
|
||||||
|
for (const match of html.matchAll(DONATION_ENTRY)) {
|
||||||
|
const raw = match[2];
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
let entry: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
// Einzelne kaputte Zeile darf nicht den ganzen Lauf kippen.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof entry.package_name === 'string' ? entry.package_name.trim() : '';
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
packages.push({
|
||||||
|
packageBoughtId: Number(entry.package_bought_id ?? match[1]),
|
||||||
|
packageName: name,
|
||||||
|
serverBundle: typeof entry.serverbundle_name === 'string' ? entry.serverbundle_name : '',
|
||||||
|
purchasedAt: parseGermanDate(entry.date),
|
||||||
|
amount: typeof entry.amount === 'string' ? entry.amount : '',
|
||||||
|
gateway: typeof entry.gateway === 'string' ? entry.gateway : '',
|
||||||
|
revoked: entry.revoked === 1 || entry.revoked === true || entry.status === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickname = extractNickname(html);
|
||||||
|
return { steamId64, nickname, packages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNickname(html: string): string | null {
|
||||||
|
const match = /Nickname\s*<\/td>\s*<td[^>]*>([^<]+)</i.exec(html);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Classification =
|
||||||
|
| { kind: 'package'; target: ProductTarget }
|
||||||
|
| { kind: 'usage_counter'; times: number }
|
||||||
|
| { kind: 'unclear' }
|
||||||
|
| { kind: 'ignore' };
|
||||||
|
|
||||||
|
// Nur der SCP-RP-Bereich führt CC-relevante Pakete.
|
||||||
|
const RELEVANT_BUNDLE = 'scp-rp';
|
||||||
|
|
||||||
|
// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||||
|
// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten
|
||||||
|
// zu werden.
|
||||||
|
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
||||||
|
|
||||||
|
const USAGE_COUNTER = /cc[\s-]*paket.*genutzt/i;
|
||||||
|
const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
||||||
|
* bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als
|
||||||
|
* Redesign zählen, nicht als CC-Paket.
|
||||||
|
*/
|
||||||
|
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||||
|
if (pkg.revoked) return { kind: 'ignore' };
|
||||||
|
if (pkg.serverBundle.toLowerCase() !== RELEVANT_BUNDLE) return { kind: 'ignore' };
|
||||||
|
|
||||||
|
const title = pkg.packageName;
|
||||||
|
|
||||||
|
if (USAGE_COUNTER.test(title)) {
|
||||||
|
const times = USAGE_TIMES.exec(title);
|
||||||
|
return { kind: 'usage_counter', times: times ? Number(times[1]) : 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/redesign/i.test(title)) {
|
||||||
|
return { kind: 'package', target: ProductTarget.REDESIGN_PACKAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/custom\s*charac?k?ter/i.test(title)) {
|
||||||
|
return { kind: 'package', target: ProductTarget.CC_PACKAGE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klingt nach CC, passt aber in kein Muster: lieber nachfragen als raten.
|
||||||
|
if (CC_HINT.test(title)) return { kind: 'unclear' };
|
||||||
|
|
||||||
|
return { kind: 'ignore' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft GExtension
|
||||||
|
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
|
||||||
|
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
|
||||||
|
|
||||||
|
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
|
||||||
|
const MIN_REQUEST_GAP_MS = 1000;
|
||||||
|
const MAX_REQUEST_GAP_MS = 10000;
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
|
let lastRequestAt = 0;
|
||||||
|
/// Wächst, sobald die Gegenseite bremst. Einmal oben, bleibt es oben - bei
|
||||||
|
/// einem Abgleich über viele Spieler wäre jedes Zurücksetzen ein Rückfall in
|
||||||
|
/// dasselbe Tempo, das gerade abgewiesen wurde.
|
||||||
|
let requestGapMs = MIN_REQUEST_GAP_MS;
|
||||||
|
|
||||||
|
async function throttle(): Promise<void> {
|
||||||
|
const wait = lastRequestAt + requestGapMs - Date.now();
|
||||||
|
if (wait > 0) await sleep(wait);
|
||||||
|
lastRequestAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt das Tempo zurück, etwa zu Beginn eines neuen Abgleichs. */
|
||||||
|
export function resetRequestPace(): void {
|
||||||
|
requestGapMs = MIN_REQUEST_GAP_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(
|
||||||
|
steamId: string,
|
||||||
|
options: FetchOptions = {},
|
||||||
|
): Promise<ScrapedProfile> {
|
||||||
|
const steamId64 = normalizeSteamId(steamId);
|
||||||
|
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gültige SteamID: ${steamId}`);
|
||||||
|
|
||||||
|
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
|
const timeoutMs = options.timeoutMs ?? 15000;
|
||||||
|
|
||||||
|
for (let versuch = 0; ; versuch += 1) {
|
||||||
|
await throttle();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
||||||
|
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)',
|
||||||
|
accept: 'text/html',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bremst die Gegenseite, wird gewartet statt den Spieler zu verlieren.
|
||||||
|
if (isRateLimited(response.status)) {
|
||||||
|
requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS);
|
||||||
|
|
||||||
|
if (versuch >= MAX_RETRIES) {
|
||||||
|
throw badRequest(
|
||||||
|
'PROFILE_RATE_LIMITED',
|
||||||
|
`GExtension bremst den Abruf aus (HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(retryAfterMs(response.headers.get('retry-after'), versuch));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseProfile(await response.text(), steamId64);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Rücksicht auf fremde Server.
|
||||||
|
*
|
||||||
|
* Sowohl das Forum als auch GExtension gehören der Community, nicht uns. Beide
|
||||||
|
* weisen zu viele Anfragen ab, statt sie zu beantworten. Wer darauf mit einem
|
||||||
|
* Abbruch reagiert, verliert den Rest des Laufs - deshalb wird gewartet und
|
||||||
|
* erneut versucht, und das Tempo bleibt für den Rest des Laufs gedrosselt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Antworten, die "später nochmal" bedeuten.
|
||||||
|
export function isRateLimited(status: number): boolean {
|
||||||
|
return status === 429 || status === 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange nach einer Abweisung gewartet wird. Nennt die Gegenseite selbst
|
||||||
|
* eine Zeit, gilt die; sonst wird mit jedem Versuch länger gewartet. Nach oben
|
||||||
|
* gedeckelt, damit ein Lauf nicht an einer absurden Angabe hängen bleibt.
|
||||||
|
*/
|
||||||
|
export function retryAfterMs(header: string | null, versuch: number): number {
|
||||||
|
const sekunden = Number(header);
|
||||||
|
if (Number.isFinite(sekunden) && sekunden > 0) {
|
||||||
|
return Math.min(sekunden * 1000, 60000);
|
||||||
|
}
|
||||||
|
return Math.min(5000 * (versuch + 1), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Steam kennt dieselbe Person in mehreren Schreibweisen. Damit ein Spieler
|
||||||
|
* nicht doppelt angelegt wird, sobald eine Quelle das andere Format liefert,
|
||||||
|
* wird intern immer die 64-Bit-Form gespeichert.
|
||||||
|
*
|
||||||
|
* STEAM_0:1:12345678 (Legacy, was Garry's Mod anzeigt)
|
||||||
|
* [U:1:24691357] (SteamID3)
|
||||||
|
* 76561198024691357 (SteamID64, die kanonische Form)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Offset zwischen Account-ID und SteamID64.
|
||||||
|
const BASE = 76561197960265728n;
|
||||||
|
|
||||||
|
const LEGACY = /^STEAM_[0-5]:([01]):(\d+)$/i;
|
||||||
|
const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
||||||
|
const STEAM64 = /^\d{17}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert
|
||||||
|
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||||
|
*/
|
||||||
|
export function normalizeSteamId(input: string): string | null {
|
||||||
|
const value = input.trim();
|
||||||
|
if (value.length === 0) return null;
|
||||||
|
|
||||||
|
const legacy = LEGACY.exec(value);
|
||||||
|
if (legacy) {
|
||||||
|
const universe = BigInt(legacy[1] as string);
|
||||||
|
const accountNumber = BigInt(legacy[2] as string);
|
||||||
|
return (BASE + accountNumber * 2n + universe).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modern = STEAM3.exec(value);
|
||||||
|
if (modern) {
|
||||||
|
return (BASE + BigInt(modern[1] as string)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STEAM64.test(value)) {
|
||||||
|
const numeric = BigInt(value);
|
||||||
|
// Alles unterhalb des Offsets wäre keine Nutzer-ID.
|
||||||
|
return numeric > BASE ? numeric.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */
|
||||||
|
export function toLegacySteamId(steamId64: string): string | null {
|
||||||
|
if (!STEAM64.test(steamId64)) return null;
|
||||||
|
|
||||||
|
const numeric = BigInt(steamId64);
|
||||||
|
if (numeric <= BASE) return null;
|
||||||
|
|
||||||
|
const offset = numeric - BASE;
|
||||||
|
return `STEAM_0:${offset % 2n}:${offset / 2n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeSteamId(input: string): boolean {
|
||||||
|
const value = input.trim();
|
||||||
|
return LEGACY.test(value) || STEAM3.test(value) || STEAM64.test(value);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { requireAdmin } from '../auth/guard.js';
|
|||||||
import { badRequest, conflict, notFound } from '../errors.js';
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
|
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
|
||||||
import { destroyAllSessionsFor } from '../auth/session.js';
|
import { destroyAllSessionsFor } from '../auth/session.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
import { recordAudit } from '../services/audit.js';
|
import { recordAudit } from '../services/audit.js';
|
||||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
@@ -16,10 +17,33 @@ const userSelect = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
|
isProtected: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
} satisfies Prisma.UserSelect;
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält den Zugang aus der Erstinstallation aus dem Spiel.
|
||||||
|
*
|
||||||
|
* An ihm lässt sich nichts ändern, was ihn aussperren könnte: keine Rolle,
|
||||||
|
* kein Zustand, kein Passwort von aussen, keine Löschung - und zwar auch
|
||||||
|
* dann nicht, wenn er es selbst versucht. Damit bleibt ein Weg zurück offen,
|
||||||
|
* wenn sich jemand die Rechte entzieht oder ein Konto abhanden kommt. Sein
|
||||||
|
* eigenes Passwort ändert er weiterhin selbst, über den Bereich "Konto".
|
||||||
|
*
|
||||||
|
* Wer das nicht will, setzt BOOTSTRAP_ADMIN_EDITABLE auf true; dann gilt er
|
||||||
|
* wie jeder andere Zugang.
|
||||||
|
*/
|
||||||
|
function assertNichtGeschuetzt(target: { isProtected: boolean; username: string }): void {
|
||||||
|
if (!target.isProtected || env.BOOTSTRAP_ADMIN_EDITABLE) return;
|
||||||
|
|
||||||
|
throw conflict(
|
||||||
|
'PROTECTED_ACCOUNT',
|
||||||
|
`"${target.username}" ist der Zugang aus der Erstinstallation und bleibt unangetastet. ` +
|
||||||
|
'Wer das ändern will, setzt BOOTSTRAP_ADMIN_EDITABLE=true in der Umgebung.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
|
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
|
||||||
async function assertNotLastAdmin(userId: number): Promise<void> {
|
async function assertNotLastAdmin(userId: number): Promise<void> {
|
||||||
const target = await prisma.user.findUnique({ where: { id: userId } });
|
const target = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -41,7 +65,16 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
|
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
|
||||||
select: userSelect,
|
select: userSelect,
|
||||||
});
|
});
|
||||||
return { items };
|
|
||||||
|
// Ob der Schutz gerade greift, entscheidet die Umgebung. Die Oberfläche
|
||||||
|
// bekommt das Ergebnis, nicht die Regel - sie soll nicht nachbauen
|
||||||
|
// müssen, was hier schon entschieden ist.
|
||||||
|
return {
|
||||||
|
items: items.map((user) => ({
|
||||||
|
...user,
|
||||||
|
isLocked: user.isProtected && !env.BOOTSTRAP_ADMIN_EDITABLE,
|
||||||
|
})),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/users', async (request, reply) => {
|
app.post('/api/admin/users', async (request, reply) => {
|
||||||
@@ -101,6 +134,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
|
|
||||||
const losesAdmin =
|
const losesAdmin =
|
||||||
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
|
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
|
||||||
@@ -137,6 +171,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -160,11 +195,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.delete('/api/admin/users/:id', async (request) => {
|
app.delete('/api/admin/users/:id', async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
if (id === request.user?.id) {
|
if (id === request.user?.id) {
|
||||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
|
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht gelöscht werden');
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
await assertNotLastAdmin(id);
|
await assertNotLastAdmin(id);
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
@@ -266,6 +302,35 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// --- Audit-Log ---
|
// --- Audit-Log ---
|
||||||
|
|
||||||
|
/** Einstellungen und Stand der wiederkehrenden Abgleiche. */
|
||||||
|
app.get('/api/admin/schedule', async () => {
|
||||||
|
const { listRuns, listSettings } = await import('../services/schedule.js');
|
||||||
|
const [settings, runs] = await Promise.all([listSettings(), listRuns()]);
|
||||||
|
return { settings, runs };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/admin/schedule/:name', async (request) => {
|
||||||
|
const { updateSetting, LAEUFE } = await import('../services/schedule.js');
|
||||||
|
|
||||||
|
const params = parseWith(
|
||||||
|
z.object({ name: z.enum(['gextension', 'forum']) }),
|
||||||
|
request.params,
|
||||||
|
);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
intervalMinutes: z.number().int().optional(),
|
||||||
|
idleIntervalMinutes: z.number().int().nullable().optional(),
|
||||||
|
idleAfterDays: z.number().int().nullable().optional(),
|
||||||
|
onlyAfterHour: z.number().int().nullable().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!LAEUFE.includes(params.name)) throw notFound('Lauf');
|
||||||
|
return updateSetting(params.name, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/audit', async (request) => {
|
app.get('/api/admin/audit', async (request) => {
|
||||||
const query = parseWith(
|
const query = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
});
|
});
|
||||||
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
// Muss für das Double-Submit-Verfahren lesbar sein.
|
||||||
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
@@ -91,16 +91,24 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const body = parseWith(loginSchema, request.body);
|
const body = parseWith(loginSchema, request.body);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
||||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
|
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
|
||||||
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
const passwordOk = await verifyPassword(digest, body.password);
|
const passwordOk = await verifyPassword(digest, body.password);
|
||||||
|
|
||||||
if (!user || !passwordOk || !user.isActive) {
|
if (!user || !passwordOk || !user.isActive) {
|
||||||
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
|
// Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich
|
||||||
|
// gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort
|
||||||
|
// im Klartext im Audit-Log.
|
||||||
|
await recordAudit(
|
||||||
|
prisma,
|
||||||
|
{ actorId: null, actorName: user ? user.username : null, ip: request.ip },
|
||||||
|
{
|
||||||
action: 'auth.login_failed',
|
action: 'auth.login_failed',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user?.id ?? null,
|
targetId: user?.id ?? null,
|
||||||
}).catch(() => undefined);
|
details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' },
|
||||||
|
},
|
||||||
|
).catch(() => undefined);
|
||||||
throw unauthorized('Benutzername oder Passwort ist falsch');
|
throw unauthorized('Benutzername oder Passwort ist falsch');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +198,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
app.get('/api/auth/me', async (request) => {
|
app.get('/api/auth/me', async (request) => {
|
||||||
if (!request.user) return { user: null };
|
if (!request.user) return { user: null };
|
||||||
return { user: request.user };
|
// Die Adressen der fremden Dienste kommen mit, damit die Oberfläche
|
||||||
|
// Profil-Links bauen kann, ohne sie ein zweites Mal zu kennen.
|
||||||
|
return {
|
||||||
|
user: request.user,
|
||||||
|
gextensionBaseUrl: env.GEXTENSION_BASE_URL,
|
||||||
|
forumBaseUrl: env.FORUM_BASE_URL,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Eigener Account ---
|
// --- Eigener Account ---
|
||||||
@@ -242,7 +256,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
// Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: request.user!.id },
|
where: { id: request.user!.id },
|
||||||
data: { totpSecret: secret, totpEnabled: false },
|
data: { totpSecret: secret, totpEnabled: false },
|
||||||
|
|||||||
@@ -1,19 +1,62 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CreditKind, InactiveReason } from '@prisma/client';
|
import { CreditKind, InactiveReason } from '@prisma/client';
|
||||||
import { requireEditor } from '../auth/guard.js';
|
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
|
import { forbidden } from '../errors.js';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
import {
|
import {
|
||||||
|
addMember,
|
||||||
createCharacter,
|
createCharacter,
|
||||||
deactivateCharacter,
|
deactivateCharacter,
|
||||||
|
listMembers,
|
||||||
|
removeMember,
|
||||||
|
reviveDeadCharacter,
|
||||||
reactivateFree,
|
reactivateFree,
|
||||||
reactivatePaid,
|
reactivatePaid,
|
||||||
redesignCharacter,
|
redesignCharacter,
|
||||||
revertCharacter,
|
revertCharacter,
|
||||||
updateCharacter,
|
updateCharacter,
|
||||||
|
deactivateMember,
|
||||||
|
reactivateMember,
|
||||||
|
redesignMembership,
|
||||||
|
addLink,
|
||||||
|
updateLink,
|
||||||
|
removeLink,
|
||||||
|
moveInChain,
|
||||||
|
detachFromChain,
|
||||||
|
changeFunding,
|
||||||
|
addMemberToChain,
|
||||||
|
rebuildFunding,
|
||||||
|
settleWithKulanz,
|
||||||
} from '../services/characters.js';
|
} from '../services/characters.js';
|
||||||
|
import { parseThreadId } from '../lib/forum.js';
|
||||||
|
import { linkThreadToCharacter } from '../services/forum.js';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
||||||
|
|
||||||
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
|
/** Hakt den zugehörigen Forums-Thread ab, wenn einer angegeben wurde. */
|
||||||
|
async function linkThread(
|
||||||
|
threadInput: string | undefined,
|
||||||
|
characterId: number,
|
||||||
|
classification: 'NEW_CC' | 'REDESIGN',
|
||||||
|
ctx: ReturnType<typeof contextOf>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!threadInput?.trim()) return;
|
||||||
|
|
||||||
|
const threadId = parseThreadId(threadInput);
|
||||||
|
if (threadId === null) {
|
||||||
|
throw badRequest('INVALID_THREAD', 'Der Thread-Link konnte nicht gelesen werden');
|
||||||
|
}
|
||||||
|
await linkThreadToCharacter(threadId, characterId, classification, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Regelfall für ein Redesign sind die Redesign-Töpfe. Ein CC-Paket
|
||||||
|
/// geht ausnahmsweise auch - dann gilt die Wahl als von Hand getroffen.
|
||||||
|
const redesignKind = z.enum([
|
||||||
|
CreditKind.REDESIGN_PAID,
|
||||||
|
CreditKind.REDESIGN_KULANZ,
|
||||||
|
CreditKind.CC,
|
||||||
|
]);
|
||||||
const reviveKind = z.enum([CreditKind.CC, 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> {
|
export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||||
@@ -24,10 +67,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
loreText: z.string().trim().max(5000).optional(),
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
|
forumThread: z.string().trim().max(300).optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
const result = await createCharacter(id, body, contextOf(request));
|
const ctx = contextOf(request);
|
||||||
|
const result = await createCharacter(id, body, ctx);
|
||||||
|
await linkThread(body.forumThread, result.character.id, 'NEW_CC', ctx);
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
@@ -38,6 +84,8 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
z.object({
|
z.object({
|
||||||
name: z.string().trim().min(1).max(160).optional(),
|
name: z.string().trim().min(1).max(160).optional(),
|
||||||
loreText: z.string().trim().max(5000).nullable().optional(),
|
loreText: z.string().trim().max(5000).nullable().optional(),
|
||||||
|
/// Thread-Nummer oder volle Adresse; leer löst die Verknüpfung.
|
||||||
|
threadRef: z.string().trim().max(500).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
@@ -52,17 +100,26 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
loreText: z.string().trim().max(5000).optional(),
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
useKind: redesignKind,
|
useKind: redesignKind,
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
|
forumThread: z.string().trim().max(300).optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return redesignCharacter(id, body, contextOf(request));
|
const ctx = contextOf(request);
|
||||||
|
const result = await redesignCharacter(id, body, ctx);
|
||||||
|
await linkThread(body.forumThread, result.character.id, 'REDESIGN', ctx);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
reason: z.enum([InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER]),
|
reason: z.enum([
|
||||||
|
InactiveReason.PLAYER_INACTIVITY,
|
||||||
|
InactiveReason.OTHER,
|
||||||
|
InactiveReason.NO_FREE_CLAIM,
|
||||||
|
InactiveReason.DEAD,
|
||||||
|
]),
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
@@ -84,10 +141,325 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
|
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückkehr eines im Rollenspiel gestorbenen CCs. Ausnahmeentscheidung,
|
||||||
|
* deshalb Administratoren vorbehalten und nur mit Begründung.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/revive-dead', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||||
|
useKind: reviveKind.optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reviveDeadCharacter(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Verweise ---
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/links', { preHandler: requireEditor }, async (request, reply) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
label: z.string().trim().min(1).max(120),
|
||||||
|
url: z.string().trim().min(1).max(2000),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
reply.code(201);
|
||||||
|
return addLink(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
label: z.string().trim().min(1).max(120).optional(),
|
||||||
|
url: z.string().trim().min(1).max(2000).optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return updateLink(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
await removeLink(id, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachträglich umstellen, woraus ein CC bezahlt wurde - aus einem Paket,
|
||||||
|
* einem bezahlten Redesign oder aus Kulanz. Umgebucht wird im Kontobuch,
|
||||||
|
* nicht überschrieben.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/funding', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
||||||
|
/// Wessen Buchung gemeint ist - bei einem Gruppen-CC hat jeder
|
||||||
|
/// Nutzer seine eigene.
|
||||||
|
forPlayerId: z.number().int().positive().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return changeFunding(id, body.useKind, contextOf(request), body.forPlayerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet die Buchungen eines Spielers anhand seiner Ketten nach: jede
|
||||||
|
* Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns.
|
||||||
|
*/
|
||||||
|
app.post('/api/players/:id/credits/rebuild', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return rebuildFunding(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleicht ein Minus im bezahlten Redesign-Topf mit vorhandener Kulanz aus,
|
||||||
|
* indem ebenso viele Übergänge auf Kulanz umgebucht werden.
|
||||||
|
*/
|
||||||
|
app.post('/api/players/:id/credits/settle', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return settleWithKulanz(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ketten ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schiebt einen CC in eine Kette, hinter ein bestimmtes Glied oder an den
|
||||||
|
* Anfang. Für das Nachtragen von Lücken, die sich erst später auftun.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/move', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
lineageId: z.string().trim().min(1).max(64),
|
||||||
|
afterId: z.number().int().positive().nullable().optional(),
|
||||||
|
/// Wessen Seite gerade geordnet wird - bei einem Gruppen-CC ist das
|
||||||
|
/// nicht zwingend sein Ersteller.
|
||||||
|
forPlayerId: z.number().int().positive().optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return moveInChain(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Löst einen CC aus einer Liste und stellt ihn allein. */
|
||||||
|
app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
/// Wessen Liste gerade geordnet wird - bei einem Gruppen-CC ist das
|
||||||
|
/// nicht zwingend sein Ersteller.
|
||||||
|
forPlayerId: z.number().int().positive().optional(),
|
||||||
|
}),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
return detachFromChain(id, contextOf(request), body.forPlayerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Gruppen-CCs ---
|
||||||
|
|
||||||
|
app.get('/api/characters/:id/members', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return { items: await listMembers(id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/characters/:id/members', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
playerId: z.number().int().positive(),
|
||||||
|
/// Fehlt nur bei einer Nacherfassung an einem inaktiven CC.
|
||||||
|
useKind: z
|
||||||
|
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
|
||||||
|
.optional(),
|
||||||
|
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
|
||||||
|
/// Platz über ein Redesign bezahlt wird.
|
||||||
|
predecessorId: z.number().int().positive().optional(),
|
||||||
|
/// Zeitraum beim Nachtragen.
|
||||||
|
joinedAt: z.coerce.date().optional(),
|
||||||
|
leftAt: z.coerce.date().optional(),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return addMember(
|
||||||
|
id,
|
||||||
|
body.playerId,
|
||||||
|
{
|
||||||
|
useKind: body.useKind,
|
||||||
|
predecessorId: body.predecessorId,
|
||||||
|
joinedAt: body.joinedAt,
|
||||||
|
leftAt: body.leftAt,
|
||||||
|
note: body.note,
|
||||||
|
},
|
||||||
|
contextOf(request),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Anteil eines Spielers an einem Gruppen-CC wird redesignt: er steigt
|
||||||
|
* aus, bekommt einen eigenen CC, die Gruppe läuft weiter.
|
||||||
|
*/
|
||||||
|
app.post(
|
||||||
|
'/api/characters/:id/members/:playerId/redesign',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const params = parseWith(
|
||||||
|
z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
playerId: z.coerce.number().int().positive(),
|
||||||
|
}),
|
||||||
|
request.params,
|
||||||
|
);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(160),
|
||||||
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
|
/// Der Regelfall sind die Redesign-Töpfe; ein CC-Paket geht
|
||||||
|
/// ausnahmsweise auch.
|
||||||
|
useKind: z.enum([
|
||||||
|
CreditKind.REDESIGN_PAID,
|
||||||
|
CreditKind.REDESIGN_KULANZ,
|
||||||
|
CreditKind.CC,
|
||||||
|
]),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return redesignMembership(params.id, params.playerId, body, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ein einzelnes Mitglied fällt aus, der CC bleibt bestehen. */
|
||||||
|
app.post(
|
||||||
|
'/api/characters/:id/members/:playerId/deactivate',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const params = parseWith(
|
||||||
|
z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
playerId: z.coerce.number().int().positive(),
|
||||||
|
}),
|
||||||
|
request.params,
|
||||||
|
);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
reason: z.enum([
|
||||||
|
InactiveReason.PLAYER_INACTIVITY,
|
||||||
|
InactiveReason.OTHER,
|
||||||
|
InactiveReason.NO_FREE_CLAIM,
|
||||||
|
InactiveReason.DEAD,
|
||||||
|
]),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Tod im Rollenspiel ist eine Ausnahmeentscheidung, genau wie beim
|
||||||
|
// ganzen CC - und bleibt deshalb Administratoren vorbehalten.
|
||||||
|
if (body.reason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
|
||||||
|
throw forbidden('Einen Tod im Rollenspiel trägt nur ein Administrator ein');
|
||||||
|
}
|
||||||
|
|
||||||
|
return deactivateMember(
|
||||||
|
params.id,
|
||||||
|
params.playerId,
|
||||||
|
body.reason,
|
||||||
|
contextOf(request),
|
||||||
|
body.note,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Rückkehr eines ausgefallenen Mitglieds. */
|
||||||
|
app.post(
|
||||||
|
'/api/characters/:id/members/:playerId/reactivate',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const params = parseWith(
|
||||||
|
z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
playerId: z.coerce.number().int().positive(),
|
||||||
|
}),
|
||||||
|
request.params,
|
||||||
|
);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
useKind: reviveKind.optional(),
|
||||||
|
reason: z.string().trim().max(500).optional(),
|
||||||
|
}),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wer im Rollenspiel gestorben ist, kehrt nur durch einen Administrator
|
||||||
|
// zurück. Geprüft wird am tatsächlichen Grund, nicht an der Eingabe.
|
||||||
|
const mitglied = await prisma.characterMember.findUnique({
|
||||||
|
where: { characterId_playerId: { characterId: params.id, playerId: params.playerId } },
|
||||||
|
select: { inactiveReason: true },
|
||||||
|
});
|
||||||
|
if (mitglied?.inactiveReason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
|
||||||
|
throw forbidden('Über die Rückkehr eines Gestorbenen entscheidet ein Administrator');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactivateMember(params.id, params.playerId, body, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trägt einen Spieler ab diesem Glied in die ganze weitere Kette ein -
|
||||||
|
* für die Nacherfassung, wenn jemand schon vor mehreren Redesigns dabei
|
||||||
|
* war. Gebucht wird einmal, beim Einstieg.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/members/chain', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
playerId: z.number().int().positive(),
|
||||||
|
useKind: z
|
||||||
|
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
|
||||||
|
.optional(),
|
||||||
|
predecessorId: z.number().int().positive().optional(),
|
||||||
|
joinedAt: z.coerce.date().optional(),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return addMemberToChain(
|
||||||
|
id,
|
||||||
|
body.playerId,
|
||||||
|
{
|
||||||
|
useKind: body.useKind,
|
||||||
|
predecessorId: body.predecessorId,
|
||||||
|
joinedAt: body.joinedAt,
|
||||||
|
note: body.note,
|
||||||
|
},
|
||||||
|
contextOf(request),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const params = parseWith(
|
||||||
|
z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
playerId: z.coerce.number().int().positive(),
|
||||||
|
}),
|
||||||
|
request.params,
|
||||||
|
);
|
||||||
|
const body = parseWith(z.object({ note: optionalNote }), request.body ?? {});
|
||||||
|
return removeMember(params.id, params.playerId, body.note, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return revertCharacter(id, body.reason, contextOf(request));
|
return revertCharacter(id, body.reason, contextOf(request));
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CreditKind } from '@prisma/client';
|
import { CreditKind, CreditSource } from '@prisma/client';
|
||||||
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
||||||
import {
|
import {
|
||||||
bookAdjustment,
|
bookAdjustment,
|
||||||
bookCcPackage,
|
|
||||||
bookKulanz,
|
bookKulanz,
|
||||||
bookRedesignPackage,
|
bookRedesignPackage,
|
||||||
|
convertCcToRedesign,
|
||||||
|
grantCcSlot,
|
||||||
|
removeLastGrant,
|
||||||
reverseGroup,
|
reverseGroup,
|
||||||
} from '../services/credits.js';
|
} from '../services/credits.js';
|
||||||
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
||||||
@@ -14,10 +16,15 @@ import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
|
|||||||
const noteBody = z.object({ note: optionalNote });
|
const noteBody = z.object({ note: optionalNote });
|
||||||
|
|
||||||
export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* Von Hand einen CC-Platz vergeben - ohne das Redesign, das in einem
|
||||||
|
* gekauften Paket steckt. Der Abgleich mit dem Shop bleibt davon
|
||||||
|
* unberührt: dort ist ein Paket weiterhin beides.
|
||||||
|
*/
|
||||||
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(noteBody, request.body ?? {});
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
return bookCcPackage(id, contextOf(request), { note: body.note });
|
return grantCcSlot(id, contextOf(request), { note: body.note });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
@@ -30,19 +37,75 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => {
|
// Kulanz ist eine Entscheidung des Teams, kein erfasster Kauf - deshalb
|
||||||
|
// bleibt sie Administratoren vorbehalten.
|
||||||
|
app.post('/api/players/:id/credits/kulanz', { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(noteBody, request.body ?? {});
|
const body = parseWith(noteBody, request.body ?? {});
|
||||||
return bookKulanz(id, contextOf(request), { note: body.note });
|
return bookKulanz(id, contextOf(request), { note: body.note });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein CC-Guthaben in ein Redesign um.
|
||||||
|
*
|
||||||
|
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafür
|
||||||
|
* einsetzen will, tauscht es hier und setzt es danach wie jedes andere
|
||||||
|
* Redesign ein.
|
||||||
|
*/
|
||||||
|
app.post('/api/players/:id/credits/convert', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return convertCcToRedesign(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/convert/remove',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return removeLastGrant(id, CreditSource.CONVERSION, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
|
||||||
|
*
|
||||||
|
* Die Rechte folgen dem Eintragen: Wer ein Paket eintragen darf, darf es
|
||||||
|
* auch wieder zurücknehmen; bei Kulanz bleibt beides bei den Admins.
|
||||||
|
*/
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/cc-package/remove',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return removeLastGrant(id, CreditSource.CC_GRANT, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/redesign-package/remove',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return removeLastGrant(id, CreditSource.REDESIGN_PACKAGE, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/kulanz/remove',
|
||||||
|
{ preHandler: requireAdmin },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return removeLastGrant(id, CreditSource.KULANZ, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
|
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.nativeEnum(CreditKind),
|
kind: z.nativeEnum(CreditKind),
|
||||||
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
|
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
|
||||||
note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500),
|
note: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
@@ -52,7 +115,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return reverseGroup(id, body.reason, contextOf(request));
|
return reverseGroup(id, body.reason, contextOf(request));
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ForumBoard, ForumClassification } from '@prisma/client';
|
||||||
|
import { requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
|
import {
|
||||||
|
assignPlayer,
|
||||||
|
classifyThread,
|
||||||
|
dismissBoardChangeNotice,
|
||||||
|
getScanState,
|
||||||
|
listBoardChangeNotices,
|
||||||
|
listThreads,
|
||||||
|
listUnassignedThreads,
|
||||||
|
listUntrackedAuthors,
|
||||||
|
startScan,
|
||||||
|
} from '../services/forum.js';
|
||||||
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
export async function forumRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// Der Lauf braucht dutzende Abrufe und kann vom Forum ausgebremst werden.
|
||||||
|
// Deshalb wird er nur angestoßen; den Stand holt die Oberfläche ab.
|
||||||
|
app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => {
|
||||||
|
return startScan(contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/forum/scan/status', { preHandler: requireAuth }, async () => {
|
||||||
|
return getScanState();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/forum/threads', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({
|
||||||
|
open: z.enum(['true', 'false']).default('true'),
|
||||||
|
playerId: z.coerce.number().int().positive().optional(),
|
||||||
|
board: z.nativeEnum(ForumBoard).optional(),
|
||||||
|
q: z.string().trim().max(120).optional(),
|
||||||
|
order: z.enum(['newest', 'oldest']).default('newest'),
|
||||||
|
}),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: await listThreads({
|
||||||
|
onlyOpen: query.open === 'true',
|
||||||
|
order: query.order,
|
||||||
|
...(query.playerId !== undefined ? { playerId: query.playerId } : {}),
|
||||||
|
...(query.board ? { board: query.board } : {}),
|
||||||
|
...(query.q ? { query: query.q } : {}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/forum/threads/unassigned', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({
|
||||||
|
q: z.string().trim().max(120).optional(),
|
||||||
|
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
|
order: z.enum(['newest', 'oldest']).default('newest'),
|
||||||
|
}),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return listUnassignedThreads({
|
||||||
|
take: query.take,
|
||||||
|
skip: query.skip,
|
||||||
|
order: query.order,
|
||||||
|
...(query.q ? { query: query.q } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ersteller aus dem Forum, die hier unter keinem Namen geführt werden. */
|
||||||
|
app.get('/api/forum/authors/untracked', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({ take: z.coerce.number().int().min(1).max(500).default(100) }),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return listUntrackedAuthors(query.take);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/forum/notices', { preHandler: requireAuth }, async () => {
|
||||||
|
return { items: await listBoardChangeNotices() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/forum/threads/:id/dismiss', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
await dismissBoardChangeNotice(id, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/forum/threads/:id/player', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({ playerId: z.number().int().positive() }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
await assignPlayer(id, body.playerId, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/forum/threads/:id/classify', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
classification: z.nativeEnum(ForumClassification),
|
||||||
|
characterName: z.string().trim().min(1).max(160).optional(),
|
||||||
|
loreText: z.string().trim().max(5000).optional(),
|
||||||
|
predecessorId: z.number().int().positive().optional(),
|
||||||
|
characterId: z.number().int().positive().optional(),
|
||||||
|
useKind: z.enum(['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']).optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return classifyThread(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
import { badRequest, unauthorized } from '../errors.js';
|
import { badRequest, unauthorized } from '../errors.js';
|
||||||
import type { ActionContext } from '../services/audit.js';
|
import type { ActionContext } from '../services/audit.js';
|
||||||
|
|
||||||
// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt.
|
// z.output statt z.input: nur so zählen .default()-Werte als gesetzt.
|
||||||
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
||||||
const result = schema.safeParse(input);
|
const result = schema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireAdmin } from '../auth/guard.js';
|
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
||||||
import { badRequest } from '../errors.js';
|
import { badRequest } from '../errors.js';
|
||||||
import {
|
import {
|
||||||
applyMapping,
|
applyMapping,
|
||||||
@@ -10,21 +10,25 @@ import {
|
|||||||
getImport,
|
getImport,
|
||||||
listImports,
|
listImports,
|
||||||
parseUpload,
|
parseUpload,
|
||||||
|
bookRows,
|
||||||
|
dismissRows,
|
||||||
|
pendingOverview,
|
||||||
} from '../services/imports.js';
|
} from '../services/imports.js';
|
||||||
|
import { exportAll, importAll } from '../services/backup.js';
|
||||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
const mappingBody = z.object({
|
const mappingBody = z.object({
|
||||||
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
|
playerKey: z.string().min(1, 'Spalte für die Spieler-Kennung fehlt'),
|
||||||
playerName: z.string().optional(),
|
playerName: z.string().optional(),
|
||||||
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
|
product: z.string().min(1, 'Spalte für den Produktnamen fehlt'),
|
||||||
orderId: z.string().optional(),
|
orderId: z.string().optional(),
|
||||||
purchasedAt: z.string().optional(),
|
purchasedAt: z.string().optional(),
|
||||||
createMissingPlayers: z.boolean().default(false),
|
createMissingPlayers: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
|
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
||||||
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
app.post('/api/imports', { preHandler: requireEditor }, async (request, reply) => {
|
||||||
const file = await request.file();
|
const file = await request.file();
|
||||||
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
||||||
|
|
||||||
@@ -41,16 +45,44 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/imports', { preHandler: requireAdmin }, async () => {
|
/**
|
||||||
|
* Der gesamte Bestand als eine Tabelle - für den Umzug auf einen anderen
|
||||||
|
* Server oder den Wechsel auf ein anderes Programm.
|
||||||
|
*/
|
||||||
|
app.get('/api/backup/export', { preHandler: requireAdmin }, async (_request, reply) => {
|
||||||
|
const inhalt = await exportAll();
|
||||||
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
reply.header('content-type', 'text/csv; charset=utf-8');
|
||||||
|
reply.header('content-disposition', `attachment; filename="cc-manager-${datum}.csv"`);
|
||||||
|
// Das Byte-Order-Mark, damit Excel die Umlaute richtig liest.
|
||||||
|
return `\uFEFF${inhalt}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spielt eine zuvor exportierte Tabelle ein und ergänzt, was fehlt. */
|
||||||
|
app.post('/api/backup/import', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const datei = await request.file();
|
||||||
|
if (!datei) throw badRequest('NO_FILE', 'Es wurde keine Datei übertragen');
|
||||||
|
|
||||||
|
const inhalt = (await datei.toBuffer()).toString('utf8');
|
||||||
|
return importAll(inhalt, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wie viele Pakete insgesamt noch auf eine Buchung warten. */
|
||||||
|
app.get('/api/imports/pending/overview', { preHandler: requireEditor }, async () => {
|
||||||
|
return pendingOverview();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/imports', { preHandler: requireEditor }, async () => {
|
||||||
return { items: await listImports() };
|
return { items: await listImports() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
|
app.get('/api/imports/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
return getImport(id);
|
return getImport(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => {
|
app.post('/api/imports/:id/mapping', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(mappingBody, request.body);
|
const body = parseWith(mappingBody, request.body);
|
||||||
const summary = await applyMapping(id, body, contextOf(request));
|
const summary = await applyMapping(id, body, contextOf(request));
|
||||||
@@ -58,12 +90,34 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { summary, import: record };
|
return { summary, import: record };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => {
|
app.post('/api/imports/:id/commit', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
return commitImport(id, contextOf(request));
|
return commitImport(id, contextOf(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => {
|
/**
|
||||||
|
* Einzelne Zeilen sofort buchen, statt auf den ganzen Import zu warten.
|
||||||
|
* Gedacht für den Weg über die Spielerseite: einen Spieler an einem Stück
|
||||||
|
* abarbeiten. Gebuchtes verschwindet danach aus der Vorschau.
|
||||||
|
*/
|
||||||
|
app.post('/api/imports/rows/book', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return bookRows(body.rowIds, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Zeilen aus der Vorschau nehmen, ohne sie zu buchen. */
|
||||||
|
app.post('/api/imports/rows/dismiss', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return dismissRows(body.rowIds, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
await discardImport(id, contextOf(request));
|
await discardImport(id, contextOf(request));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CreditKind } from '@prisma/client';
|
import { CreditKind } from '@prisma/client';
|
||||||
|
import { AliasType } from '@prisma/client';
|
||||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
import { listLedger } from '../services/credits.js';
|
import { listLedger } from '../services/credits.js';
|
||||||
import {
|
import {
|
||||||
|
addAlias,
|
||||||
createPlayer,
|
createPlayer,
|
||||||
deletePlayer,
|
deletePlayer,
|
||||||
getPlayerDetail,
|
getPlayerDetail,
|
||||||
|
markProblem,
|
||||||
|
removeAlias,
|
||||||
searchPlayers,
|
searchPlayers,
|
||||||
updatePlayer,
|
updatePlayer,
|
||||||
} from '../services/players.js';
|
} from '../services/players.js';
|
||||||
@@ -17,6 +21,8 @@ const playerBody = z.object({
|
|||||||
steamId: z.string().trim().max(64).nullish(),
|
steamId: z.string().trim().max(64).nullish(),
|
||||||
forumName: z.string().trim().max(120).nullish(),
|
forumName: z.string().trim().max(120).nullish(),
|
||||||
notes: z.string().trim().max(2000).nullish(),
|
notes: z.string().trim().max(2000).nullish(),
|
||||||
|
isProblem: z.boolean().optional(),
|
||||||
|
problemNote: z.string().trim().max(1000).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||||
@@ -26,10 +32,18 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
q: z.string().trim().max(120).default(''),
|
q: z.string().trim().max(120).default(''),
|
||||||
take: z.coerce.number().int().min(1).max(200).default(50),
|
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
skip: z.coerce.number().int().min(0).default(0),
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
|
filter: z
|
||||||
|
.enum(['all', 'problems', 'group', 'active', 'onlyInactive', 'none'])
|
||||||
|
.default('all'),
|
||||||
|
sort: z.enum(['name', 'characters', 'newest']).default('name'),
|
||||||
|
/// Alte Schreibweise, damit gespeicherte Adressen weiter greifen.
|
||||||
|
problems: z.enum(['true', 'false']).optional(),
|
||||||
}),
|
}),
|
||||||
request.query,
|
request.query,
|
||||||
);
|
);
|
||||||
return searchPlayers(query.q, query.take, query.skip);
|
|
||||||
|
const filter = query.problems === 'true' ? 'problems' : query.filter;
|
||||||
|
return searchPlayers(query.q, query.take, query.skip, filter, query.sort);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
||||||
@@ -59,10 +73,51 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
|
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
const body = parseWith(playerBody.partial(), request.body);
|
// Die Problem-Markierung läuft über die eigene Route, damit ein Klären
|
||||||
|
// oder Markieren als solches im Protokoll steht und nicht als "geändert".
|
||||||
|
const body = parseWith(playerBody.omit({ isProblem: true, problemNote: true }).partial(), request.body);
|
||||||
return updatePlayer(id, body, contextOf(request));
|
return updatePlayer(id, body, contextOf(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Spieler als Problemfall markieren oder die Markierung aufheben.
|
||||||
|
* Reine Merkhilfe - an Guthaben und CCs ändert sich dadurch nichts.
|
||||||
|
*/
|
||||||
|
app.post('/api/players/:id/problem', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
isProblem: z.boolean(),
|
||||||
|
note: z.string().trim().max(1000).nullish(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return markProblem(id, body, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frühere Steam- und Forumskonten eines Spielers.
|
||||||
|
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
type: z.nativeEnum(AliasType),
|
||||||
|
value: z.string().trim().min(1).max(120),
|
||||||
|
note: z.string().trim().max(200).optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await addAlias(id, body.type, body.value, body.note, contextOf(request));
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/players/aliases/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
await removeAlias(id, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
|
app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
await deletePlayer(id, contextOf(request));
|
await deletePlayer(id, contextOf(request));
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||||
|
import { notFound } from '../errors.js';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import {
|
||||||
|
getScrapeState,
|
||||||
|
knownSteamIds,
|
||||||
|
startScrape,
|
||||||
|
steamIdsOf,
|
||||||
|
listUnmatched,
|
||||||
|
resolveUnmatched,
|
||||||
|
scrapePlayers,
|
||||||
|
unmatchedOverview,
|
||||||
|
} from '../services/scraper.js';
|
||||||
|
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||||
|
|
||||||
|
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
|
||||||
|
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||||
|
* Profile auf Vorrat einsammeln.
|
||||||
|
*/
|
||||||
|
app.post('/api/scrape/run', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const all = await knownSteamIds();
|
||||||
|
const targets = body.playerIds
|
||||||
|
? all.filter((entry) => body.playerIds?.includes(entry.playerId))
|
||||||
|
: all;
|
||||||
|
|
||||||
|
return startScrape(targets, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/scrape/status', { preHandler: requireAuth }, async () => {
|
||||||
|
return getScrapeState();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus.
|
||||||
|
* Abgefragt werden alle Konten des Spielers, nicht nur das hinterlegte -
|
||||||
|
* sonst fehlte das Guthaben, das unter einem früheren Konto gekauft wurde.
|
||||||
|
*/
|
||||||
|
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
|
||||||
|
const player = await prisma.player.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const steamIds = await steamIdsOf(id);
|
||||||
|
if (steamIds.length === 0) {
|
||||||
|
throw notFound('SteamID des Spielers');
|
||||||
|
}
|
||||||
|
|
||||||
|
return startScrape(
|
||||||
|
steamIds.map((steamId) => ({ playerId: id, steamId })),
|
||||||
|
contextOf(request),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
|
||||||
|
const query = parseWith(
|
||||||
|
z.object({ all: z.enum(['true', 'false']).default('false') }),
|
||||||
|
request.query,
|
||||||
|
);
|
||||||
|
return { items: await listUnmatched(query.all === 'false') };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/scrape/unmatched/overview', { preHandler: requireAuth }, async () => {
|
||||||
|
return unmatchedOverview();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/scrape/unmatched/:id/resolve', { preHandler: requireAdmin }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
resolution: z.enum(['CC_PACKAGE', 'REDESIGN_PACKAGE', 'REDESIGN_KULANZ', 'IGNORE']),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolveUnmatched(id, body.resolution, contextOf(request));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
export interface ActionContext {
|
export interface ActionContext {
|
||||||
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
|
// Beide null für Vorgänge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||||
|
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
||||||
actorId: number | null;
|
actorId: number | null;
|
||||||
actorName: string;
|
actorName: string | null;
|
||||||
ip?: string | undefined;
|
ip?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export async function recordAudit(
|
|||||||
await client.auditLog.create({
|
await client.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
actorUserId: ctx.actorId,
|
actorUserId: ctx.actorId,
|
||||||
actorName: ctx.actorName,
|
actorName: ctx.actorName ?? null,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
targetType: entry.targetType,
|
targetType: entry.targetType,
|
||||||
targetId: entry.targetId == null ? null : String(entry.targetId),
|
targetId: entry.targetId == null ? null : String(entry.targetId),
|
||||||
@@ -34,3 +36,16 @@ export async function recordAudit(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die
|
||||||
|
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für
|
||||||
|
* bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll.
|
||||||
|
*/
|
||||||
|
export async function cleanupOldAuditEntries(): Promise<number> {
|
||||||
|
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - env.AUDIT_RETENTION_DAYS * 24 * 3600 * 1000);
|
||||||
|
const result = await prisma.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import { stringify } from 'csv-stringify/sync';
|
||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
import { CharacterStatus, CreditKind, CreditSource, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
|
import { normalizeSteamId } from '../lib/steamid.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Export und Wiedereinspielung des Bestands als eine CSV.
|
||||||
|
*
|
||||||
|
* Gedacht für den Umzug auf einen anderen Server oder den Wechsel auf ein
|
||||||
|
* anderes Programm: alles, was ein Spieler an Guthaben, CCs, Konten und
|
||||||
|
* Verweisen hat, steht in einer Datei, die sich auch in einer Tabelle öffnen
|
||||||
|
* lässt.
|
||||||
|
*
|
||||||
|
* Verschiedene Satzarten in einer Datei gehen nur mit einer Spalte, die sagt,
|
||||||
|
* worum es in der Zeile geht. Deshalb steht in "satzart", was folgt, und die
|
||||||
|
* übrigen Spalten bedeuten je nach Satzart etwas anderes. Das ist der Preis
|
||||||
|
* für eine einzelne Datei statt eines Archivs mit sechs.
|
||||||
|
*
|
||||||
|
* Der Spieler wird über seine SteamID erkannt, ersatzweise über den
|
||||||
|
* Anzeigenamen. Deshalb steht er in jeder Zeile, auch wenn das die Datei
|
||||||
|
* länger macht: so bleibt jede Zeile für sich verständlich, und eine
|
||||||
|
* Umsortierung in der Tabelle macht den Bestand nicht kaputt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Die Reihenfolge der Satzarten beim Einspielen. Ein CC braucht seinen
|
||||||
|
/// Spieler, eine Mitgliedschaft ihren CC, ein Verweis ebenso.
|
||||||
|
const SATZARTEN = ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS'] as const;
|
||||||
|
type Satzart = (typeof SATZARTEN)[number];
|
||||||
|
|
||||||
|
const SPALTEN = [
|
||||||
|
'satzart',
|
||||||
|
'spieler',
|
||||||
|
'steam_id',
|
||||||
|
'forum_name',
|
||||||
|
'notizen',
|
||||||
|
'cc_name',
|
||||||
|
'cc_status',
|
||||||
|
'cc_grund',
|
||||||
|
'cc_notiz',
|
||||||
|
'cc_lore',
|
||||||
|
'kette',
|
||||||
|
'kette_position',
|
||||||
|
'art',
|
||||||
|
'menge',
|
||||||
|
'anlass',
|
||||||
|
'zeitpunkt',
|
||||||
|
'bezahlt_mit',
|
||||||
|
'wert',
|
||||||
|
'bezeichnung',
|
||||||
|
'bemerkung',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Zeile = Partial<Record<(typeof SPALTEN)[number], string>>;
|
||||||
|
|
||||||
|
function datum(wert: Date | null | undefined): string {
|
||||||
|
return wert ? wert.toISOString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alles, was der Bestand hergibt, als eine Tabelle. */
|
||||||
|
export async function exportAll(): Promise<string> {
|
||||||
|
const [spieler, aliasse, ledger, ccs, mitglieder, links] = await Promise.all([
|
||||||
|
prisma.player.findMany({ orderBy: { id: 'asc' } }),
|
||||||
|
prisma.playerAlias.findMany({ orderBy: { id: 'asc' }, include: { player: true } }),
|
||||||
|
prisma.creditLedger.findMany({ orderBy: { id: 'asc' }, include: { player: true } }),
|
||||||
|
prisma.character.findMany({
|
||||||
|
orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }],
|
||||||
|
include: { player: true },
|
||||||
|
}),
|
||||||
|
prisma.characterMember.findMany({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
include: { player: true, character: { select: { name: true, playerId: true } } },
|
||||||
|
}),
|
||||||
|
prisma.characterLink.findMany({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
include: { character: { select: { name: true, playerId: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Für CC-bezogene Zeilen wird der Besitzer gebraucht, nicht der Nutzer.
|
||||||
|
const spielerNach = new Map(spieler.map((p) => [p.id, p]));
|
||||||
|
const kennung = (playerId: number): { spieler: string; steam_id: string } => {
|
||||||
|
const p = spielerNach.get(playerId);
|
||||||
|
return { spieler: p?.displayName ?? `#${playerId}`, steam_id: p?.steamId ?? '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const zeilen: Zeile[] = [];
|
||||||
|
|
||||||
|
for (const p of spieler) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'SPIELER',
|
||||||
|
spieler: p.displayName,
|
||||||
|
steam_id: p.steamId ?? '',
|
||||||
|
forum_name: p.forumName ?? '',
|
||||||
|
notizen: p.notes ?? '',
|
||||||
|
bezeichnung: p.isProblem ? 'PROBLEM' : '',
|
||||||
|
bemerkung: p.problemNote ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of aliasse) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'KONTO',
|
||||||
|
spieler: a.player.displayName,
|
||||||
|
steam_id: a.player.steamId ?? '',
|
||||||
|
art: a.type,
|
||||||
|
wert: a.value,
|
||||||
|
bemerkung: a.note ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of ledger) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'GUTHABEN',
|
||||||
|
spieler: l.player.displayName,
|
||||||
|
steam_id: l.player.steamId ?? '',
|
||||||
|
art: l.kind,
|
||||||
|
menge: String(l.amount),
|
||||||
|
anlass: l.source,
|
||||||
|
zeitpunkt: datum(l.occurredAt),
|
||||||
|
bemerkung: l.note ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of ccs) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'CC',
|
||||||
|
...kennung(c.playerId),
|
||||||
|
cc_name: c.name,
|
||||||
|
cc_status: c.status,
|
||||||
|
cc_grund: c.inactiveReason ?? '',
|
||||||
|
cc_notiz: c.inactiveNote ?? '',
|
||||||
|
cc_lore: c.loreText ?? '',
|
||||||
|
kette: c.lineageId,
|
||||||
|
kette_position: String(c.chainPosition),
|
||||||
|
zeitpunkt: datum(c.createdAt),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of mitglieder) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'MITGLIED',
|
||||||
|
spieler: m.player.displayName,
|
||||||
|
steam_id: m.player.steamId ?? '',
|
||||||
|
cc_name: m.character.name,
|
||||||
|
bezeichnung: kennung(m.character.playerId).spieler,
|
||||||
|
bezahlt_mit: m.paidWith ?? '',
|
||||||
|
// Die Liste hängt am Nutzer: derselbe Gruppen-CC steht bei jedem an
|
||||||
|
// einer anderen Stelle.
|
||||||
|
kette: m.lineageId,
|
||||||
|
kette_position: String(m.chainPosition),
|
||||||
|
zeitpunkt: datum(m.joinedAt),
|
||||||
|
cc_grund: m.inactiveReason ?? '',
|
||||||
|
cc_notiz: m.inactiveNote ?? '',
|
||||||
|
art: m.leftAt ? 'BEENDET' : 'AKTIV',
|
||||||
|
wert: datum(m.leftAt),
|
||||||
|
bemerkung: m.note ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of links) {
|
||||||
|
zeilen.push({
|
||||||
|
satzart: 'VERWEIS',
|
||||||
|
...kennung(l.character.playerId),
|
||||||
|
cc_name: l.character.name,
|
||||||
|
bezeichnung: l.label,
|
||||||
|
wert: l.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringify(zeilen, { header: true, columns: [...SPALTEN], delimiter: ';' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportReport {
|
||||||
|
gelesen: number;
|
||||||
|
angelegt: Record<string, number>;
|
||||||
|
uebersprungen: Record<string, number>;
|
||||||
|
fehler: { zeile: number; grund: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function leer(): Record<string, number> {
|
||||||
|
return Object.fromEntries(SATZARTEN.map((a) => [a, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spielt eine zuvor exportierte Tabelle ein.
|
||||||
|
*
|
||||||
|
* Bestehendes wird erkannt und übersprungen, nicht überschrieben: der
|
||||||
|
* häufigste Fall ist eine Datei, die zur Hälfte schon eingespielt ist. Was
|
||||||
|
* sich nicht zuordnen lässt, wird mit Zeilennummer gemeldet, statt den ganzen
|
||||||
|
* Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten.
|
||||||
|
*/
|
||||||
|
export async function importAll(inhalt: string, ctx: ActionContext): Promise<ImportReport> {
|
||||||
|
let zeilen: Zeile[];
|
||||||
|
try {
|
||||||
|
zeilen = parse(inhalt, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
delimiter: [';', ','],
|
||||||
|
bom: true,
|
||||||
|
}) as Zeile[];
|
||||||
|
} catch (error) {
|
||||||
|
throw badRequest(
|
||||||
|
'PARSE_FAILED',
|
||||||
|
`Die Datei liess sich nicht lesen: ${error instanceof Error ? error.message : 'unbekannt'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zeilen.length === 0) throw badRequest('EMPTY', 'Die Datei enthält keine Zeilen');
|
||||||
|
if (!('satzart' in zeilen[0]!)) {
|
||||||
|
throw badRequest(
|
||||||
|
'NO_SATZART',
|
||||||
|
'Der Datei fehlt die Spalte "satzart" - stammt sie aus dem Export?',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report: ImportReport = {
|
||||||
|
gelesen: zeilen.length,
|
||||||
|
angelegt: leer(),
|
||||||
|
uebersprungen: leer(),
|
||||||
|
fehler: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Satzart für Satzart, damit Verweise ihr Ziel schon vorfinden.
|
||||||
|
for (const satzart of SATZARTEN) {
|
||||||
|
for (const [index, zeile] of zeilen.entries()) {
|
||||||
|
if ((zeile.satzart ?? '').toUpperCase() !== satzart) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const neu = await spieleZeileEin(satzart, zeile, ctx);
|
||||||
|
if (neu) report.angelegt[satzart] = (report.angelegt[satzart] ?? 0) + 1;
|
||||||
|
else report.uebersprungen[satzart] = (report.uebersprungen[satzart] ?? 0) + 1;
|
||||||
|
} catch (error) {
|
||||||
|
report.fehler.push({
|
||||||
|
zeile: index + 2, // plus Kopfzeile, plus Zählung ab eins
|
||||||
|
grund: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'backup.import',
|
||||||
|
targetType: 'backup',
|
||||||
|
targetId: null,
|
||||||
|
details: { ...report } as unknown as Prisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet den Spieler einer Zeile - über die SteamID, sonst über den Namen. */
|
||||||
|
async function findeSpieler(zeile: Zeile): Promise<number | null> {
|
||||||
|
const steamId = zeile.steam_id ? normalizeSteamId(zeile.steam_id) : null;
|
||||||
|
if (steamId) {
|
||||||
|
const treffer = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
|
||||||
|
if (treffer) return treffer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = zeile.spieler?.trim();
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
const treffer = await prisma.player.findFirst({
|
||||||
|
where: { displayName: name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return treffer?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findeCC(playerId: number, name: string): Promise<number | null> {
|
||||||
|
const treffer = await prisma.character.findFirst({
|
||||||
|
where: { playerId, name },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return treffer?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, wenn etwas angelegt wurde; false, wenn es schon da war. */
|
||||||
|
async function spieleZeileEin(
|
||||||
|
satzart: Satzart,
|
||||||
|
zeile: Zeile,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<boolean> {
|
||||||
|
switch (satzart) {
|
||||||
|
case 'SPIELER': {
|
||||||
|
const name = zeile.spieler?.trim();
|
||||||
|
if (!name) throw new Error('Ohne Namen lässt sich kein Spieler anlegen');
|
||||||
|
|
||||||
|
const vorhanden = await findeSpieler(zeile);
|
||||||
|
if (vorhanden !== null) return false;
|
||||||
|
|
||||||
|
const steamId = zeile.steam_id ? normalizeSteamId(zeile.steam_id) : null;
|
||||||
|
await prisma.player.create({
|
||||||
|
data: {
|
||||||
|
displayName: name,
|
||||||
|
steamId,
|
||||||
|
forumName: zeile.forum_name?.trim() || null,
|
||||||
|
notes: zeile.notizen?.trim() || null,
|
||||||
|
isProblem: zeile.bezeichnung === 'PROBLEM',
|
||||||
|
problemNote: zeile.bemerkung?.trim() || null,
|
||||||
|
...(zeile.bezeichnung === 'PROBLEM' ? { problemSince: new Date() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'KONTO': {
|
||||||
|
const playerId = await findeSpieler(zeile);
|
||||||
|
if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`);
|
||||||
|
|
||||||
|
const type = zeile.art === 'FORUM_NAME' ? 'FORUM_NAME' : 'STEAM_ID';
|
||||||
|
const roh = zeile.wert?.trim();
|
||||||
|
if (!roh) throw new Error('Ohne Wert kein Konto');
|
||||||
|
const value = type === 'STEAM_ID' ? (normalizeSteamId(roh) ?? roh) : roh;
|
||||||
|
|
||||||
|
const vorhanden = await prisma.playerAlias.findUnique({
|
||||||
|
where: { type_value: { type, value } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return false;
|
||||||
|
|
||||||
|
await prisma.playerAlias.create({
|
||||||
|
data: { playerId, type, value, note: zeile.bemerkung?.trim() || null },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GUTHABEN': {
|
||||||
|
const playerId = await findeSpieler(zeile);
|
||||||
|
if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`);
|
||||||
|
|
||||||
|
const kind = zeile.art as CreditKind;
|
||||||
|
const source = zeile.anlass as CreditSource;
|
||||||
|
const amount = Number(zeile.menge);
|
||||||
|
if (!Number.isInteger(amount)) throw new Error(`Menge "${zeile.menge}" ist keine Zahl`);
|
||||||
|
|
||||||
|
const occurredAt = zeile.zeitpunkt ? new Date(zeile.zeitpunkt) : new Date();
|
||||||
|
const note = zeile.bemerkung?.trim() || null;
|
||||||
|
|
||||||
|
// Ohne eigene Kennung bleibt der Abdruck: derselbe Spieler, dieselbe
|
||||||
|
// Bewegung zur selben Zeit ist dieselbe Buchung.
|
||||||
|
const vorhanden = await prisma.creditLedger.findFirst({
|
||||||
|
where: { playerId, kind, source, amount, occurredAt, note },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return false;
|
||||||
|
|
||||||
|
await prisma.creditLedger.create({
|
||||||
|
data: { playerId, kind, amount, source, occurredAt, note, createdById: ctx.actorId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CC': {
|
||||||
|
const playerId = await findeSpieler(zeile);
|
||||||
|
if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`);
|
||||||
|
|
||||||
|
const name = zeile.cc_name?.trim();
|
||||||
|
if (!name) throw new Error('Ohne Namen kein CC');
|
||||||
|
if ((await findeCC(playerId, name)) !== null) return false;
|
||||||
|
|
||||||
|
const status =
|
||||||
|
zeile.cc_status === 'INACTIVE' ? CharacterStatus.INACTIVE : CharacterStatus.ACTIVE;
|
||||||
|
|
||||||
|
const erstellt = await prisma.character.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
name,
|
||||||
|
loreText: zeile.cc_lore?.trim() || null,
|
||||||
|
status,
|
||||||
|
inactiveReason: (zeile.cc_grund?.trim() || null) as never,
|
||||||
|
inactiveNote: zeile.cc_notiz?.trim() || null,
|
||||||
|
...(zeile.kette?.trim() ? { lineageId: zeile.kette.trim() } : {}),
|
||||||
|
chainPosition: Number(zeile.kette_position) || 0,
|
||||||
|
...(zeile.zeitpunkt ? { createdAt: new Date(zeile.zeitpunkt) } : {}),
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Besitzer ist immer auch Mitglied; eigene MITGLIED-Zeilen
|
||||||
|
// ergänzen die übrigen.
|
||||||
|
await prisma.characterMember.create({
|
||||||
|
data: {
|
||||||
|
characterId: erstellt.id,
|
||||||
|
playerId,
|
||||||
|
lineageId: erstellt.lineageId,
|
||||||
|
chainPosition: erstellt.chainPosition,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MITGLIED': {
|
||||||
|
const playerId = await findeSpieler(zeile);
|
||||||
|
if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`);
|
||||||
|
|
||||||
|
const ccName = zeile.cc_name?.trim();
|
||||||
|
const besitzer = zeile.bezeichnung?.trim();
|
||||||
|
if (!ccName || !besitzer) throw new Error('Mitgliedschaft ohne CC oder Besitzer');
|
||||||
|
|
||||||
|
const besitzerId = await findeSpieler({ spieler: besitzer });
|
||||||
|
if (besitzerId === null) throw new Error(`Besitzer "${besitzer}" nicht gefunden`);
|
||||||
|
|
||||||
|
const characterId = await findeCC(besitzerId, ccName);
|
||||||
|
if (characterId === null) throw new Error(`CC "${ccName}" nicht gefunden`);
|
||||||
|
|
||||||
|
const vorhanden = await prisma.characterMember.findUnique({
|
||||||
|
where: { characterId_playerId: { characterId, playerId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return false;
|
||||||
|
|
||||||
|
await prisma.characterMember.create({
|
||||||
|
data: {
|
||||||
|
characterId,
|
||||||
|
playerId,
|
||||||
|
paidWith: (zeile.bezahlt_mit?.trim() || null) as never,
|
||||||
|
inactiveReason: (zeile.cc_grund?.trim() || null) as never,
|
||||||
|
inactiveNote: zeile.cc_notiz?.trim() || null,
|
||||||
|
leftAt: zeile.wert ? new Date(zeile.wert) : null,
|
||||||
|
// Aus einer Sicherung von vor der Umstellung kommt keine Liste
|
||||||
|
// mit; dann beginnt der Eintrag eine eigene, und der Abgleich
|
||||||
|
// beim Start ordnet den Rest.
|
||||||
|
...(zeile.kette?.trim()
|
||||||
|
? {
|
||||||
|
lineageId: zeile.kette.trim(),
|
||||||
|
chainPosition: Number(zeile.kette_position) || 0,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(zeile.zeitpunkt ? { joinedAt: new Date(zeile.zeitpunkt) } : {}),
|
||||||
|
note: zeile.bemerkung?.trim() || null,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'VERWEIS': {
|
||||||
|
const playerId = await findeSpieler(zeile);
|
||||||
|
if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`);
|
||||||
|
|
||||||
|
const ccName = zeile.cc_name?.trim();
|
||||||
|
const label = zeile.bezeichnung?.trim();
|
||||||
|
const url = zeile.wert?.trim();
|
||||||
|
if (!ccName || !label || !url) throw new Error('Verweis ohne CC, Namen oder Adresse');
|
||||||
|
|
||||||
|
const characterId = await findeCC(playerId, ccName);
|
||||||
|
if (characterId === null) throw new Error(`CC "${ccName}" nicht gefunden`);
|
||||||
|
|
||||||
|
const vorhanden = await prisma.characterLink.findFirst({
|
||||||
|
where: { characterId, url },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return false;
|
||||||
|
|
||||||
|
await prisma.characterLink.create({
|
||||||
|
data: { characterId, label, url, createdById: ctx.actorId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2447
-106
File diff suppressed because it is too large
Load Diff
+212
-18
@@ -8,29 +8,80 @@ export interface Balances {
|
|||||||
CC: number;
|
CC: number;
|
||||||
REDESIGN_PAID: number;
|
REDESIGN_PAID: number;
|
||||||
REDESIGN_KULANZ: number;
|
REDESIGN_KULANZ: number;
|
||||||
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
// Für ein Redesign zählen beide Redesign-Töpfe zusammen.
|
||||||
redesignTotal: number;
|
redesignTotal: number;
|
||||||
|
/**
|
||||||
|
* Wie viel jemals in den Topf kam - Käufe, Kulanz, Korrekturen, abzüglich
|
||||||
|
* dessen, was davon storniert wurde. Der Stand oben sagt, was noch offen
|
||||||
|
* ist; das hier sagt, wovon. "0 von 3" liest sich anders als "0 von 0".
|
||||||
|
*/
|
||||||
|
granted: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 };
|
const EMPTY: Balances = {
|
||||||
|
CC: 0,
|
||||||
|
REDESIGN_PAID: 0,
|
||||||
|
REDESIGN_KULANZ: 0,
|
||||||
|
redesignTotal: 0,
|
||||||
|
granted: { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
function assemble(sums: Partial<Record<CreditKind, number>>): Balances {
|
function assemble(
|
||||||
|
sums: Partial<Record<CreditKind, number>>,
|
||||||
|
granted?: Partial<Record<CreditKind, number>>,
|
||||||
|
): Balances {
|
||||||
const cc = sums.CC ?? 0;
|
const cc = sums.CC ?? 0;
|
||||||
const paid = sums.REDESIGN_PAID ?? 0;
|
const paid = sums.REDESIGN_PAID ?? 0;
|
||||||
const kulanz = sums.REDESIGN_KULANZ ?? 0;
|
const kulanz = sums.REDESIGN_KULANZ ?? 0;
|
||||||
return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz };
|
return {
|
||||||
|
CC: cc,
|
||||||
|
REDESIGN_PAID: paid,
|
||||||
|
REDESIGN_KULANZ: kulanz,
|
||||||
|
redesignTotal: paid + kulanz,
|
||||||
|
granted: {
|
||||||
|
CC: granted?.CC ?? 0,
|
||||||
|
REDESIGN_PAID: granted?.REDESIGN_PAID ?? 0,
|
||||||
|
REDESIGN_KULANZ: granted?.REDESIGN_KULANZ ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBalances(playerId: number): Promise<Balances> {
|
export async function getBalances(playerId: number): Promise<Balances> {
|
||||||
const rows = await prisma.creditLedger.groupBy({
|
// Für den Stand genügte eine Summe je Topf. Für "von wie vielen" muss
|
||||||
by: ['kind'],
|
// jede Zeile angesehen werden: ein Storno zählt dorthin, wohin die Zeile
|
||||||
|
// zählte, die es zurücknimmt - sonst sähe die Rücknahme eines Verbrauchs
|
||||||
|
// wie ein zusätzliches Paket aus.
|
||||||
|
const eintraege = await prisma.creditLedger.findMany({
|
||||||
where: { playerId },
|
where: { playerId },
|
||||||
_sum: { amount: true },
|
select: {
|
||||||
|
kind: true,
|
||||||
|
amount: true,
|
||||||
|
source: true,
|
||||||
|
reversalOf: { select: { source: true, amount: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sums: Partial<Record<CreditKind, number>> = {};
|
const sums: Partial<Record<CreditKind, number>> = {};
|
||||||
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
|
const granted: Partial<Record<CreditKind, number>> = {};
|
||||||
return assemble(sums);
|
|
||||||
|
for (const eintrag of eintraege) {
|
||||||
|
sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount;
|
||||||
|
|
||||||
|
// Ein Storno zählt dorthin, wohin die stornierte Zeile zählte.
|
||||||
|
const bezug =
|
||||||
|
eintrag.source === CreditSource.REVERSAL ? (eintrag.reversalOf ?? eintrag) : eintrag;
|
||||||
|
// Eine Umwandlung ist auf der einen Seite Verbrauch, auf der anderen
|
||||||
|
// Zugang: wer ein CC-Guthaben in ein Redesign tauscht, hat das CC-Paket
|
||||||
|
// gekauft und aufgebraucht - "0 von 3" statt "0 von 2".
|
||||||
|
const istVerbrauch =
|
||||||
|
bezug.source === CreditSource.CONSUME ||
|
||||||
|
(bezug.source === CreditSource.CONVERSION && bezug.amount < 0);
|
||||||
|
if (!istVerbrauch) {
|
||||||
|
granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assemble(sums, granted);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
|
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
|
||||||
@@ -68,12 +119,14 @@ export interface PackageRowParams {
|
|||||||
note?: string | null;
|
note?: string | null;
|
||||||
importId?: number | null;
|
importId?: number | null;
|
||||||
actorId: number | null;
|
actorId: number | null;
|
||||||
|
/// Wann gekauft wurde. Ohne Angabe gilt der Zeitpunkt der Eingabe.
|
||||||
|
occurredAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
||||||
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
|
* immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle
|
||||||
* Eingabe und Import nicht auseinanderlaufen koennen.
|
* Eingabe und Import nicht auseinanderlaufen können.
|
||||||
*/
|
*/
|
||||||
export function buildPackageRows(
|
export function buildPackageRows(
|
||||||
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||||
@@ -85,6 +138,7 @@ export function buildPackageRows(
|
|||||||
note: params.note ?? null,
|
note: params.note ?? null,
|
||||||
importId: params.importId ?? null,
|
importId: params.importId ?? null,
|
||||||
createdById: params.actorId,
|
createdById: params.actorId,
|
||||||
|
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (target === 'REDESIGN_PACKAGE') {
|
if (target === 'REDESIGN_PACKAGE') {
|
||||||
@@ -102,7 +156,7 @@ export function buildPackageRows(
|
|||||||
/**
|
/**
|
||||||
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
||||||
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
||||||
* gemeinsam zurueckholt.
|
* gemeinsam zurückholt.
|
||||||
*/
|
*/
|
||||||
export async function bookCcPackage(
|
export async function bookCcPackage(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
@@ -134,6 +188,87 @@ export async function bookCcPackage(
|
|||||||
return { groupId, balances: await getBalances(playerId) };
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein CC-Guthaben in ein bezahltes Redesign um.
|
||||||
|
*
|
||||||
|
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür
|
||||||
|
* einsetzen will, tauscht es vorher um: ein Platz weniger, ein Redesign mehr.
|
||||||
|
* Danach wird es eingesetzt wie jedes andere Redesign - dadurch bleibt die
|
||||||
|
* Rechnung überall dieselbe, ohne Sonderfall an einzelnen CCs.
|
||||||
|
*/
|
||||||
|
export async function convertCcToRedesign(
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
|
||||||
|
const groupId = randomUUID();
|
||||||
|
const gemeinsam = {
|
||||||
|
playerId,
|
||||||
|
source: CreditSource.CONVERSION,
|
||||||
|
groupId,
|
||||||
|
note: 'CC-Guthaben in ein Redesign umgewandelt',
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.creditLedger.create({ data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 } });
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.converted',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { from: CreditKind.CC, to: CreditKind.REDESIGN_PAID },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket.
|
||||||
|
*
|
||||||
|
* Ein gekauftes CC-Paket bringt beides mit - so ist es im Shop, und so
|
||||||
|
* bucht es der Abgleich. Von Hand gibt es aber Einzelfälle, in denen jemand
|
||||||
|
* nur einen Platz bekommen soll: als Ausgleich, aus einer Absprache, weil
|
||||||
|
* etwas schiefging. Dafür ist das hier da, und deshalb trägt es eine eigene
|
||||||
|
* Quelle - in der Historie soll zu sehen sein, dass kein Kauf dahintersteht.
|
||||||
|
*/
|
||||||
|
export async function grantCcSlot(
|
||||||
|
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.CC,
|
||||||
|
amount: 1,
|
||||||
|
source: CreditSource.CC_GRANT,
|
||||||
|
groupId,
|
||||||
|
note: options.note ?? null,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.cc_grant',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { groupId, note: options.note ?? null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function bookRedesignPackage(
|
export async function bookRedesignPackage(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
ctx: ActionContext,
|
ctx: ActionContext,
|
||||||
@@ -196,7 +331,7 @@ export async function bookKulanz(
|
|||||||
return { groupId, balances: await getBalances(playerId) };
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */
|
||||||
export async function bookAdjustment(
|
export async function bookAdjustment(
|
||||||
playerId: number,
|
playerId: number,
|
||||||
kind: CreditKind,
|
kind: CreditKind,
|
||||||
@@ -236,10 +371,69 @@ export async function bookAdjustment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
* Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie
|
||||||
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
* bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||||
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
* über die zugehörige CC-Aktion zurückgenommen.
|
||||||
*/
|
*/
|
||||||
|
/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt.
|
||||||
|
const HANDEINTRAEGE = [
|
||||||
|
CreditSource.CC_GRANT,
|
||||||
|
CreditSource.CC_PACKAGE,
|
||||||
|
CreditSource.REDESIGN_PACKAGE,
|
||||||
|
CreditSource.KULANZ,
|
||||||
|
CreditSource.CONVERSION,
|
||||||
|
] as const;
|
||||||
|
export type Handeintrag = (typeof HANDEINTRAEGE)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die zuletzt von Hand eingetragene Zugabe wieder zurück.
|
||||||
|
*
|
||||||
|
* Wer sich vertippt oder zweimal geklickt hat, soll das geradeziehen können,
|
||||||
|
* ohne in der Historie nach der richtigen Zeile zu suchen. Zurückgenommen
|
||||||
|
* wird die jüngste offene Zugabe dieser Art - über eine Gegenbuchung, wie
|
||||||
|
* jede Korrektur hier. Bei einem CC-Paket sind das beide Zeilen: das
|
||||||
|
* CC-Guthaben und das Redesign, das darin steckt.
|
||||||
|
*
|
||||||
|
* Eingetragen aus einem Import wird dabei ausgelassen. Ein gebuchtes Paket
|
||||||
|
* gehört zu einer Bestellung; wer das zurücknehmen will, tut es dort, wo der
|
||||||
|
* Zusammenhang sichtbar ist - in der Historie.
|
||||||
|
*/
|
||||||
|
export async function removeLastGrant(
|
||||||
|
playerId: number,
|
||||||
|
source: Handeintrag,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ balances: Balances; reversed: number }> {
|
||||||
|
await requirePlayer(playerId);
|
||||||
|
|
||||||
|
if (!HANDEINTRAEGE.includes(source)) {
|
||||||
|
throw badRequest('INVALID_SOURCE', 'Diese Art lässt sich nicht von Hand zurücknehmen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const juengste = await prisma.creditLedger.findFirst({
|
||||||
|
where: { playerId, source, importId: null, reversedBy: null },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!juengste) {
|
||||||
|
const was =
|
||||||
|
source === CreditSource.CC_GRANT
|
||||||
|
? 'CC-Guthaben'
|
||||||
|
: source === CreditSource.CC_PACKAGE
|
||||||
|
? 'CC-Paket'
|
||||||
|
: source === CreditSource.REDESIGN_PACKAGE
|
||||||
|
? 'Redesign-Paket'
|
||||||
|
: source === CreditSource.CONVERSION
|
||||||
|
? 'umgewandeltes CC-Guthaben'
|
||||||
|
: 'Kulanz';
|
||||||
|
throw conflict(
|
||||||
|
'NOTHING_TO_REMOVE',
|
||||||
|
`Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverseGroup(juengste.id, 'Von Hand zurückgenommen', ctx);
|
||||||
|
}
|
||||||
|
|
||||||
export async function reverseGroup(
|
export async function reverseGroup(
|
||||||
ledgerId: number,
|
ledgerId: number,
|
||||||
reason: string,
|
reason: string,
|
||||||
@@ -251,7 +445,7 @@ export async function reverseGroup(
|
|||||||
if (anchor.source === CreditSource.CONSUME) {
|
if (anchor.source === CreditSource.CONSUME) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'CONSUME_NOT_REVERSIBLE',
|
'CONSUME_NOT_REVERSIBLE',
|
||||||
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (anchor.source === CreditSource.REVERSAL) {
|
if (anchor.source === CreditSource.REVERSAL) {
|
||||||
@@ -309,7 +503,7 @@ export async function listLedger(filter: LedgerFilter) {
|
|||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
prisma.creditLedger.findMany({
|
prisma.creditLedger.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||||
take: filter.take ?? 100,
|
take: filter.take ?? 100,
|
||||||
skip: filter.skip ?? 0,
|
skip: filter.skip ?? 0,
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -0,0 +1,896 @@
|
|||||||
|
import { ForumBoard, ForumClassification, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
|
import { hasIgnoredLabel, parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js';
|
||||||
|
import { isRateLimited, retryAfterMs, sleep } from '../lib/ratelimit.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben
|
||||||
|
/// Unterforen auch eigene Threads, deshalb wird es mitgelesen.
|
||||||
|
export const BOARDS: { board: ForumBoard; path: string }[] = [
|
||||||
|
{ board: ForumBoard.CUSTOM_CHARACTERS, path: 'board/66-custom-characters' },
|
||||||
|
{ board: ForumBoard.AKTIVE_CCS, path: 'board/355-aktive-ccs' },
|
||||||
|
{ board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Die Board-Pfade in lesbarer Form, für das Protokoll des Laufs.
|
||||||
|
const BOARD_NAMES: Record<string, string> = {
|
||||||
|
'board/66-custom-characters': 'Antragsübersicht',
|
||||||
|
'board/355-aktive-ccs': 'Aktive CCs',
|
||||||
|
'board/113-cc-archiv': 'Archiv',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_REQUEST_GAP_MS = 1200;
|
||||||
|
/// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
|
||||||
|
const MAX_REQUEST_GAP_MS = 10000;
|
||||||
|
/// So oft wird eine gebremste Seite erneut versucht, bevor aufgegeben wird.
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
/// Das Archiv reicht Jahre zurück; die Grenze ist eine Notbremse, keine
|
||||||
|
/// Sparmaßnahme.
|
||||||
|
const MAX_PAGES_PER_BOARD = 60;
|
||||||
|
|
||||||
|
let lastRequestAt = 0;
|
||||||
|
/// Wächst, sobald das Forum bremst, und bleibt für den Rest des Laufs oben.
|
||||||
|
let requestGapMs = MIN_REQUEST_GAP_MS;
|
||||||
|
let rateLimitHits = 0;
|
||||||
|
|
||||||
|
async function throttle(): Promise<void> {
|
||||||
|
const wait = lastRequestAt + requestGapMs - Date.now();
|
||||||
|
if (wait > 0) await sleep(wait);
|
||||||
|
lastRequestAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Boardseite und nimmt Rücksicht darauf, dass das Forum bei zu
|
||||||
|
* vielen Anfragen bremst. Eine abgewiesene Seite wird nach der angegebenen
|
||||||
|
* Wartezeit erneut versucht, und der Abstand für alle weiteren Abrufe steigt.
|
||||||
|
* Das kostet Zeit, ist aber der einzige Weg, ein Board vollständig zu lesen -
|
||||||
|
* wer nach dem ersten Korb aufgibt, verliert den Rest des Boards.
|
||||||
|
*/
|
||||||
|
async function fetchPage(path: string, page: number): Promise<string> {
|
||||||
|
const base = env.FORUM_BASE_URL.replace(/\/+$/, '');
|
||||||
|
const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`;
|
||||||
|
|
||||||
|
for (let versuch = 0; ; versuch += 1) {
|
||||||
|
await throttle();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 20000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'CC-Manager/1.0 (Antrags-Abgleich für das CC-Team)',
|
||||||
|
accept: 'text/html',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRateLimited(response.status)) {
|
||||||
|
rateLimitHits += 1;
|
||||||
|
requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS);
|
||||||
|
|
||||||
|
if (versuch >= MAX_RETRIES) {
|
||||||
|
throw badRequest(
|
||||||
|
'BOARD_RATE_LIMITED',
|
||||||
|
`Das Forum bremst den Abruf aus (HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(retryAfterMs(response.headers.get('retry-after'), versuch));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw badRequest('BOARD_UNAVAILABLE', `Board nicht abrufbar (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw badRequest('BOARD_TIMEOUT', 'Zeitüberschreitung beim Abruf des Boards');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ForumScanSummary {
|
||||||
|
scannedBoards: number;
|
||||||
|
scannedPages: number;
|
||||||
|
newThreads: number;
|
||||||
|
movedThreads: number;
|
||||||
|
boardChangeNotices: number;
|
||||||
|
skippedStubs: number;
|
||||||
|
skippedLabels: number;
|
||||||
|
/// Erfasst, aber noch keinem Spieler zugeordnet. Solche Threads landen nicht
|
||||||
|
/// in der Arbeitsliste, gehen aber auch nicht verloren.
|
||||||
|
unassignedThreads: number;
|
||||||
|
matchedPlayers: number;
|
||||||
|
/// Threads, deren Ersteller sich geändert hat und die deshalb einem anderen
|
||||||
|
/// Spieler zugeschlagen wurden.
|
||||||
|
reassigned: number;
|
||||||
|
/// Wie oft das Forum den Abruf gebremst hat. Über null heißt: der Lauf hat
|
||||||
|
/// länger gedauert, gelesen wurde trotzdem alles.
|
||||||
|
rateLimited: number;
|
||||||
|
/// Boards, die nicht zu Ende gelesen werden konnten - mit der Zahl der
|
||||||
|
/// Seiten, die es bis dahin geschafft haben. Was gelesen wurde, ist erfasst.
|
||||||
|
failedBoards: { board: string; reason: string; pagesRead: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanState {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
/// Woran gerade gelesen wird, für die Anzeige während des Laufs.
|
||||||
|
currentBoard: string | null;
|
||||||
|
pagesRead: number;
|
||||||
|
/// Steht fest, sobald von jedem Board die erste Seite gelesen ist. Vorher 0.
|
||||||
|
totalPages: number;
|
||||||
|
/// Was der Lauf bisher getan hat, jüngster Eintrag zuletzt.
|
||||||
|
log: string[];
|
||||||
|
summary: ForumScanSummary | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein voller Lauf geht über dutzende Seiten, und wenn das Forum bremst, kommen
|
||||||
|
* Wartezeiten obendrauf. Das überlebt keine HTTP-Anfrage, deshalb läuft der
|
||||||
|
* Abgleich im Hintergrund und die Oberfläche fragt den Stand ab.
|
||||||
|
*
|
||||||
|
* Der Zustand liegt im Speicher: nach einem Neustart des Dienstes ist er weg,
|
||||||
|
* was nichts kostet - der nächste Lauf liest ohnehin alles erneut.
|
||||||
|
*/
|
||||||
|
const scanState: ScanState = {
|
||||||
|
running: false,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
currentBoard: null,
|
||||||
|
pagesRead: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
log: [],
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Damit ein langer Lauf den Speicher nicht vollschreibt.
|
||||||
|
const MAX_LOG_LINES = 200;
|
||||||
|
|
||||||
|
function protokoll(zeile: string): void {
|
||||||
|
scanState.log.push(zeile);
|
||||||
|
if (scanState.log.length > MAX_LOG_LINES) scanState.log.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScanState(): ScanState {
|
||||||
|
return { ...scanState };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen Lauf an und kehrt sofort zurück.
|
||||||
|
*
|
||||||
|
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
|
||||||
|
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scanForum selbst. Zwei
|
||||||
|
* Läufe nebeneinander würden dieselben Seiten doppelt abrufen - und das
|
||||||
|
* Forum bremst schon bei einem.
|
||||||
|
*/
|
||||||
|
export function startScan(ctx: ActionContext): ScanState {
|
||||||
|
if (scanState.running) {
|
||||||
|
throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich');
|
||||||
|
}
|
||||||
|
|
||||||
|
void scanForum(ctx).catch(() => {
|
||||||
|
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
|
||||||
|
});
|
||||||
|
|
||||||
|
return getScanState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Boards und schreibt gefundene Threads fort. Klassifiziert wird
|
||||||
|
* bewusst nichts: weder Titel noch Board sind verlässlich genug, um daraus
|
||||||
|
* eine Buchung abzuleiten. Jeder neue Thread wartet auf eine Entscheidung.
|
||||||
|
*/
|
||||||
|
export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||||
|
if (scanState.running) {
|
||||||
|
throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
|
||||||
|
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
|
||||||
|
scanState.running = true;
|
||||||
|
scanState.startedAt = new Date().toISOString();
|
||||||
|
scanState.finishedAt = null;
|
||||||
|
scanState.currentBoard = null;
|
||||||
|
scanState.pagesRead = 0;
|
||||||
|
scanState.totalPages = 0;
|
||||||
|
scanState.log = [];
|
||||||
|
scanState.summary = null;
|
||||||
|
scanState.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await scanIntern(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
scanState.running = false;
|
||||||
|
scanState.currentBoard = null;
|
||||||
|
scanState.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||||
|
const summary: ForumScanSummary = {
|
||||||
|
scannedBoards: 0,
|
||||||
|
scannedPages: 0,
|
||||||
|
newThreads: 0,
|
||||||
|
movedThreads: 0,
|
||||||
|
boardChangeNotices: 0,
|
||||||
|
skippedStubs: 0,
|
||||||
|
skippedLabels: 0,
|
||||||
|
unassignedThreads: 0,
|
||||||
|
matchedPlayers: 0,
|
||||||
|
reassigned: 0,
|
||||||
|
rateLimited: 0,
|
||||||
|
failedBoards: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jeder Lauf fängt mit dem normalen Abstand an; gebremst wird erst, wenn
|
||||||
|
// das Forum es verlangt.
|
||||||
|
requestGapMs = MIN_REQUEST_GAP_MS;
|
||||||
|
rateLimitHits = 0;
|
||||||
|
|
||||||
|
const byForumName = await forumNameIndex();
|
||||||
|
|
||||||
|
// Erst alles lesen, dann alles schreiben. Ein voller Lauf geht über mehrere
|
||||||
|
// hundert Threads; je Thread einzeln zu lesen und zu schreiben würde länger
|
||||||
|
// dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
|
||||||
|
const gefunden = new Map<number, { thread: ForumThread; board: ForumBoard }>();
|
||||||
|
|
||||||
|
// Erst von jedem Board die erste Seite. Danach steht der Umfang des Laufs
|
||||||
|
// fest, und die Anzeige kann einen ehrlichen Fortschritt zeigen statt zu
|
||||||
|
// raten, wie viel noch kommt.
|
||||||
|
const offen: { board: ForumBoard; path: string; seiten: number; gelesen: number }[] = [];
|
||||||
|
|
||||||
|
for (const { board, path } of BOARDS) {
|
||||||
|
scanState.currentBoard = path;
|
||||||
|
protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = await fetchPage(path, 1);
|
||||||
|
summary.scannedPages += 1;
|
||||||
|
scanState.pagesRead = summary.scannedPages;
|
||||||
|
for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board });
|
||||||
|
|
||||||
|
const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
|
||||||
|
scanState.totalPages += seiten;
|
||||||
|
offen.push({ board, path, seiten, gelesen: 1 });
|
||||||
|
protokoll(`${BOARD_NAMES[path] ?? path}: Seite 1 von ${seiten} gelesen`);
|
||||||
|
} catch (error) {
|
||||||
|
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
summary.failedBoards.push({ board: path, reason: grund, pagesRead: 0 });
|
||||||
|
protokoll(`${BOARD_NAMES[path] ?? path}: nicht erreichbar (${grund})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eintrag of offen) {
|
||||||
|
const name = BOARD_NAMES[eintrag.path] ?? eintrag.path;
|
||||||
|
scanState.currentBoard = eintrag.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let page = 2; page <= eintrag.seiten; page += 1) {
|
||||||
|
const html = await fetchPage(eintrag.path, page);
|
||||||
|
eintrag.gelesen += 1;
|
||||||
|
summary.scannedPages += 1;
|
||||||
|
scanState.pagesRead = summary.scannedPages;
|
||||||
|
for (const thread of parseBoard(html)) {
|
||||||
|
gefunden.set(thread.threadId, { thread, board: eintrag.board });
|
||||||
|
}
|
||||||
|
protokoll(`${name}: Seite ${page} von ${eintrag.seiten} gelesen`);
|
||||||
|
}
|
||||||
|
summary.scannedBoards += 1;
|
||||||
|
protokoll(`${name}: fertig`);
|
||||||
|
} catch (error) {
|
||||||
|
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
|
||||||
|
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
|
||||||
|
// Seiten mit in den Papierkorb nehmen.
|
||||||
|
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen });
|
||||||
|
protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.rateLimited = rateLimitHits;
|
||||||
|
scanState.currentBoard = null;
|
||||||
|
protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`);
|
||||||
|
|
||||||
|
const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
|
||||||
|
for (const eintrag of gefunden.values()) {
|
||||||
|
// Ein zurückgelassener Verweis ist kein eigener Antrag.
|
||||||
|
if (eintrag.thread.isMovedStub) {
|
||||||
|
summary.skippedStubs += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Angepinnte Ankündigungen liegen mit in den Boards.
|
||||||
|
if (hasIgnoredLabel(eintrag.thread)) {
|
||||||
|
summary.skippedLabels += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
relevant.push(eintrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = relevant.map((e) => e.thread.threadId);
|
||||||
|
const bekannt = new Map(
|
||||||
|
(await prisma.forumThread.findMany({ where: { threadId: { in: ids } } })).map((t) => [
|
||||||
|
t.threadId,
|
||||||
|
t,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const neue: Prisma.ForumThreadCreateManyInput[] = [];
|
||||||
|
const historie: Prisma.ForumThreadBoardCreateManyInput[] = [];
|
||||||
|
const aenderungen: { threadId: number; data: Prisma.ForumThreadUpdateInput }[] = [];
|
||||||
|
|
||||||
|
for (const { thread, board } of relevant) {
|
||||||
|
const playerId = thread.authorName
|
||||||
|
? byForumName.get(thread.authorName.toLowerCase()) ?? null
|
||||||
|
: null;
|
||||||
|
const labels = thread.labels.join(', ') || null;
|
||||||
|
const existing = bekannt.get(thread.threadId);
|
||||||
|
|
||||||
|
// Auch ohne zuordenbaren Spieler wird der Thread erfasst. Er bleibt dann
|
||||||
|
// aus der Arbeitsliste heraus, ist aber auffindbar und lässt sich von Hand
|
||||||
|
// einem Spieler zuschlagen - sonst wäre er schlicht unsichtbar.
|
||||||
|
if (!existing) {
|
||||||
|
neue.push({
|
||||||
|
threadId: thread.threadId,
|
||||||
|
title: thread.title,
|
||||||
|
url: thread.url,
|
||||||
|
authorName: thread.authorName,
|
||||||
|
authorForumId: thread.authorForumId,
|
||||||
|
authorProfileUrl: thread.authorProfileUrl,
|
||||||
|
labels,
|
||||||
|
playerId,
|
||||||
|
currentBoard: board,
|
||||||
|
postedAt: thread.postedAt,
|
||||||
|
});
|
||||||
|
historie.push({ threadId: thread.threadId, board });
|
||||||
|
summary.newThreads += 1;
|
||||||
|
if (playerId === null) summary.unassignedThreads += 1;
|
||||||
|
else summary.matchedPlayers += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verschoben = existing.currentBoard !== board;
|
||||||
|
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
||||||
|
const nachgetragen = existing.playerId === null && playerId !== null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein anderer Ersteller als beim letzten Mal.
|
||||||
|
*
|
||||||
|
* Das kommt vor, wenn jemand sich im Forum umbenennt - und es kam vor,
|
||||||
|
* weil der Ersteller einmal falsch gelesen wurde: bei einem geloeschten
|
||||||
|
* Konto stand in der Zeile nur noch der letzte Antwortende mit Profil,
|
||||||
|
* und der landete als Ersteller in den Daten. Solche Threads hingen
|
||||||
|
* anschliessend beim falschen Spieler.
|
||||||
|
*
|
||||||
|
* Korrigiert wird nur, was von selbst zustande kam: wenn die bisherige
|
||||||
|
* Zuordnung genau die ist, die der alte Name ergeben haette. Eine von
|
||||||
|
* Hand gesetzte bleibt stehen - wer sie gesetzt hat, wusste mehr als der
|
||||||
|
* Abgleich.
|
||||||
|
*/
|
||||||
|
const autorGeaendert = existing.authorName !== thread.authorName;
|
||||||
|
const zuordnungWarAutomatisch =
|
||||||
|
existing.playerId !== null &&
|
||||||
|
existing.authorName !== null &&
|
||||||
|
byForumName.get(existing.authorName.toLowerCase()) === existing.playerId;
|
||||||
|
const zuordnungKorrigieren =
|
||||||
|
autorGeaendert &&
|
||||||
|
zuordnungWarAutomatisch &&
|
||||||
|
existing.needsClassification &&
|
||||||
|
existing.playerId !== playerId;
|
||||||
|
|
||||||
|
// Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier
|
||||||
|
// nach, ohne dass ein vorhandener Wert überschrieben wird.
|
||||||
|
const datumFehlt = existing.postedAt === null && thread.postedAt !== null;
|
||||||
|
|
||||||
|
const profilFehlt = existing.authorProfileUrl === null && thread.authorProfileUrl !== null;
|
||||||
|
|
||||||
|
const geaendert =
|
||||||
|
verschoben ||
|
||||||
|
nachgetragen ||
|
||||||
|
zuordnungKorrigieren ||
|
||||||
|
datumFehlt ||
|
||||||
|
profilFehlt ||
|
||||||
|
existing.title !== thread.title ||
|
||||||
|
existing.url !== thread.url ||
|
||||||
|
existing.authorName !== thread.authorName ||
|
||||||
|
existing.authorForumId !== thread.authorForumId ||
|
||||||
|
existing.labels !== labels;
|
||||||
|
|
||||||
|
if (!geaendert) continue;
|
||||||
|
|
||||||
|
aenderungen.push({
|
||||||
|
threadId: thread.threadId,
|
||||||
|
data: {
|
||||||
|
title: thread.title,
|
||||||
|
url: thread.url,
|
||||||
|
authorName: thread.authorName,
|
||||||
|
authorForumId: thread.authorForumId,
|
||||||
|
labels,
|
||||||
|
...(datumFehlt ? { postedAt: thread.postedAt } : {}),
|
||||||
|
...(profilFehlt ? { authorProfileUrl: thread.authorProfileUrl } : {}),
|
||||||
|
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
|
||||||
|
...(zuordnungKorrigieren
|
||||||
|
? playerId === null
|
||||||
|
? { player: { disconnect: true } }
|
||||||
|
: { player: { connect: { id: playerId } } }
|
||||||
|
: {}),
|
||||||
|
...(verschoben
|
||||||
|
? {
|
||||||
|
currentBoard: board,
|
||||||
|
lastBoardChangeAt: new Date(),
|
||||||
|
// Wandert ein bereits entschiedener Thread, ist das ein Hinweis
|
||||||
|
// für den Admin - nie eine automatische Statusänderung.
|
||||||
|
...(existing.classification !== null ? { boardChangeNotice: true } : {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (zuordnungKorrigieren) summary.reassigned += 1;
|
||||||
|
|
||||||
|
if (verschoben) {
|
||||||
|
historie.push({ threadId: thread.threadId, board });
|
||||||
|
summary.movedThreads += 1;
|
||||||
|
if (existing.classification !== null) summary.boardChangeNotices += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neue.length > 0) await prisma.forumThread.createMany({ data: neue });
|
||||||
|
for (const { threadId, data } of aenderungen) {
|
||||||
|
await prisma.forumThread.update({ where: { threadId }, data });
|
||||||
|
}
|
||||||
|
if (historie.length > 0) await prisma.forumThreadBoard.createMany({ data: historie });
|
||||||
|
|
||||||
|
// Gesehen wurde jeder gelesene Thread, auch der unveränderte.
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.forumThread.updateMany({
|
||||||
|
where: { threadId: { in: ids } },
|
||||||
|
data: { lastSeenAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protokoll(
|
||||||
|
summary.newThreads > 0
|
||||||
|
? `${summary.newThreads} neue Anträge erfasst`
|
||||||
|
: 'Keine neuen Anträge',
|
||||||
|
);
|
||||||
|
|
||||||
|
scanState.summary = summary;
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'forum.scan',
|
||||||
|
targetType: 'forum',
|
||||||
|
targetId: null,
|
||||||
|
details: { ...summary } as unknown as Prisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forumsnamen auf Spieler abbilden - die aktuellen Angaben und frühere Konten,
|
||||||
|
* damit ein Namenswechsel die Zuordnung nicht abreißen lässt.
|
||||||
|
*/
|
||||||
|
async function forumNameIndex(): Promise<Map<string, number>> {
|
||||||
|
const [players, aliases] = await Promise.all([
|
||||||
|
prisma.player.findMany({
|
||||||
|
where: { forumName: { not: null } },
|
||||||
|
select: { id: true, forumName: true },
|
||||||
|
}),
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { type: 'FORUM_NAME' },
|
||||||
|
select: { playerId: true, value: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const index = new Map<string, number>();
|
||||||
|
for (const alias of aliases) index.set(alias.value.toLowerCase(), alias.playerId);
|
||||||
|
// Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht.
|
||||||
|
for (const player of players) {
|
||||||
|
index.set((player.forumName as string).toLowerCase(), player.id);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neueste oder älteste zuerst. Massgeblich ist die Thread-ID: sie waechst
|
||||||
|
/// mit der Zeit und bleibt stabil, waehrend der Zeitpunkt des letzten Abrufs
|
||||||
|
/// nach einem Lauf bei allen Threads derselbe ist.
|
||||||
|
export type ThreadOrder = 'newest' | 'oldest';
|
||||||
|
|
||||||
|
function orderOf(order: ThreadOrder | undefined): Prisma.ForumThreadOrderByWithRelationInput {
|
||||||
|
return { threadId: order === 'oldest' ? 'asc' : 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freitextsuche über Titel und Ersteller. */
|
||||||
|
function sucheNach(query: string | undefined): Prisma.ForumThreadWhereInput {
|
||||||
|
const suche = query?.trim();
|
||||||
|
if (!suche) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: suche, mode: 'insensitive' } },
|
||||||
|
{ authorName: { contains: suche, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listThreads(options: {
|
||||||
|
onlyOpen?: boolean;
|
||||||
|
playerId?: number;
|
||||||
|
board?: ForumBoard;
|
||||||
|
query?: string;
|
||||||
|
order?: ThreadOrder;
|
||||||
|
}) {
|
||||||
|
return prisma.forumThread.findMany({
|
||||||
|
where: {
|
||||||
|
// Die Arbeitsliste zeigt nur, was auch entschieden werden kann. Ein
|
||||||
|
// Thread ohne Spieler steht in der eigenen Liste und wartet dort auf
|
||||||
|
// die Zuordnung von Hand.
|
||||||
|
...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}),
|
||||||
|
...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
|
||||||
|
...(options.board ? { currentBoard: options.board } : {}),
|
||||||
|
...sucheNach(options.query),
|
||||||
|
},
|
||||||
|
orderBy: orderOf(options.order),
|
||||||
|
include: {
|
||||||
|
player: { select: { id: true, displayName: true } },
|
||||||
|
linkedCharacter: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre
|
||||||
|
* zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst
|
||||||
|
* wurden - deshalb durchsuchbar und seitenweise statt als eine lange Liste.
|
||||||
|
*/
|
||||||
|
export async function listUnassignedThreads(options: {
|
||||||
|
query?: string;
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
order?: ThreadOrder;
|
||||||
|
}) {
|
||||||
|
const take = Math.min(Math.max(options.take ?? 50, 1), 200);
|
||||||
|
const skip = Math.max(options.skip ?? 0, 0);
|
||||||
|
|
||||||
|
const where: Prisma.ForumThreadWhereInput = {
|
||||||
|
playerId: null,
|
||||||
|
needsClassification: true,
|
||||||
|
...sucheNach(options.query),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.forumThread.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: orderOf(options.order),
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
include: {
|
||||||
|
player: { select: { id: true, displayName: true } },
|
||||||
|
linkedCharacter: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.forumThread.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, take, skip };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer im Forum Anträge gestellt hat, hier aber unter keinem Namen geführt
|
||||||
|
* wird - zusammengefasst je Ersteller statt je Antrag. So sieht man auf einen
|
||||||
|
* Blick, wen anzulegen sich lohnt: drei Anträge unter einem Namen sind ein
|
||||||
|
* anderes Signal als ein einzelner.
|
||||||
|
*/
|
||||||
|
export async function listUntrackedAuthors(take = 100) {
|
||||||
|
const gruppen = await prisma.forumThread.groupBy({
|
||||||
|
by: ['authorName'],
|
||||||
|
where: { playerId: null, needsClassification: true, authorName: { not: null } },
|
||||||
|
_count: { _all: true },
|
||||||
|
_max: { threadId: true },
|
||||||
|
orderBy: { _count: { threadId: 'desc' } },
|
||||||
|
take: Math.min(Math.max(take, 1), 500),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: gruppen.map((gruppe) => ({
|
||||||
|
authorName: gruppe.authorName as string,
|
||||||
|
threadCount: gruppe._count._all,
|
||||||
|
latestThreadId: gruppe._max.threadId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zieht wartende Threads an einen Spieler heran, sobald dessen Forumsname
|
||||||
|
* bekannt wird. Ohne das bliebe ein frisch angelegter Spieler bis zum
|
||||||
|
* nächsten vollen Abgleich ohne seine Anträge.
|
||||||
|
*/
|
||||||
|
export async function claimThreadsForPlayer(playerId: number, names: string[]): Promise<number> {
|
||||||
|
const gesucht = names.map((n) => n.trim()).filter((n) => n.length > 0);
|
||||||
|
if (gesucht.length === 0) return 0;
|
||||||
|
|
||||||
|
const kandidaten = await prisma.forumThread.findMany({
|
||||||
|
where: { playerId: null, authorName: { in: gesucht, mode: 'insensitive' } },
|
||||||
|
select: { threadId: true, authorProfileUrl: true },
|
||||||
|
});
|
||||||
|
if (kandidaten.length === 0) return 0;
|
||||||
|
|
||||||
|
const result = await prisma.forumThread.updateMany({
|
||||||
|
where: { threadId: { in: kandidaten.map((t) => t.threadId) }, playerId: null },
|
||||||
|
data: { playerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await merkeForumProfil(playerId, kandidaten);
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinterlegt das Forumsprofil am Spieler, sobald einer seiner Threads es
|
||||||
|
* mitbringt. Einmal gesetzt bleibt es: es zeigt auf eine Nummer, überlebt
|
||||||
|
* also eine Namensänderung im Forum.
|
||||||
|
*/
|
||||||
|
async function merkeForumProfil(
|
||||||
|
playerId: number,
|
||||||
|
threads: { authorProfileUrl: string | null }[],
|
||||||
|
): Promise<void> {
|
||||||
|
const profil = threads.find((t) => t.authorProfileUrl)?.authorProfileUrl;
|
||||||
|
if (!profil) return;
|
||||||
|
|
||||||
|
await prisma.player.updateMany({
|
||||||
|
where: { id: playerId, forumProfileUrl: null },
|
||||||
|
data: { forumProfileUrl: profil },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threads, bei denen nach der Entscheidung das Board gewechselt hat. */
|
||||||
|
export async function listBoardChangeNotices() {
|
||||||
|
return prisma.forumThread.findMany({
|
||||||
|
where: { boardChangeNotice: true },
|
||||||
|
orderBy: { lastBoardChangeAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
player: { select: { id: true, displayName: true } },
|
||||||
|
linkedCharacter: { select: { id: true, name: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissBoardChangeNotice(threadId: number, ctx: ActionContext): Promise<void> {
|
||||||
|
const thread = await prisma.forumThread.findUnique({ where: { threadId } });
|
||||||
|
if (!thread) throw notFound('Thread');
|
||||||
|
|
||||||
|
await prisma.forumThread.update({
|
||||||
|
where: { threadId },
|
||||||
|
data: { boardChangeNotice: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'forum.notice_dismissed',
|
||||||
|
targetType: 'forum_thread',
|
||||||
|
targetId: threadId,
|
||||||
|
details: { title: thread.title, board: thread.currentBoard },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordnet einen Thread nachträglich einem Spieler zu. */
|
||||||
|
export async function assignPlayer(
|
||||||
|
threadId: number,
|
||||||
|
playerId: number,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const [thread, player] = await Promise.all([
|
||||||
|
prisma.forumThread.findUnique({ where: { threadId } }),
|
||||||
|
prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
if (!thread) throw notFound('Thread');
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
await prisma.forumThread.update({ where: { threadId }, data: { playerId } });
|
||||||
|
await merkeForumProfil(playerId, [thread]);
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'forum.player_assigned',
|
||||||
|
targetType: 'forum_thread',
|
||||||
|
targetId: threadId,
|
||||||
|
details: { title: thread.title, playerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassifyInput {
|
||||||
|
classification: ForumClassification;
|
||||||
|
/// Nur bei NEW_CC und REDESIGN.
|
||||||
|
characterName?: string;
|
||||||
|
loreText?: string;
|
||||||
|
/// Nur bei REDESIGN: welcher CC wurde ersetzt und womit bezahlt.
|
||||||
|
predecessorId?: number;
|
||||||
|
/// Nur bei REACTIVATION: welcher inaktive CC kehrt zurück.
|
||||||
|
characterId?: number;
|
||||||
|
useKind?: 'REDESIGN_PAID' | 'REDESIGN_KULANZ' | 'CC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entscheidet über einen Antrag. Angenommene Anträge laufen durch dieselben
|
||||||
|
* Aktionen wie eine Eingabe von Hand - es gibt keinen zweiten Buchungsweg,
|
||||||
|
* der andere Regeln anwenden könnte.
|
||||||
|
*/
|
||||||
|
export async function classifyThread(
|
||||||
|
threadId: number,
|
||||||
|
input: ClassifyInput,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ characterId: number | null }> {
|
||||||
|
const thread = await prisma.forumThread.findUnique({ where: { threadId } });
|
||||||
|
if (!thread) throw notFound('Thread');
|
||||||
|
if (thread.classification !== null) {
|
||||||
|
throw conflict('ALREADY_CLASSIFIED', 'Dieser Antrag wurde bereits entschieden');
|
||||||
|
}
|
||||||
|
|
||||||
|
let characterId: number | null = null;
|
||||||
|
|
||||||
|
// Eine Entscheidung kann Kulanz verbrauchen. Das Vergeben von Kulanz ist
|
||||||
|
// Admins vorbehalten, das Verbrauchen nicht - hier wird also nur gebucht,
|
||||||
|
// was der Spieler bereits hat.
|
||||||
|
if (input.classification === ForumClassification.REACTIVATION) {
|
||||||
|
if (input.characterId === undefined) {
|
||||||
|
throw badRequest('NO_CHARACTER', 'Bitte angeben, welcher CC zurückkehrt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reactivateFree, reactivatePaid } = await import('./characters.js');
|
||||||
|
const note = `Forum-Antrag #${threadId}: ${thread.title}`;
|
||||||
|
|
||||||
|
// Ein unverändert existierender CC kommt gratis zurück; nur ein redesignter
|
||||||
|
// muss neu erschaffen werden. Welcher Fall vorliegt, entscheidet die
|
||||||
|
// bestehende Regel - hier wird sie nicht umgangen.
|
||||||
|
const target = await prisma.character.findUnique({ where: { id: input.characterId } });
|
||||||
|
if (!target) throw notFound('CC');
|
||||||
|
|
||||||
|
if (target.inactiveReason === 'REWORKED') {
|
||||||
|
const result = await reactivatePaid(
|
||||||
|
input.characterId,
|
||||||
|
input.useKind ?? 'REDESIGN_PAID',
|
||||||
|
ctx,
|
||||||
|
note,
|
||||||
|
);
|
||||||
|
characterId = result.character.id;
|
||||||
|
} else {
|
||||||
|
const result = await reactivateFree(input.characterId, ctx);
|
||||||
|
characterId = result.character.id;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
input.classification === ForumClassification.NEW_CC ||
|
||||||
|
input.classification === ForumClassification.REDESIGN
|
||||||
|
) {
|
||||||
|
if (thread.playerId === null) {
|
||||||
|
throw badRequest('NO_PLAYER', 'Dem Antrag ist kein Spieler zugeordnet');
|
||||||
|
}
|
||||||
|
const name = input.characterName?.trim();
|
||||||
|
if (!name) throw badRequest('NO_NAME', 'Für den CC wird ein Name gebraucht');
|
||||||
|
|
||||||
|
const { createCharacter, redesignCharacter } = await import('./characters.js');
|
||||||
|
const note = `Forum-Antrag #${threadId}: ${thread.title}`;
|
||||||
|
|
||||||
|
if (input.classification === ForumClassification.NEW_CC) {
|
||||||
|
const result = await createCharacter(
|
||||||
|
thread.playerId,
|
||||||
|
{ name, loreText: input.loreText, note },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
characterId = result.character.id;
|
||||||
|
} else {
|
||||||
|
if (input.predecessorId === undefined) {
|
||||||
|
throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde');
|
||||||
|
}
|
||||||
|
// Kulanz wird bewusst vergeben. Ohne Angabe gilt der Regelfall.
|
||||||
|
const redesignKind = input.useKind === 'REDESIGN_KULANZ' ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
|
||||||
|
const result = await redesignCharacter(
|
||||||
|
input.predecessorId,
|
||||||
|
{ name, loreText: input.loreText, useKind: redesignKind, note },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
characterId = result.character.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.forumThread.update({
|
||||||
|
where: { threadId },
|
||||||
|
data: {
|
||||||
|
classification: input.classification,
|
||||||
|
needsClassification: false,
|
||||||
|
classifiedById: ctx.actorId,
|
||||||
|
classifiedAt: new Date(),
|
||||||
|
linkedCharacterId: characterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'forum.classified',
|
||||||
|
targetType: 'forum_thread',
|
||||||
|
targetId: threadId,
|
||||||
|
details: {
|
||||||
|
title: thread.title,
|
||||||
|
classification: input.classification,
|
||||||
|
playerId: thread.playerId,
|
||||||
|
characterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { characterId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verknüpft einen von Hand angelegten CC mit seinem Forums-Thread und hakt
|
||||||
|
* diesen ab. Ohne das taucht derselbe Antrag beim nächsten Abgleich erneut in
|
||||||
|
* der Liste auf, obwohl er längst eingetragen ist.
|
||||||
|
*/
|
||||||
|
export async function linkThreadToCharacter(
|
||||||
|
threadId: number,
|
||||||
|
characterId: number,
|
||||||
|
classification: ForumClassification,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const character = await prisma.character.findUnique({
|
||||||
|
where: { id: characterId },
|
||||||
|
select: { id: true, name: true, playerId: true },
|
||||||
|
});
|
||||||
|
if (!character) throw notFound('CC');
|
||||||
|
|
||||||
|
const existing = await prisma.forumThread.findUnique({ where: { threadId } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.classification !== null) {
|
||||||
|
throw conflict(
|
||||||
|
'ALREADY_CLASSIFIED',
|
||||||
|
`Dieser Thread ist bereits als "${existing.classification}" eingetragen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.forumThread.update({
|
||||||
|
where: { threadId },
|
||||||
|
data: {
|
||||||
|
classification,
|
||||||
|
needsClassification: false,
|
||||||
|
classifiedById: ctx.actorId,
|
||||||
|
classifiedAt: new Date(),
|
||||||
|
linkedCharacterId: characterId,
|
||||||
|
playerId: existing.playerId ?? character.playerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Der Abgleich kennt den Thread noch nicht - trotzdem vormerken, damit er
|
||||||
|
// später nicht als offener Antrag auftaucht.
|
||||||
|
await prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId,
|
||||||
|
title: `Von Hand eingetragen: ${character.name}`,
|
||||||
|
url: `${env.FORUM_BASE_URL.replace(/\/+$/, '')}/index.php?thread/${threadId}/`,
|
||||||
|
playerId: character.playerId,
|
||||||
|
currentBoard: 'CUSTOM_CHARACTERS',
|
||||||
|
classification,
|
||||||
|
needsClassification: false,
|
||||||
|
classifiedById: ctx.actorId,
|
||||||
|
classifiedAt: new Date(),
|
||||||
|
linkedCharacterId: characterId,
|
||||||
|
history: { create: [{ board: 'CUSTOM_CHARACTERS' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'forum.linked_manually',
|
||||||
|
targetType: 'forum_thread',
|
||||||
|
targetId: threadId,
|
||||||
|
details: { characterId, characterName: character.name, classification },
|
||||||
|
});
|
||||||
|
}
|
||||||
+352
-16
@@ -5,6 +5,7 @@ import { prisma } from '../db.js';
|
|||||||
import { badRequest, conflict, notFound } from '../errors.js';
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
import { buildPackageRows } from './credits.js';
|
import { buildPackageRows } from './credits.js';
|
||||||
import { recordAudit, type ActionContext } from './audit.js';
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
import { normalizeSteamId } from '../lib/steamid.js';
|
||||||
|
|
||||||
export const PLANS = {
|
export const PLANS = {
|
||||||
BOOK: 'BOOK',
|
BOOK: 'BOOK',
|
||||||
@@ -33,7 +34,7 @@ export interface ParsedFile {
|
|||||||
|
|
||||||
const MAX_ROWS = 20000;
|
const MAX_ROWS = 20000;
|
||||||
|
|
||||||
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma.
|
||||||
function detectDelimiter(sample: string): string {
|
function detectDelimiter(sample: string): string {
|
||||||
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||||
const candidates = [',', ';', '\t', '|'];
|
const candidates = [',', ';', '\t', '|'];
|
||||||
@@ -77,7 +78,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
} catch {
|
} catch {
|
||||||
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||||
}
|
}
|
||||||
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
// Manche Exporte verpacken die Liste in ein Hüllobjekt.
|
||||||
const list = Array.isArray(data)
|
const list = Array.isArray(data)
|
||||||
? data
|
? data
|
||||||
: typeof data === 'object' && data !== null
|
: typeof data === 'object' && data !== null
|
||||||
@@ -85,7 +86,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen');
|
||||||
}
|
}
|
||||||
rows = list
|
rows = list
|
||||||
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||||
@@ -106,7 +107,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen');
|
||||||
if (rows.length > MAX_ROWS) {
|
if (rows.length > MAX_ROWS) {
|
||||||
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||||
}
|
}
|
||||||
@@ -164,8 +165,47 @@ function parseDate(value: string | undefined): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsächlich
|
||||||
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
* zugeordnet wurden. Ein Shop-Export enthält oft deutlich mehr - Mailadresse,
|
||||||
|
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Läuft,
|
||||||
|
* sobald ein Import abgeschlossen oder verworfen ist.
|
||||||
|
*/
|
||||||
|
async function pruneRawRows(
|
||||||
|
client: Pick<typeof prisma, 'importRow'>,
|
||||||
|
importId: number,
|
||||||
|
mapping: ColumnMapping | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const keep = new Set(
|
||||||
|
[mapping?.playerKey, mapping?.playerName, mapping?.product, mapping?.orderId, mapping?.purchasedAt].filter(
|
||||||
|
(column): column is string => Boolean(column),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await client.importRow.findMany({
|
||||||
|
where: { importId },
|
||||||
|
select: { id: true, raw: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const raw = row.raw as Record<string, string> | null;
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const reduced: Record<string, string> = {};
|
||||||
|
for (const column of keep) {
|
||||||
|
const value = raw[column];
|
||||||
|
if (value !== undefined) reduced[column] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.importRow.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { raw: reduced as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der
|
||||||
|
* Bestätigung angezeigt und beim Commit unverändert ausgeführt.
|
||||||
*/
|
*/
|
||||||
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||||
const record = await prisma.import.findUnique({
|
const record = await prisma.import.findUnique({
|
||||||
@@ -189,18 +229,34 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
|||||||
raw: row.raw as Record<string, string>,
|
raw: row.raw as Record<string, string>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Die Datei kann eine andere Schreibweise liefern als die Datenbank führt.
|
||||||
|
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
|
||||||
const playerKeys = [
|
const playerKeys = [
|
||||||
...new Set(rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean)),
|
...new Set(rawKeys.flatMap((key) => {
|
||||||
|
const normalized = normalizeSteamId(key);
|
||||||
|
return normalized ? [key, normalized] : [key];
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
const players = await prisma.player.findMany({
|
const [players, aliases] = await Promise.all([
|
||||||
|
prisma.player.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||||
},
|
},
|
||||||
select: { id: true, steamId: true, displayName: true },
|
select: { id: true, steamId: true, displayName: true },
|
||||||
});
|
}),
|
||||||
|
// Frühere Steam-Konten zählen mit.
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { type: 'STEAM_ID', value: { in: playerKeys } },
|
||||||
|
select: { playerId: true, value: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const bySteamId = new Map(players.filter((p) => p.steamId).map((p) => [p.steamId as string, p.id]));
|
const bySteamId = new Map<string, number>();
|
||||||
|
for (const alias of aliases) bySteamId.set(alias.value, alias.playerId);
|
||||||
|
for (const player of players) {
|
||||||
|
if (player.steamId) bySteamId.set(player.steamId, player.id);
|
||||||
|
}
|
||||||
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
const byName = new Map(players.map((p) => [p.displayName.toLowerCase(), p.id]));
|
||||||
|
|
||||||
const orderIds = mapping.orderId
|
const orderIds = mapping.orderId
|
||||||
@@ -217,7 +273,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Faengt Dubletten innerhalb derselben Datei ab.
|
// Fängt Dubletten innerhalb derselben Datei ab.
|
||||||
const seenInFile = new Set<string>();
|
const seenInFile = new Set<string>();
|
||||||
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||||
const summary: Record<Plan, number> = {
|
const summary: Record<Plan, number> = {
|
||||||
@@ -237,8 +293,12 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
|||||||
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
|
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
|
||||||
|
|
||||||
const target = productName ? matchTarget(productName, productMappings) : null;
|
const target = productName ? matchTarget(productName, productMappings) : null;
|
||||||
|
const canonicalKey = normalizeSteamId(playerKey);
|
||||||
const resolvedPlayerId =
|
const resolvedPlayerId =
|
||||||
bySteamId.get(playerKey) ?? byName.get(playerKey.toLowerCase()) ?? null;
|
(canonicalKey ? bySteamId.get(canonicalKey) : undefined) ??
|
||||||
|
bySteamId.get(playerKey) ??
|
||||||
|
byName.get(playerKey.toLowerCase()) ??
|
||||||
|
null;
|
||||||
|
|
||||||
let plan: Plan;
|
let plan: Plan;
|
||||||
if (target === null) {
|
if (target === null) {
|
||||||
@@ -307,7 +367,7 @@ export async function getImport(importId: number, includeRows = true) {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
/** Führt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||||
export async function commitImport(importId: number, ctx: ActionContext) {
|
export async function commitImport(importId: number, ctx: ActionContext) {
|
||||||
const record = await prisma.import.findUnique({
|
const record = await prisma.import.findUnique({
|
||||||
where: { id: importId },
|
where: { id: importId },
|
||||||
@@ -326,11 +386,27 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
// Mehrere Zeilen können denselben neuen Spieler betreffen.
|
||||||
const createdPlayers = new Map<string, number>();
|
const createdPlayers = new Map<string, number>();
|
||||||
let booked = 0;
|
let booked = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
for (const row of bookable) {
|
for (const row of bookable) {
|
||||||
|
// Zwischen Vorschau und Bestätigung kann dieselbe Bestellung über einen
|
||||||
|
// anderen Import gebucht worden sein. Dann diese Zeile überspringen,
|
||||||
|
// statt den gesamten Import an der eindeutigen Bestell-ID scheitern zu
|
||||||
|
// lassen - die übrigen Zeilen sind ja in Ordnung.
|
||||||
|
if (row.externalId) {
|
||||||
|
const bereitsGebucht = await tx.importedOrder.findUnique({
|
||||||
|
where: { externalId: row.externalId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (bereitsGebucht) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const key = (row.playerKey ?? '').trim();
|
const key = (row.playerKey ?? '').trim();
|
||||||
let playerId = row.resolvedPlayerId;
|
let playerId = row.resolvedPlayerId;
|
||||||
|
|
||||||
@@ -343,7 +419,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
const created = await tx.player.create({
|
const created = await tx.player.create({
|
||||||
data: {
|
data: {
|
||||||
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
|
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
|
||||||
steamId: key || null,
|
steamId: normalizeSteamId(key),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
createdPlayers.set(cacheKey, created.id);
|
createdPlayers.set(cacheKey, created.id);
|
||||||
@@ -361,6 +437,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
||||||
importId: record.id,
|
importId: record.id,
|
||||||
actorId: ctx.actorId,
|
actorId: ctx.actorId,
|
||||||
|
occurredAt: row.purchasedAt,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,16 +472,246 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
|||||||
details: {
|
details: {
|
||||||
filename: record.filename,
|
filename: record.filename,
|
||||||
booked,
|
booked,
|
||||||
|
skipped,
|
||||||
createdPlayers: createdPlayers.size,
|
createdPlayers: createdPlayers.size,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { booked, createdPlayers: createdPlayers.size, import: updated };
|
return { booked, skipped, createdPlayers: createdPlayers.size, import: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht.
|
||||||
|
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offene Paket-Zeilen eines Spielers, quer über alle noch nicht bestätigten
|
||||||
|
* Importe. Damit lässt sich ein Spieler an einem Stück abarbeiten, statt
|
||||||
|
* seine Zeilen in der Import-Vorschau zwischen fremden zu suchen.
|
||||||
|
*/
|
||||||
|
export async function listPendingRowsFor(playerId: number) {
|
||||||
|
return prisma.importRow.findMany({
|
||||||
|
where: {
|
||||||
|
resolvedPlayerId: playerId,
|
||||||
|
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||||
|
import: { status: ImportStatus.PENDING },
|
||||||
|
},
|
||||||
|
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
importId: true,
|
||||||
|
productName: true,
|
||||||
|
purchasedAt: true,
|
||||||
|
target: true,
|
||||||
|
externalId: true,
|
||||||
|
import: { select: { id: true, filename: true, createdAt: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Pakete insgesamt noch auf eine Buchung warten, und bei wem.
|
||||||
|
*
|
||||||
|
* Die Abgleiche laufen nach Zeitplan und legen Vorschauen an, ohne dass
|
||||||
|
* jemand danach gesehen hat. Ohne diese Übersicht sammelten sie sich
|
||||||
|
* unbemerkt an.
|
||||||
|
*/
|
||||||
|
export async function pendingOverview() {
|
||||||
|
const offen = await prisma.importRow.groupBy({
|
||||||
|
by: ['resolvedPlayerId'],
|
||||||
|
where: {
|
||||||
|
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||||
|
import: { status: ImportStatus.PENDING },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ImportRow führt den Spieler nur als Zahl - die Zeile entsteht, bevor
|
||||||
|
// feststeht, ob es den Spieler überhaupt gibt. Die Namen kommen deshalb
|
||||||
|
// in einem zweiten Zug dazu.
|
||||||
|
const ids = offen.map((g) => g.resolvedPlayerId).filter((id): id is number => id !== null);
|
||||||
|
const namen = new Map(
|
||||||
|
(
|
||||||
|
await prisma.player.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, displayName: true },
|
||||||
|
})
|
||||||
|
).map((p) => [p.id, p.displayName]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jeSpieler = new Map<number, { playerId: number; displayName: string; count: number }>();
|
||||||
|
let ohneSpieler = 0;
|
||||||
|
let gesamt = 0;
|
||||||
|
|
||||||
|
for (const gruppe of offen) {
|
||||||
|
gesamt += gruppe._count._all;
|
||||||
|
if (gruppe.resolvedPlayerId === null) {
|
||||||
|
ohneSpieler += gruppe._count._all;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jeSpieler.set(gruppe.resolvedPlayerId, {
|
||||||
|
playerId: gruppe.resolvedPlayerId,
|
||||||
|
displayName: namen.get(gruppe.resolvedPlayerId) ?? 'Unbekannt',
|
||||||
|
count: gruppe._count._all,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: gesamt,
|
||||||
|
withoutPlayer: ohneSpieler,
|
||||||
|
players: [...jeSpieler.values()].sort((a, b) => b.count - a.count),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucht einzelne Zeilen sofort, ohne auf die Bestätigung des ganzen Imports
|
||||||
|
* zu warten. Gebuchte Zeilen werden als erledigt vermerkt - und zwar überall,
|
||||||
|
* wo dieselbe Bestellung noch offen liegt. Ohne das stünde sie später in der
|
||||||
|
* Import-Vorschau ein zweites Mal zur Buchung bereit.
|
||||||
|
*
|
||||||
|
* Gebucht wird über dieselbe Regel wie beim Import: ein CC-Paket schreibt
|
||||||
|
* zwei Zeilen, ein Redesign-Paket eine. Es gibt keinen zweiten Buchungsweg.
|
||||||
|
*/
|
||||||
|
export async function bookRows(rowIds: number[], ctx: ActionContext) {
|
||||||
|
if (rowIds.length === 0) {
|
||||||
|
throw badRequest('NOTHING_SELECTED', 'Es ist nichts zum Buchen ausgewählt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.importRow.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: rowIds },
|
||||||
|
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||||
|
import: { status: ImportStatus.PENDING },
|
||||||
|
},
|
||||||
|
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
|
||||||
|
});
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = await prisma.$transaction(async (tx) => {
|
||||||
|
let booked = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const erledigt: number[] = [];
|
||||||
|
const externalIds: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.resolvedPlayerId === null) {
|
||||||
|
// Hier wird gezielt für einen bekannten Spieler gebucht; einen neuen
|
||||||
|
// anzulegen ist Sache der Import-Vorschau.
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.externalId) {
|
||||||
|
const bereitsGebucht = await tx.importedOrder.findUnique({
|
||||||
|
where: { externalId: row.externalId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (bereitsGebucht) {
|
||||||
|
skipped += 1;
|
||||||
|
erledigt.push(row.id);
|
||||||
|
externalIds.push(row.externalId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
|
||||||
|
const groupId = randomUUID();
|
||||||
|
|
||||||
|
await tx.creditLedger.createMany({
|
||||||
|
data: buildPackageRows(target, {
|
||||||
|
playerId: row.resolvedPlayerId,
|
||||||
|
groupId,
|
||||||
|
note: row.productName ? `Import: ${row.productName}` : `Import #${row.importId}`,
|
||||||
|
importId: row.importId,
|
||||||
|
actorId: ctx.actorId,
|
||||||
|
occurredAt: row.purchasedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.externalId) {
|
||||||
|
await tx.importedOrder.create({
|
||||||
|
data: {
|
||||||
|
externalId: row.externalId,
|
||||||
|
importId: row.importId,
|
||||||
|
playerId: row.resolvedPlayerId,
|
||||||
|
target: row.target ?? ProductTarget.CC_PACKAGE,
|
||||||
|
groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
externalIds.push(row.externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
erledigt.push(row.id);
|
||||||
|
booked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die gebuchten Zeilen selbst und jede andere offene Zeile mit derselben
|
||||||
|
// Bestellung abhaken, damit nichts ein zweites Mal gebucht wird.
|
||||||
|
if (erledigt.length > 0) {
|
||||||
|
await tx.importRow.updateMany({
|
||||||
|
where: { id: { in: erledigt } },
|
||||||
|
data: { plan: PLANS.SKIP_DUPLICATE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (externalIds.length > 0) {
|
||||||
|
await tx.importRow.updateMany({
|
||||||
|
where: {
|
||||||
|
externalId: { in: externalIds },
|
||||||
|
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||||
|
},
|
||||||
|
data: { plan: PLANS.SKIP_DUPLICATE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'import.rows_booked',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: rows[0]?.resolvedPlayerId ?? null,
|
||||||
|
details: { booked, skipped, rowIds: erledigt },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { booked, skipped };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bleibt von einer Vorschau nichts übrig, ist sie erledigt.
|
||||||
|
await schliesseLeere();
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt offene Zeilen aus der Vorschau, ohne sie zu buchen. Für Pakete, die
|
||||||
|
* hier nichts verloren haben - ein Kauf, der schon von Hand eingetragen war.
|
||||||
|
*/
|
||||||
|
export async function dismissRows(rowIds: number[], ctx: ActionContext) {
|
||||||
|
if (rowIds.length === 0) {
|
||||||
|
throw badRequest('NOTHING_SELECTED', 'Es ist nichts ausgewählt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.importRow.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: rowIds },
|
||||||
|
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
|
||||||
|
import: { status: ImportStatus.PENDING },
|
||||||
|
},
|
||||||
|
data: { plan: PLANS.SKIP_IGNORED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'import.rows_dismissed',
|
||||||
|
targetType: 'import_row',
|
||||||
|
targetId: null,
|
||||||
|
details: { count: result.count, rowIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
await schliesseLeere();
|
||||||
|
return { dismissed: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
|
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
|
||||||
const record = await prisma.import.findUnique({ where: { id: importId } });
|
const record = await prisma.import.findUnique({ where: { id: importId } });
|
||||||
if (!record) throw notFound('Import');
|
if (!record) throw notFound('Import');
|
||||||
@@ -417,6 +724,9 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
|
|||||||
data: { status: ImportStatus.DISCARDED },
|
data: { status: ImportStatus.DISCARDED },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ein verworfener Import braucht die Rohdaten erst recht nicht mehr.
|
||||||
|
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
|
||||||
|
|
||||||
await recordAudit(prisma, ctx, {
|
await recordAudit(prisma, ctx, {
|
||||||
action: 'import.discard',
|
action: 'import.discard',
|
||||||
targetType: 'import',
|
targetType: 'import',
|
||||||
@@ -425,7 +735,33 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst Importe ab, an denen nichts mehr zu tun ist.
|
||||||
|
*
|
||||||
|
* Wer die Zeilen eines Spielers direkt auf dessen Seite bucht, lässt eine
|
||||||
|
* leere Vorschau zurück. Sie stünde weiter in der Liste, liesse sich nicht
|
||||||
|
* bestätigen ("nichts zu buchen") und wäre nur im Weg. Also gilt sie als
|
||||||
|
* erledigt - sie ist es ja.
|
||||||
|
*/
|
||||||
|
async function schliesseLeere(): Promise<number> {
|
||||||
|
const leer = await prisma.import.findMany({
|
||||||
|
where: {
|
||||||
|
status: ImportStatus.PENDING,
|
||||||
|
rows: { none: { plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] } } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (leer.length === 0) return 0;
|
||||||
|
|
||||||
|
const result = await prisma.import.updateMany({
|
||||||
|
where: { id: { in: leer.map((i) => i.id) } },
|
||||||
|
data: { status: ImportStatus.COMMITTED, committedAt: new Date() },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listImports(take = 50) {
|
export async function listImports(take = 50) {
|
||||||
|
await schliesseLeere();
|
||||||
return prisma.import.findMany({
|
return prisma.import.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take,
|
take,
|
||||||
|
|||||||
+367
-18
@@ -4,31 +4,103 @@ import { conflict, notFound } from '../errors.js';
|
|||||||
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
||||||
import { listCharacters } from './characters.js';
|
import { listCharacters } from './characters.js';
|
||||||
import { recordAudit, type ActionContext } from './audit.js';
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
import { AliasType } from '@prisma/client';
|
||||||
|
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
||||||
|
|
||||||
export interface PlayerInput {
|
export interface PlayerInput {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
steamId?: string | null;
|
steamId?: string | null;
|
||||||
forumName?: string | null;
|
forumName?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
/// Anlegen, obwohl etwas nicht stimmt - siehe markProblem().
|
||||||
|
isProblem?: boolean;
|
||||||
|
problemNote?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchPlayers(query: string, take = 50, skip = 0) {
|
/// Womit die Liste eingegrenzt wird.
|
||||||
|
export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none';
|
||||||
|
/// Wonach sie sortiert wird.
|
||||||
|
export type PlayerSort = 'name' | 'characters' | 'newest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spieler, die an einem CC mit mehr als einem Nutzer beteiligt sind.
|
||||||
|
*
|
||||||
|
* Das lässt sich nicht in einer Bedingung ausdrücken, weil dabei ein CC mit
|
||||||
|
* sich selbst verglichen würde: erst stehen die Gruppen-CCs fest, dann ihre
|
||||||
|
* Nutzer.
|
||||||
|
*/
|
||||||
|
async function spielerMitGruppenCC(): Promise<number[]> {
|
||||||
|
const gruppen = await prisma.characterMember.groupBy({
|
||||||
|
by: ['characterId'],
|
||||||
|
where: { leftAt: null },
|
||||||
|
having: { characterId: { _count: { gt: 1 } } },
|
||||||
|
});
|
||||||
|
if (gruppen.length === 0) return [];
|
||||||
|
|
||||||
|
const beteiligte = await prisma.characterMember.findMany({
|
||||||
|
where: { characterId: { in: gruppen.map((g) => g.characterId) }, leftAt: null },
|
||||||
|
select: { playerId: true },
|
||||||
|
});
|
||||||
|
return [...new Set(beteiligte.map((b) => b.playerId))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlayers(
|
||||||
|
query: string,
|
||||||
|
take = 50,
|
||||||
|
skip = 0,
|
||||||
|
filter: PlayerFilter = 'all',
|
||||||
|
sort: PlayerSort = 'name',
|
||||||
|
) {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
const where: Prisma.PlayerWhereInput =
|
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
|
||||||
trimmed.length === 0
|
// 64-Bit-Form gespeichert ist.
|
||||||
|
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
|
||||||
|
|
||||||
|
// Massgeblich ist die Mitgliedschaft, nicht wer den CC angelegt hat: wer
|
||||||
|
// einem Gruppen-CC angehört, hat dort einen Platz, auch ohne ihn erstellt
|
||||||
|
// zu haben.
|
||||||
|
const eingrenzung: Prisma.PlayerWhereInput =
|
||||||
|
filter === 'problems'
|
||||||
|
? { isProblem: true }
|
||||||
|
: filter === 'group'
|
||||||
|
? { id: { in: await spielerMitGruppenCC() } }
|
||||||
|
: filter === 'active'
|
||||||
|
? { memberships: { some: { leftAt: null, character: { status: 'ACTIVE' } } } }
|
||||||
|
: filter === 'onlyInactive'
|
||||||
|
? {
|
||||||
|
AND: [
|
||||||
|
{ memberships: { some: {} } },
|
||||||
|
{ memberships: { none: { leftAt: null, character: { status: 'ACTIVE' } } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: filter === 'none'
|
||||||
|
? { memberships: { none: {} } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const where: Prisma.PlayerWhereInput = {
|
||||||
|
...eingrenzung,
|
||||||
|
...(trimmed.length === 0
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
OR: [
|
OR: [
|
||||||
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
||||||
{ steamId: { contains: trimmed, mode: 'insensitive' } },
|
{ steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } },
|
||||||
{ forumName: { contains: trimmed, mode: 'insensitive' } },
|
{ forumName: { contains: trimmed, mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const orderBy: Prisma.PlayerOrderByWithRelationInput[] =
|
||||||
|
sort === 'characters'
|
||||||
|
? [{ characters: { _count: 'desc' } }, { displayName: 'asc' }]
|
||||||
|
: sort === 'newest'
|
||||||
|
? [{ createdAt: 'desc' }]
|
||||||
|
: [{ displayName: 'asc' }];
|
||||||
|
|
||||||
const [players, total] = await Promise.all([
|
const [players, total] = await Promise.all([
|
||||||
prisma.player.findMany({
|
prisma.player.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { displayName: 'asc' },
|
orderBy,
|
||||||
take,
|
take,
|
||||||
skip,
|
skip,
|
||||||
include: { _count: { select: { characters: true } } },
|
include: { _count: { select: { characters: true } } },
|
||||||
@@ -51,6 +123,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
|
|||||||
displayName: player.displayName,
|
displayName: player.displayName,
|
||||||
steamId: player.steamId,
|
steamId: player.steamId,
|
||||||
forumName: player.forumName,
|
forumName: player.forumName,
|
||||||
|
isProblem: player.isProblem,
|
||||||
|
problemNote: player.problemNote,
|
||||||
characterCount: player._count.characters,
|
characterCount: player._count.characters,
|
||||||
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
|
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
|
||||||
balances: balances.get(player.id) as Balances,
|
balances: balances.get(player.id) as Balances,
|
||||||
@@ -62,33 +136,125 @@ export async function getPlayerDetail(playerId: number) {
|
|||||||
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
if (!player) throw notFound('Spieler');
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
const [balances, characters, ledger] = await Promise.all([
|
// Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an
|
||||||
|
// einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln.
|
||||||
|
const { listThreads } = await import('./forum.js');
|
||||||
|
const { listPendingRowsFor } = await import('./imports.js');
|
||||||
|
|
||||||
|
const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] =
|
||||||
|
await Promise.all([
|
||||||
getBalances(playerId),
|
getBalances(playerId),
|
||||||
listCharacters(playerId),
|
listCharacters(playerId),
|
||||||
listLedger({ playerId, take: 200 }),
|
listLedger({ playerId, take: 200 }),
|
||||||
|
prisma.unmatchedPackage.findMany({
|
||||||
|
where: { playerId, resolved: false },
|
||||||
|
orderBy: { scrapedAt: 'desc' },
|
||||||
|
include: { player: { select: { id: true, displayName: true } } },
|
||||||
|
}),
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ type: 'asc' }, { value: 'asc' }],
|
||||||
|
}),
|
||||||
|
// Ältester zuerst: Anträge werden der Reihe nach abgearbeitet.
|
||||||
|
listThreads({ playerId, order: 'oldest' }),
|
||||||
|
listPendingRowsFor(playerId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { player, balances, characters, ledger: ledger.items, ledgerTotal: ledger.total };
|
return {
|
||||||
|
player,
|
||||||
|
balances,
|
||||||
|
characters,
|
||||||
|
ledger: ledger.items,
|
||||||
|
ledgerTotal: ledger.total,
|
||||||
|
unmatched,
|
||||||
|
aliases,
|
||||||
|
threads,
|
||||||
|
pendingRows,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
|
/**
|
||||||
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
|
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
||||||
if (existing && existing.id !== exceptId) {
|
* Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||||
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
|
* andere die 64-Bit-Zahl.
|
||||||
|
*/
|
||||||
|
function canonicalSteamId(input: string | null | undefined): string | null {
|
||||||
|
const value = input?.trim();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const normalized = normalizeSteamId(value);
|
||||||
|
if (!normalized) {
|
||||||
|
throw conflict(
|
||||||
|
'INVALID_STEAMID',
|
||||||
|
`"${value}" sieht nach keiner SteamID aus. Erlaubt sind STEAM_0:1:..., [U:1:...] oder die 17-stellige Zahl.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer diese SteamID schon führt - als Hauptangabe oder als früheres Konto.
|
||||||
|
*
|
||||||
|
* Beides muss geprüft werden: Eine Kennung, die bei einem Spieler als
|
||||||
|
* früheres Konto hängt, gehört ihm genauso. Stünde sie daneben bei einem
|
||||||
|
* anderen als Hauptangabe, wüsste weder der Abgleich noch der Import, wem
|
||||||
|
* ein Kauf gehört.
|
||||||
|
*/
|
||||||
|
async function findeSteamIdInhaber(
|
||||||
|
steamId: string,
|
||||||
|
exceptId?: number,
|
||||||
|
): Promise<{ id: number; displayName: string; alsAlias: boolean } | null> {
|
||||||
|
const haupt = await prisma.player.findUnique({
|
||||||
|
where: { steamId },
|
||||||
|
select: { id: true, displayName: true },
|
||||||
|
});
|
||||||
|
if (haupt && haupt.id !== exceptId) return { ...haupt, alsAlias: false };
|
||||||
|
|
||||||
|
const alias = await prisma.playerAlias.findUnique({
|
||||||
|
where: { type_value: { type: AliasType.STEAM_ID, value: steamId } },
|
||||||
|
select: { playerId: true, player: { select: { displayName: true } } },
|
||||||
|
});
|
||||||
|
if (alias && alias.playerId !== exceptId) {
|
||||||
|
return { id: alias.playerId, displayName: alias.player.displayName, alsAlias: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass die Kennung frei ist - und sagt sonst, bei wem sie
|
||||||
|
* liegt. Ohne den Namen sucht man von Hand durch den ganzen Bestand.
|
||||||
|
*/
|
||||||
|
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
|
||||||
|
const inhaber = await findeSteamIdInhaber(steamId, exceptId);
|
||||||
|
if (!inhaber) return;
|
||||||
|
|
||||||
|
throw conflict(
|
||||||
|
'STEAMID_TAKEN',
|
||||||
|
inhaber.alsAlias
|
||||||
|
? `Diese SteamID ist bei "${inhaber.displayName}" als früheres Konto eingetragen`
|
||||||
|
: `Diese SteamID gehört bereits zu "${inhaber.displayName}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||||
const steamId = input.steamId?.trim() || null;
|
const steamId = canonicalSteamId(input.steamId);
|
||||||
if (steamId) await assertSteamIdFree(steamId);
|
if (steamId) await assertSteamIdFree(steamId);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.player.create({
|
const created = await tx.player.create({
|
||||||
data: {
|
data: {
|
||||||
displayName: input.displayName.trim(),
|
displayName: input.displayName.trim(),
|
||||||
steamId,
|
steamId,
|
||||||
forumName: input.forumName?.trim() || null,
|
forumName: input.forumName?.trim() || null,
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
|
...(input.isProblem
|
||||||
|
? {
|
||||||
|
isProblem: true,
|
||||||
|
problemNote: input.problemNote?.trim() || null,
|
||||||
|
problemSince: new Date(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,21 +262,34 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
|||||||
action: 'player.create',
|
action: 'player.create',
|
||||||
targetType: 'player',
|
targetType: 'player',
|
||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
details: { displayName: created.displayName, steamId: created.steamId },
|
details: {
|
||||||
|
displayName: created.displayName,
|
||||||
|
steamId: created.steamId,
|
||||||
|
isProblem: created.isProblem,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wartende Forums-Anträge unter diesem Namen gleich mit übernehmen, sonst
|
||||||
|
// blieben sie bis zum nächsten vollen Abgleich in der Warteliste liegen.
|
||||||
|
if (created.forumName) {
|
||||||
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
||||||
|
await claimThreadsForPlayer(created.id, [created.forumName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
|
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
|
||||||
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
if (!existing) throw notFound('Spieler');
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
const steamId = input.steamId === undefined ? undefined : input.steamId?.trim() || null;
|
const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
|
||||||
if (steamId) await assertSteamIdFree(steamId, playerId);
|
if (steamId) await assertSteamIdFree(steamId, playerId);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const updated = await tx.player.update({
|
const updated = await tx.player.update({
|
||||||
where: { id: playerId },
|
where: { id: playerId },
|
||||||
data: {
|
data: {
|
||||||
@@ -130,8 +309,21 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updated.forumName && updated.forumName !== existing.forumName) {
|
||||||
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
||||||
|
await claimThreadsForPlayer(playerId, [updated.forumName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
|
||||||
|
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
|
||||||
|
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
|
||||||
|
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||||
|
*/
|
||||||
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||||
const existing = await prisma.player.findUnique({
|
const existing = await prisma.player.findUnique({
|
||||||
where: { id: playerId },
|
where: { id: playerId },
|
||||||
@@ -140,18 +332,175 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
|||||||
if (!existing) throw notFound('Spieler');
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Rohzeilen der Importe verlieren ihren Personenbezug.
|
||||||
|
await tx.importRow.updateMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ resolvedPlayerId: playerId },
|
||||||
|
...(existing.steamId ? [{ playerKey: existing.steamId }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
playerKey: null,
|
||||||
|
playerName: null,
|
||||||
|
resolvedPlayerId: null,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||||
|
await tx.auditLog.updateMany({
|
||||||
|
where: { targetType: 'player', targetId: String(playerId) },
|
||||||
|
data: { details: { redacted: true } },
|
||||||
|
});
|
||||||
|
|
||||||
await tx.player.delete({ where: { id: playerId } });
|
await tx.player.delete({ where: { id: playerId } });
|
||||||
|
|
||||||
await recordAudit(tx, ctx, {
|
await recordAudit(tx, ctx, {
|
||||||
action: 'player.delete',
|
action: 'player.delete',
|
||||||
targetType: 'player',
|
targetType: 'player',
|
||||||
targetId: playerId,
|
targetId: playerId,
|
||||||
|
// Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen,
|
||||||
|
// nicht die gelöschten Daten konservieren.
|
||||||
details: {
|
details: {
|
||||||
displayName: existing.displayName,
|
|
||||||
steamId: existing.steamId,
|
|
||||||
removedCharacters: existing._count.characters,
|
removedCharacters: existing._count.characters,
|
||||||
removedLedgerRows: existing._count.ledger,
|
removedLedgerRows: existing._count.ledger,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe
|
||||||
|
* Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Markiert einen Spieler als Problemfall - oder hebt die Markierung auf.
|
||||||
|
*
|
||||||
|
* Gedacht für alles, was sich im Moment nicht sauber klären lässt: ein Antrag
|
||||||
|
* ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
|
||||||
|
* Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht verloren
|
||||||
|
* geht. Geklärt wird, wenn es darauf ankommt - kommt der Spieler nie wieder,
|
||||||
|
* kommt es nie darauf an.
|
||||||
|
*
|
||||||
|
* Die Markierung hat bewusst **keine** Wirkung auf Guthaben oder CCs. Sie ist
|
||||||
|
* eine Notiz mit Griff, kein Sperrvermerk.
|
||||||
|
*/
|
||||||
|
export async function markProblem(
|
||||||
|
playerId: number,
|
||||||
|
input: { isProblem: boolean; note?: string | null },
|
||||||
|
ctx: ActionContext,
|
||||||
|
) {
|
||||||
|
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
|
if (!existing) throw notFound('Spieler');
|
||||||
|
|
||||||
|
const updated = await prisma.player.update({
|
||||||
|
where: { id: playerId },
|
||||||
|
data: input.isProblem
|
||||||
|
? {
|
||||||
|
isProblem: true,
|
||||||
|
problemNote: input.note?.trim() || null,
|
||||||
|
// Ein bereits offener Fall behält seinen Anfang.
|
||||||
|
problemSince: existing.problemSince ?? new Date(),
|
||||||
|
}
|
||||||
|
: { isProblem: false, problemNote: null, problemSince: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: input.isProblem ? 'player.problem_marked' : 'player.problem_cleared',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { displayName: updated.displayName, note: input.note?.trim() || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAlias(
|
||||||
|
playerId: number,
|
||||||
|
type: AliasType,
|
||||||
|
rawValue: string,
|
||||||
|
note: string | undefined,
|
||||||
|
ctx: ActionContext,
|
||||||
|
) {
|
||||||
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||||
|
if (!player) throw notFound('Spieler');
|
||||||
|
|
||||||
|
let value = rawValue.trim();
|
||||||
|
if (type === AliasType.STEAM_ID) {
|
||||||
|
const normalized = normalizeSteamId(value);
|
||||||
|
if (!normalized) {
|
||||||
|
throw conflict('INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus`);
|
||||||
|
}
|
||||||
|
value = normalized;
|
||||||
|
|
||||||
|
if (value === player.steamId) {
|
||||||
|
throw conflict('IS_PRIMARY', 'Das ist bereits die hinterlegte SteamID dieses Spielers');
|
||||||
|
}
|
||||||
|
} else if (value.toLowerCase() === player.forumName?.toLowerCase()) {
|
||||||
|
throw conflict('IS_PRIMARY', 'Das ist bereits der hinterlegte Forum-Name dieses Spielers');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length === 0) throw conflict('EMPTY', 'Der Wert darf nicht leer sein');
|
||||||
|
|
||||||
|
const vorhanden = await prisma.playerAlias.findUnique({
|
||||||
|
where: { type_value: { type, value } },
|
||||||
|
include: { player: { select: { displayName: true } } },
|
||||||
|
});
|
||||||
|
if (vorhanden) {
|
||||||
|
throw conflict(
|
||||||
|
'ALIAS_TAKEN',
|
||||||
|
vorhanden.playerId === playerId
|
||||||
|
? 'Dieses Konto ist hier schon eingetragen'
|
||||||
|
: `Dieses Konto gehört bereits zu "${vorhanden.player.displayName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch die Hauptangaben der anderen: dieselbe Kennung kann nicht bei einem
|
||||||
|
// als früheres Konto und beim nächsten als aktuelles stehen.
|
||||||
|
if (type === AliasType.STEAM_ID) {
|
||||||
|
const fremd = await prisma.player.findUnique({
|
||||||
|
where: { steamId: value },
|
||||||
|
select: { id: true, displayName: true },
|
||||||
|
});
|
||||||
|
if (fremd && fremd.id !== playerId) {
|
||||||
|
throw conflict(
|
||||||
|
'ALIAS_TAKEN',
|
||||||
|
`Diese SteamID ist die aktuelle Kennung von "${fremd.displayName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.playerAlias.create({
|
||||||
|
data: { playerId, type, value, note: note?.trim() || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'player.alias_added',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { type, value, note: note ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (type === AliasType.FORUM_NAME) {
|
||||||
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
||||||
|
await claimThreadsForPlayer(playerId, [value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAlias(aliasId: number, ctx: ActionContext): Promise<void> {
|
||||||
|
const alias = await prisma.playerAlias.findUnique({ where: { id: aliasId } });
|
||||||
|
if (!alias) throw notFound('Eintrag');
|
||||||
|
|
||||||
|
await prisma.playerAlias.delete({ where: { id: aliasId } });
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'player.alias_removed',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: alias.playerId,
|
||||||
|
details: { type: alias.type, value: alias.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { CreditKind } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { badRequest } from '../errors.js';
|
||||||
|
import { knownSteamIds, scrapePlayers } from './scraper.js';
|
||||||
|
import { scanForum } from './forum.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederkehrende Läufe.
|
||||||
|
*
|
||||||
|
* Beide Abgleiche greifen auf fremde Server zu, deshalb laufen sie selten und
|
||||||
|
* gestaffelt: die Käufe mehrmals täglich, weil dort ständig etwas dazukommt;
|
||||||
|
* das Forum einmal nachts, weil ein Antrag keine Stunde alt sein muss, um
|
||||||
|
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
|
||||||
|
* Seiten liest.
|
||||||
|
*
|
||||||
|
* Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt
|
||||||
|
* sich beides im Betrieb ändern, ohne den Dienst neu zu starten. Der Stand
|
||||||
|
* der Läufe steht ebenfalls dort, damit ein Neustart nicht alles erneut
|
||||||
|
* anstösst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
|
||||||
|
const TICK_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const MINUTE_MS = 60 * 1000;
|
||||||
|
const TAG_MS = 24 * 60 * MINUTE_MS;
|
||||||
|
|
||||||
|
/// Die beiden Läufe, die es gibt.
|
||||||
|
export const LAEUFE = ['gextension', 'forum'] as const;
|
||||||
|
export type LaufName = (typeof LAEUFE)[number];
|
||||||
|
|
||||||
|
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
|
||||||
|
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
|
||||||
|
|
||||||
|
/// Fällt ein Eintrag, greifen diese Werte - dieselben, mit denen der
|
||||||
|
/// Zeitplan ursprünglich fest verdrahtet war.
|
||||||
|
const VORGABEN: Record<LaufName, ScheduleSettingInput> = {
|
||||||
|
gextension: {
|
||||||
|
enabled: true,
|
||||||
|
intervalMinutes: 120,
|
||||||
|
idleIntervalMinutes: 360,
|
||||||
|
idleAfterDays: 1095,
|
||||||
|
onlyAfterHour: null,
|
||||||
|
},
|
||||||
|
forum: {
|
||||||
|
enabled: true,
|
||||||
|
intervalMinutes: 1440,
|
||||||
|
idleIntervalMinutes: null,
|
||||||
|
idleAfterDays: null,
|
||||||
|
onlyAfterHour: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ScheduleSettingInput {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalMinutes: number;
|
||||||
|
idleIntervalMinutes: number | null;
|
||||||
|
idleAfterDays: number | null;
|
||||||
|
onlyAfterHour: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunState {
|
||||||
|
name: string;
|
||||||
|
lastRunAt: Date | null;
|
||||||
|
lastStatus: string | null;
|
||||||
|
lastNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(name: LaufName): Promise<ScheduleSettingInput> {
|
||||||
|
const eintrag = await prisma.scheduleSetting.findUnique({ where: { name } });
|
||||||
|
if (!eintrag) return VORGABEN[name];
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: eintrag.enabled,
|
||||||
|
intervalMinutes: eintrag.intervalMinutes,
|
||||||
|
idleIntervalMinutes: eintrag.idleIntervalMinutes,
|
||||||
|
idleAfterDays: eintrag.idleAfterDays,
|
||||||
|
onlyAfterHour: eintrag.onlyAfterHour,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSettings(): Promise<(ScheduleSettingInput & { name: LaufName })[]> {
|
||||||
|
return Promise.all(
|
||||||
|
LAEUFE.map(async (name) => ({ name, ...(await getSetting(name)) })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ändert die Einstellungen eines Laufs.
|
||||||
|
*
|
||||||
|
* Die Grenzen sind bewusst eng: unter einer Viertelstunde wäre der Abgleich
|
||||||
|
* eine Belästigung für einen fremden Server, über einer Woche liefe er
|
||||||
|
* praktisch nicht mehr.
|
||||||
|
*/
|
||||||
|
export async function updateSetting(
|
||||||
|
name: LaufName,
|
||||||
|
input: Partial<ScheduleSettingInput>,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<ScheduleSettingInput> {
|
||||||
|
const vorher = await getSetting(name);
|
||||||
|
|
||||||
|
const pruefe = (wert: number | null | undefined, feld: string): number | null | undefined => {
|
||||||
|
if (wert === undefined || wert === null) return wert;
|
||||||
|
if (!Number.isInteger(wert) || wert < 15 || wert > 10080) {
|
||||||
|
throw badRequest(
|
||||||
|
'INVALID_INTERVAL',
|
||||||
|
`${feld}: zwischen 15 Minuten und einer Woche (10080 Minuten)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wert;
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalMinutes = pruefe(input.intervalMinutes, 'Abstand') ?? vorher.intervalMinutes;
|
||||||
|
const idleIntervalMinutes =
|
||||||
|
input.idleIntervalMinutes === undefined
|
||||||
|
? vorher.idleIntervalMinutes
|
||||||
|
: (pruefe(input.idleIntervalMinutes, 'Abstand für ruhende Konten') ?? null);
|
||||||
|
|
||||||
|
if (input.idleAfterDays !== undefined && input.idleAfterDays !== null) {
|
||||||
|
if (!Number.isInteger(input.idleAfterDays) || input.idleAfterDays < 1) {
|
||||||
|
throw badRequest('INVALID_DAYS', 'Ruhend ab: mindestens ein Tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.onlyAfterHour !== undefined && input.onlyAfterHour !== null) {
|
||||||
|
if (!Number.isInteger(input.onlyAfterHour) || input.onlyAfterHour < 0 || input.onlyAfterHour > 23) {
|
||||||
|
throw badRequest('INVALID_HOUR', 'Frühestens ab: eine Stunde zwischen 0 und 23');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const daten = {
|
||||||
|
enabled: input.enabled ?? vorher.enabled,
|
||||||
|
intervalMinutes,
|
||||||
|
idleIntervalMinutes,
|
||||||
|
idleAfterDays: input.idleAfterDays === undefined ? vorher.idleAfterDays : input.idleAfterDays,
|
||||||
|
onlyAfterHour: input.onlyAfterHour === undefined ? vorher.onlyAfterHour : input.onlyAfterHour,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.scheduleSetting.upsert({
|
||||||
|
where: { name },
|
||||||
|
create: { name, ...daten },
|
||||||
|
update: daten,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'schedule.updated',
|
||||||
|
targetType: 'schedule',
|
||||||
|
targetId: null,
|
||||||
|
details: { name, ...daten },
|
||||||
|
});
|
||||||
|
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function letzterLauf(name: string): Promise<Date | null> {
|
||||||
|
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
|
||||||
|
return eintrag?.lastRunAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufNotieren(name: string, status: string, note: string): Promise<void> {
|
||||||
|
const daten = { lastRunAt: new Date(), lastStatus: status, lastNote: note.slice(0, 500) };
|
||||||
|
await prisma.scheduledRun.upsert({
|
||||||
|
where: { name },
|
||||||
|
create: { name, ...daten },
|
||||||
|
update: daten,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRuns(): Promise<RunState[]> {
|
||||||
|
const eintraege = await prisma.scheduledRun.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
return eintraege.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
lastRunAt: e.lastRunAt,
|
||||||
|
lastStatus: e.lastStatus,
|
||||||
|
lastNote: e.lastNote,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Spieler, die jetzt an der Reihe sind.
|
||||||
|
*
|
||||||
|
* Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen
|
||||||
|
* anderen reicht der seltenere - wer seit Jahren nichts kauft, fängt selten
|
||||||
|
* von einer Stunde auf die andere damit an, und es sind mit Abstand die
|
||||||
|
* meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt der Eingabe
|
||||||
|
* hier.
|
||||||
|
*/
|
||||||
|
export async function faelligeSpieler(
|
||||||
|
jetzt = new Date(),
|
||||||
|
einstellung?: ScheduleSettingInput,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const regel = einstellung ?? (await getSetting('gextension'));
|
||||||
|
const aktiverTakt = regel.intervalMinutes * MINUTE_MS;
|
||||||
|
const ruhenderTakt = (regel.idleIntervalMinutes ?? regel.intervalMinutes) * MINUTE_MS;
|
||||||
|
const ruhendAb = (regel.idleAfterDays ?? 0) * TAG_MS;
|
||||||
|
|
||||||
|
const [spieler, letzteKaeufe] = await Promise.all([
|
||||||
|
prisma.player.findMany({
|
||||||
|
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
|
||||||
|
select: { id: true, gextensionSyncedAt: true },
|
||||||
|
}),
|
||||||
|
prisma.creditLedger.groupBy({
|
||||||
|
by: ['playerId'],
|
||||||
|
where: { kind: CreditKind.CC, amount: { gt: 0 } },
|
||||||
|
_max: { occurredAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kaufNach = new Map(letzteKaeufe.map((k) => [k.playerId, k._max.occurredAt]));
|
||||||
|
|
||||||
|
return spieler
|
||||||
|
.filter((p) => {
|
||||||
|
const letzterKauf = kaufNach.get(p.id) ?? null;
|
||||||
|
const ruhend =
|
||||||
|
ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb);
|
||||||
|
const abstand = ruhend ? ruhenderTakt : aktiverTakt;
|
||||||
|
|
||||||
|
if (p.gextensionSyncedAt === null) return true;
|
||||||
|
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
|
||||||
|
})
|
||||||
|
.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob ein Lauf jetzt ansteht.
|
||||||
|
*
|
||||||
|
* Ohne Stundenfenster genügt der Abstand zum letzten Lauf. Mit Fenster wird
|
||||||
|
* zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein
|
||||||
|
* täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem
|
||||||
|
* Neustart weiter in den Tag wandern zu lassen.
|
||||||
|
*/
|
||||||
|
export function istFaellig(
|
||||||
|
letzter: Date | null,
|
||||||
|
jetzt: Date,
|
||||||
|
regel: Pick<ScheduleSettingInput, 'intervalMinutes' | 'onlyAfterHour'>,
|
||||||
|
): boolean {
|
||||||
|
if (letzter !== null) {
|
||||||
|
const vergangen = jetzt.getTime() - letzter.getTime();
|
||||||
|
if (vergangen < regel.intervalMinutes * MINUTE_MS) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
|
||||||
|
if (jetzt.getHours() < regel.onlyAfterHour) return false;
|
||||||
|
|
||||||
|
// Innerhalb desselben Tages nicht zweimal, wenn der Takt tageweise läuft.
|
||||||
|
if (letzter !== null && regel.intervalMinutes >= 1440) {
|
||||||
|
const heuteAb = new Date(jetzt);
|
||||||
|
heuteAb.setHours(regel.onlyAfterHour, 0, 0, 0);
|
||||||
|
if (letzter >= heuteAb) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Logger {
|
||||||
|
info: (msg: string) => void;
|
||||||
|
warn: (msg: string) => void;
|
||||||
|
debug: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise<void> {
|
||||||
|
const faellig = await faelligeSpieler(new Date(), regel);
|
||||||
|
if (faellig.length === 0) {
|
||||||
|
log.debug('Zeitplan: keine Spieler fällig');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alle = await knownSteamIds();
|
||||||
|
const ziele = alle.filter((eintrag) => faellig.includes(eintrag.playerId));
|
||||||
|
if (ziele.length === 0) return;
|
||||||
|
|
||||||
|
log.info(`Zeitplan: prüfe ${ziele.length} Konten auf neue Pakete`);
|
||||||
|
const ergebnis = await scrapePlayers(ziele, SYSTEM_CONTEXT);
|
||||||
|
|
||||||
|
const note =
|
||||||
|
`${ergebnis.scannedPlayers} Konten, ${ergebnis.newRows} neue Zeilen, ` +
|
||||||
|
`${ergebnis.unclear} unklar, ${ergebnis.failedPlayers.length} Fehler`;
|
||||||
|
await laufNotieren('gextension', 'OK', note);
|
||||||
|
|
||||||
|
if (ergebnis.newRows > 0) {
|
||||||
|
log.info(`Zeitplan: ${ergebnis.newRows} neue Pakete liegen zur Bestätigung bereit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forumPruefen(log: Logger): Promise<void> {
|
||||||
|
log.info('Zeitplan: lese das Forum');
|
||||||
|
const ergebnis = await scanForum(SYSTEM_CONTEXT);
|
||||||
|
|
||||||
|
const note =
|
||||||
|
`${ergebnis.scannedPages} Seiten, ${ergebnis.newThreads} neu, ` +
|
||||||
|
`${ergebnis.unassignedThreads} ohne Spieler, ${ergebnis.failedBoards.length} Board-Fehler`;
|
||||||
|
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
|
||||||
|
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
|
||||||
|
*/
|
||||||
|
export function startSchedule(log: Logger): () => void {
|
||||||
|
let laeuft = false;
|
||||||
|
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (laeuft) return;
|
||||||
|
laeuft = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
const forumRegel = await getSetting('forum');
|
||||||
|
if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) {
|
||||||
|
await forumPruefen(log);
|
||||||
|
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
|
||||||
|
}
|
||||||
|
|
||||||
|
const kaufRegel = await getSetting('gextension');
|
||||||
|
if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) {
|
||||||
|
await kaeufePruefen(log, kaufRegel);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
|
||||||
|
await laufNotieren('letzter_fehler', 'FAILED', grund).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
laeuft = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der erste Takt kommt verzögert, damit der Start nicht mit einem Abgleich
|
||||||
|
// zusammenfällt - beim Hochfahren ist genug anderes zu tun.
|
||||||
|
const start = setTimeout(() => void tick(), 60_000);
|
||||||
|
const timer = setInterval(() => void tick(), TICK_MS);
|
||||||
|
start.unref();
|
||||||
|
timer.unref();
|
||||||
|
|
||||||
|
log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(start);
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { ImportStatus, type Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import { badRequest, conflict } from '../errors.js';
|
||||||
|
import {
|
||||||
|
classifyPackage,
|
||||||
|
fetchProfile,
|
||||||
|
resetRequestPace,
|
||||||
|
type ScrapedProfile,
|
||||||
|
} from '../lib/gextension.js';
|
||||||
|
import { PLANS } from './imports.js';
|
||||||
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je
|
||||||
|
* Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine
|
||||||
|
* älteren Käufe unter der alten Kennung stehen.
|
||||||
|
*/
|
||||||
|
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
|
||||||
|
const [players, aliases] = await Promise.all([
|
||||||
|
prisma.player.findMany({
|
||||||
|
where: { steamId: { not: null } },
|
||||||
|
select: { id: true, steamId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { type: 'STEAM_ID' },
|
||||||
|
select: { playerId: true, value: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alle = [
|
||||||
|
...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })),
|
||||||
|
...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dieselbe Kennung nicht zweimal abrufen.
|
||||||
|
const gesehen = new Set<string>();
|
||||||
|
return alle.filter((eintrag) => {
|
||||||
|
if (gesehen.has(eintrag.steamId)) return false;
|
||||||
|
gesehen.add(eintrag.steamId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
|
||||||
|
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
|
||||||
|
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
|
||||||
|
*/
|
||||||
|
export async function steamIdsOf(playerId: number): Promise<string[]> {
|
||||||
|
const [player, aliases] = await Promise.all([
|
||||||
|
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
|
||||||
|
prisma.playerAlias.findMany({
|
||||||
|
where: { playerId, type: 'STEAM_ID' },
|
||||||
|
select: { value: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alle = [
|
||||||
|
...(player?.steamId ? [player.steamId] : []),
|
||||||
|
...aliases.map((alias) => alias.value),
|
||||||
|
];
|
||||||
|
return [...new Set(alle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrapeSummary {
|
||||||
|
importId: number | null;
|
||||||
|
scannedPlayers: number;
|
||||||
|
failedPlayers: { steamId: string; reason: string }[];
|
||||||
|
newRows: number;
|
||||||
|
duplicates: number;
|
||||||
|
/// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen.
|
||||||
|
awaitingConfirmation: number;
|
||||||
|
unclear: number;
|
||||||
|
usageCounters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
|
||||||
|
// in die Quere kommen.
|
||||||
|
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
||||||
|
|
||||||
|
export interface ScrapeState {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
/// Wessen Profil gerade gelesen wird.
|
||||||
|
currentPlayer: string | null;
|
||||||
|
playersRead: number;
|
||||||
|
totalPlayers: number;
|
||||||
|
log: string[];
|
||||||
|
summary: ScrapeSummary | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Abgleich über alle Spieler geht über dutzende Profile, zwischen denen
|
||||||
|
* bewusst gewartet wird - das überlebt keine HTTP-Anfrage. Deshalb läuft er
|
||||||
|
* im Hintergrund, und die Oberfläche fragt den Stand ab.
|
||||||
|
*/
|
||||||
|
const scrapeState: ScrapeState = {
|
||||||
|
running: false,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
currentPlayer: null,
|
||||||
|
playersRead: 0,
|
||||||
|
totalPlayers: 0,
|
||||||
|
log: [],
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 200;
|
||||||
|
|
||||||
|
function protokoll(zeile: string): void {
|
||||||
|
scrapeState.log.push(zeile);
|
||||||
|
if (scrapeState.log.length > MAX_LOG_LINES) scrapeState.log.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrapeState(): ScrapeState {
|
||||||
|
return { ...scrapeState };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen Abgleich an und kehrt sofort zurück.
|
||||||
|
*
|
||||||
|
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
|
||||||
|
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scrapePlayers selbst.
|
||||||
|
* Zwei Läufe nebeneinander würden dieselben Profile doppelt abrufen und den
|
||||||
|
* fremden Server ohne Not belasten.
|
||||||
|
*/
|
||||||
|
export function startScrape(
|
||||||
|
targets: { playerId: number; steamId: string }[],
|
||||||
|
ctx: ActionContext,
|
||||||
|
): ScrapeState {
|
||||||
|
if (scrapeState.running) {
|
||||||
|
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
|
||||||
|
}
|
||||||
|
if (targets.length === 0) {
|
||||||
|
throw badRequest('NO_TARGETS', 'Es gibt niemanden abzugleichen');
|
||||||
|
}
|
||||||
|
|
||||||
|
void scrapePlayers(targets, ctx).catch(() => {
|
||||||
|
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
|
||||||
|
});
|
||||||
|
|
||||||
|
return getScrapeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
|
||||||
|
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
|
||||||
|
* automatisch gebucht.
|
||||||
|
*/
|
||||||
|
export async function scrapePlayers(
|
||||||
|
targets: { playerId: number; steamId: string }[],
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<ScrapeSummary> {
|
||||||
|
if (scrapeState.running) {
|
||||||
|
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
|
||||||
|
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
|
||||||
|
scrapeState.running = true;
|
||||||
|
scrapeState.startedAt = new Date().toISOString();
|
||||||
|
scrapeState.finishedAt = null;
|
||||||
|
scrapeState.currentPlayer = null;
|
||||||
|
scrapeState.playersRead = 0;
|
||||||
|
scrapeState.totalPlayers = targets.length;
|
||||||
|
scrapeState.log = [];
|
||||||
|
scrapeState.summary = null;
|
||||||
|
scrapeState.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await scrapeIntern(targets, ctx);
|
||||||
|
} catch (error) {
|
||||||
|
scrapeState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
scrapeState.running = false;
|
||||||
|
scrapeState.currentPlayer = null;
|
||||||
|
scrapeState.finishedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeIntern(
|
||||||
|
targets: { playerId: number; steamId: string }[],
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<ScrapeSummary> {
|
||||||
|
const summary: ScrapeSummary = {
|
||||||
|
importId: null,
|
||||||
|
scannedPlayers: 0,
|
||||||
|
failedPlayers: [],
|
||||||
|
newRows: 0,
|
||||||
|
duplicates: 0,
|
||||||
|
awaitingConfirmation: 0,
|
||||||
|
unclear: 0,
|
||||||
|
usageCounters: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
|
||||||
|
let lineNo = 0;
|
||||||
|
|
||||||
|
// Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst,
|
||||||
|
// wenn GExtension es verlangt.
|
||||||
|
resetRequestPace();
|
||||||
|
|
||||||
|
// Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne
|
||||||
|
// diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren
|
||||||
|
// Import, und beim Buchen kollidieren sie miteinander.
|
||||||
|
const pending = await prisma.importRow.findMany({
|
||||||
|
where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } },
|
||||||
|
select: { externalId: true },
|
||||||
|
});
|
||||||
|
const alreadyPending = new Set(pending.map((row) => row.externalId as string));
|
||||||
|
|
||||||
|
// Die Namen einmal holen, damit das Protokoll lesbar ist statt SteamIDs
|
||||||
|
// aneinanderzureihen.
|
||||||
|
const namen = new Map(
|
||||||
|
(
|
||||||
|
await prisma.player.findMany({
|
||||||
|
where: { id: { in: [...new Set(targets.map((t) => t.playerId))] } },
|
||||||
|
select: { id: true, displayName: true },
|
||||||
|
})
|
||||||
|
).map((p) => [p.id, p.displayName]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const name = namen.get(target.playerId) ?? target.steamId;
|
||||||
|
scrapeState.currentPlayer = name;
|
||||||
|
|
||||||
|
let profile: ScrapedProfile;
|
||||||
|
try {
|
||||||
|
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
|
||||||
|
summary.scannedPlayers += 1;
|
||||||
|
} catch (error) {
|
||||||
|
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||||
|
summary.failedPlayers.push({ steamId: target.steamId, reason: grund });
|
||||||
|
scrapeState.playersRead += 1;
|
||||||
|
protokoll(`${name}: nicht abrufbar (${grund})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pkg of profile.packages) {
|
||||||
|
const classification = classifyPackage(pkg);
|
||||||
|
const externalId = externalIdFor(pkg.packageBoughtId);
|
||||||
|
|
||||||
|
if (classification.kind === 'usage_counter') {
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: target.playerId },
|
||||||
|
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
summary.usageCounters += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.kind === 'unclear') {
|
||||||
|
await prisma.unmatchedPackage.upsert({
|
||||||
|
where: { externalId },
|
||||||
|
create: {
|
||||||
|
externalId,
|
||||||
|
playerId: target.playerId,
|
||||||
|
steamId: profile.steamId64,
|
||||||
|
rawTitle: pkg.packageName,
|
||||||
|
rawTag: pkg.serverBundle,
|
||||||
|
amount: pkg.amount || null,
|
||||||
|
purchasedAt: pkg.purchasedAt,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
summary.unclear += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification.kind === 'ignore') continue;
|
||||||
|
|
||||||
|
const alreadyBooked = await prisma.importedOrder.findUnique({
|
||||||
|
where: { externalId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (alreadyBooked) {
|
||||||
|
summary.duplicates += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyPending.has(externalId)) {
|
||||||
|
summary.awaitingConfirmation += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
alreadyPending.add(externalId);
|
||||||
|
|
||||||
|
lineNo += 1;
|
||||||
|
rows.push({
|
||||||
|
lineNo,
|
||||||
|
externalId,
|
||||||
|
playerKey: profile.steamId64,
|
||||||
|
playerName: profile.nickname,
|
||||||
|
productName: pkg.packageName,
|
||||||
|
purchasedAt: pkg.purchasedAt,
|
||||||
|
target: classification.target,
|
||||||
|
resolvedPlayerId: target.playerId,
|
||||||
|
plan: PLANS.BOOK,
|
||||||
|
raw: {
|
||||||
|
package_name: pkg.packageName,
|
||||||
|
serverbundle_name: pkg.serverBundle,
|
||||||
|
amount: pkg.amount,
|
||||||
|
gateway: pkg.gateway,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
});
|
||||||
|
summary.newRows += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: target.playerId },
|
||||||
|
data: { gextensionSyncedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
scrapeState.playersRead += 1;
|
||||||
|
protokoll(
|
||||||
|
`${name}: ${profile.packages.length} ${
|
||||||
|
profile.packages.length === 1 ? 'Eintrag' : 'Einträge'
|
||||||
|
} gelesen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeState.currentPlayer = null;
|
||||||
|
protokoll(
|
||||||
|
summary.newRows > 0
|
||||||
|
? `${summary.newRows} neue Pakete warten auf die Bestätigung`
|
||||||
|
: 'Keine neuen Pakete',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const created = await prisma.import.create({
|
||||||
|
data: {
|
||||||
|
filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`,
|
||||||
|
status: ImportStatus.PENDING,
|
||||||
|
rowCount: rows.length,
|
||||||
|
importedById: ctx.actorId,
|
||||||
|
mapping: { source: 'gextension' } as Prisma.InputJsonValue,
|
||||||
|
rows: { createMany: { data: rows } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
summary.importId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeState.summary = summary;
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'scrape.run',
|
||||||
|
targetType: 'import',
|
||||||
|
targetId: summary.importId,
|
||||||
|
details: {
|
||||||
|
scannedPlayers: summary.scannedPlayers,
|
||||||
|
newRows: summary.newRows,
|
||||||
|
duplicates: summary.duplicates,
|
||||||
|
awaitingConfirmation: summary.awaitingConfirmation,
|
||||||
|
unclear: summary.unclear,
|
||||||
|
failed: summary.failedPlayers.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handprüfung der unklaren Einträge ---
|
||||||
|
|
||||||
|
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||||
|
|
||||||
|
export async function listUnmatched(onlyOpen = true) {
|
||||||
|
return prisma.unmatchedPackage.findMany({
|
||||||
|
where: onlyOpen ? { resolved: false } : {},
|
||||||
|
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
include: { player: { select: { id: true, displayName: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
|
||||||
|
export async function unmatchedOverview() {
|
||||||
|
const open = await prisma.unmatchedPackage.findMany({
|
||||||
|
where: { resolved: false },
|
||||||
|
select: { playerId: true, player: { select: { displayName: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const byPlayer = new Map<number, { playerId: number; displayName: string; count: number }>();
|
||||||
|
for (const entry of open) {
|
||||||
|
if (entry.playerId === null) continue;
|
||||||
|
const current = byPlayer.get(entry.playerId);
|
||||||
|
if (current) {
|
||||||
|
current.count += 1;
|
||||||
|
} else {
|
||||||
|
byPlayer.set(entry.playerId, {
|
||||||
|
playerId: entry.playerId,
|
||||||
|
displayName: entry.player?.displayName ?? 'Unbekannt',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: open.length, players: [...byPlayer.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||||
|
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
||||||
|
* ein Redesign mitbringt.
|
||||||
|
*/
|
||||||
|
export async function resolveUnmatched(
|
||||||
|
id: number,
|
||||||
|
resolution: Resolution,
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const entry = await prisma.unmatchedPackage.findUnique({ where: { id } });
|
||||||
|
if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden');
|
||||||
|
if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden');
|
||||||
|
|
||||||
|
if (resolution !== 'IGNORE') {
|
||||||
|
if (entry.playerId === null) {
|
||||||
|
throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js');
|
||||||
|
const note = `GExtension: ${entry.rawTitle}`;
|
||||||
|
|
||||||
|
if (resolution === 'CC_PACKAGE') {
|
||||||
|
await bookCcPackage(entry.playerId, ctx, { note });
|
||||||
|
} else if (resolution === 'REDESIGN_PACKAGE') {
|
||||||
|
await bookRedesignPackage(entry.playerId, ctx, { note });
|
||||||
|
} else {
|
||||||
|
await bookKulanz(entry.playerId, ctx, { note });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.unmatchedPackage.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
resolved: true,
|
||||||
|
resolvedAs: resolution,
|
||||||
|
resolvedById: ctx.actorId,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAudit(prisma, ctx, {
|
||||||
|
action: 'scrape.resolve',
|
||||||
|
targetType: 'unmatched_package',
|
||||||
|
targetId: id,
|
||||||
|
details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Players = typeof import('../src/services/players.js');
|
||||||
|
type Scraper = typeof import('../src/services/scraper.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let players: Players;
|
||||||
|
let scraper: Scraper;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54335);
|
||||||
|
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');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
scraper = await import('../src/services/scraper.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Frühere Konten', () => {
|
||||||
|
it('bringt eine nachgetragene SteamID auf dieselbe Form wie die Hauptangabe', async () => {
|
||||||
|
const player = await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Umzügler', steamId: '76561198000001111' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// In der Legacy-Schreibweise eingegeben, gespeichert wird die 64-Bit-Form.
|
||||||
|
await players.addAlias(player.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Alter Account', ctx);
|
||||||
|
|
||||||
|
const alias = await db.prisma.playerAlias.findFirstOrThrow({ where: { playerId: player.id } });
|
||||||
|
assert.equal(alias.value, '76561198000000999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('klappert beim Abgleich auch die früheren Konten ab', async () => {
|
||||||
|
const ids = await scraper.knownSteamIds();
|
||||||
|
const fuerUmzuegler = ids.filter((e) => e.steamId.startsWith('765611980000'));
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fuerUmzuegler.some((e) => e.steamId === '76561198000001111'),
|
||||||
|
'die aktuelle Kennung',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fuerUmzuegler.some((e) => e.steamId === '76561198000000999'),
|
||||||
|
'und die frühere',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt dieselbe Kennung nicht zweimal in den Lauf', async () => {
|
||||||
|
const ids = await scraper.knownSteamIds();
|
||||||
|
const eindeutig = new Set(ids.map((e) => e.steamId));
|
||||||
|
assert.equal(ids.length, eindeutig.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ein Konto nicht zwei Spielern gehören', async () => {
|
||||||
|
const anderer = await db.prisma.player.create({ data: { displayName: 'Fremder' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(anderer.id, 'STEAM_ID', '76561198000000999', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'ALIAS_TAKEN',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist die eigene Hauptangabe als Alias ab', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(player.id, 'STEAM_ID', '76561198000001111', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'IS_PRIMARY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unsinn als SteamID ab', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(player.id, 'STEAM_ID', 'kein-account', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'INVALID_STEAMID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt Forum-Namen ohne Umrechnung', async () => {
|
||||||
|
const player = await db.prisma.player.findFirstOrThrow({ where: { displayName: 'Umzügler' } });
|
||||||
|
const alias = await players.addAlias(player.id, 'FORUM_NAME', 'AlterName2019', undefined, ctx);
|
||||||
|
|
||||||
|
assert.equal(alias.value, 'AlterName2019');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Eine SteamID gehört genau einem Spieler', () => {
|
||||||
|
it('nennt den Spieler, bei dem sie schon liegt', async () => {
|
||||||
|
await players.createPlayer(
|
||||||
|
{ displayName: 'Hat sie schon', steamId: '76561198000060001' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.createPlayer({ displayName: 'Will sie auch', steamId: '76561198000060001' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => {
|
||||||
|
assert.equal(error.code, 'STEAMID_TAKEN');
|
||||||
|
assert.match(
|
||||||
|
error.message,
|
||||||
|
/Hat sie schon/,
|
||||||
|
'ohne den Namen sucht man von Hand durch den ganzen Bestand',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet sie auch, wenn sie als früheres Konto hängt', async () => {
|
||||||
|
const umzuegler = await players.createPlayer(
|
||||||
|
{ displayName: 'Umgezogen', steamId: '76561198000060002' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await players.addAlias(umzuegler.id, 'STEAM_ID', '76561198000060003', 'Altes Konto', ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.createPlayer({ displayName: 'Neuling', steamId: '76561198000060003' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => {
|
||||||
|
assert.equal(error.code, 'STEAMID_TAKEN');
|
||||||
|
assert.match(error.message, /Umgezogen/);
|
||||||
|
assert.match(error.message, /früheres Konto/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt sie auch in einer anderen Schreibweise', async () => {
|
||||||
|
// STEAM_0:0:19897141 und 76561198000060010 sind dieselbe Kennung.
|
||||||
|
await players.createPlayer(
|
||||||
|
{ displayName: 'Legacy-Schreiber', steamId: '76561198000060010' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.createPlayer({ displayName: 'Doppelt', steamId: 'STEAM_0:0:19897141' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => {
|
||||||
|
assert.equal(error.code, 'STEAMID_TAKEN');
|
||||||
|
assert.match(error.message, /Legacy-Schreiber/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt die aktuelle Kennung eines anderen nicht als früheres Konto eintragen', async () => {
|
||||||
|
const einer = await players.createPlayer(
|
||||||
|
{ displayName: 'Aktueller Inhaber', steamId: '76561198000060004' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const anderer = await players.createPlayer(
|
||||||
|
{ displayName: 'Möchtegern', steamId: '76561198000060005' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => players.addAlias(anderer.id, 'STEAM_ID', '76561198000060004', undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => {
|
||||||
|
assert.equal(error.code, 'ALIAS_TAKEN');
|
||||||
|
assert.match(error.message, /Aktueller Inhaber/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(einer.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stört sich nicht an der eigenen Kennung beim Bearbeiten', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Bleibt sich treu', steamId: '76561198000060006' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const geaendert = await players.updatePlayer(
|
||||||
|
spieler.id,
|
||||||
|
{ displayName: 'Neuer Name', steamId: '76561198000060006' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(geaendert.displayName, 'Neuer Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Forum = typeof import('../src/services/forum.js');
|
||||||
|
type Players = typeof import('../src/services/players.js');
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Scraper = typeof import('../src/services/scraper.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let forum: Forum;
|
||||||
|
let players: Players;
|
||||||
|
let characters: Characters;
|
||||||
|
let scraper: Scraper;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54337);
|
||||||
|
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');
|
||||||
|
forum = await import('../src/services/forum.js');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
scraper = await import('../src/services/scraper.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Legt einen Thread an, wie ihn der Abgleich ohne Spieler vorfinden würde. */
|
||||||
|
async function offenerThread(threadId: number, titel: string, autor: string) {
|
||||||
|
return db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId,
|
||||||
|
title: titel,
|
||||||
|
url: `https://forum.example.org/index.php?thread/${threadId}-antrag/`,
|
||||||
|
authorName: autor,
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Anträge ohne zugeordneten Spieler', () => {
|
||||||
|
it('hält sie aus der Arbeitsliste heraus, aber nicht aus der Datenbank', async () => {
|
||||||
|
await offenerThread(9001, 'CC Antrag von einem Fremden', 'Unbekannter');
|
||||||
|
|
||||||
|
const arbeitsliste = await forum.listThreads({ onlyOpen: true });
|
||||||
|
assert.equal(
|
||||||
|
arbeitsliste.some((t) => t.threadId === 9001),
|
||||||
|
false,
|
||||||
|
'ohne Spieler ist nichts zu entscheiden',
|
||||||
|
);
|
||||||
|
|
||||||
|
const wartend = await forum.listUnassignedThreads({});
|
||||||
|
assert.equal(
|
||||||
|
wartend.items.some((t) => t.threadId === 9001),
|
||||||
|
true,
|
||||||
|
'auffindbar bleibt er trotzdem',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet sie über Titel und Ersteller', async () => {
|
||||||
|
await offenerThread(9002, 'Feldagent Ersthelfer', 'Darkbrine44');
|
||||||
|
|
||||||
|
const nachTitel = await forum.listUnassignedThreads({ query: 'ersthelfer' });
|
||||||
|
assert.deepEqual(
|
||||||
|
nachTitel.items.map((t) => t.threadId),
|
||||||
|
[9002],
|
||||||
|
'Grossschreibung darf keine Rolle spielen',
|
||||||
|
);
|
||||||
|
|
||||||
|
const nachAutor = await forum.listUnassignedThreads({ query: 'darkbrine' });
|
||||||
|
assert.deepEqual(
|
||||||
|
nachAutor.items.map((t) => t.threadId),
|
||||||
|
[9002],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('begrenzt die Ausgabe, nennt aber die volle Zahl', async () => {
|
||||||
|
for (const id of [9010, 9011, 9012]) await offenerThread(id, `Antrag ${id}`, 'Serienbeantrager');
|
||||||
|
|
||||||
|
const seite = await forum.listUnassignedThreads({ query: 'Serienbeantrager', take: 2 });
|
||||||
|
assert.equal(seite.items.length, 2);
|
||||||
|
assert.equal(seite.total, 3, 'sonst wüsste niemand, dass etwas fehlt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nachträgliche Zuordnung', () => {
|
||||||
|
it('zieht wartende Anträge an einen neu angelegten Spieler', async () => {
|
||||||
|
await offenerThread(9101, 'CC Antrag von Monox', 'Monox');
|
||||||
|
await offenerThread(9102, 'Redesign von Monox', 'monox');
|
||||||
|
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Monox', steamId: '76561198000002222', forumName: 'Monox' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
|
||||||
|
assert.deepEqual(
|
||||||
|
zugeordnet.map((t) => t.threadId).sort(),
|
||||||
|
[9101, 9102],
|
||||||
|
'auch die abweichende Schreibweise gehört dazu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht sie auch nach, wenn der Forum-Name erst später eingetragen wird', async () => {
|
||||||
|
await offenerThread(9201, 'Antrag eines Umbenannten', 'NeuerName');
|
||||||
|
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Ohne Forum', steamId: '76561198000003333' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal((await forum.listThreads({ playerId: spieler.id })).length, 0);
|
||||||
|
|
||||||
|
await players.updatePlayer(spieler.id, { forumName: 'NeuerName' }, ctx);
|
||||||
|
|
||||||
|
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
|
||||||
|
assert.deepEqual(
|
||||||
|
zugeordnet.map((t) => t.threadId),
|
||||||
|
[9201],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holt über ein früheres Konto auch die alten Anträge', async () => {
|
||||||
|
await offenerThread(9301, 'Antrag unter dem alten Namen', 'AltesKonto');
|
||||||
|
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Umzügler', steamId: '76561198000004444', forumName: 'NeuesKonto' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await players.addAlias(spieler.id, 'FORUM_NAME', 'AltesKonto', 'Vor dem Wechsel', ctx);
|
||||||
|
|
||||||
|
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
|
||||||
|
assert.deepEqual(
|
||||||
|
zugeordnet.map((t) => t.threadId),
|
||||||
|
[9301],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt einem Spieler nie einen bereits zugeordneten Antrag weg', async () => {
|
||||||
|
const erster = await players.createPlayer(
|
||||||
|
{ displayName: 'Erster', steamId: '76561198000005555', forumName: 'Doppelgänger' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await offenerThread(9401, 'Strittiger Antrag', 'Doppelgänger');
|
||||||
|
await forum.assignPlayer(9401, erster.id, ctx);
|
||||||
|
|
||||||
|
// Ein zweiter Spieler trägt denselben Namen als früheres Konto nach.
|
||||||
|
const zweiter = await players.createPlayer(
|
||||||
|
{ displayName: 'Zweiter', steamId: '76561198000006666' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await players.updatePlayer(zweiter.id, { forumName: 'Doppelgänger' }, ctx);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9401 } });
|
||||||
|
assert.equal(thread.playerId, erster.id, 'eine getroffene Entscheidung bleibt stehen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Antrag am CC', () => {
|
||||||
|
it('hängt den Thread an den CC, der aus ihm hervorgeht', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Antragsteller', steamId: '76561198000007777', forumName: 'Antragsteller' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await offenerThread(9501, 'The Duke', 'Antragsteller');
|
||||||
|
await forum.claimThreadsForPlayer(spieler.id, ['Antragsteller']);
|
||||||
|
|
||||||
|
const { characterId } = await forum.classifyThread(
|
||||||
|
9501,
|
||||||
|
{ classification: 'NEW_CC', characterName: 'The Duke' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.ok(characterId);
|
||||||
|
|
||||||
|
const liste = await characters.listCharacters(spieler.id);
|
||||||
|
const cc = liste.find((c) => c.id === characterId);
|
||||||
|
assert.deepEqual(
|
||||||
|
cc?.forumThreads.map((t) => t.threadId),
|
||||||
|
[9501],
|
||||||
|
'ohne das bliebe der Name auf der Spielerseite ein toter Text',
|
||||||
|
);
|
||||||
|
assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Problemfälle', () => {
|
||||||
|
it('legt einen Spieler an, obwohl etwas offen ist', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{
|
||||||
|
displayName: 'Fall ohne Paket',
|
||||||
|
isProblem: true,
|
||||||
|
problemNote: 'CC-Antrag da, aber kein Paket auffindbar',
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(spieler.isProblem, true);
|
||||||
|
assert.equal(spieler.problemNote, 'CC-Antrag da, aber kein Paket auffindbar');
|
||||||
|
assert.ok(spieler.problemSince, 'wann es aufgefallen ist, gehört dazu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markiert auch im Nachhinein', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Erst später auffällig', steamId: '76561198000011111' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(spieler.isProblem, false);
|
||||||
|
|
||||||
|
const markiert = await players.markProblem(
|
||||||
|
spieler.id,
|
||||||
|
{ isProblem: true, note: 'Kein GExtension-Eintrag' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(markiert.isProblem, true);
|
||||||
|
assert.equal(markiert.problemNote, 'Kein GExtension-Eintrag');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält den Anfang, wenn die Notiz nachgeschärft wird', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Langer Fall', isProblem: true, problemNote: 'unklar' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const anfang = spieler.problemSince;
|
||||||
|
|
||||||
|
const nachgeschaerft = await players.markProblem(
|
||||||
|
spieler.id,
|
||||||
|
{ isProblem: true, note: 'Paket liegt unter altem Konto' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.deepEqual(nachgeschaerft.problemSince, anfang, 'der Fall ist derselbe, nur klarer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt die Markierung restlos ab', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Geklärt', isProblem: true, problemNote: 'war ein Tippfehler' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const geklaert = await players.markProblem(spieler.id, { isProblem: false }, ctx);
|
||||||
|
assert.equal(geklaert.isProblem, false);
|
||||||
|
assert.equal(geklaert.problemNote, null);
|
||||||
|
assert.equal(geklaert.problemSince, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert an Guthaben und CCs nichts', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Trotzdem berechtigt', steamId: '76561198000012222' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
const { character } = await characters.createCharacter(spieler.id, { name: 'Bleibt' }, ctx);
|
||||||
|
|
||||||
|
await players.markProblem(spieler.id, { isProblem: true, note: 'Herkunft unklar' }, ctx);
|
||||||
|
|
||||||
|
const detail = await players.getPlayerDetail(spieler.id);
|
||||||
|
assert.equal(detail.balances.CC, 0, 'das Paket ist verbraucht, nicht verschwunden');
|
||||||
|
assert.equal(detail.characters.find((c) => c.id === character.id)?.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listet sie auf Wunsch für sich', async () => {
|
||||||
|
const offen = await players.searchPlayers('', 100, 0, 'problems');
|
||||||
|
assert.ok(offen.total >= 3);
|
||||||
|
assert.equal(
|
||||||
|
offen.items.every((p) => p.isProblem),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alle = await players.searchPlayers('', 100, 0, 'all');
|
||||||
|
assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Konten beim Abgleich', () => {
|
||||||
|
it('sammelt alle Steam-Kennungen eines Spielers, nicht nur die hinterlegte', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Kontowechsler', steamId: '76561198000013333' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await players.addAlias(spieler.id, 'STEAM_ID', '76561198000014444', 'Erstes Konto', ctx);
|
||||||
|
await players.addAlias(spieler.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Ganz altes Konto', ctx);
|
||||||
|
|
||||||
|
const kennungen = await scraper.steamIdsOf(spieler.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
kennungen.sort(),
|
||||||
|
['76561198000013333', '76561198000014444', '76561198000000999'].sort(),
|
||||||
|
'auch in der Legacy-Schreibweise nachgetragene Konten gehören dazu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einem Spieler ohne jede Kennung klar', async () => {
|
||||||
|
const spieler = await players.createPlayer({ displayName: 'Nur ein Name' }, ctx);
|
||||||
|
assert.deepEqual(await scraper.steamIdsOf(spieler.id), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sortieren, Blättern und Suchen', () => {
|
||||||
|
before(async () => {
|
||||||
|
// Eigener Namensraum, damit die Faelle davor nicht hineinspielen.
|
||||||
|
for (const id of [8801, 8802, 8803, 8804, 8805]) {
|
||||||
|
await offenerThread(id, `Serienantrag ${id}`, 'Vielschreiber');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert nach jüngstem und ältestem Antrag', async () => {
|
||||||
|
const neueste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'newest' });
|
||||||
|
assert.deepEqual(
|
||||||
|
neueste.items.map((t) => t.threadId),
|
||||||
|
[8805, 8804, 8803, 8802, 8801],
|
||||||
|
);
|
||||||
|
|
||||||
|
const aelteste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest' });
|
||||||
|
assert.deepEqual(
|
||||||
|
aelteste.items.map((t) => t.threadId),
|
||||||
|
[8801, 8802, 8803, 8804, 8805],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blättert seitenweise durch und nennt die volle Zahl', async () => {
|
||||||
|
const erste = await forum.listUnassignedThreads({
|
||||||
|
query: 'Vielschreiber',
|
||||||
|
order: 'oldest',
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
erste.items.map((t) => t.threadId),
|
||||||
|
[8801, 8802],
|
||||||
|
);
|
||||||
|
assert.equal(erste.total, 5);
|
||||||
|
|
||||||
|
const zweite = await forum.listUnassignedThreads({
|
||||||
|
query: 'Vielschreiber',
|
||||||
|
order: 'oldest',
|
||||||
|
take: 2,
|
||||||
|
skip: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
zweite.items.map((t) => t.threadId),
|
||||||
|
[8803, 8804],
|
||||||
|
'keine Ueberschneidung mit der ersten Seite',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('durchsucht auch die Arbeitsliste', async () => {
|
||||||
|
const spieler = await players.createPlayer(
|
||||||
|
{ displayName: 'Suchbar', steamId: '76561198000015555', forumName: 'Suchbar' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await offenerThread(8901, 'Ein ganz bestimmter Antrag', 'Suchbar');
|
||||||
|
await offenerThread(8902, 'Etwas voellig anderes', 'Suchbar');
|
||||||
|
await forum.claimThreadsForPlayer(spieler.id, ['Suchbar']);
|
||||||
|
|
||||||
|
const treffer = await forum.listThreads({ onlyOpen: true, query: 'bestimmter' });
|
||||||
|
assert.deepEqual(
|
||||||
|
treffer.map((t) => t.threadId),
|
||||||
|
[8901],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nachAutor = await forum.listThreads({ onlyOpen: true, query: 'Suchbar' });
|
||||||
|
assert.equal(nachAutor.length, 2, 'die Suche greift auch auf den Ersteller');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ersteller ohne Spieler', () => {
|
||||||
|
it('fasst die Anträge je Ersteller zusammen', async () => {
|
||||||
|
const liste = await forum.listUntrackedAuthors();
|
||||||
|
const vielschreiber = liste.items.find((a) => a.authorName === 'Vielschreiber');
|
||||||
|
|
||||||
|
assert.ok(vielschreiber, 'wer fuenf Antraege gestellt hat, faellt auf');
|
||||||
|
assert.equal(vielschreiber?.threadCount, 5);
|
||||||
|
assert.equal(vielschreiber?.latestThreadId, 8805);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('führt niemanden auf, der längst einem Spieler gehört', async () => {
|
||||||
|
const liste = await forum.listUntrackedAuthors();
|
||||||
|
assert.equal(
|
||||||
|
liste.items.some((a) => a.authorName === 'Suchbar'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschwindet aus der Liste, sobald der Spieler angelegt ist', async () => {
|
||||||
|
assert.equal(
|
||||||
|
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await players.createPlayer(
|
||||||
|
{ displayName: 'Vielschreiber', steamId: '76561198000016666', forumName: 'Vielschreiber' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
|
||||||
|
false,
|
||||||
|
'seine Antraege stehen jetzt in der Arbeitsliste',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Spielerliste eingrenzen und sortieren', () => {
|
||||||
|
before(async () => {
|
||||||
|
// Ein Gruppen-CC, ein stillgelegter CC und ein Spieler ohne alles.
|
||||||
|
const wirt = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Wirt', steamId: '76561198000040001' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const gast = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Gast', steamId: '76561198000040002' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
for (const id of [wirt.id, gast.id]) {
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gruppe = await characters.createCharacter(wirt.id, { name: 'Filter-Runde' }, ctx);
|
||||||
|
await characters.addMember(gruppe.character.id, gast.id, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const einsam = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Einsam', steamId: '76561198000040003' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: einsam.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
const stillgelegt = await characters.createCharacter(einsam.id, { name: 'Ruht' }, ctx);
|
||||||
|
await characters.deactivateCharacter(stillgelegt.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Leer', steamId: '76561198000040004' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const namen = async (filter: Parameters<typeof players.searchPlayers>[3]) =>
|
||||||
|
(await players.searchPlayers('Filter-', 100, 0, filter)).items.map((p) => p.displayName).sort();
|
||||||
|
|
||||||
|
it('findet die Beteiligten eines Gruppen-CCs', async () => {
|
||||||
|
assert.deepEqual(await namen('group'), ['Filter-Gast', 'Filter-Wirt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer einen aktiven CC hat', async () => {
|
||||||
|
assert.deepEqual(await namen('active'), ['Filter-Gast', 'Filter-Wirt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer nur noch stillgelegte CCs hat', async () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
await namen('onlyInactive'),
|
||||||
|
['Filter-Einsam'],
|
||||||
|
'wer gar keinen CC hat, gehört nicht dazu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer gar keinen CC hat', async () => {
|
||||||
|
assert.deepEqual(await namen('none'), ['Filter-Leer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert nach der Zahl der CC-Einträge', async () => {
|
||||||
|
const liste = await players.searchPlayers('Filter-', 100, 0, 'all', 'characters');
|
||||||
|
const zahlen = liste.items.map((p) => p.characterCount);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...zahlen].sort((a, b) => b - a),
|
||||||
|
zahlen,
|
||||||
|
'die längste Liste steht oben',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Eine falsch gelesene Zuordnung geraderücken', () => {
|
||||||
|
it('schlägt den Thread dem richtigen Spieler zu, sobald der Ersteller stimmt', async () => {
|
||||||
|
// So sah es nach dem alten Parser aus: als Ersteller stand der letzte
|
||||||
|
// Antwortende in den Daten, und der Thread hing an ihm.
|
||||||
|
const falsch = await players.createPlayer(
|
||||||
|
{ displayName: 'Letzter Antwortender', steamId: '76561198000050001', forumName: 'Antworter' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const richtig = await players.createPlayer(
|
||||||
|
{ displayName: 'Echter Ersteller', steamId: '76561198000050002', forumName: 'Ersteller' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId: 7301,
|
||||||
|
title: 'Falsch zugeordnet',
|
||||||
|
url: 'https://forum.example.org/index.php?thread/7301-x/',
|
||||||
|
authorName: 'Antworter',
|
||||||
|
playerId: falsch.id,
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(thread.playerId, falsch.id);
|
||||||
|
|
||||||
|
// Der berichtigte Parser liest jetzt den echten Ersteller. Beim nächsten
|
||||||
|
// Abgleich zieht die Zuordnung nach - hier direkt geprüft.
|
||||||
|
await db.prisma.forumThread.update({
|
||||||
|
where: { threadId: 7301 },
|
||||||
|
data: { authorName: 'Ersteller', playerId: richtig.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const danach = await forum.listThreads({ playerId: richtig.id });
|
||||||
|
assert.ok(
|
||||||
|
danach.some((t) => t.threadId === 7301),
|
||||||
|
'er steht jetzt beim Ersteller',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await forum.listThreads({ playerId: falsch.id })).some((t) => t.threadId === 7301),
|
||||||
|
false,
|
||||||
|
'und nicht mehr beim Antwortenden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt eine von Hand gesetzte Zuordnung in Ruhe', async () => {
|
||||||
|
// Wer den Thread von Hand zugeordnet hat, wusste mehr als der Abgleich -
|
||||||
|
// etwa weil der Antrag für jemand anderen geschrieben wurde.
|
||||||
|
const schreiber = await players.createPlayer(
|
||||||
|
{ displayName: 'Schreiber', steamId: '76561198000050003', forumName: 'Schreiber' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const gemeint = await players.createPlayer(
|
||||||
|
{ displayName: 'Gemeint', steamId: '76561198000050004' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId: 7302,
|
||||||
|
title: 'Von Hand zugeordnet',
|
||||||
|
url: 'https://forum.example.org/index.php?thread/7302-x/',
|
||||||
|
authorName: 'Schreiber',
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await forum.assignPlayer(7302, gemeint.id, ctx);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7302 } });
|
||||||
|
assert.equal(
|
||||||
|
thread.playerId,
|
||||||
|
gemeint.id,
|
||||||
|
'die Zuordnung zeigt bewusst woanders hin als der Ersteller',
|
||||||
|
);
|
||||||
|
assert.notEqual(thread.playerId, schreiber.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Backup = typeof import('../src/services/backup.js');
|
||||||
|
type Players = typeof import('../src/services/players.js');
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let backup: Backup;
|
||||||
|
let players: Players;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54339);
|
||||||
|
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');
|
||||||
|
backup = await import('../src/services/backup.js');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'umzug', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Räumt den Bestand ab, als wäre es eine frische Instanz. */
|
||||||
|
async function leereInstanz() {
|
||||||
|
await db.prisma.characterLink.deleteMany();
|
||||||
|
await db.prisma.characterMember.deleteMany();
|
||||||
|
await db.prisma.creditLedger.deleteMany();
|
||||||
|
await db.prisma.character.deleteMany();
|
||||||
|
await db.prisma.playerAlias.deleteMany();
|
||||||
|
await db.prisma.player.deleteMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Bestand mit allem, was der Export kennen muss. */
|
||||||
|
async function bestandAnlegen() {
|
||||||
|
const chef = await players.createPlayer(
|
||||||
|
{
|
||||||
|
displayName: 'Umzugs-Chef',
|
||||||
|
steamId: '76561198000030001',
|
||||||
|
forumName: 'UmzugsChef',
|
||||||
|
notes: 'Führt die Gruppe',
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const gast = await players.createPlayer(
|
||||||
|
{ displayName: 'Umzugs-Gast', steamId: '76561198000030002' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const problemfall = await players.createPlayer(
|
||||||
|
{ displayName: 'Umzugs-Problem', isProblem: true, problemNote: 'Kein Paket auffindbar' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await players.addAlias(chef.id, 'STEAM_ID', '76561198000030099', 'Erstes Konto', ctx);
|
||||||
|
await credits.bookCcPackage(chef.id, ctx);
|
||||||
|
await credits.bookCcPackage(gast.id, ctx);
|
||||||
|
await credits.bookRedesignPackage(chef.id, ctx);
|
||||||
|
|
||||||
|
const erster = await characters.createCharacter(
|
||||||
|
chef.id,
|
||||||
|
{ name: 'Erster Wurf', loreText: 'Ein Anfang' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const zweiter = await characters.redesignCharacter(
|
||||||
|
erster.character.id,
|
||||||
|
{ name: 'Zweiter Wurf', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await characters.addMember(zweiter.character.id, gast.id, { useKind: 'CC' }, ctx);
|
||||||
|
await characters.addLink(
|
||||||
|
zweiter.character.id,
|
||||||
|
{ label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=1' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { chef, gast, problemfall, erster, zweiter };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Umzug auf eine andere Instanz', () => {
|
||||||
|
let csv = '';
|
||||||
|
|
||||||
|
it('schreibt jede Satzart in die Tabelle', async () => {
|
||||||
|
await bestandAnlegen();
|
||||||
|
csv = await backup.exportAll();
|
||||||
|
|
||||||
|
for (const satzart of ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS']) {
|
||||||
|
assert.ok(csv.includes(`${satzart};`), `${satzart} fehlt im Export`);
|
||||||
|
}
|
||||||
|
assert.ok(csv.startsWith('satzart;'), 'die Kopfzeile nennt die Satzart zuerst');
|
||||||
|
assert.ok(csv.includes('Ersteller-Addon'), 'Verweise gehören dazu');
|
||||||
|
assert.ok(csv.includes('Kein Paket auffindbar'), 'Problemfälle auch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stellt den Bestand auf einer leeren Instanz wieder her', async () => {
|
||||||
|
await leereInstanz();
|
||||||
|
assert.equal(await db.prisma.player.count(), 0);
|
||||||
|
|
||||||
|
const bericht = await backup.importAll(csv, ctx);
|
||||||
|
assert.deepEqual(bericht.fehler, [], 'keine Zeile darf hängen bleiben');
|
||||||
|
|
||||||
|
assert.equal(await db.prisma.player.count(), 3);
|
||||||
|
assert.equal(await db.prisma.character.count(), 2);
|
||||||
|
assert.equal(await db.prisma.characterLink.count(), 1);
|
||||||
|
|
||||||
|
const chef = await db.prisma.player.findFirstOrThrow({
|
||||||
|
where: { displayName: 'Umzugs-Chef' },
|
||||||
|
});
|
||||||
|
assert.equal(chef.steamId, '76561198000030001');
|
||||||
|
assert.equal(chef.forumName, 'UmzugsChef');
|
||||||
|
|
||||||
|
// Guthaben und Verbräuche stehen wieder wie zuvor.
|
||||||
|
const stand = await credits.getBalances(chef.id);
|
||||||
|
assert.equal(stand.CC, 0, 'ein Paket gekauft, einen CC daraus gebaut');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 1, 'zwei Redesigns erhalten, eines verbraucht');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält die Redesign-Kette zusammen', async () => {
|
||||||
|
const chef = await db.prisma.player.findFirstOrThrow({
|
||||||
|
where: { displayName: 'Umzugs-Chef' },
|
||||||
|
});
|
||||||
|
const kette = await db.prisma.character.findMany({
|
||||||
|
where: { playerId: chef.id },
|
||||||
|
orderBy: { chainPosition: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(kette.map((c) => c.name), ['Erster Wurf', 'Zweiter Wurf']);
|
||||||
|
assert.equal(
|
||||||
|
kette[0]?.lineageId,
|
||||||
|
kette[1]?.lineageId,
|
||||||
|
'beide gehören zur selben Kette',
|
||||||
|
);
|
||||||
|
assert.equal(kette[0]?.status, 'INACTIVE');
|
||||||
|
assert.equal(kette[1]?.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt den Gruppen-CC samt seinem zweiten Nutzer mit', async () => {
|
||||||
|
const gast = await db.prisma.player.findFirstOrThrow({
|
||||||
|
where: { displayName: 'Umzugs-Gast' },
|
||||||
|
});
|
||||||
|
const seine = await characters.listCharacters(gast.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
seine.map((c) => c.name),
|
||||||
|
['Zweiter Wurf'],
|
||||||
|
'er ist Mitglied, nicht Besitzer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt beim zweiten Einspielen nichts doppelt an', async () => {
|
||||||
|
const vorher = {
|
||||||
|
spieler: await db.prisma.player.count(),
|
||||||
|
ccs: await db.prisma.character.count(),
|
||||||
|
buchungen: await db.prisma.creditLedger.count(),
|
||||||
|
mitglieder: await db.prisma.characterMember.count(),
|
||||||
|
links: await db.prisma.characterLink.count(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const bericht = await backup.importAll(csv, ctx);
|
||||||
|
assert.deepEqual(bericht.fehler, []);
|
||||||
|
assert.equal(
|
||||||
|
Object.values(bericht.angelegt).reduce((a, b) => a + b, 0),
|
||||||
|
0,
|
||||||
|
'alles war schon da',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
spieler: await db.prisma.player.count(),
|
||||||
|
ccs: await db.prisma.character.count(),
|
||||||
|
buchungen: await db.prisma.creditLedger.count(),
|
||||||
|
mitglieder: await db.prisma.characterMember.count(),
|
||||||
|
links: await db.prisma.characterLink.count(),
|
||||||
|
},
|
||||||
|
vorher,
|
||||||
|
'ein zweiter Lauf darf den Bestand nicht verdoppeln',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ergänzt einen Spieler, der auf der Zielinstanz fehlt', async () => {
|
||||||
|
await db.prisma.player.deleteMany({ where: { displayName: 'Umzugs-Problem' } });
|
||||||
|
assert.equal(await db.prisma.player.count(), 2);
|
||||||
|
|
||||||
|
const bericht = await backup.importAll(csv, ctx);
|
||||||
|
assert.equal(bericht.angelegt.SPIELER, 1, 'nur der fehlende kommt dazu');
|
||||||
|
assert.equal(await db.prisma.player.count(), 3);
|
||||||
|
|
||||||
|
const wieder = await db.prisma.player.findFirstOrThrow({
|
||||||
|
where: { displayName: 'Umzugs-Problem' },
|
||||||
|
});
|
||||||
|
assert.equal(wieder.isProblem, true, 'auch die Markierung kommt mit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet krumme Zeilen, statt den Lauf abzubrechen', async () => {
|
||||||
|
const kaputt = [
|
||||||
|
'satzart;spieler;steam_id;cc_name;bezeichnung;wert',
|
||||||
|
'VERWEIS;Gibt-Es-Nicht;;Kein CC;Irgendwas;https://example.org/',
|
||||||
|
'SPIELER;Kommt-Trotzdem-Durch;76561198000030777;;;',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const bericht = await backup.importAll(kaputt, ctx);
|
||||||
|
assert.equal(bericht.fehler.length, 1);
|
||||||
|
assert.match(bericht.fehler[0]?.grund ?? '', /nicht gefunden/);
|
||||||
|
assert.equal(bericht.angelegt.SPIELER, 1, 'die gute Zeile läuft trotzdem durch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine Datei ohne Satzart ab', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => backup.importAll('name;wert\nirgendwas;1', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NO_SATZART',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+123
@@ -0,0 +1,123 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Nachbau einer WoltLab-Boardübersicht mit erfundenen Daten.
|
||||||
|
Die echte Seite baut die Thread-Liste genauso auf. -->
|
||||||
|
<html lang="de"><body>
|
||||||
|
<div class="wbbBoardNode">
|
||||||
|
<div class="wbbBoardNode__title"><a href="https://forum.example.org/index.php?board/99-unterforum/">Ein Unterforum</a></div>
|
||||||
|
<div class="wbbBoardNode__lastPost">
|
||||||
|
<a href="https://forum.example.org/index.php?thread/9999-letzter-beitrag-im-unterforum/" class="wbbBoardNode__lastPostLink wbbTopicLink" data-object-id="9999">Letzter Beitrag im Unterforum</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="tabularList wbbTopicList">
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/501-antragsteller/" data-object-id="501" class="userLink"><i class="sl"></i><span style="color: #333;">Antragsteller</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7001-cc-antrag-testfigur/" class="messageGroupLink wbbTopicLink" data-object-id="7001">CC Antrag | Testfigur</a>
|
||||||
|
<ul class="inlineList dotSeparated small messageGroupInfo">
|
||||||
|
<li class="messageGroupTime"><woltlab-core-date-time date="2025-03-04T18:22:05+00:00">4. März 2025 um 19:22</woltlab-core-date-time></li>
|
||||||
|
<li class="messageGroupLastPostTime"><woltlab-core-date-time date="2025-09-01T08:00:00+00:00">1. September 2025</woltlab-core-date-time></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/502-zweiter-nutzer/" data-object-id="502" class="userLink"><span>Zweiter Nutzer</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7002-custom-character-redesign-alt/" class="messageGroupLink wbbTopicLink" data-object-id="7002">Custom Character Redesign | Alt zu Neu</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<ul class="labelList">
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=1" class="badge label red jsTooltip" title="Label">Wichtig</a></li>
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=2" class="badge label global jsTooltip" title="Label">Global</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7004-regelwerk-changelog/" class="messageGroupLink wbbTopicLink" data-object-id="7004">Regelwerk / Changelog</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/503-ankuendiger/" data-object-id="503" class="userLink"><span>Ankuendiger</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<ul class="labelList">
|
||||||
|
<li><a href="index.php?board/1/&labelIDs=3" class="badge label orange jsTooltip" title="Label">Information</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7005-das-grosse-update/" class="messageGroupLink wbbTopicLink" data-object-id="7005">Das grosse Update</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<a href="https://forum.example.org/index.php?user/501-antragsteller/" data-object-id="501" class="userLink"><span>Antragsteller</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<span class="badge label green">Verschoben</span>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7003-verschobener-thread/" class="messageGroupLink wbbTopicLink wbbTopicLinkMoved" data-object-id="7003">Ein verschobener Thread</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<p><img src="avatar-default.png" class="userAvatarImage" alt=""></p>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<h3>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7006-antrag-eines-geloeschten-kontos/" class="messageGroupLink wbbTopicLink" data-object-id="7006">Antrag eines geloeschten Kontos</a>
|
||||||
|
</h3>
|
||||||
|
<ul class="inlineList dotSeparated small messageGroupInfo">
|
||||||
|
<li class="messageGroupAuthor"><span>Ehemaliger</span></li>
|
||||||
|
<li class="messageGroupTime"><woltlab-core-date-time date="2020-01-06T19:10:45+00:00">6. Januar 2020</woltlab-core-date-time></li>
|
||||||
|
</ul>
|
||||||
|
<ul class="messageGroupInfoMobile">
|
||||||
|
<li class="messageGroupAuthorMobile">Ehemaliger</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="tabularListRow">
|
||||||
|
<ul class="tabularListColumns">
|
||||||
|
<li class="columnIcon columnAvatar">
|
||||||
|
<p><img src="avatar-default.png" class="userAvatarImage" alt=""></p>
|
||||||
|
</li>
|
||||||
|
<li class="columnSubject">
|
||||||
|
<h3>
|
||||||
|
<a href="https://forum.example.org/index.php?thread/7007-antrag-eines-geloeschten-kontos-mit-antworten/" class="messageGroupLink wbbTopicLink" data-object-id="7007">Antrag mit fremder Antwort</a>
|
||||||
|
</h3>
|
||||||
|
<ul class="inlineList dotSeparated small messageGroupInfo">
|
||||||
|
<li class="messageGroupAuthor"><span>-Verschwunden-</span></li>
|
||||||
|
<li class="messageGroupTime"><woltlab-core-date-time date="2021-04-18T14:14:00+00:00">18. April 2021</woltlab-core-date-time></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="columnLastPost">
|
||||||
|
<div class="box32">
|
||||||
|
<a href="https://forum.example.org/index.php?user/999-letzter-antwortender/" data-object-id="999" class="userLink">Letzter Antwortender</a>
|
||||||
|
<small><woltlab-core-date-time date="2022-01-01T10:00:00+00:00">1. Januar 2022</woltlab-core-date-time></small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<woltlab-core-pagination page="1" count="25" url="https://forum.example.org/index.php?board/66-custom-characters/"></woltlab-core-pagination>
|
||||||
|
<nav class="pagination"><ul>
|
||||||
|
<li><a href="index.php?board/66-custom-characters/&pageNo=2" class="pageNavigationLink">2</a></li>
|
||||||
|
<li><a href="index.php?board/66-custom-characters/&pageNo=3" class="pageNavigationLink">3</a></li>
|
||||||
|
</ul></nav>
|
||||||
|
</body></html>
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Nachbau der Struktur einer GExtension-Profilseite mit erfundenen Daten.
|
||||||
|
Die echte Seite bettet die Kaeufe genauso als donations_data-Zuweisungen ein. -->
|
||||||
|
<html lang="de">
|
||||||
|
<head><title>Profil</title></head>
|
||||||
|
<body>
|
||||||
|
<div class="box"><h3>ALLGEMEIN</h3>
|
||||||
|
<table><tr><td>Nickname</td><td>Testspieler</td></tr>
|
||||||
|
<tr><td>SteamID64</td><td>76561198000000999</td></tr></table>
|
||||||
|
</div>
|
||||||
|
<div class="box"><h3>Pakete</h3><div id="packages"></div></div>
|
||||||
|
<script>
|
||||||
|
var donations_data = [];
|
||||||
|
donations_data[9001] = {"id":"","package_bought_id":9001,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:54","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||||
|
donations_data[9002] = {"id":"","package_bought_id":9002,"amount":"15.00 EUR","package":10,"package_name":"Custom Character Redesign","steamid64_buyer":"","nick_buyer":"","date":"07.10.2025 10:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"Paypal","revoked":0,"status":1};
|
||||||
|
donations_data[9003] = {"id":"","package_bought_id":9003,"amount":"0.00 EUR","package":11,"package_name":"Custom Charakter-Beantragung","steamid64_buyer":"","nick_buyer":"","date":"08.10.2025 11:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||||
|
donations_data[9004] = {"id":"","package_bought_id":9004,"amount":"0.00 EUR","package":70,"package_name":"FG-Tasse | Merch","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:53","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||||
|
donations_data[9005] = {"id":"","package_bought_id":9005,"amount":"49.67 EUR","package":80,"package_name":"Ablassbrief (01.04.2026)","steamid64_buyer":"","nick_buyer":"","date":"01.04.2026 09:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"Web","serverbundle":0,"gateway":"Paypal","revoked":0,"status":1};
|
||||||
|
donations_data[9006] = {"id":"","package_bought_id":9006,"amount":"0.00 EUR","package":12,"package_name":"CC-Paket oder Redesign genutzt | 1 Mal","steamid64_buyer":"","nick_buyer":"","date":"09.10.2025 12:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||||
|
donations_data[9007] = {"id":"","package_bought_id":9007,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"10.10.2025 13:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":1,"status":0};
|
||||||
|
donations_data[9008] = {"id":"","package_bought_id":9008,"amount":"0.00 EUR","package":99,"package_name":"CC Sonderaktion Winter","steamid64_buyer":"","nick_buyer":"","date":"11.10.2025 14:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
|
||||||
|
import { isRateLimited, retryAfterMs } from '../src/lib/ratelimit.js';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
|
||||||
|
|
||||||
|
describe('Board-Übersicht lesen', () => {
|
||||||
|
const threads = parseBoard(BOARD);
|
||||||
|
|
||||||
|
it('liest die Threads der Liste', () => {
|
||||||
|
assert.equal(threads.length, 7);
|
||||||
|
assert.deepEqual(
|
||||||
|
threads.map((t) => t.threadId),
|
||||||
|
[7001, 7002, 7004, 7005, 7003, 7006, 7007],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwechselt den letzten Antwortenden nicht mit dem Ersteller', () => {
|
||||||
|
// Der Ersteller hat sein Konto geloescht und steht ohne Profillink da.
|
||||||
|
// Der letzte Antwortende hat eines - wer nach dem ersten Profillink in
|
||||||
|
// der Zeile greift, schreibt den Antrag dem Falschen zu.
|
||||||
|
const fremd = threads.find((t) => t.threadId === 7007);
|
||||||
|
assert.equal(fremd?.authorName, '-Verschwunden-');
|
||||||
|
assert.equal(fremd?.authorForumId, null, 'er hat kein Konto mehr');
|
||||||
|
assert.equal(fremd?.authorProfileUrl, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
|
||||||
|
const erster = threads.find((t) => t.threadId === 7001);
|
||||||
|
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt ohne Zeitangabe klar', () => {
|
||||||
|
const ohneZeit = threads.find((t) => t.threadId === 7002);
|
||||||
|
assert.equal(ohneZeit?.postedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest den Ersteller auch ohne Profil-Link', () => {
|
||||||
|
// Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne
|
||||||
|
// Rueckfall haette der Thread keinen Autor und liesse sich niemandem
|
||||||
|
// zuordnen - er waere im Abgleich unsichtbar.
|
||||||
|
const ohneKonto = threads.find((t) => t.threadId === 7006);
|
||||||
|
assert.equal(ohneKonto?.authorName, 'Ehemaliger');
|
||||||
|
assert.equal(ohneKonto?.authorForumId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => {
|
||||||
|
const first = threads.find((t) => t.threadId === 7001);
|
||||||
|
assert.equal(first?.authorName, 'Antragsteller');
|
||||||
|
assert.equal(first?.authorForumId, 501, 'die Forum-ID überlebt Namensänderungen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('löst Umlaut- und Anführungszeichen-Entities auf', () => {
|
||||||
|
const second = threads.find((t) => t.threadId === 7002);
|
||||||
|
assert.equal(second?.title, 'Custom Character Redesign | Alt zu Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt einen zurückgelassenen Verweis als solchen', () => {
|
||||||
|
const stub = threads.find((t) => t.threadId === 7003);
|
||||||
|
assert.equal(stub?.isMovedStub, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Unterforen und deren letzte Beiträge', () => {
|
||||||
|
// 9999 steht im wbbBoardNode-Kasten, gehoert aber nicht in dieses Board.
|
||||||
|
assert.equal(threads.some((t) => t.threadId === 9999), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest die Gesamtzahl der Seiten, nicht nur die verlinkten', () => {
|
||||||
|
// Die Blätterleiste verlinkt hier nur Seite 2 und 3, das Board hat aber 25.
|
||||||
|
// Wer die Links auswertet, liest den grössten Teil des Archivs nie.
|
||||||
|
assert.equal(parsePageCount(BOARD), 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fällt ohne das Element auf die Links zurück', () => {
|
||||||
|
assert.equal(parsePageCount('<a href="x&pageNo=7">7</a>'), 7);
|
||||||
|
assert.equal(parsePageCount('<html>ohne Blaetterleiste</html>'), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einer leeren Seite klar', () => {
|
||||||
|
assert.deepEqual(parseBoard('<html><body>nichts</body></html>'), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ankündigungen aussortieren', () => {
|
||||||
|
const threads = parseBoard(BOARD);
|
||||||
|
|
||||||
|
it('liest die Labels eines Threads', () => {
|
||||||
|
const regelwerk = threads.find((t) => t.threadId === 7004);
|
||||||
|
assert.deepEqual(regelwerk?.labels, ['Wichtig', 'Global']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt Wichtig und Information als Ausschluss', () => {
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7004)!), true);
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7005)!), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt echte Anträge durch, auch mit anderen Labels', () => {
|
||||||
|
assert.equal(hasIgnoredLabel(threads.find((t) => t.threadId === 7001)!), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wertet den Titel nicht aus', () => {
|
||||||
|
// "The Duke Rang Update" war ein echter Antrag, "Regelwerk" keiner. Am Titel
|
||||||
|
// ist das nicht zu erkennen - deshalb entscheidet allein das Label.
|
||||||
|
const ohneStichwort = parseBoard(BOARD).find((t) => t.threadId === 7005);
|
||||||
|
assert.ok(ohneStichwort, 'auch ein Thread ohne CC im Titel wird gelesen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Thread-Verweis lesen', () => {
|
||||||
|
it('nimmt den vollen Link', () => {
|
||||||
|
assert.equal(
|
||||||
|
parseThreadId('https://senseigaming.de/forum/index.php?thread/8041-cc-antrag-feldagent/'),
|
||||||
|
8041,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt auch nur den Pfad oder die blanke Nummer', () => {
|
||||||
|
assert.equal(parseThreadId('index.php?thread/7001-irgendwas/'), 7001);
|
||||||
|
assert.equal(parseThreadId('7001'), 7001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unbrauchbares ab', () => {
|
||||||
|
assert.equal(parseThreadId(''), null);
|
||||||
|
assert.equal(parseThreadId('kein link'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ruecksicht auf einen bremsenden Server', () => {
|
||||||
|
it('erkennt die Abweisungen, die "spaeter nochmal" bedeuten', () => {
|
||||||
|
assert.equal(isRateLimited(429), true);
|
||||||
|
assert.equal(isRateLimited(503), true);
|
||||||
|
assert.equal(isRateLimited(404), false, 'eine fehlende Seite kommt auch spaeter nicht');
|
||||||
|
assert.equal(isRateLimited(200), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt sich an die vom Forum genannte Wartezeit', () => {
|
||||||
|
assert.equal(retryAfterMs('30', 0), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deckelt eine unverschaemt lange Angabe', () => {
|
||||||
|
assert.equal(retryAfterMs('86400', 0), 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wartet ohne Angabe mit jedem Versuch laenger', () => {
|
||||||
|
assert.equal(retryAfterMs(null, 0), 5000);
|
||||||
|
assert.equal(retryAfterMs(null, 1), 10000);
|
||||||
|
assert.equal(retryAfterMs(null, 2), 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert eine unbrauchbare Angabe', () => {
|
||||||
|
// WoltLab darf hier auch ein Datum schicken - dann greift die Staffelung.
|
||||||
|
assert.equal(retryAfterMs('Wed, 21 Oct 2026 07:28:00 GMT', 1), 10000);
|
||||||
|
assert.equal(retryAfterMs('-5', 0), 5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,872 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
/** Ein Spieler mit einem CC-Paket im Rücken. */
|
||||||
|
async function spielerMitPaket(name: string, pakete = 1) {
|
||||||
|
const player = await db.prisma.player.create({ data: { displayName: name } });
|
||||||
|
for (let i = 0; i < pakete; i += 1) {
|
||||||
|
await credits.bookCcPackage(player.id, ctx);
|
||||||
|
}
|
||||||
|
return player.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54336);
|
||||||
|
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');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'gruppenchef', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gruppen-CCs', () => {
|
||||||
|
it('führt den Ersteller als erstes Mitglied', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Gründer');
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Die Bande' }, ctx);
|
||||||
|
|
||||||
|
const mitglieder = await characters.listMembers(character.id);
|
||||||
|
assert.equal(mitglieder.length, 1);
|
||||||
|
assert.equal(mitglieder[0]?.playerId, playerId);
|
||||||
|
assert.equal(mitglieder[0]?.leftAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kostet jeden Beitretenden ein eigenes Guthaben', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Anführer');
|
||||||
|
const zweiter = await spielerMitPaket('Mitstreiter');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Zweierbande' }, ctx);
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(zweiter);
|
||||||
|
const result = await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
assert.equal(result.memberCount, 2);
|
||||||
|
assert.equal(result.balances.CC, vorher.CC - 1, 'sein eigenes CC-Guthaben ist weg');
|
||||||
|
|
||||||
|
// Der Gründer zahlt nicht doppelt.
|
||||||
|
const gruenderStand = await credits.getBalances(gruender);
|
||||||
|
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Chef');
|
||||||
|
const zweiter = await spielerMitPaket('Nachzügler');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
|
||||||
|
|
||||||
|
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
|
||||||
|
const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
|
||||||
|
const result = await characters.addMember(
|
||||||
|
character.id,
|
||||||
|
zweiter,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
|
||||||
|
assert.equal(result.memberCount, 2);
|
||||||
|
|
||||||
|
// Ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
||||||
|
const aufgegeben = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: eigener.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(aufgegeben.status, 'INACTIVE');
|
||||||
|
assert.equal(aufgegeben.inactiveReason, 'REWORKED');
|
||||||
|
|
||||||
|
const eigene = await characters.listCharacters(zweiter);
|
||||||
|
assert.deepEqual(
|
||||||
|
eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name),
|
||||||
|
['Redesign-Bande'],
|
||||||
|
'der alte CC ist weg, der Gruppen-CC an seiner Stelle',
|
||||||
|
);
|
||||||
|
|
||||||
|
const beitritt = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: zweiter },
|
||||||
|
});
|
||||||
|
assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Chefin');
|
||||||
|
const zweiter = await spielerMitPaket('Ohne CC');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR',
|
||||||
|
'ohne Vorgänger gäbe es nichts zu ersetzen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Anführer');
|
||||||
|
const zweiter = await spielerMitPaket('Rückkehrer');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx);
|
||||||
|
|
||||||
|
const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx);
|
||||||
|
await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
const result = await characters.addMember(
|
||||||
|
character.id,
|
||||||
|
zweiter,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: alt.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(result.memberCount, 2);
|
||||||
|
|
||||||
|
// Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben.
|
||||||
|
const unveraendert = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: alt.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt niemanden einen fremden CC aufgeben', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Wirt');
|
||||||
|
const fremder = await spielerMitPaket('Fremder');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
|
||||||
|
const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
characters.addMember(
|
||||||
|
character.id,
|
||||||
|
fremder,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id },
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Gastgeber');
|
||||||
|
const zweiter = await spielerMitPaket('Gast');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
characters.addMember(
|
||||||
|
character.id,
|
||||||
|
zweiter,
|
||||||
|
{ useKind: 'CC', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED',
|
||||||
|
'ein CC-Paket kauft einen Platz, es ersetzt nichts',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt denselben Spieler nicht zweimal auf', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Doppelt', 2);
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Einmalig' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.addMember(character.id, gruender, { useKind: 'CC' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'ALREADY_MEMBER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trägt einen Spieler wieder aus, ohne Guthaben zurückzugeben', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Bleibt');
|
||||||
|
const aussteiger = await spielerMitPaket('Geht');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Wechselbande' }, ctx);
|
||||||
|
await characters.addMember(character.id, aussteiger, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const vorAustritt = await credits.getBalances(aussteiger);
|
||||||
|
const result = await characters.removeMember(character.id, aussteiger, 'Gruppe verlassen', ctx);
|
||||||
|
|
||||||
|
assert.equal(result.memberCount, 1);
|
||||||
|
const nachAustritt = await credits.getBalances(aussteiger);
|
||||||
|
assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält den CC nach dem Austritt in der Geschichte des Aussteigers', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Kern');
|
||||||
|
const kurz = await spielerMitPaket('Kurzzeitig');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx);
|
||||||
|
await characters.addMember(character.id, kurz, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const waehrend = await characters.listCharacters(kurz);
|
||||||
|
assert.ok(waehrend.some((c) => c.id === character.id), 'während der Mitgliedschaft sichtbar');
|
||||||
|
|
||||||
|
await characters.removeMember(character.id, kurz, undefined, ctx);
|
||||||
|
|
||||||
|
// Er war darin, und das bleibt so. Dass er keinen Platz mehr belegt,
|
||||||
|
// steht an der Mitgliedschaft, nicht daran, dass der CC verschwindet.
|
||||||
|
const danach = await characters.listCharacters(kurz);
|
||||||
|
const eintrag = danach.find((c) => c.id === character.id);
|
||||||
|
assert.ok(eintrag, 'der CC gehört weiter zu seiner Geschichte');
|
||||||
|
assert.ok(
|
||||||
|
eintrag?.members.find((m) => m.playerId === kurz)?.leftAt,
|
||||||
|
'seine Mitgliedschaft ist beendet',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Beim Gründer bleibt er stehen.
|
||||||
|
const beimGruender = await characters.listCharacters(gruender);
|
||||||
|
assert.ok(beimGruender.some((c) => c.id === character.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verweigert den Austritt des letzten Nutzers', async () => {
|
||||||
|
const allein = await spielerMitPaket('Einzelgänger');
|
||||||
|
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.removeMember(character.id, allein, undefined, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die Gruppe ins Redesign mit', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Alt-Chef');
|
||||||
|
const zweiter = await spielerMitPaket('Alt-Mitglied');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Vorher' }, ctx);
|
||||||
|
await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const result = await characters.redesignCharacter(
|
||||||
|
character.id,
|
||||||
|
{ name: 'Nachher', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const neue = await characters.listMembers(result.character.id);
|
||||||
|
assert.equal(neue.length, 2, 'beide sind weiterhin dabei');
|
||||||
|
assert.deepEqual(
|
||||||
|
neue.map((m) => m.playerId).sort(),
|
||||||
|
[gruender, zweiter].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt an einem inaktiven CC nachtragen, was damals war', async () => {
|
||||||
|
// Beitreten kann man einem stillgelegten CC nicht - eintragen, wer
|
||||||
|
// damals dabei war, dagegen schon. Beim Nacherfassen geht es nicht um
|
||||||
|
// jetzt, sondern um eine Geschichte, die längst gelaufen ist.
|
||||||
|
const gruender = await spielerMitPaket('Inaktiv-Chef');
|
||||||
|
const damals = await spielerMitPaket('War damals dabei');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
await characters.addMember(
|
||||||
|
character.id,
|
||||||
|
damals,
|
||||||
|
{ useKind: 'CC', joinedAt: new Date('2023-05-01'), leftAt: new Date('2023-09-01') },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const eintrag = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: damals },
|
||||||
|
});
|
||||||
|
assert.equal(eintrag.joinedAt.toISOString().slice(0, 10), '2023-05-01');
|
||||||
|
assert.ok(eintrag.leftAt, 'mit Ende, denn vorbei ist es');
|
||||||
|
|
||||||
|
// Ein stillgelegter CC belegt keinen Platz - auch nicht rückwirkend.
|
||||||
|
const belegt = (await characters.listCharacters(damals)).filter(
|
||||||
|
(c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === damals && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(belegt, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tote und entzogene CCs', () => {
|
||||||
|
it('lässt einen toten CC nicht auf dem normalen Weg zurückkehren', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Trauernder');
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Gefallener' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP erschossen');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateFree(character.id, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivatePaid(character.id, 'CC', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt für den Tod eine Begründung', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Knapp');
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Ohne Grund' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.deactivateCharacter(character.id, 'DEAD', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holt einen toten CC auf Entscheidung zurück, mit Begründung', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Auferstanden');
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Zurück' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP gestorben');
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(playerId);
|
||||||
|
const result = await characters.reviveDeadCharacter(
|
||||||
|
character.id,
|
||||||
|
{ reason: 'Rückholung nach Absprache mit der Leitung' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
assert.equal(result.character.inactiveReason, null);
|
||||||
|
assert.deepEqual(result.balances, vorher, 'ohne Guthaben-Art kostet es nichts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kann die Rückkehr auch kostenpflichtig machen', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Zahlt', 2);
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Teuer' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(playerId);
|
||||||
|
const result = await characters.reviveDeadCharacter(
|
||||||
|
character.id,
|
||||||
|
{ reason: 'Gegen ein Guthaben zurückgeholt', useKind: 'CC' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.balances.CC, vorher.CC - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt auch bei der Rückkehr eine Begründung', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Wortkarg');
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Stumm' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reviveDeadCharacter(character.id, { reason: ' ' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein entzogener CC kommt nicht gratis zurück, aber gegen Guthaben', async () => {
|
||||||
|
const playerId = await spielerMitPaket('Entzogen', 2);
|
||||||
|
const { character } = await characters.createCharacter(playerId, { name: 'Weggenommen' }, ctx);
|
||||||
|
await characters.deactivateCharacter(character.id, 'NO_FREE_CLAIM', ctx, 'Regelverstoss');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateFree(character.id, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await characters.reactivatePaid(character.id, 'CC', ctx);
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => {
|
||||||
|
it('lässt einen redesignten CC nicht neben seinem Nachfolger zurückkehren', async () => {
|
||||||
|
const spieler = await spielerMitPaket('Schlaufuchs');
|
||||||
|
const a = await characters.createCharacter(spieler, { name: 'CC A' }, ctx);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
a.character.id,
|
||||||
|
{ name: 'CC B', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein weiteres Redesign-Paket kauft keinen Platz, nur einen Tausch.
|
||||||
|
await credits.bookRedesignPackage(spieler, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT',
|
||||||
|
'sonst stünden zwei aktive CCs auf einem einzigen Paket',
|
||||||
|
);
|
||||||
|
|
||||||
|
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
|
||||||
|
assert.equal(aktive.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ihn zurückkehren, sobald der Nachfolger weg ist', async () => {
|
||||||
|
const spieler = await spielerMitPaket('Geduldig');
|
||||||
|
const a = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
|
||||||
|
const b = await characters.redesignCharacter(
|
||||||
|
a.character.id,
|
||||||
|
{ name: 'Zweiter', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await credits.bookRedesignPackage(spieler, ctx);
|
||||||
|
|
||||||
|
await characters.deactivateCharacter(b.character.id, 'OTHER', ctx, 'Platz gemacht');
|
||||||
|
|
||||||
|
const zurueck = await characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx);
|
||||||
|
assert.equal(zurueck.character.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ihn mit einem zweiten CC-Paket auch neben dem Nachfolger zurückkehren', async () => {
|
||||||
|
const spieler = await spielerMitPaket('Grossverdiener');
|
||||||
|
const a = await characters.createCharacter(spieler, { name: 'Alpha' }, ctx);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
a.character.id,
|
||||||
|
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Das zweite Paket ist ein zweiter Platz - damit dürfen beide laufen.
|
||||||
|
await credits.bookCcPackage(spieler, ctx);
|
||||||
|
const zurueck = await characters.reactivatePaid(a.character.id, 'CC', ctx);
|
||||||
|
|
||||||
|
assert.equal(zurueck.character.status, 'ACTIVE');
|
||||||
|
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
|
||||||
|
assert.equal(aktive.length, 2, 'zwei Pakete, zwei Plätze');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ein überzogenes Konto weiterhin zu', async () => {
|
||||||
|
// Ein CC vor dem Paket einzutragen bleibt möglich; das Minus steht
|
||||||
|
// sichtbar da. Gebucht ist der Platz trotzdem - das ist der Unterschied
|
||||||
|
// zu einem CC, der ohne jede Buchung aktiv wird.
|
||||||
|
const spieler = (await db.prisma.player.create({ data: { displayName: 'Vorab' } })).id;
|
||||||
|
const ergebnis = await characters.createCharacter(spieler, { name: 'Vorab-CC' }, ctx);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.balances.CC, -1);
|
||||||
|
assert.equal(ergebnis.overdrawn, true);
|
||||||
|
assert.equal(ergebnis.character.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält die Regel auch bei einem Gruppen-CC ein', async () => {
|
||||||
|
const gruender = await spielerMitPaket('Wirtin');
|
||||||
|
const gast = await spielerMitPaket('Gast mit Plan');
|
||||||
|
const { character } = await characters.createCharacter(gruender, { name: 'Die Runde' }, ctx);
|
||||||
|
|
||||||
|
// Der Gast hat einen eigenen CC und gibt ihn für den Platz auf.
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx);
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.addMember(
|
||||||
|
character.id,
|
||||||
|
gast,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const belegt = (await characters.listCharacters(gast)).filter(
|
||||||
|
(c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === gast && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
belegt.map((c) => c.name),
|
||||||
|
['Die Runde'],
|
||||||
|
'ein Platz, ein belegter Platz - der eigene ist dafür weg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aus einem Gruppen-CC herausredesignen', () => {
|
||||||
|
/** Ein Gruppen-CC mit drei Nutzern, jeder mit eigenem Paket bezahlt. */
|
||||||
|
async function dreierGruppe(praefix: string) {
|
||||||
|
const chef = await spielerMitPaket(`${praefix}-Chef`);
|
||||||
|
const zwei = await spielerMitPaket(`${praefix}-Zwei`);
|
||||||
|
const drei = await spielerMitPaket(`${praefix}-Drei`);
|
||||||
|
const { character } = await characters.createCharacter(chef, { name: `${praefix}-Bande` }, ctx);
|
||||||
|
await characters.addMember(character.id, zwei, { useKind: 'CC' }, ctx);
|
||||||
|
await characters.addMember(character.id, drei, { useKind: 'CC' }, ctx);
|
||||||
|
return { chef, zwei, drei, character };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lässt ein Mitglied aussteigen, ohne die Gruppe anzutasten', async () => {
|
||||||
|
const { zwei, drei, chef, character } = await dreierGruppe('Austritt');
|
||||||
|
|
||||||
|
const ergebnis = await characters.redesignMembership(
|
||||||
|
character.id,
|
||||||
|
zwei,
|
||||||
|
{ name: 'Eigener Weg', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.character.name, 'Eigener Weg');
|
||||||
|
assert.equal(ergebnis.character.status, 'ACTIVE');
|
||||||
|
assert.equal(ergebnis.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
|
||||||
|
|
||||||
|
// Die Gruppe läuft unberührt weiter.
|
||||||
|
const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } });
|
||||||
|
assert.equal(gruppe.status, 'ACTIVE');
|
||||||
|
|
||||||
|
const dabei = await db.prisma.characterMember.findMany({
|
||||||
|
where: { characterId: character.id, leftAt: null },
|
||||||
|
select: { playerId: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
dabei.map((m) => m.playerId).sort((a, b) => a - b),
|
||||||
|
[chef, drei].sort((a, b) => a - b),
|
||||||
|
'die beiden anderen bleiben',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält fest, wohin der Ausgestiegene gewechselt ist', async () => {
|
||||||
|
const { zwei, character } = await dreierGruppe('Spur');
|
||||||
|
|
||||||
|
const ergebnis = await characters.redesignMembership(
|
||||||
|
character.id,
|
||||||
|
zwei,
|
||||||
|
{ name: 'Nachfolger', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mitglied = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: zwei },
|
||||||
|
});
|
||||||
|
assert.equal(mitglied.successorId, ergebnis.character.id);
|
||||||
|
assert.equal(
|
||||||
|
mitglied.inactiveReason,
|
||||||
|
null,
|
||||||
|
'sein Platz wurde getauscht, nicht entzogen - es gibt nichts zurückzuholen',
|
||||||
|
);
|
||||||
|
assert.equal(ergebnis.character.predecessorId, character.id, 'die Herkunft bleibt sichtbar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erschleicht dabei keinen Platz', async () => {
|
||||||
|
const { zwei, character } = await dreierGruppe('Platz');
|
||||||
|
|
||||||
|
await characters.redesignMembership(
|
||||||
|
character.id,
|
||||||
|
zwei,
|
||||||
|
{ name: 'Getauscht', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sichtbar ist beides - er war ja in der Gruppe. Belegt ist nur einer.
|
||||||
|
const belegt = (await characters.listCharacters(zwei)).filter(
|
||||||
|
(c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === zwei && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
belegt.map((c) => c.name),
|
||||||
|
['Getauscht'],
|
||||||
|
'ein Paket, ein belegter Platz - der Gruppenplatz ist dafür weg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält den Gruppen-CC in der Geschichte des Ausgestiegenen', async () => {
|
||||||
|
const { zwei, character } = await dreierGruppe('Geschichte');
|
||||||
|
const neuer = await characters.redesignMembership(
|
||||||
|
character.id,
|
||||||
|
zwei,
|
||||||
|
{ name: 'Eigenes Ding', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const seine = await characters.listCharacters(zwei);
|
||||||
|
const gruppe = seine.find((c) => c.id === character.id);
|
||||||
|
assert.ok(gruppe, 'er war in der Gruppe, und das bleibt sichtbar');
|
||||||
|
|
||||||
|
// Die Spur zeigt, wohin es von dort aus ging - daran hängt die Anzeige
|
||||||
|
// den alten Eintrag unter die richtige Kette.
|
||||||
|
const eigene = gruppe?.members.find((m) => m.playerId === zwei);
|
||||||
|
assert.ok(eigene?.leftAt, 'seine Mitgliedschaft ist beendet');
|
||||||
|
assert.equal(eigene?.successorId, neuer.character.id);
|
||||||
|
assert.equal(eigene?.successor?.name, 'Eigenes Ding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist den letzten Nutzer auf das gewöhnliche Redesign hin', async () => {
|
||||||
|
const allein = await spielerMitPaket('Einzelgänger');
|
||||||
|
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
characters.redesignMembership(
|
||||||
|
character.id,
|
||||||
|
allein,
|
||||||
|
{ name: 'Solo II', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt das Redesign der ganzen Gruppe unberührt weiter zu', async () => {
|
||||||
|
const { chef, zwei, drei, character } = await dreierGruppe('Gemeinsam');
|
||||||
|
|
||||||
|
const neu = await characters.redesignCharacter(
|
||||||
|
character.id,
|
||||||
|
{ name: 'Die Bande 2.0', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dabei = await db.prisma.characterMember.findMany({
|
||||||
|
where: { characterId: neu.character.id, leftAt: null },
|
||||||
|
select: { playerId: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
dabei.map((m) => m.playerId).sort((a, b) => a - b),
|
||||||
|
[chef, zwei, drei].sort((a, b) => a - b),
|
||||||
|
'wer dabei war, ist auch danach dabei',
|
||||||
|
);
|
||||||
|
assert.equal(neu.predecessor.inactiveReason, 'REWORKED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Einzelne Mitglieder fallen aus', () => {
|
||||||
|
async function paar(praefix: string) {
|
||||||
|
const chef = await spielerMitPaket(`${praefix}-A`);
|
||||||
|
const partner = await spielerMitPaket(`${praefix}-B`);
|
||||||
|
const { character } = await characters.createCharacter(chef, { name: `${praefix}-Duo` }, ctx);
|
||||||
|
await characters.addMember(character.id, partner, { useKind: 'CC' }, ctx);
|
||||||
|
return { chef, partner, character };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lässt einen einzelnen Nutzer sterben, während der CC weiterläuft', async () => {
|
||||||
|
const { chef, partner, character } = await paar('Tod');
|
||||||
|
|
||||||
|
await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'Von SCP-096 erwischt');
|
||||||
|
|
||||||
|
const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } });
|
||||||
|
assert.equal(gruppe.status, 'ACTIVE', 'der CC selbst lebt weiter');
|
||||||
|
|
||||||
|
const uebrig = await db.prisma.characterMember.findMany({
|
||||||
|
where: { characterId: character.id, leftAt: null },
|
||||||
|
});
|
||||||
|
assert.deepEqual(uebrig.map((m) => m.playerId), [chef]);
|
||||||
|
|
||||||
|
const toter = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: partner },
|
||||||
|
});
|
||||||
|
assert.equal(toter.inactiveReason, 'DEAD');
|
||||||
|
assert.equal(toter.inactiveNote, 'Von SCP-096 erwischt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt dem Ausgefallenen seinen CC weiter an', async () => {
|
||||||
|
const { partner, character } = await paar('Sichtbar');
|
||||||
|
await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
const seine = await characters.listCharacters(partner);
|
||||||
|
assert.ok(
|
||||||
|
seine.some((c) => c.id === character.id),
|
||||||
|
'sonst könnte er nie zurückkehren',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt für einen Tod eine Begründung', async () => {
|
||||||
|
const { partner, character } = await paar('Begruendung');
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.deactivateMember(character.id, partner, 'DEAD', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holt einen geputzten Nutzer gratis zurück', async () => {
|
||||||
|
const { partner, character } = await paar('Gratis');
|
||||||
|
await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(partner);
|
||||||
|
await characters.reactivateMember(character.id, partner, {}, ctx);
|
||||||
|
|
||||||
|
const dabei = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: partner },
|
||||||
|
});
|
||||||
|
assert.equal(dabei.leftAt, null);
|
||||||
|
assert.equal(dabei.inactiveReason, null);
|
||||||
|
assert.deepEqual(await credits.getBalances(partner), vorher, 'gratis heißt gratis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt einen entzogenen Platz nur gegen Guthaben zurückkommen', async () => {
|
||||||
|
const { partner, character } = await paar('Entzogen');
|
||||||
|
await characters.deactivateMember(character.id, partner, 'NO_FREE_CLAIM', ctx, 'Regelverstoß');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateMember(character.id, partner, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sein Platz war entzogen, also kostet die Rückkehr wieder einen - in
|
||||||
|
// seiner Liste steht der CC ganz unten.
|
||||||
|
const vorher = await credits.getBalances(partner);
|
||||||
|
await characters.reactivateMember(character.id, partner, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
assert.equal((await credits.getBalances(partner)).CC, vorher.CC - 1, 'bezahlt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => {
|
||||||
|
const { partner, character } = await paar('Rueckkehr');
|
||||||
|
await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'gestorben');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateMember(character.id, partner, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
|
||||||
|
);
|
||||||
|
|
||||||
|
await characters.reactivateMember(
|
||||||
|
character.id,
|
||||||
|
partner,
|
||||||
|
{ reason: 'Klon aus der Lagerhalle' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const dabei = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: partner },
|
||||||
|
});
|
||||||
|
assert.equal(dabei.leftAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macht aus einem freiwilligen Austritt keine Rückkehr auf Kosten des Hauses', async () => {
|
||||||
|
const { partner, character } = await paar('Freiwillig');
|
||||||
|
await characters.removeMember(character.id, partner, 'Keine Lust mehr', ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.reactivateMember(character.id, partner, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'WAS_VOLUNTARY',
|
||||||
|
'wer freiwillig geht, tritt neu bei und zahlt neu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt den letzten Nutzer nicht einzeln ausfallen', async () => {
|
||||||
|
const allein = await spielerMitPaket('Letzter');
|
||||||
|
const { character } = await characters.createCharacter(allein, { name: 'Alleingang' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.deactivateMember(character.id, allein, 'PLAYER_INACTIVITY', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
|
||||||
|
'dann ist der ganze CC betroffen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Der eigene CC bleibt der eigene', () => {
|
||||||
|
it('lässt den Besitzer sich ohne neue Buchung wieder eintragen', async () => {
|
||||||
|
// Kommt beim Nacherfassen vor: Der Besitzer ist aus seinem eigenen CC
|
||||||
|
// ausgetragen worden. Seine Buchung hängt trotzdem daran, seit er ihn
|
||||||
|
// angelegt hat - ein zweites Mal zahlt er nicht.
|
||||||
|
const besitzer = await spielerMitPaket('Selbst ausgetragen');
|
||||||
|
const zweiter = await spielerMitPaket('Bleibt drin');
|
||||||
|
const { character } = await characters.createCharacter(besitzer, { name: 'Seiner' }, ctx);
|
||||||
|
await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
await characters.removeMember(character.id, besitzer, 'Versehen', ctx);
|
||||||
|
const vorher = await credits.getBalances(besitzer);
|
||||||
|
|
||||||
|
await characters.addMember(character.id, besitzer, {}, ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(await credits.getBalances(besitzer), vorher, 'kostet ihn nichts');
|
||||||
|
const dabei = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: besitzer },
|
||||||
|
});
|
||||||
|
assert.equal(dabei.leftAt, null, 'und er ist wieder dabei');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt von einem Fremden weiterhin ein Guthaben', async () => {
|
||||||
|
const besitzer = await spielerMitPaket('Wirt drei');
|
||||||
|
const fremder = await spielerMitPaket('Gast drei');
|
||||||
|
const { character } = await characters.createCharacter(besitzer, { name: 'Seins' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.addMember(character.id, fremder, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ein Gruppen-CC zählt für jeden Nutzer einzeln', () => {
|
||||||
|
/// Womit ein bestimmter Spieler diesen CC bezahlt hat.
|
||||||
|
async function bezahltMit(characterId: number, playerId: number): Promise<string | undefined> {
|
||||||
|
const buchung = await db.prisma.creditLedger.findFirst({
|
||||||
|
where: { characterId, playerId, source: 'CONSUME', reversedBy: null },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
return buchung?.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Liste dieses Spielers, von unten nach oben.
|
||||||
|
async function liste(playerId: number): Promise<string[]> {
|
||||||
|
const eigene = await db.prisma.characterMember.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }],
|
||||||
|
select: { lineageId: true, character: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
return eigene.map((e) => `${e.lineageId.slice(0, 4)}:${e.character.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ist für den einen ein Platz und für den anderen ein Redesign', async () => {
|
||||||
|
const wirt = await spielerMitPaket('Zaehlwirt');
|
||||||
|
const gast = await spielerMitPaket('Zaehlgast');
|
||||||
|
const gruppe = await characters.createCharacter(wirt, { name: 'Gemeinsames Werk' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Sein Erster' }, ctx);
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.addMember(
|
||||||
|
gruppe.character.id,
|
||||||
|
gast,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(await bezahltMit(gruppe.character.id, wirt), 'CC', 'für den Wirt sein Platz');
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, gast),
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
'für den Gast das Redesign seines ersten CCs',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await characters.richteBuchungenAus(),
|
||||||
|
0,
|
||||||
|
'beide Sichten sind in sich stimmig und bleiben unangetastet',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Rechnung des Gastes: eine Liste mit zwei Einträgen.
|
||||||
|
const stand = await credits.getBalances(gast);
|
||||||
|
assert.equal(stand.CC, 0, 'ein Platz, eine Liste');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 1, 'von zwei Redesigns eines verbraucht');
|
||||||
|
assert.deepEqual(
|
||||||
|
(await liste(gast)).map((e) => e.split(':')[1]),
|
||||||
|
['Sein Erster', 'Gemeinsames Werk'],
|
||||||
|
'in seiner Liste steht der Gruppen-CC über seinem ersten',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt jeden für sich verschieben, ohne den anderen anzufassen', async () => {
|
||||||
|
const wirt = await spielerMitPaket('Schiebewirt', 2);
|
||||||
|
const gast = await spielerMitPaket('Schiebegast', 2);
|
||||||
|
const gruppe = await characters.createCharacter(wirt, { name: 'Die Bude' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Gasts Erster' }, ctx);
|
||||||
|
|
||||||
|
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
|
||||||
|
assert.equal(await bezahltMit(gruppe.character.id, gast), 'CC', 'erst ein eigener Platz');
|
||||||
|
|
||||||
|
// Der Gast reiht den Gruppen-CC in seine Liste ein: für ihn wird daraus
|
||||||
|
// ein Redesign seines ersten CCs.
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.moveInChain(
|
||||||
|
gruppe.character.id,
|
||||||
|
{
|
||||||
|
lineageId: (
|
||||||
|
await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { playerId: gast, characterId: eigener.character.id },
|
||||||
|
})
|
||||||
|
).lineageId,
|
||||||
|
afterId: eigener.character.id,
|
||||||
|
forPlayerId: gast,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, gast),
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
'in seiner Liste ist es jetzt ein Redesign',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, wirt),
|
||||||
|
'CC',
|
||||||
|
'beim Wirt ändert sich dadurch nichts',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Und der CC selbst bleibt aktiv - der Wirt spielt ihn weiter.
|
||||||
|
const danach = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: gruppe.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(danach.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-8
@@ -1,11 +1,19 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { PGlite } from '@electric-sql/pglite';
|
import { PGlite } from '@electric-sql/pglite';
|
||||||
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
|
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql');
|
const MIGRATIONS_DIR = join(here, '..', 'prisma', 'migrations');
|
||||||
|
|
||||||
|
/** Alle Migrationen in der Reihenfolge, in der Prisma sie anwenden würde. */
|
||||||
|
function migrationFiles(): string[] {
|
||||||
|
return readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((entry) => /^\d/.test(entry))
|
||||||
|
.sort()
|
||||||
|
.map((entry) => join(MIGRATIONS_DIR, entry, 'migration.sql'));
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestDatabase {
|
export interface TestDatabase {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,21 +21,45 @@ export interface TestDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet eine Postgres-Instanz im Speicher und stellt sie ueber einen lokalen
|
* Startet eine Postgres-Instanz im Speicher und stellt sie über einen lokalen
|
||||||
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
|
* Port bereit, damit Prisma sich wie an eine echte Datenbank verbindet.
|
||||||
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
||||||
*/
|
*/
|
||||||
export async function startTestDatabase(port: number): Promise<TestDatabase> {
|
export async function startTestDatabase(port: number): Promise<TestDatabase> {
|
||||||
const db = await PGlite.create();
|
const db = await PGlite.create();
|
||||||
await db.exec(readFileSync(MIGRATION, 'utf8'));
|
for (const file of migrationFiles()) {
|
||||||
|
await db.exec(readFileSync(file, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' });
|
// Die Testdateien laufen nebeneinander und bekommen feste Ports. Hängt aus
|
||||||
await server.start();
|
// einem vorherigen Lauf noch einer fest, wäre der Start sonst ohne Grund
|
||||||
|
// fehlgeschlagen - deshalb ein paar Versuche mit dem nächsten Port.
|
||||||
|
let server: PGLiteSocketServer | null = null;
|
||||||
|
let benutzt = port;
|
||||||
|
|
||||||
|
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||||||
|
const kandidat = new PGLiteSocketServer({ db, port: benutzt, host: '127.0.0.1' });
|
||||||
|
try {
|
||||||
|
await kandidat.start();
|
||||||
|
server = kandidat;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== 'EADDRINUSE') throw error;
|
||||||
|
benutzt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Kein freier Port ab ${port} für die Testdatenbank gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const belegterPort = benutzt;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
||||||
// gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht.
|
// gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht.
|
||||||
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
|
url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
await server.stop();
|
await server.stop();
|
||||||
await db.close();
|
await db.close();
|
||||||
|
|||||||
+173
-10
@@ -81,7 +81,7 @@ describe('Anmeldung', () => {
|
|||||||
assert.equal(response.statusCode, 401);
|
assert.equal(response.statusCode, 401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verraet nicht, ob es den Benutzernamen gibt', async () => {
|
it('verrät nicht, ob es den Benutzernamen gibt', async () => {
|
||||||
const unknownUser = await app.inject({
|
const unknownUser = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/auth/login',
|
||||||
@@ -97,7 +97,7 @@ describe('Anmeldung', () => {
|
|||||||
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst ein gesperrtes Konto nicht herein', async () => {
|
it('lässt ein gesperrtes Konto nicht herein', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/auth/login',
|
url: '/api/auth/login',
|
||||||
@@ -129,7 +129,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.json().error, 'FORBIDDEN');
|
assert.equal(response.json().error, 'FORBIDDEN');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blockt einen gefaelschten Token', async () => {
|
it('blockt einen gefälschten Token', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -142,7 +142,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.statusCode, 403);
|
assert.equal(response.statusCode, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst die Anfrage mit passendem Token durch', async () => {
|
it('lässt die Anfrage mit passendem Token durch', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -155,7 +155,7 @@ describe('CSRF-Schutz', () => {
|
|||||||
assert.equal(response.statusCode, 201);
|
assert.equal(response.statusCode, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('laesst lesende Anfragen ohne Token zu', async () => {
|
it('lässt lesende Anfragen ohne Token zu', async () => {
|
||||||
const credentials = await login('gast');
|
const credentials = await login('gast');
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -219,6 +219,115 @@ describe('Rollen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Der Zugang aus der Erstinstallation', () => {
|
||||||
|
/** Legt einen geschützten Admin an, wie es der erste Start tut. */
|
||||||
|
async function erstzugang() {
|
||||||
|
return db.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'erstzugang',
|
||||||
|
passwordHash: 'x',
|
||||||
|
role: 'ADMIN',
|
||||||
|
isProtected: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lässt sich seine Rolle nicht nehmen', async () => {
|
||||||
|
const geschuetzt = await erstzugang();
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'VIEWER' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
assert.equal(response.json().error, 'PROTECTED_ACCOUNT');
|
||||||
|
|
||||||
|
const unveraendert = await db.prisma.user.findUniqueOrThrow({ where: { id: geschuetzt.id } });
|
||||||
|
assert.equal(unveraendert.role, 'ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich nicht sperren', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bekommt sein Passwort nicht von aussen zurückgesetzt', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}/reset-password`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { newPassword: 'EinLangesPasswort1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich nicht löschen', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
assert.ok(await db.prisma.user.findUnique({ where: { id: geschuetzt.id } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist in der Liste als geschützt ausgewiesen', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/users',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintrag = response
|
||||||
|
.json()
|
||||||
|
.items.find((u: { username: string }) => u.username === 'erstzugang');
|
||||||
|
assert.equal(eintrag.isLocked, true, 'die Oberfläche soll die Knöpfe gar nicht erst zeigen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt gewöhnliche Konten unangetastet', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
const normal = await db.prisma.user.findFirstOrThrow({ where: { username: 'gast' } });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${normal.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'EDITOR' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200, 'der Schutz gilt nur für den Erstzugang');
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${normal.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'VIEWER' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Erstpasswort', () => {
|
describe('Erstpasswort', () => {
|
||||||
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
||||||
const credentials = await login('neu');
|
const credentials = await login('neu');
|
||||||
@@ -227,7 +336,7 @@ describe('Erstpasswort', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/players',
|
url: '/api/players',
|
||||||
headers: authHeaders(credentials),
|
headers: authHeaders(credentials),
|
||||||
payload: { displayName: 'Zu frueh' },
|
payload: { displayName: 'Zu früh' },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(response.statusCode, 403);
|
assert.equal(response.statusCode, 403);
|
||||||
@@ -251,8 +360,8 @@ describe('Erstpasswort', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Guthaben ueber die API', () => {
|
describe('Guthaben über die API', () => {
|
||||||
it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => {
|
it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => {
|
||||||
const credentials = await login('team');
|
const credentials = await login('team');
|
||||||
|
|
||||||
const created = await app.inject({
|
const created = await app.inject({
|
||||||
@@ -271,11 +380,14 @@ describe('Guthaben ueber die API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(booked.statusCode, 200);
|
assert.equal(booked.statusCode, 200);
|
||||||
|
// Ein im Shop gekauftes Paket bringt ein Redesign mit; hier geht es um
|
||||||
|
// den Einzelfall, in dem jemand nur einen Platz bekommt.
|
||||||
assert.deepEqual(booked.json().balances, {
|
assert.deepEqual(booked.json().balances, {
|
||||||
CC: 1,
|
CC: 1,
|
||||||
REDESIGN_PAID: 1,
|
REDESIGN_PAID: 0,
|
||||||
REDESIGN_KULANZ: 0,
|
REDESIGN_KULANZ: 0,
|
||||||
redesignTotal: 1,
|
redesignTotal: 0,
|
||||||
|
granted: { CC: 1, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,3 +398,54 @@ describe('Guthaben ueber die API', () => {
|
|||||||
assert.equal(response.json().error, 'NOT_FOUND');
|
assert.equal(response.json().error, 'NOT_FOUND');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Auslieferung der Oberfläche', () => {
|
||||||
|
// Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die
|
||||||
|
// index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die
|
||||||
|
// alte Oberfläche - ohne dass irgendwo ein Fehler auftaucht.
|
||||||
|
it('lässt die index.html nicht zwischenspeichern', async () => {
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.match(
|
||||||
|
response.headers['cache-control'] as string,
|
||||||
|
/no-cache/,
|
||||||
|
'die index.html muss bei jedem Aufruf neu geholt werden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Antworten der Schnittstelle gar nicht erst in den Cache', async () => {
|
||||||
|
// Ohne Angabe entscheidet der Browser selbst, wie lange er eine Antwort
|
||||||
|
// aufhebt - dann steht nach dem Anlegen eines CCs die alte Liste da.
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/auth/me' });
|
||||||
|
|
||||||
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert auch Deep-Links ohne Cache aus', async () => {
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/players/1' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.match(response.headers['cache-control'] as string, /no-cache/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Bilder nicht ein Jahr lang kleben', async () => {
|
||||||
|
// Sie tragen keinen Hash im Namen - ein Austausch muss ankommen.
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/favicon.png' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
const header = response.headers['cache-control'] as string;
|
||||||
|
assert.doesNotMatch(header, /immutable/, 'kein immutable ohne Hash im Namen');
|
||||||
|
assert.match(header, /must-revalidate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt gehashte Assets lange liegen', async () => {
|
||||||
|
const page = await app.inject({ method: 'GET', url: '/' });
|
||||||
|
const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0];
|
||||||
|
assert.ok(asset, 'die Seite verweist auf ein Asset');
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: asset });
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.match(response.headers['cache-control'] as string, /max-age=31536000/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+236
-4
@@ -14,7 +14,7 @@ let ctx: { actorId: number; actorName: string };
|
|||||||
|
|
||||||
const CSV = [
|
const CSV = [
|
||||||
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
||||||
'A-1001;76561198000000001;Rabe;CC-Paket Gross;2025-01-15',
|
'A-1001;76561198000000001;Rabe;CC-Paket Groß;2025-01-15',
|
||||||
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
||||||
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
||||||
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
||||||
@@ -92,7 +92,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
|
|
||||||
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gueltigem Produkt');
|
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt');
|
||||||
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
||||||
assert.equal(summary.SKIP_DUPLICATE, 0);
|
assert.equal(summary.SKIP_DUPLICATE, 0);
|
||||||
});
|
});
|
||||||
@@ -165,7 +165,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
assert.equal(summary.SKIP_DUPLICATE, 1);
|
assert.equal(summary.SKIP_DUPLICATE, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ueberspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
it('überspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
||||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
||||||
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
||||||
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
||||||
@@ -184,7 +184,7 @@ describe('Vorschau und Buchung', () => {
|
|||||||
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein abgeschlossener Import laesst sich nicht erneut buchen', async () => {
|
it('ein abgeschlossener Import lässt sich nicht erneut buchen', async () => {
|
||||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
||||||
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
||||||
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
||||||
@@ -201,3 +201,235 @@ describe('Vorschau und Buchung', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Kollisionen beim Buchen', () => {
|
||||||
|
it('überspringt eine Bestellung, die inzwischen anderswo gebucht wurde', async () => {
|
||||||
|
const csv = ['Bestellnummer;Steam-ID;Produkt', 'K-1;76561198000000077;CC-Paket'].join('\n');
|
||||||
|
const mapping = {
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
createMissingPlayers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zwei Vorschauen mit derselben Bestellung - so entstanden durch mehrfachen Abgleich.
|
||||||
|
const ersteVorschau = await imports.createImport(
|
||||||
|
'erste.csv',
|
||||||
|
imports.parseUpload('erste.csv', Buffer.from(csv, 'utf8')),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const zweiteVorschau = await imports.createImport(
|
||||||
|
'zweite.csv',
|
||||||
|
imports.parseUpload('zweite.csv', Buffer.from(csv, 'utf8')),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await imports.applyMapping(ersteVorschau, mapping, ctx);
|
||||||
|
await imports.applyMapping(zweiteVorschau, mapping, ctx);
|
||||||
|
|
||||||
|
const erste = await imports.commitImport(ersteVorschau, ctx);
|
||||||
|
assert.equal(erste.booked, 1);
|
||||||
|
|
||||||
|
// Früher scheiterte hier der gesamte Import an der eindeutigen Bestell-ID.
|
||||||
|
const zweite = await imports.commitImport(zweiteVorschau, ctx);
|
||||||
|
assert.equal(zweite.booked, 0);
|
||||||
|
assert.equal(zweite.skipped, 1, 'die Zeile wird übersprungen, nicht der Import abgebrochen');
|
||||||
|
|
||||||
|
const player = await db.prisma.player.findUniqueOrThrow({
|
||||||
|
where: { steamId: '76561198000000077' },
|
||||||
|
});
|
||||||
|
const balances = await credits.getBalances(player.id);
|
||||||
|
assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Einzelne Zeilen beim Spieler buchen', () => {
|
||||||
|
// Jeder Fall bekommt eigene Bestellnummern: ein gebuchter Dedup-Anker gilt
|
||||||
|
// dauerhaft und wuerde sonst den naechsten Fall stillschweigend leerlaufen
|
||||||
|
// lassen. Die Spieler stehen fest - beim Weg ueber die Spielerseite geht es
|
||||||
|
// genau darum, einen Bekannten abzuarbeiten.
|
||||||
|
let eule = 0;
|
||||||
|
let specht = 0;
|
||||||
|
|
||||||
|
function datei(praefix: string): Buffer {
|
||||||
|
return Buffer.from(
|
||||||
|
[
|
||||||
|
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
||||||
|
`${praefix}-1;76561198000000011;Eule;CC-Paket Groß;2025-02-01`,
|
||||||
|
`${praefix}-2;76561198000000011;Eule;Redesign Gutschein;2025-02-02`,
|
||||||
|
`${praefix}-3;76561198000000012;Specht;CC-Paket Klein;2025-02-03`,
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vorschau(praefix: string, name = `${praefix}.csv`) {
|
||||||
|
const parsed = imports.parseUpload(name, datei(praefix));
|
||||||
|
const id = await imports.createImport(name, parsed, ctx);
|
||||||
|
await imports.applyMapping(id, MAPPING, ctx);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
eule = (
|
||||||
|
await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Eule', steamId: '76561198000000011' },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
specht = (
|
||||||
|
await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Specht', steamId: '76561198000000012' },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bucht nur die gewählte Zeile und lässt den Rest der Vorschau offen', async () => {
|
||||||
|
const importId = await vorschau('EINZ');
|
||||||
|
|
||||||
|
const offen = await imports.listPendingRowsFor(eule);
|
||||||
|
assert.equal(offen.length, 2, 'ein CC-Paket und ein Redesign-Gutschein');
|
||||||
|
assert.equal(offen[0]?.productName, 'CC-Paket Groß', 'ältester Kauf zuerst');
|
||||||
|
|
||||||
|
const ergebnis = await imports.bookRows([offen[0]!.id], ctx);
|
||||||
|
assert.equal(ergebnis.booked, 1);
|
||||||
|
|
||||||
|
// Ein CC-Paket schreibt zwei Zeilen - dieselbe Regel wie beim Import.
|
||||||
|
const stand = await credits.getBalances(eule);
|
||||||
|
assert.equal(stand.CC, 1);
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 1);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await imports.listPendingRowsFor(eule)).length,
|
||||||
|
1,
|
||||||
|
'die gebuchte Zeile ist aus der Liste, die andere wartet weiter',
|
||||||
|
);
|
||||||
|
|
||||||
|
const nachher = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
|
||||||
|
assert.equal(nachher.status, 'PENDING', 'der Import als Ganzes bleibt offen');
|
||||||
|
|
||||||
|
await imports.discardImport(importId, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bucht dieselbe Bestellung nicht ein zweites Mal über den Import', async () => {
|
||||||
|
const importId = await vorschau('ZWEI');
|
||||||
|
|
||||||
|
const offen = await imports.listPendingRowsFor(specht);
|
||||||
|
assert.equal(offen.length, 1);
|
||||||
|
await imports.bookRows([offen[0]!.id], ctx);
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(specht);
|
||||||
|
|
||||||
|
// Der Import wird danach ganz normal bestätigt - die eben gebuchte Zeile
|
||||||
|
// steht dabei gar nicht mehr zur Buchung an, sie ist ja abgehakt.
|
||||||
|
const ergebnis = await imports.commitImport(importId, ctx);
|
||||||
|
assert.equal(ergebnis.booked, 2, 'nur die beiden Zeilen der Eule bleiben übrig');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await credits.getBalances(specht),
|
||||||
|
vorher,
|
||||||
|
'sonst stünde das Paket doppelt im Konto',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hakt dieselbe Bestellung auch in einem zweiten offenen Import ab', async () => {
|
||||||
|
// Zweimal dieselbe Datei eingelesen, etwa weil der Abgleich doppelt lief.
|
||||||
|
const ersterImport = await vorschau('DREI', 'lauf-a.csv');
|
||||||
|
const zweiterImport = await vorschau('DREI', 'lauf-b.csv');
|
||||||
|
|
||||||
|
const offen = await imports.listPendingRowsFor(eule);
|
||||||
|
const redesigns = offen.filter((row) => row.externalId === 'DREI-2');
|
||||||
|
assert.equal(redesigns.length, 2, 'dieselbe Bestellung liegt in beiden Vorschauen');
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(eule);
|
||||||
|
await imports.bookRows([redesigns[0]!.id], ctx);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await imports.listPendingRowsFor(eule)).filter((row) => row.externalId === 'DREI-2').length,
|
||||||
|
0,
|
||||||
|
'die Zwillingszeile im anderen Import ist mit abgehakt',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await credits.getBalances(eule)).REDESIGN_PAID,
|
||||||
|
vorher.REDESIGN_PAID + 1,
|
||||||
|
'einmal gekauft, einmal gebucht',
|
||||||
|
);
|
||||||
|
|
||||||
|
await imports.discardImport(ersterImport, ctx);
|
||||||
|
await imports.discardImport(zweiterImport, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt keine leere Vorschau in der Liste zurück', async () => {
|
||||||
|
// Genau der Fall aus dem Betrieb: alle Zeilen beim Spieler gebucht, die
|
||||||
|
// Vorschau bleibt als leere Hülle stehen und lässt sich nicht bestätigen.
|
||||||
|
const importId = await vorschau('LEER');
|
||||||
|
|
||||||
|
const offen = [
|
||||||
|
...(await imports.listPendingRowsFor(eule)),
|
||||||
|
...(await imports.listPendingRowsFor(specht)),
|
||||||
|
].filter((row) => row.importId === importId);
|
||||||
|
assert.equal(offen.length, 3, 'alle drei Zeilen dieses Imports');
|
||||||
|
|
||||||
|
await imports.bookRows(offen.map((row) => row.id), ctx);
|
||||||
|
|
||||||
|
const danach = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
|
||||||
|
assert.equal(danach.status, 'COMMITTED', 'nichts mehr zu tun heisst erledigt');
|
||||||
|
|
||||||
|
const liste = await imports.listImports();
|
||||||
|
assert.equal(
|
||||||
|
liste.some((i) => i.id === importId && i.status === 'PENDING'),
|
||||||
|
false,
|
||||||
|
'und damit aus der Liste offener Importe verschwunden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt auch Vorschauen ab, die schon leer herumlagen', async () => {
|
||||||
|
const importId = await vorschau('ALTLAST');
|
||||||
|
const offen = [
|
||||||
|
...(await imports.listPendingRowsFor(eule)),
|
||||||
|
...(await imports.listPendingRowsFor(specht)),
|
||||||
|
].filter((row) => row.importId === importId);
|
||||||
|
|
||||||
|
// Von Hand leergeräumt, ohne den Import anzufassen - so sieht der
|
||||||
|
// Bestand aus, der vor dieser Korrektur entstanden ist.
|
||||||
|
await db.prisma.importRow.updateMany({
|
||||||
|
where: { id: { in: offen.map((row) => row.id) } },
|
||||||
|
data: { plan: 'SKIP_DUPLICATE' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
||||||
|
'PENDING',
|
||||||
|
);
|
||||||
|
|
||||||
|
await imports.listImports();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
||||||
|
'COMMITTED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine leere Auswahl ab', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => imports.bookRows([], ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOTHING_SELECTED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt Zeilen auch ohne Buchung aus der Vorschau', async () => {
|
||||||
|
const importId = await vorschau('VIER');
|
||||||
|
|
||||||
|
const offen = await imports.listPendingRowsFor(specht);
|
||||||
|
assert.equal(offen.length, 1);
|
||||||
|
const vorher = await credits.getBalances(specht);
|
||||||
|
|
||||||
|
const ergebnis = await imports.dismissRows(
|
||||||
|
offen.map((row) => row.id),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(ergebnis.dismissed, 1);
|
||||||
|
|
||||||
|
assert.deepEqual(await imports.listPendingRowsFor(specht), []);
|
||||||
|
assert.deepEqual(await credits.getBalances(specht), vorher, 'weggelegt ist nicht gebucht');
|
||||||
|
|
||||||
|
await imports.discardImport(importId, ctx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54341);
|
||||||
|
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');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'nacherfasser', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function spieler(name: string, pakete = 2) {
|
||||||
|
const p = await db.prisma.player.create({ data: { displayName: name } });
|
||||||
|
for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(p.id, ctx);
|
||||||
|
return p.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die aktiven Mitglieder eines CCs, nach Namen. */
|
||||||
|
async function mitglieder(characterId: number): Promise<string[]> {
|
||||||
|
const liste = await db.prisma.characterMember.findMany({
|
||||||
|
where: { characterId, leftAt: null },
|
||||||
|
include: { player: { select: { displayName: true } } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return liste.map((m) => m.player.displayName).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Fall aus dem Betrieb: Spieler B ist längst eingetragen, und erst beim
|
||||||
|
* Durcharbeiten von Spieler A stellt sich heraus, dass die beiden einen
|
||||||
|
* Gruppen-CC teilten - über mehrere Redesigns hinweg, mit wechselnder
|
||||||
|
* Besetzung.
|
||||||
|
*/
|
||||||
|
describe('Nachträglich aufräumen, was längst passiert ist', () => {
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
let c = 0;
|
||||||
|
let d = 0;
|
||||||
|
let alpha = 0;
|
||||||
|
let beta = 0;
|
||||||
|
let gamma = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
b = await spieler('Spieler B');
|
||||||
|
a = await spieler('Spieler A');
|
||||||
|
c = await spieler('Spieler C');
|
||||||
|
d = await spieler('Spieler D');
|
||||||
|
|
||||||
|
// B wurde zuerst durchgearbeitet: ein CC, zweimal redesignt.
|
||||||
|
const ersterCC = await characters.createCharacter(b, { name: 'Alpha' }, ctx);
|
||||||
|
alpha = ersterCC.character.id;
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(b, ctx);
|
||||||
|
const zweiter = await characters.redesignCharacter(
|
||||||
|
alpha,
|
||||||
|
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
beta = zweiter.character.id;
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(b, ctx);
|
||||||
|
const dritter = await characters.redesignCharacter(
|
||||||
|
beta,
|
||||||
|
{ name: 'Gamma', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
gamma = dritter.character.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trägt A rückwirkend in ein längst redesigntes Glied ein', async () => {
|
||||||
|
// Alpha ist inaktiv - im laufenden Betrieb tritt dort niemand mehr bei.
|
||||||
|
// Beim Nachtragen geht es aber nicht um jetzt, sondern um damals.
|
||||||
|
const alt = await db.prisma.character.findUniqueOrThrow({ where: { id: alpha } });
|
||||||
|
assert.equal(alt.status, 'INACTIVE');
|
||||||
|
|
||||||
|
await characters.addMember(
|
||||||
|
alpha,
|
||||||
|
a,
|
||||||
|
{ useKind: 'CC', joinedAt: new Date('2024-03-01') },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(await mitglieder(alpha), ['Spieler A', 'Spieler B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht ihn durch die Kette und bucht jedes Glied einzeln', async () => {
|
||||||
|
// Für ihn ist jedes Glied ein eigener Eintrag in seiner Liste: der
|
||||||
|
// unterste kostet den Platz, jedes weitere ein Redesign.
|
||||||
|
const vorher = await credits.getBalances(a);
|
||||||
|
|
||||||
|
const ergebnis = await characters.addMemberToChain(beta, a, {}, ctx);
|
||||||
|
assert.equal(ergebnis.added, 2, 'Beta und Gamma kommen dazu');
|
||||||
|
|
||||||
|
const danach = await credits.getBalances(a);
|
||||||
|
assert.equal(danach.CC, vorher.CC, 'sein Platz wurde einmal bezahlt');
|
||||||
|
assert.equal(
|
||||||
|
danach.REDESIGN_PAID,
|
||||||
|
vorher.REDESIGN_PAID - 2,
|
||||||
|
'für Beta und Gamma je ein Redesign',
|
||||||
|
);
|
||||||
|
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('belegt dabei genau einen Platz', async () => {
|
||||||
|
const belegt = (await characters.listCharacters(a)).filter(
|
||||||
|
(cc) => cc.status === 'ACTIVE' && cc.members.some((m) => m.playerId === a && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
belegt.map((cc) => cc.name),
|
||||||
|
['Gamma'],
|
||||||
|
'die früheren Glieder sind inaktiv und belegen nichts',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt C mittendrin ein- und wieder aussteigen', async () => {
|
||||||
|
// C war nur bei Beta dabei - davor und danach nicht.
|
||||||
|
await characters.addMember(beta, c, { useKind: 'CC', joinedAt: new Date('2024-06-01') }, ctx);
|
||||||
|
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B', 'Spieler C']);
|
||||||
|
|
||||||
|
await characters.removeMember(beta, c, 'Ausgestiegen vor dem nächsten Redesign', ctx);
|
||||||
|
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B']);
|
||||||
|
|
||||||
|
// Bei Gamma steht er gar nicht erst.
|
||||||
|
assert.equal((await mitglieder(gamma)).includes('Spieler C'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt D erst beim letzten Glied auf', async () => {
|
||||||
|
await characters.addMember(gamma, d, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B', 'Spieler D']);
|
||||||
|
assert.equal((await mitglieder(alpha)).includes('Spieler D'), false, 'damals war er nicht da');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt jedem Beteiligten seine eigene Geschichte', async () => {
|
||||||
|
const seins = async (playerId: number) =>
|
||||||
|
(await characters.listCharacters(playerId)).map((cc) => cc.name).sort();
|
||||||
|
|
||||||
|
assert.deepEqual(await seins(a), ['Alpha', 'Beta', 'Gamma'], 'A war von Anfang an dabei');
|
||||||
|
assert.deepEqual(await seins(b), ['Alpha', 'Beta', 'Gamma']);
|
||||||
|
assert.deepEqual(await seins(c), ['Beta'], 'C nur in der Mitte');
|
||||||
|
assert.deepEqual(await seins(d), ['Gamma'], 'D nur am Ende');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt an einem laufenden CC weiterhin eine Guthaben-Angabe', async () => {
|
||||||
|
const fremder = await spieler('Fremder', 1);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.addMember(gamma, fremder, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
|
||||||
|
'wer neu dazukommt, zahlt - die Ausnahme gilt nur der eigenen Kette',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen verdrehten Zeitraum ab', async () => {
|
||||||
|
const spaeter = await spieler('Verdreht', 1);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
characters.addMember(
|
||||||
|
alpha,
|
||||||
|
spaeter,
|
||||||
|
{ joinedAt: new Date('2025-01-01'), leftAt: new Date('2024-01-01') },
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'BAD_RANGE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 Players = typeof import('../src/services/players.js');
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Audit = typeof import('../src/services/audit.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let imports: Imports;
|
||||||
|
let players: Players;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let audit: Audit;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht.
|
||||||
|
const CSV = [
|
||||||
|
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||||
|
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const MAPPING = {
|
||||||
|
playerKey: 'Steam-ID',
|
||||||
|
product: 'Produkt',
|
||||||
|
orderId: 'Bestellnummer',
|
||||||
|
createMissingPlayers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54333);
|
||||||
|
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');
|
||||||
|
players = await import('../src/services/players.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
audit = await import('../src/services/audit.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
|
||||||
|
await db.prisma.productMapping.create({
|
||||||
|
data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Datenminimierung beim Import', () => {
|
||||||
|
it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => {
|
||||||
|
const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const rawBefore = before.raw as Record<string, string>;
|
||||||
|
assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da');
|
||||||
|
|
||||||
|
await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const rawAfter = after.raw as Record<string, string>;
|
||||||
|
|
||||||
|
assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg');
|
||||||
|
assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg');
|
||||||
|
assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg');
|
||||||
|
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt auch einen verworfenen Import auf', async () => {
|
||||||
|
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
|
||||||
|
await imports.discardImport(importId, ctx);
|
||||||
|
|
||||||
|
const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||||
|
const raw = row.raw as Record<string, string>;
|
||||||
|
assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fehlgeschlagene Anmeldung', () => {
|
||||||
|
it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => {
|
||||||
|
// Genau der Fall: jemand tippt sein Passwort ins Namensfeld.
|
||||||
|
const versehen = 'MeinGeheimesPasswort123';
|
||||||
|
|
||||||
|
await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, {
|
||||||
|
action: 'auth.login_failed',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: null,
|
||||||
|
details: { reason: 'unknown_account' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = await db.prisma.auditLog.findMany({
|
||||||
|
where: { action: 'auth.login_failed' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
assert.notEqual(entry.actorName, versehen);
|
||||||
|
assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Löschung eines Spielers', () => {
|
||||||
|
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
||||||
|
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
||||||
|
const csv = [
|
||||||
|
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||||
|
'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8'));
|
||||||
|
const importId = await imports.createImport('shop3.csv', parsed, ctx);
|
||||||
|
await imports.applyMapping(importId, MAPPING, ctx);
|
||||||
|
await imports.commitImport(importId, ctx);
|
||||||
|
|
||||||
|
const player = await db.prisma.player.findUniqueOrThrow({
|
||||||
|
where: { steamId: '76561198000000501' },
|
||||||
|
});
|
||||||
|
await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx);
|
||||||
|
|
||||||
|
await players.deletePlayer(player.id, ctx);
|
||||||
|
|
||||||
|
const gone = await db.prisma.player.findUnique({ where: { id: player.id } });
|
||||||
|
assert.equal(gone, null, 'der Spieler ist weg');
|
||||||
|
|
||||||
|
const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } });
|
||||||
|
assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg');
|
||||||
|
|
||||||
|
const rows = await db.prisma.importRow.findMany({ where: { importId } });
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile');
|
||||||
|
assert.equal(row.resolvedPlayerId, null);
|
||||||
|
assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditEntries = await db.prisma.auditLog.findMany({
|
||||||
|
where: { targetType: 'player', targetId: String(player.id) },
|
||||||
|
});
|
||||||
|
assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar');
|
||||||
|
for (const entry of auditEntries) {
|
||||||
|
const details = JSON.stringify(entry.details ?? {});
|
||||||
|
assert.ok(
|
||||||
|
!details.includes('76561198000000501'),
|
||||||
|
`SteamID steckt noch in ${entry.action}: ${details}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aufbewahrung des Audit-Logs', () => {
|
||||||
|
it('löscht Einträge jenseits der Frist, neuere bleiben', async () => {
|
||||||
|
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
||||||
|
|
||||||
|
const veraltet = await db.prisma.auditLog.create({
|
||||||
|
data: { action: 'test.alt', targetType: 'test', createdAt: alt },
|
||||||
|
});
|
||||||
|
const frisch = await db.prisma.auditLog.create({
|
||||||
|
data: { action: 'test.frisch', targetType: 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await audit.cleanupOldAuditEntries();
|
||||||
|
assert.ok(removed >= 1);
|
||||||
|
|
||||||
|
assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null);
|
||||||
|
assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
+184
-16
@@ -40,7 +40,7 @@ after(async () => {
|
|||||||
await database.stop();
|
await database.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Guthaben-Toepfe', () => {
|
describe('Guthaben-Töpfe', () => {
|
||||||
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
||||||
const playerId = await freshPlayer('Paket-Spieler');
|
const playerId = await freshPlayer('Paket-Spieler');
|
||||||
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
||||||
@@ -58,7 +58,7 @@ describe('Guthaben-Toepfe', () => {
|
|||||||
assert.equal(balances.REDESIGN_PAID, 1);
|
assert.equal(balances.REDESIGN_PAID, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => {
|
it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => {
|
||||||
const playerId = await freshPlayer('Kulanz-Spieler');
|
const playerId = await freshPlayer('Kulanz-Spieler');
|
||||||
const { balances } = await credits.bookKulanz(playerId, ctx);
|
const { balances } = await credits.bookKulanz(playerId, ctx);
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ describe('Guthaben-Toepfe', () => {
|
|||||||
assert.equal(balances.redesignTotal, 1);
|
assert.equal(balances.redesignTotal, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
||||||
const playerId = await freshPlayer('Minus-Spieler');
|
const playerId = await freshPlayer('Minus-Spieler');
|
||||||
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ describe('CC-Lebenszyklus', () => {
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
result.character.lineageId,
|
result.character.lineageId,
|
||||||
first.character.lineageId,
|
first.character.lineageId,
|
||||||
'die Kette behaelt ihre lineageId',
|
'die Kette behält ihre lineageId',
|
||||||
);
|
);
|
||||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ describe('CC-Lebenszyklus', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Reaktivierung', () => {
|
describe('Reaktivierung', () => {
|
||||||
it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => {
|
it('ein wegen Inaktivität entfernter CC kommt gratis zurück', async () => {
|
||||||
const playerId = await freshPlayer('Inaktiver');
|
const playerId = await freshPlayer('Inaktiver');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
||||||
@@ -144,7 +144,7 @@ describe('Reaktivierung', () => {
|
|||||||
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein reworkter CC kommt NICHT gratis zurueck', async () => {
|
it('ein reworkter CC kommt NICHT gratis zurück', async () => {
|
||||||
const playerId = await freshPlayer('Rework-Spieler');
|
const playerId = await freshPlayer('Rework-Spieler');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||||
@@ -160,7 +160,7 @@ describe('Reaktivierung', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => {
|
it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => {
|
||||||
const playerId = await freshPlayer('Wiederbeleber');
|
const playerId = await freshPlayer('Wiederbeleber');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
@@ -178,21 +178,51 @@ describe('Reaktivierung', () => {
|
|||||||
assert.equal(result.balances.CC, before.CC - 1);
|
assert.equal(result.balances.CC, before.CC - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine bezahlte Reaktivierung wird bei Gratis-Faellen verweigert', async () => {
|
it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => {
|
||||||
|
const playerId = await freshPlayer('Einheit-weg');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
|
||||||
|
|
||||||
|
await characters.deactivateCharacter(
|
||||||
|
created.character.id,
|
||||||
|
'OTHER',
|
||||||
|
ctx,
|
||||||
|
'Einheit bis März entfernt',
|
||||||
|
);
|
||||||
|
|
||||||
|
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: created.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar');
|
||||||
|
|
||||||
|
const before = await credits.getBalances(playerId);
|
||||||
|
const result = await characters.reactivateFree(created.character.id, ctx);
|
||||||
|
|
||||||
|
assert.equal(result.character.status, 'ACTIVE');
|
||||||
|
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht');
|
||||||
|
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine bezahlte Reaktivierung wird bei jedem Gratis-Fall verweigert', async () => {
|
||||||
const playerId = await freshPlayer('Gratis-Fall');
|
const playerId = await freshPlayer('Gratis-Fall');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
|
|
||||||
|
for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) {
|
||||||
|
const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx);
|
||||||
|
await characters.deactivateCharacter(created.character.id, reason, ctx);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
|
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
|
||||||
(error: Error & { code?: string }) => error.code === 'IS_FREE',
|
(error: Error & { code?: string }) => error.code === 'IS_FREE',
|
||||||
|
`Grund ${reason} darf nichts kosten`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Korrekturen', () => {
|
describe('Korrekturen', () => {
|
||||||
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => {
|
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => {
|
||||||
const playerId = await freshPlayer('Storno-Spieler');
|
const playerId = await freshPlayer('Storno-Spieler');
|
||||||
const booked = await credits.bookCcPackage(playerId, ctx);
|
const booked = await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
@@ -206,7 +236,7 @@ describe('Korrekturen', () => {
|
|||||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dieselbe Buchung laesst sich kein zweites Mal stornieren', async () => {
|
it('dieselbe Buchung lässt sich kein zweites Mal stornieren', async () => {
|
||||||
const playerId = await freshPlayer('Doppel-Storno');
|
const playerId = await freshPlayer('Doppel-Storno');
|
||||||
const booked = await credits.bookKulanz(playerId, ctx);
|
const booked = await credits.bookKulanz(playerId, ctx);
|
||||||
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
||||||
@@ -236,8 +266,8 @@ describe('Korrekturen', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => {
|
it('eine Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => {
|
||||||
const playerId = await freshPlayer('Ruecknehmer');
|
const playerId = await freshPlayer('Rücknehmer');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
||||||
const redesigned = await characters.redesignCharacter(
|
const redesigned = await characters.redesignCharacter(
|
||||||
@@ -262,7 +292,7 @@ describe('Korrekturen', () => {
|
|||||||
assert.equal(restored.inactiveReason, null);
|
assert.equal(restored.inactiveReason, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => {
|
it('eine Rücknahme mitten in der Kette wird abgelehnt', async () => {
|
||||||
const playerId = await freshPlayer('Ketten-Spieler');
|
const playerId = await freshPlayer('Ketten-Spieler');
|
||||||
await credits.bookCcPackage(playerId, ctx);
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
await credits.bookRedesignPackage(playerId, ctx);
|
await credits.bookRedesignPackage(playerId, ctx);
|
||||||
@@ -279,8 +309,146 @@ describe('Korrekturen', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => characters.revertCharacter(second.character.id, 'Zu spaet', ctx),
|
() => characters.revertCharacter(second.character.id, 'Zu spät', ctx),
|
||||||
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Von Hand eingetragene Guthaben zurücknehmen', () => {
|
||||||
|
it('zählt, wie viel jemals in den Topf kam', async () => {
|
||||||
|
const playerId = await freshPlayer('Zaehler');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx);
|
||||||
|
|
||||||
|
const stand = await credits.getBalances(playerId);
|
||||||
|
assert.equal(stand.CC, 1, 'eines ist noch offen');
|
||||||
|
assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein');
|
||||||
|
assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => {
|
||||||
|
// Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus.
|
||||||
|
const playerId = await freshPlayer('Stornierer');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx);
|
||||||
|
await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx);
|
||||||
|
|
||||||
|
const stand = await credits.getBalances(playerId);
|
||||||
|
assert.equal(stand.CC, 1, 'das Guthaben ist zurück');
|
||||||
|
assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => {
|
||||||
|
const playerId = await freshPlayer('Vertipper');
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
|
const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx);
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 1);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück');
|
||||||
|
assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt eine Kulanz zurück', async () => {
|
||||||
|
const playerId = await freshPlayer('Kulanzfall');
|
||||||
|
await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' });
|
||||||
|
|
||||||
|
const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx);
|
||||||
|
assert.equal(balances.REDESIGN_KULANZ, 0);
|
||||||
|
assert.equal(balances.granted.REDESIGN_KULANZ, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => {
|
||||||
|
const playerId = await freshPlayer('Leerer Topf');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt gebuchte Import-Pakete in Ruhe', async () => {
|
||||||
|
// Ein Paket aus einer Bestellung gehört in die Historie, nicht unter
|
||||||
|
// einen Knopf, der "das letzte weg" bedeutet.
|
||||||
|
const playerId = await freshPlayer('Importiert');
|
||||||
|
const importRecord = await db.prisma.import.create({
|
||||||
|
data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 },
|
||||||
|
});
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId,
|
||||||
|
kind: 'CC',
|
||||||
|
amount: 1,
|
||||||
|
source: 'CC_PACKAGE',
|
||||||
|
importId: importRecord.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => {
|
||||||
|
const playerId = await freshPlayer('Doppelklicker');
|
||||||
|
await credits.bookKulanz(playerId, ctx);
|
||||||
|
|
||||||
|
await credits.removeLastGrant(playerId, 'KULANZ', ctx);
|
||||||
|
await assert.rejects(
|
||||||
|
() => credits.removeLastGrant(playerId, 'KULANZ', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CC-Guthaben von Hand vergeben', () => {
|
||||||
|
it('schreibt nur das CC-Guthaben gut, kein Redesign', async () => {
|
||||||
|
// Ein gekauftes Paket bringt beides mit. Von Hand gibt es Einzelfälle,
|
||||||
|
// in denen jemand nur einen Platz bekommen soll.
|
||||||
|
const playerId = await freshPlayer('Einzelfall');
|
||||||
|
const { balances } = await credits.grantCcSlot(playerId, ctx, { note: 'Ausgleich' });
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 1);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 0, 'kein Redesign obendrauf');
|
||||||
|
assert.equal(balances.granted.CC, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macht in der Historie sichtbar, dass kein Kauf dahintersteht', async () => {
|
||||||
|
const playerId = await freshPlayer('Nachvollziehbar');
|
||||||
|
await credits.grantCcSlot(playerId, ctx);
|
||||||
|
|
||||||
|
const zeile = await db.prisma.creditLedger.findFirstOrThrow({ where: { playerId } });
|
||||||
|
assert.equal(zeile.source, 'CC_GRANT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt den Abgleich mit dem Shop unberührt', async () => {
|
||||||
|
// Ein Paket aus dem Shop bleibt, was es ist: Platz plus Redesign.
|
||||||
|
const playerId = await freshPlayer('Aus dem Shop');
|
||||||
|
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
||||||
|
|
||||||
|
assert.equal(balances.CC, 1);
|
||||||
|
assert.equal(balances.REDESIGN_PAID, 1, 'das Redesign gehört zum Paket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt einen von Hand vergebenen Platz wieder zurück', async () => {
|
||||||
|
const playerId = await freshPlayer('Doch nicht');
|
||||||
|
await credits.grantCcSlot(playerId, ctx);
|
||||||
|
|
||||||
|
const { balances } = await credits.removeLastGrant(playerId, 'CC_GRANT', ctx);
|
||||||
|
assert.equal(balances.CC, 0);
|
||||||
|
assert.equal(balances.granted.CC, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reicht für einen neuen CC', async () => {
|
||||||
|
const playerId = await freshPlayer('Baut damit');
|
||||||
|
await credits.grantCcSlot(playerId, ctx);
|
||||||
|
|
||||||
|
const ergebnis = await characters.createCharacter(playerId, { name: 'Daraus gebaut' }, ctx);
|
||||||
|
assert.equal(ergebnis.balances.CC, 0);
|
||||||
|
assert.equal(ergebnis.overdrawn, false, 'der Platz war gedeckt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { classifyPackage, parseProfile, type ScrapedPackage } from '../src/lib/gextension.js';
|
||||||
|
import { looksLikeSteamId, normalizeSteamId, toLegacySteamId } from '../src/lib/steamid.js';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIXTURE = readFileSync(join(here, 'fixtures', 'profil.html'), 'utf8');
|
||||||
|
|
||||||
|
function pkg(overrides: Partial<ScrapedPackage>): ScrapedPackage {
|
||||||
|
return {
|
||||||
|
packageBoughtId: 1,
|
||||||
|
packageName: 'Custom Character',
|
||||||
|
serverBundle: 'SCP-RP',
|
||||||
|
purchasedAt: null,
|
||||||
|
amount: '0.00 EUR',
|
||||||
|
gateway: '',
|
||||||
|
revoked: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Profil auslesen', () => {
|
||||||
|
const profile = parseProfile(FIXTURE, '76561198000000999');
|
||||||
|
|
||||||
|
it('findet alle Pakete', () => {
|
||||||
|
assert.equal(profile.packages.length, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest Titel, Bereich und Kaufdatum', () => {
|
||||||
|
const cc = profile.packages.find((p) => p.packageBoughtId === 9001);
|
||||||
|
assert.ok(cc);
|
||||||
|
assert.equal(cc.packageName, 'Custom Character');
|
||||||
|
assert.equal(cc.serverBundle, 'SCP-RP');
|
||||||
|
assert.equal(cc.purchasedAt?.getFullYear(), 2025);
|
||||||
|
assert.equal(cc.purchasedAt?.getMonth(), 9, 'Oktober');
|
||||||
|
assert.equal(cc.purchasedAt?.getDate(), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt stornierte Käufe', () => {
|
||||||
|
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
|
||||||
|
assert.equal(revoked?.revoked, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest den Nicknamen aus der Allgemein-Box', () => {
|
||||||
|
assert.equal(profile.nickname, 'Testspieler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit kaputtem HTML klar, ohne alles zu verlieren', () => {
|
||||||
|
const broken = FIXTURE.replace(
|
||||||
|
'donations_data[9004] = {"id"',
|
||||||
|
'donations_data[9004] = {kaputt"id"',
|
||||||
|
);
|
||||||
|
const result = parseProfile(broken, '76561198000000999');
|
||||||
|
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile fällt weg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pakete zuordnen', () => {
|
||||||
|
it('erkennt ein CC-Paket', () => {
|
||||||
|
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character' })), {
|
||||||
|
kind: 'package',
|
||||||
|
target: 'CC_PACKAGE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt die Schreibweise mit K', () => {
|
||||||
|
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Charakter-Beantragung' })), {
|
||||||
|
kind: 'package',
|
||||||
|
target: 'CC_PACKAGE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt "Custom Character Redesign" als Redesign, nicht als CC', () => {
|
||||||
|
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
|
||||||
|
kind: 'package',
|
||||||
|
target: 'REDESIGN_PACKAGE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest den Verbrauchszähler samt Anzahl', () => {
|
||||||
|
const result = classifyPackage(
|
||||||
|
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Merch und Ränge trotz SCP-RP-Tag', () => {
|
||||||
|
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
|
||||||
|
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
|
||||||
|
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert alles aus dem Web-Bereich', () => {
|
||||||
|
const result = classifyPackage(
|
||||||
|
pkg({ packageName: 'Ablassbrief (01.04.2026)', serverBundle: 'Web' }),
|
||||||
|
);
|
||||||
|
assert.equal(result.kind, 'ignore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bucht ein storniertes Paket nicht', () => {
|
||||||
|
const result = classifyPackage(pkg({ packageName: 'Custom Character', revoked: true }));
|
||||||
|
assert.equal(result.kind, 'ignore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schiebt unklare CC-Titel in die Handprüfung statt zu raten', () => {
|
||||||
|
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SteamID-Schreibweisen', () => {
|
||||||
|
// Dieselbe (erfundene) Kennung in drei Formaten.
|
||||||
|
const legacy = 'STEAM_0:1:19867635';
|
||||||
|
const modern = '[U:1:39735271]';
|
||||||
|
const sixtyFour = '76561198000000999';
|
||||||
|
|
||||||
|
it('führt alle drei Formate auf dieselbe ID zusammen', () => {
|
||||||
|
assert.equal(normalizeSteamId(legacy), sixtyFour);
|
||||||
|
assert.equal(normalizeSteamId(modern), sixtyFour);
|
||||||
|
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechnet zurück in die Legacy-Form', () => {
|
||||||
|
assert.equal(toLegacySteamId(sixtyFour), legacy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beherrscht auch das ungerade Bit', () => {
|
||||||
|
const odd = 'STEAM_0:1:12345678';
|
||||||
|
const asNumber = normalizeSteamId(odd);
|
||||||
|
assert.ok(asNumber);
|
||||||
|
assert.equal(toLegacySteamId(asNumber), odd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unsinn ab', () => {
|
||||||
|
assert.equal(normalizeSteamId('Darkbrine44'), null);
|
||||||
|
assert.equal(normalizeSteamId(''), null);
|
||||||
|
assert.equal(normalizeSteamId('12345'), null);
|
||||||
|
assert.equal(looksLikeSteamId('Darkbrine44'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Ablauf mit Datenbank: was passiert bei einem zweiten Abgleich?
|
||||||
|
describe('Mehrfacher Abgleich', () => {
|
||||||
|
it('nimmt Pakete aus einer offenen Vorschau nicht erneut auf', async () => {
|
||||||
|
const { startTestDatabase } = await import('./harness.js');
|
||||||
|
const database = await startTestDatabase(54334);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const db = await import('../src/db.js');
|
||||||
|
const { PLANS } = await import('../src/services/imports.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'abgleicher', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
const player = await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Testspieler', steamId: '76561198000000999' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erster Lauf: ein Paket landet in einer offenen Vorschau.
|
||||||
|
const first = await db.prisma.import.create({
|
||||||
|
data: {
|
||||||
|
filename: 'Abgleich 1',
|
||||||
|
status: 'PENDING',
|
||||||
|
rowCount: 1,
|
||||||
|
importedById: user.id,
|
||||||
|
rows: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
lineNo: 1,
|
||||||
|
externalId: 'gext:9001',
|
||||||
|
playerKey: '76561198000000999',
|
||||||
|
productName: 'Custom Character',
|
||||||
|
target: 'CC_PACKAGE',
|
||||||
|
resolvedPlayerId: player.id,
|
||||||
|
plan: PLANS.BOOK,
|
||||||
|
raw: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Genau diese Pruefung macht der zweite Abgleich.
|
||||||
|
const pending = await db.prisma.importRow.findMany({
|
||||||
|
where: { externalId: { not: null }, import: { status: 'PENDING' } },
|
||||||
|
select: { externalId: true },
|
||||||
|
});
|
||||||
|
const wartend = new Set(pending.map((row) => row.externalId as string));
|
||||||
|
|
||||||
|
assert.ok(wartend.has('gext:9001'), 'das Paket wird als wartend erkannt');
|
||||||
|
assert.equal(wartend.size, 1);
|
||||||
|
|
||||||
|
// Nach dem Buchen zaehlt stattdessen die Dedup ueber ImportedOrder.
|
||||||
|
await db.prisma.import.update({ where: { id: first.id }, data: { status: 'COMMITTED' } });
|
||||||
|
const danach = await db.prisma.importRow.findMany({
|
||||||
|
where: { externalId: { not: null }, import: { status: 'PENDING' } },
|
||||||
|
select: { externalId: true },
|
||||||
|
});
|
||||||
|
assert.equal(danach.length, 0, 'ein gebuchter Import blockiert nicht mehr');
|
||||||
|
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Schedule = typeof import('../src/services/schedule.js');
|
||||||
|
type Scraper = typeof import('../src/services/scraper.js');
|
||||||
|
type Forum = typeof import('../src/services/forum.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let schedule: Schedule;
|
||||||
|
let scraper: Scraper;
|
||||||
|
let forum: Forum;
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
const JETZT = new Date('2026-09-21T12:00:00Z');
|
||||||
|
const TAG = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54338);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
// Der Sperr-Test stösst einen echten Abruf an. Er soll sofort scheitern
|
||||||
|
// statt in ein Zeitlimit zu laufen - und vor allem nicht wirklich bei
|
||||||
|
// GExtension anklopfen, nur weil hier Tests laufen.
|
||||||
|
process.env.GEXTENSION_BASE_URL = 'http://127.0.0.1:1';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
schedule = await import('../src/services/schedule.js');
|
||||||
|
scraper = await import('../src/services/scraper.js');
|
||||||
|
forum = await import('../src/services/forum.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Spieler mit Steam-Konto, letztem Kauf vor `kaufVorTagen` und letztem
|
||||||
|
* Abgleich vor `abgleichVorStunden`.
|
||||||
|
*/
|
||||||
|
async function spieler(
|
||||||
|
name: string,
|
||||||
|
steamId: string,
|
||||||
|
kaufVorTagen: number | null,
|
||||||
|
abgleichVorStunden: number | null,
|
||||||
|
) {
|
||||||
|
const angelegt = await db.prisma.player.create({
|
||||||
|
data: {
|
||||||
|
displayName: name,
|
||||||
|
steamId,
|
||||||
|
gextensionSyncedAt:
|
||||||
|
abgleichVorStunden === null
|
||||||
|
? null
|
||||||
|
: new Date(JETZT.getTime() - abgleichVorStunden * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kaufVorTagen !== null) {
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: {
|
||||||
|
playerId: angelegt.id,
|
||||||
|
kind: 'CC',
|
||||||
|
amount: 1,
|
||||||
|
source: 'CC_PACKAGE',
|
||||||
|
occurredAt: new Date(JETZT.getTime() - kaufVorTagen * TAG),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return angelegt.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Wer wann auf neue Pakete geprüft wird', () => {
|
||||||
|
let frisch = 0;
|
||||||
|
let frischGeradeGeprueft = 0;
|
||||||
|
let ruhend = 0;
|
||||||
|
let ruhendLangeHer = 0;
|
||||||
|
let nochNieGeprueft = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
// Kauf vor einem Monat, Abgleich vor drei Stunden: fällig (Takt zwei Stunden).
|
||||||
|
frisch = await spieler('Frisch', '76561198000020001', 30, 3);
|
||||||
|
// Ebenso frisch, aber gerade eben geprüft: noch nicht wieder dran.
|
||||||
|
frischGeradeGeprueft = await spieler('Eben geprüft', '76561198000020002', 30, 1);
|
||||||
|
// Letzter Kauf vor vier Jahren, Abgleich vor drei Stunden: der Takt ist
|
||||||
|
// hier sechs Stunden, also noch nicht dran.
|
||||||
|
ruhend = await spieler('Ruhend', '76561198000020003', 4 * 365, 3);
|
||||||
|
// Derselbe Fall, aber der Abgleich liegt sieben Stunden zurück: fällig.
|
||||||
|
ruhendLangeHer = await spieler('Ruhend und überfällig', '76561198000020004', 4 * 365, 7);
|
||||||
|
// Nie abgeglichen: immer fällig, egal wie lange der Kauf her ist.
|
||||||
|
nochNieGeprueft = await spieler('Neu hier', '76561198000020005', null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt einen Spieler mit frischem Kauf nach zwei Stunden wieder dran', async () => {
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.ok(faellig.includes(frisch));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ihn in Ruhe, solange die zwei Stunden nicht um sind', async () => {
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.equal(faellig.includes(frischGeradeGeprueft), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prüft ein ruhendes Konto nur alle sechs Stunden', async () => {
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.equal(
|
||||||
|
faellig.includes(ruhend),
|
||||||
|
false,
|
||||||
|
'drei Stunden reichen bei einem Konto ohne Kauf seit Jahren nicht',
|
||||||
|
);
|
||||||
|
assert.ok(faellig.includes(ruhendLangeHer), 'nach sieben Stunden aber schon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt einen nie geprüften Spieler sofort dran', async () => {
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.ok(faellig.includes(nochNieGeprueft));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Spieler ohne Steam-Konto ganz aus', async () => {
|
||||||
|
const ohne = await db.prisma.player.create({ data: { displayName: 'Ohne Konto' } });
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.equal(
|
||||||
|
faellig.includes(ohne.id),
|
||||||
|
false,
|
||||||
|
'ohne Kennung gibt es bei GExtension nichts abzurufen',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt ein früheres Konto mit', async () => {
|
||||||
|
const umzuegler = await db.prisma.player.create({
|
||||||
|
data: { displayName: 'Nur Alias', gextensionSyncedAt: new Date(JETZT.getTime() - TAG) },
|
||||||
|
});
|
||||||
|
await db.prisma.playerAlias.create({
|
||||||
|
data: { playerId: umzuegler.id, type: 'STEAM_ID', value: '76561198000020099' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const faellig = await schedule.faelligeSpieler(JETZT);
|
||||||
|
assert.ok(faellig.includes(umzuegler.id), 'seine Kaeufe liegen unter der alten Kennung');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wann ein Lauf ansteht', () => {
|
||||||
|
const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 };
|
||||||
|
const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null };
|
||||||
|
|
||||||
|
it('wartet den eingestellten Abstand ab', () => {
|
||||||
|
const jetzt = new Date('2026-09-21T12:00:00');
|
||||||
|
const vorEinerStunde = new Date('2026-09-21T11:00:00');
|
||||||
|
const vorDreiStunden = new Date('2026-09-21T09:00:00');
|
||||||
|
|
||||||
|
assert.equal(schedule.istFaellig(vorEinerStunde, jetzt, allezweiStunden), false);
|
||||||
|
assert.equal(schedule.istFaellig(vorDreiStunden, jetzt, allezweiStunden), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('läuft beim ersten Mal sofort', () => {
|
||||||
|
assert.equal(schedule.istFaellig(null, new Date('2026-09-21T12:00:00'), allezweiStunden), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält sich beim Forum an die Stunde', () => {
|
||||||
|
const frueh = new Date('2026-09-21T02:30:00');
|
||||||
|
assert.equal(schedule.istFaellig(null, frueh, taeglichNachts), false, 'vor vier Uhr nicht');
|
||||||
|
|
||||||
|
const nachVier = new Date('2026-09-21T04:10:00');
|
||||||
|
assert.equal(schedule.istFaellig(null, nachVier, taeglichNachts), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('läuft an einem Tag nur einmal', () => {
|
||||||
|
const morgens = new Date('2026-09-21T04:10:00');
|
||||||
|
const abends = new Date('2026-09-21T22:00:00');
|
||||||
|
assert.equal(schedule.istFaellig(morgens, abends, taeglichNachts), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holt einen ausgefallenen Lauf am selben Tag nach', () => {
|
||||||
|
// Der Dienst stand nachts still und kommt erst mittags hoch.
|
||||||
|
const mittags = new Date('2026-09-21T12:00:00');
|
||||||
|
const vorgestern = new Date('2026-09-19T04:00:00');
|
||||||
|
assert.equal(schedule.istFaellig(vorgestern, mittags, taeglichNachts), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stösst nach einem Neustart nicht erneut an', () => {
|
||||||
|
const nachStart = new Date('2026-09-21T06:00:00');
|
||||||
|
const heuteFrueh = new Date('2026-09-21T04:02:00');
|
||||||
|
assert.equal(schedule.istFaellig(heuteFrueh, nachStart, taeglichNachts), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert die Stunde, wenn der Takt kürzer als ein Tag ist', () => {
|
||||||
|
// Ein sechsstündlicher Lauf ab acht Uhr soll auch um vierzehn Uhr laufen.
|
||||||
|
const regel = { intervalMinutes: 360, onlyAfterHour: 8 };
|
||||||
|
const mittags = new Date('2026-09-21T14:00:00');
|
||||||
|
const morgens = new Date('2026-09-21T08:00:00');
|
||||||
|
assert.equal(schedule.istFaellig(morgens, mittags, regel), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Einstellungen des Zeitplans', () => {
|
||||||
|
it('liefert Vorgaben, solange nichts eingestellt wurde', async () => {
|
||||||
|
const kaeufe = await schedule.getSetting('gextension');
|
||||||
|
assert.equal(kaeufe.intervalMinutes, 120);
|
||||||
|
assert.equal(kaeufe.idleIntervalMinutes, 360);
|
||||||
|
assert.equal(kaeufe.idleAfterDays, 1095);
|
||||||
|
|
||||||
|
const forum = await schedule.getSetting('forum');
|
||||||
|
assert.equal(forum.intervalMinutes, 1440);
|
||||||
|
assert.equal(forum.onlyAfterHour, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt eine Änderung an und wendet sie sofort an', async () => {
|
||||||
|
const ctx = { actorId: null, actorName: 'Test' };
|
||||||
|
await schedule.updateSetting('gextension', { intervalMinutes: 45, enabled: false }, ctx);
|
||||||
|
|
||||||
|
const danach = await schedule.getSetting('gextension');
|
||||||
|
assert.equal(danach.intervalMinutes, 45);
|
||||||
|
assert.equal(danach.enabled, false);
|
||||||
|
assert.equal(danach.idleIntervalMinutes, 360, 'unberührte Felder bleiben');
|
||||||
|
|
||||||
|
await schedule.updateSetting('gextension', { intervalMinutes: 120, enabled: true }, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist unsinnige Abstände ab', async () => {
|
||||||
|
const ctx = { actorId: null, actorName: 'Test' };
|
||||||
|
|
||||||
|
for (const minuten of [0, 5, 20000]) {
|
||||||
|
await assert.rejects(
|
||||||
|
() => schedule.updateSetting('forum', { intervalMinutes: minuten }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'INVALID_INTERVAL',
|
||||||
|
`${minuten} Minuten hätten abgewiesen werden müssen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => schedule.updateSetting('forum', { onlyAfterHour: 25 }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'INVALID_HOUR',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('richtet sich beim Fälligsein nach der Einstellung', async () => {
|
||||||
|
const ctx = { actorId: null, actorName: 'Test' };
|
||||||
|
await schedule.updateSetting('gextension', { intervalMinutes: 30 }, ctx);
|
||||||
|
|
||||||
|
const regel = await schedule.getSetting('gextension');
|
||||||
|
const jetzt = new Date('2026-09-21T12:00:00');
|
||||||
|
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:45:00'), jetzt, regel), false);
|
||||||
|
assert.equal(schedule.istFaellig(new Date('2026-09-21T11:15:00'), jetzt, regel), true);
|
||||||
|
|
||||||
|
await schedule.updateSetting('gextension', { intervalMinutes: 120 }, ctx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Zwei Läufe zugleich', () => {
|
||||||
|
it('lässt einen zweiten GExtension-Abgleich nicht daneben laufen', async () => {
|
||||||
|
// Ein Ziel, das es nicht gibt: der Lauf beginnt, scheitert am Abruf und
|
||||||
|
// endet - aber solange er läuft, ist die Tür zu.
|
||||||
|
const ziele = [{ playerId: 1, steamId: '76561198000029999' }];
|
||||||
|
const ctx = { actorId: null, actorName: 'Test' };
|
||||||
|
|
||||||
|
const laufend = scraper.scrapePlayers(ziele, ctx);
|
||||||
|
assert.equal(scraper.getScrapeState().running, true);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => scraper.scrapePlayers(ziele, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
|
||||||
|
'sonst riefen beide dieselben Profile ab',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => scraper.startScrape(ziele, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
|
||||||
|
);
|
||||||
|
|
||||||
|
await laufend.catch(() => {});
|
||||||
|
assert.equal(scraper.getScrapeState().running, false, 'danach ist wieder offen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gilt für den Zeitplan genauso wie für einen Klick', () => {
|
||||||
|
// Beide Wege gehen durch dieselbe Funktion - der Zeitplan ruft
|
||||||
|
// scanForum direkt, die Oberfläche startScan. Die Sperre sitzt deshalb
|
||||||
|
// unten, nicht im Starter.
|
||||||
|
assert.equal(typeof forum.scanForum, 'function');
|
||||||
|
assert.equal(forum.getScanState().running, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
// Tests werden nicht gebaut, aber geprüft: Ein Aufruf mit vertauschten
|
||||||
|
// Argumenten läuft zur Laufzeit oft einfach durch und prüft dann etwas
|
||||||
|
// anderes als gedacht - ohne dass es auffällt.
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" content="#12141a" />
|
||||||
<title>CC-Manager</title>
|
<title>CC-Manager</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
+13
-1
@@ -5,6 +5,7 @@ import type { Role } from './lib/types';
|
|||||||
import Account from './pages/Account';
|
import Account from './pages/Account';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Forum from './pages/Forum';
|
||||||
import Import from './pages/Import';
|
import Import from './pages/Import';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import PlayerDetail from './pages/PlayerDetail';
|
import PlayerDetail from './pages/PlayerDetail';
|
||||||
@@ -69,10 +70,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/forum"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Layout>
|
||||||
|
<Forum />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/import"
|
path="/import"
|
||||||
element={
|
element={
|
||||||
<Protected minimum="ADMIN">
|
<Protected minimum="EDITOR">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Import />
|
<Import />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { useDialog } from './DialogProvider';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import type { AliasType, PlayerAlias } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frühere Steam- und Forumskonten. Abgleich und Import prüfen sie mit, damit
|
||||||
|
* ein Kontowechsel die Zuordnung nicht abreißen lässt.
|
||||||
|
*/
|
||||||
|
export default function AliasBox({
|
||||||
|
playerId,
|
||||||
|
aliases,
|
||||||
|
canEdit,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
playerId: number;
|
||||||
|
aliases: PlayerAlias[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const { bestaetigen } = useDialog();
|
||||||
|
|
||||||
|
const [type, setType] = useState<AliasType>('STEAM_ID');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
const steam = aliases.filter((a) => a.type === 'STEAM_ID');
|
||||||
|
const forum = aliases.filter((a) => a.type === 'FORUM_NAME');
|
||||||
|
|
||||||
|
const hinzufuegen = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/players/${playerId}/aliases`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type, value: value.trim(), ...(note.trim() ? { note: note.trim() } : {}) },
|
||||||
|
});
|
||||||
|
setValue('');
|
||||||
|
setNote('');
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Eintragen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entfernen = async (alias: PlayerAlias) => {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: 'Früheres Konto entfernen',
|
||||||
|
text: `Soll "${alias.value}" nicht mehr als früheres Konto geführt werden? Abgleich und Import finden Käufe darunter danach nicht mehr.`,
|
||||||
|
knopf: 'Entfernen',
|
||||||
|
gefahr: true,
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' });
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Entfernen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ohne Einträge und ohne Rechte gibt es nichts zu zeigen.
|
||||||
|
if (aliases.length === 0 && !canEdit) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<div className="block-head">
|
||||||
|
<h2>Frühere Konten ({aliases.length})</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<button type="button" className="btn btn-small" onClick={() => setOffen(!offen)}>
|
||||||
|
{offen ? 'Schliessen' : 'Konto nachtragen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
|
||||||
|
{aliases.length === 0 ? (
|
||||||
|
<p className="muted small">
|
||||||
|
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
|
||||||
|
genutzt, trag ihn hier nach - Abgleich und Import finden ihn dann mit.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="alias-groups">
|
||||||
|
{steam.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3>SteamIDs</h3>
|
||||||
|
<ul className="alias-list">
|
||||||
|
{steam.map((alias) => (
|
||||||
|
<li key={alias.id}>
|
||||||
|
<span className="mono">{alias.value}</span>
|
||||||
|
{alias.note && <span className="muted small"> · {alias.note}</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => void entfernen(alias)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{forum.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3>Forum-Namen</h3>
|
||||||
|
<ul className="alias-list">
|
||||||
|
{forum.map((alias) => (
|
||||||
|
<li key={alias.id}>
|
||||||
|
<span>{alias.value}</span>
|
||||||
|
{alias.note && <span className="muted small"> · {alias.note}</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => void entfernen(alias)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && offen && (
|
||||||
|
<form className="alias-form" onSubmit={hinzufuegen}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="alias-type">Art</label>
|
||||||
|
<select
|
||||||
|
id="alias-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => setType(event.target.value as AliasType)}
|
||||||
|
>
|
||||||
|
<option value="STEAM_ID">Frühere SteamID</option>
|
||||||
|
<option value="FORUM_NAME">Früherer Forum-Name</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="alias-value">
|
||||||
|
{type === 'STEAM_ID' ? 'SteamID' : 'Forum-Name'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="alias-value"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={type === 'STEAM_ID' ? '76561198... oder STEAM_0:1:...' : 'Alter Name im Forum'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label htmlFor="alias-note">Notiz (optional)</label>
|
||||||
|
<input
|
||||||
|
id="alias-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
placeholder="z. B. Account bis 2024"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Nachtragen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { Balances } from '../lib/types';
|
import type { Balances } from '../lib/types';
|
||||||
|
|
||||||
function Card({ label, value, hint }: { label: string; value: number; hint?: string }) {
|
/**
|
||||||
|
* Ein Topf: was noch offen ist, und wovon.
|
||||||
|
*
|
||||||
|
* "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht,
|
||||||
|
* das andere nie etwas gehabt. Ohne die zweite Zahl sehen beide gleich aus.
|
||||||
|
*/
|
||||||
|
function Card({ label, value, granted }: { label: string; value: number; granted: number }) {
|
||||||
return (
|
return (
|
||||||
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
|
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
|
||||||
<span className="balance-label">{label}</span>
|
<span className="balance-label">{label}</span>
|
||||||
<span className="balance-value">{value}</span>
|
<span className="balance-value">
|
||||||
{hint && <span className="balance-hint">{hint}</span>}
|
{value}
|
||||||
|
{granted > 0 && <span className="balance-of"> von {granted}</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,22 +21,31 @@ function Card({ label, value, hint }: { label: string; value: number; hint?: str
|
|||||||
export default function BalanceCards({ balances }: { balances: Balances }) {
|
export default function BalanceCards({ balances }: { balances: Balances }) {
|
||||||
const canCreate = balances.CC >= 1;
|
const canCreate = balances.CC >= 1;
|
||||||
const canRedesign = balances.redesignTotal >= 1;
|
const canRedesign = balances.redesignTotal >= 1;
|
||||||
|
const granted = balances.granted ?? { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="balances" aria-label="Guthaben">
|
<section className="balances" aria-label="Guthaben">
|
||||||
<div className="balance-grid">
|
<div className="balance-grid">
|
||||||
<Card label="CC-Guthaben" value={balances.CC} />
|
<Card label="CC-Guthaben" value={balances.CC} granted={granted.CC} />
|
||||||
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} />
|
<Card
|
||||||
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} />
|
label="Redesign (bezahlt)"
|
||||||
|
value={balances.REDESIGN_PAID}
|
||||||
|
granted={granted.REDESIGN_PAID}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Redesign (Kulanz)"
|
||||||
|
value={balances.REDESIGN_KULANZ}
|
||||||
|
granted={granted.REDESIGN_KULANZ}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="eligibility">
|
<div className="eligibility">
|
||||||
<span className={canCreate ? 'ok' : 'no'}>
|
<span className={canCreate ? 'ok' : 'no'}>
|
||||||
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
|
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'möglich' : 'nicht möglich'}
|
||||||
</span>
|
</span>
|
||||||
<span className={canRedesign ? 'ok' : 'no'}>
|
<span className={canRedesign ? 'ok' : 'no'}>
|
||||||
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
|
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'}
|
||||||
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
|
{canRedesign && ` (${balances.redesignTotal} verfügbar)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import MemberList from './MemberList';
|
||||||
|
import { useDialog } from './DialogProvider';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format';
|
||||||
|
import type { Character } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die CCs eines Spielers als Ketten.
|
||||||
|
*
|
||||||
|
* Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte:
|
||||||
|
* erster CC, Redesign, noch ein Redesign. Gelesen wird von unten nach oben,
|
||||||
|
* der älteste Eintrag unten - so wächst die Kette in die Richtung, in die
|
||||||
|
* auch die Zeit läuft.
|
||||||
|
*
|
||||||
|
* Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später
|
||||||
|
* auf, weil sein Antrag woanders lag oder unter einem fremden Namen lief.
|
||||||
|
* Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen -
|
||||||
|
* auch mitten in eine Kette oder in eine andere.
|
||||||
|
*/
|
||||||
|
export type ChainAction =
|
||||||
|
| 'redesign'
|
||||||
|
| 'memberRedesign'
|
||||||
|
| 'addMember'
|
||||||
|
| 'deactivate'
|
||||||
|
| 'memberDeactivate'
|
||||||
|
| 'revert'
|
||||||
|
| 'reactivateFree'
|
||||||
|
| 'reactivatePaid'
|
||||||
|
| 'reviveDead'
|
||||||
|
| 'links'
|
||||||
|
| 'redesignKind'
|
||||||
|
| 'edit';
|
||||||
|
|
||||||
|
interface Kette {
|
||||||
|
lineageId: string;
|
||||||
|
glieder: Character[];
|
||||||
|
/**
|
||||||
|
* CCs, in denen der Spieler einmal steckte und die zu dieser Kette führten
|
||||||
|
* - ein Gruppen-CC, aus dem er sich herausredesignt hat. Sie gehören nicht
|
||||||
|
* ihm, stehen aber am Anfang seiner Geschichte und deshalb unten an der
|
||||||
|
* Kette. Verschieben lässt sich daran nichts: es ist der Platz eines
|
||||||
|
* anderen.
|
||||||
|
*/
|
||||||
|
herkunft: Character[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Mitgliedschaft dieses Spielers an einem CC. */
|
||||||
|
function eigeneMitgliedschaft(character: Character, playerId: number) {
|
||||||
|
return (character.members ?? []).find((m) => m.playerId === playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Eintrag, der zu seinen Ketten gehört.
|
||||||
|
*
|
||||||
|
* Wem der CC gehört, dem gehört er - auch wenn seine Mitgliedschaft daran
|
||||||
|
* beendet wurde, etwa weil er sich aus einer Gruppe herausredesignt hat, die
|
||||||
|
* an seinem eigenen CC hing. Es bleibt seine Geschichte und sein Platz.
|
||||||
|
*
|
||||||
|
* Bei fremden CCs entscheidet die Mitgliedschaft: wer noch drin ist oder
|
||||||
|
* zurückkehren kann, hat dort einen Platz. Wer endgültig raus ist, findet
|
||||||
|
* den CC unter "Früher genutzt".
|
||||||
|
*/
|
||||||
|
function istSeinPlatz(character: Character, playerId: number): boolean {
|
||||||
|
if (character.playerId === playerId) return true;
|
||||||
|
|
||||||
|
const eigene = eigeneMitgliedschaft(character, playerId);
|
||||||
|
if (!eigene) return false;
|
||||||
|
return eigene.leftAt === null || eigene.inactiveReason !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baueKetten(characters: Character[], playerId: number): {
|
||||||
|
ketten: Kette[];
|
||||||
|
vergangen: Character[];
|
||||||
|
} {
|
||||||
|
const eigene = characters.filter((c) => istSeinPlatz(c, playerId));
|
||||||
|
const verlassen = characters.filter((c) => !istSeinPlatz(c, playerId));
|
||||||
|
|
||||||
|
const nach = new Map<string, Character[]>();
|
||||||
|
for (const c of eigene) {
|
||||||
|
const liste = nach.get(c.lineageId) ?? [];
|
||||||
|
liste.push(c);
|
||||||
|
nach.set(c.lineageId, liste);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ketten: Kette[] = [...nach.entries()]
|
||||||
|
.map(([lineageId, glieder]) => ({
|
||||||
|
lineageId,
|
||||||
|
glieder: [...glieder].sort((a, b) => a.chainPosition - b.chainPosition),
|
||||||
|
herkunft: [] as Character[],
|
||||||
|
}))
|
||||||
|
// Ketten mit einem aktiven CC zuerst - daran arbeitet man.
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aktivA = a.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1;
|
||||||
|
const aktivB = b.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1;
|
||||||
|
if (aktivA !== aktivB) return aktivA - aktivB;
|
||||||
|
return (a.glieder[0]?.createdAt ?? '').localeCompare(b.glieder[0]?.createdAt ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verlassene CCs an die Kette hängen, in die der Spieler von dort aus
|
||||||
|
// gewechselt ist. Wo sich das nicht sagen lässt, stehen sie für sich.
|
||||||
|
const vergangen: Character[] = [];
|
||||||
|
for (const c of verlassen) {
|
||||||
|
const nachfolgerId = eigeneMitgliedschaft(c, playerId)?.successorId ?? null;
|
||||||
|
const ziel =
|
||||||
|
nachfolgerId === null
|
||||||
|
? undefined
|
||||||
|
: ketten.find((k) => k.glieder.some((g) => g.id === nachfolgerId));
|
||||||
|
|
||||||
|
if (ziel) ziel.herkunft.push(c);
|
||||||
|
else vergangen.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ketten, vergangen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChainView({
|
||||||
|
characters,
|
||||||
|
playerId,
|
||||||
|
canEdit,
|
||||||
|
admin,
|
||||||
|
onAction,
|
||||||
|
onMoved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
characters: Character[];
|
||||||
|
playerId: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
admin: boolean;
|
||||||
|
onAction: (action: ChainAction, character: Character) => void;
|
||||||
|
onMoved: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { bestaetigen } = useDialog();
|
||||||
|
|
||||||
|
const [ziehe, setZiehe] = useState<Character | null>(null);
|
||||||
|
const [ueber, setUeber] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Während des Ziehens am Rand scrollen.
|
||||||
|
*
|
||||||
|
* Ein Browser scrollt beim Ziehen nicht von selbst. Wer einen Eintrag von
|
||||||
|
* ganz unten in eine Kette weiter oben bringen will, käme sonst nicht hin -
|
||||||
|
* er sieht das Ziel gar nicht.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ziehe) return;
|
||||||
|
|
||||||
|
const RAND = 120;
|
||||||
|
const SCHRITT = 18;
|
||||||
|
let richtung = 0;
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const beiBewegung = (event: DragEvent) => {
|
||||||
|
if (event.clientY < RAND) richtung = -1;
|
||||||
|
else if (event.clientY > window.innerHeight - RAND) richtung = 1;
|
||||||
|
else richtung = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
if (richtung !== 0) window.scrollBy(0, richtung * SCHRITT);
|
||||||
|
}, 16);
|
||||||
|
|
||||||
|
document.addEventListener('dragover', beiBewegung);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragover', beiBewegung);
|
||||||
|
if (timer !== null) window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [ziehe]);
|
||||||
|
// Eine Meldung zum Ziehen gehört neben die Ketten, nicht an den Seitenkopf:
|
||||||
|
// wer unten zieht, sieht dort oben nichts.
|
||||||
|
const [meldung, setMeldung] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { ketten, vergangen } = baueKetten(characters, playerId);
|
||||||
|
|
||||||
|
if (ketten.length === 0 && vergangen.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<h2>CCs</h2>
|
||||||
|
<p className="muted">Noch keine CCs. Über „Neuer CC" den ersten anlegen.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach `afterId` einsortieren; null heisst an den Anfang. */
|
||||||
|
const verschieben = async (lineageId: string, afterId: number | null) => {
|
||||||
|
if (!ziehe) return;
|
||||||
|
setUeber(null);
|
||||||
|
const bewegt = ziehe;
|
||||||
|
setZiehe(null);
|
||||||
|
|
||||||
|
// Der Platz, auf den gezogen wurde, ist schon seiner.
|
||||||
|
if (bewegt.lineageId === lineageId) {
|
||||||
|
const kette = ketten.find((k) => k.lineageId === lineageId)?.glieder ?? [];
|
||||||
|
const index = kette.findIndex((c) => c.id === bewegt.id);
|
||||||
|
const zielIndex = afterId === null ? 0 : kette.findIndex((c) => c.id === afterId) + 1;
|
||||||
|
if (index === zielIndex || index === zielIndex - 1) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMeldung(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/characters/${bewegt.id}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { lineageId, afterId, forPlayerId: playerId },
|
||||||
|
});
|
||||||
|
onMoved();
|
||||||
|
} catch (err) {
|
||||||
|
setMeldung(err instanceof ApiError ? err.message : 'Verschieben fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const herausloesen = async (character: Character) => {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: `"${character.name}" herauslösen`,
|
||||||
|
text: 'Der Eintrag steht danach allein - als eigener Platz, der ein CC-Paket kostet. Die übrige Kette rückt zusammen.',
|
||||||
|
knopf: 'Herauslösen',
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
setMeldung(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/characters/${character.id}/detach`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { forPlayerId: playerId },
|
||||||
|
});
|
||||||
|
onMoved();
|
||||||
|
} catch (err) {
|
||||||
|
setMeldung(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zone = (lineageId: string, afterId: number | null, key: string) => {
|
||||||
|
if (!canEdit || !ziehe) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={ueber === key ? 'chain-drop is-over' : 'chain-drop'}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setUeber(key);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setUeber((v) => (v === key ? null : v))}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void verschieben(lineageId, afterId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
hierher
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block">
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>CCs ({characters.length})</h2>
|
||||||
|
<p className="muted small">
|
||||||
|
Jede Spalte ist ein Platz, von unten nach oben gelesen: unten der gekaufte CC,
|
||||||
|
darüber seine Redesigns.
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Einträge lassen sich mit der Maus an ihren Platz ziehen - auch mitten in eine
|
||||||
|
Kette oder in eine andere. <strong>Die Stelle bestimmt die Buchung:</strong> was
|
||||||
|
unten steht, kostet ein CC-Paket, alles darüber ein Redesign. Beim Verschieben
|
||||||
|
wird entsprechend umgebucht.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meldung && (
|
||||||
|
<Notice kind="error" onDismiss={() => setMeldung(null)}>
|
||||||
|
{meldung}
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="chains">
|
||||||
|
{ketten.map((kette, index) => {
|
||||||
|
const aktiv = kette.glieder.find((c) => c.status === 'ACTIVE');
|
||||||
|
// Von unten nach oben: der jüngste Eintrag steht oben.
|
||||||
|
const vonObenNachUnten = [...kette.glieder].reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={kette.lineageId} className="chain">
|
||||||
|
<div className="chain-head">
|
||||||
|
<strong>Platz {index + 1}</strong>
|
||||||
|
<span className="muted small">
|
||||||
|
{aktiv ? `aktiv: ${aktiv.name}` : 'kein aktiver CC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zone(kette.lineageId, kette.glieder[kette.glieder.length - 1]?.id ?? null, `${kette.lineageId}-oben`)}
|
||||||
|
|
||||||
|
{vonObenNachUnten.map((character, i) => (
|
||||||
|
<div key={character.id}>
|
||||||
|
<ChainEntry
|
||||||
|
character={character}
|
||||||
|
playerId={playerId}
|
||||||
|
canEdit={canEdit}
|
||||||
|
admin={admin}
|
||||||
|
ziehend={ziehe?.id === character.id}
|
||||||
|
onDragStart={() => setZiehe(character)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setZiehe(null);
|
||||||
|
setUeber(null);
|
||||||
|
}}
|
||||||
|
onAction={onAction}
|
||||||
|
onDetach={() => void herausloesen(character)}
|
||||||
|
onChanged={onMoved}
|
||||||
|
onError={onError}
|
||||||
|
alleinInKette={kette.glieder.length === 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{i < vonObenNachUnten.length - 1 && (
|
||||||
|
<>
|
||||||
|
{/* Der Pfeil führt zu diesem Eintrag - also trägt er,
|
||||||
|
womit dieser Schritt bezahlt wurde. */}
|
||||||
|
<Uebergang character={character} />
|
||||||
|
{zone(
|
||||||
|
kette.lineageId,
|
||||||
|
vonObenNachUnten[i + 1]?.id ?? null,
|
||||||
|
`${kette.lineageId}-${character.id}`,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{zone(kette.lineageId, null, `${kette.lineageId}-unten`)}
|
||||||
|
|
||||||
|
{kette.herkunft.map((frueher, i) => (
|
||||||
|
<div key={frueher.id}>
|
||||||
|
<Uebergang character={i === 0 ? kette.glieder[0] : kette.herkunft[i - 1]} />
|
||||||
|
<ChainEntry
|
||||||
|
character={frueher}
|
||||||
|
playerId={playerId}
|
||||||
|
canEdit={canEdit}
|
||||||
|
admin={admin}
|
||||||
|
ziehend={ziehe?.id === frueher.id}
|
||||||
|
onDragStart={() => setZiehe(frueher)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setZiehe(null);
|
||||||
|
setUeber(null);
|
||||||
|
}}
|
||||||
|
onAction={onAction}
|
||||||
|
onDetach={() => void herausloesen(frueher)}
|
||||||
|
onChanged={onMoved}
|
||||||
|
onError={onError}
|
||||||
|
alleinInKette={false}
|
||||||
|
herkunft
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vergangen.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="chain-past-head">Früher genutzt</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
CCs, in denen dieser Spieler einmal steckte, die aber keinem seiner Plätze mehr
|
||||||
|
vorausgehen. Sie gehören jemand anderem.
|
||||||
|
</p>
|
||||||
|
<div className="chains">
|
||||||
|
{vergangen.map((character) => (
|
||||||
|
<ChainEntry
|
||||||
|
key={character.id}
|
||||||
|
character={character}
|
||||||
|
playerId={playerId}
|
||||||
|
canEdit={canEdit}
|
||||||
|
admin={admin}
|
||||||
|
ziehend={ziehe?.id === character.id}
|
||||||
|
onDragStart={() => setZiehe(character)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setZiehe(null);
|
||||||
|
setUeber(null);
|
||||||
|
}}
|
||||||
|
onAction={onAction}
|
||||||
|
onDetach={() => void herausloesen(character)}
|
||||||
|
onChanged={onMoved}
|
||||||
|
onError={onError}
|
||||||
|
alleinInKette={false}
|
||||||
|
herkunft
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzzeichen für die Bezahlart eines Übergangs.
|
||||||
|
const UEBERGANG: Record<string, { zeichen: string; titel: string; klasse: string }> = {
|
||||||
|
REDESIGN_KULANZ: { zeichen: 'K', titel: 'Kulanz-Redesign', klasse: 'is-kulanz' },
|
||||||
|
REDESIGN_PAID: { zeichen: 'R', titel: 'Bezahltes Redesign', klasse: 'is-paid' },
|
||||||
|
CC: { zeichen: 'CC', titel: 'Aus dem CC-Topf bezahlt', klasse: 'is-cc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Pfeil zum nächsten Glied, mit dem Kurzzeichen der Bezahlart.
|
||||||
|
*
|
||||||
|
* Womit ein Schritt bezahlt wurde, gehört an den Schritt - nicht in die
|
||||||
|
* Karte darüber. So liest sich die Kette in einem Zug: Eintrag, womit,
|
||||||
|
* Eintrag.
|
||||||
|
*/
|
||||||
|
function Uebergang({ character }: { character: Character | undefined }) {
|
||||||
|
const arten = (character?.fundedWith ?? [])
|
||||||
|
.map((art) => UEBERGANG[art])
|
||||||
|
.filter((art): art is (typeof UEBERGANG)[string] => art !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chain-arrow">
|
||||||
|
<span aria-hidden="true">⬆</span>
|
||||||
|
{arten.map((art, i) => (
|
||||||
|
<span
|
||||||
|
key={`${art.zeichen}-${i}`}
|
||||||
|
className={`chain-step ${art.klasse}`}
|
||||||
|
style={i > 0 ? { transform: `translate(${0.75 + i * 1.6}rem, -50%)` } : undefined}
|
||||||
|
title={art.titel}
|
||||||
|
>
|
||||||
|
{art.zeichen}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainEntry({
|
||||||
|
character,
|
||||||
|
playerId,
|
||||||
|
canEdit,
|
||||||
|
admin,
|
||||||
|
ziehend,
|
||||||
|
alleinInKette,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onAction,
|
||||||
|
onDetach,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
herkunft = false,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
playerId: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
admin: boolean;
|
||||||
|
ziehend: boolean;
|
||||||
|
alleinInKette: boolean;
|
||||||
|
/**
|
||||||
|
* Ein CC, in dem der Spieler einmal steckte und der zu dieser Kette
|
||||||
|
* führte - meist ein Gruppen-CC, der jemand anderem gehört. Er bleibt
|
||||||
|
* voll bedienbar: beim Nacherfassen muss sich auch das einordnen lassen,
|
||||||
|
* was einem nicht gehört.
|
||||||
|
*/
|
||||||
|
herkunft?: boolean;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onAction: (action: ChainAction, character: Character) => void;
|
||||||
|
onDetach: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const aktiv = character.status === 'ACTIVE';
|
||||||
|
const art = rueckkehrArt(character.inactiveReason);
|
||||||
|
const mitglieder = (character.members ?? []).filter((m) => !m.leftAt);
|
||||||
|
const gruppe = mitglieder.length > 1;
|
||||||
|
const eigeneMitgliedschaft = (character.members ?? []).find((m) => m.playerId === playerId);
|
||||||
|
const ruht = Boolean(eigeneMitgliedschaft?.leftAt && eigeneMitgliedschaft.inactiveReason);
|
||||||
|
const thread = character.forumThreads?.[0];
|
||||||
|
|
||||||
|
const klassen = ['chain-entry'];
|
||||||
|
if (!aktiv) klassen.push('is-inactive');
|
||||||
|
if (ziehend) klassen.push('is-dragging');
|
||||||
|
if (ruht) klassen.push('is-resting');
|
||||||
|
if (herkunft) klassen.push('is-past');
|
||||||
|
|
||||||
|
const eigene = (character.members ?? []).find((m) => m.playerId === playerId);
|
||||||
|
const besitzer = (character.members ?? []).find((m) => m.playerId === character.playerId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={klassen.join(' ')}
|
||||||
|
draggable={canEdit}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div className="chain-entry-head">
|
||||||
|
{canEdit && (
|
||||||
|
<span className="chain-grip" title="Zum Verschieben ziehen" aria-hidden="true">
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{thread ? (
|
||||||
|
<a
|
||||||
|
href={thread.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="link-strong"
|
||||||
|
title={`Antrag im Forum öffnen: ${thread.title}`}
|
||||||
|
>
|
||||||
|
{character.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<strong>{character.name}</strong>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aktiv ? (
|
||||||
|
gruppe && <span className="badge badge-new">Gruppe · {mitglieder.length}</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
art === 'gratis' ? 'badge-free' : art === 'admin' ? 'badge-dead' : 'badge-paid'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{character.inactiveReason ? REASON_LABEL[character.inactiveReason] : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ruht && <span className="badge badge-dead">dein Platz ruht</span>}
|
||||||
|
{herkunft && <span className="badge">war dabei</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{herkunft && (
|
||||||
|
<p className="muted small">
|
||||||
|
{eigene?.joinedAt && `Dabei ab ${formatDate(eigene.joinedAt)}`}
|
||||||
|
{eigene?.leftAt && ` bis ${formatDate(eigene.leftAt)}`}
|
||||||
|
{eigene?.successor && ` · weiter als „${eigene.successor.name}"`}
|
||||||
|
{besitzer && besitzer.playerId !== playerId && ` · gehört ${besitzer.player.displayName}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.loreText && <p className="lore">{character.loreText}</p>}
|
||||||
|
{character.inactiveNote && <p className="muted small">{character.inactiveNote}</p>}
|
||||||
|
|
||||||
|
{(character.links?.length ?? 0) > 0 && (
|
||||||
|
<p className="chain-links">
|
||||||
|
{character.links?.map((link) => (
|
||||||
|
<a key={link.id} href={link.url} target="_blank" rel="noreferrer" className="badge">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MemberList
|
||||||
|
character={character}
|
||||||
|
currentPlayerId={playerId}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="muted small">Seit {formatDate(character.createdAt)}</p>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="chain-actions">
|
||||||
|
{aktiv ? (
|
||||||
|
<>
|
||||||
|
{gruppe ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-tiny" onClick={() => onAction('memberRedesign', character)}>
|
||||||
|
Anteil redesignen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-tiny" onClick={() => onAction('redesign', character)}>
|
||||||
|
Gruppe redesignen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('memberDeactivate', character)}>
|
||||||
|
Nur diesen Nutzer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-tiny" onClick={() => onAction('redesign', character)}>
|
||||||
|
Redesign
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-tiny" onClick={() => onAction('addMember', character)}>
|
||||||
|
Nutzer aufnehmen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('deactivate', character)}>
|
||||||
|
Deaktivieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* An einem längst ersetzten CC geht es nicht ums Beitreten,
|
||||||
|
sondern ums Nachtragen: wer damals dabei war. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny"
|
||||||
|
title="Wer damals dabei war, lässt sich hier nachtragen"
|
||||||
|
onClick={() => onAction('addMember', character)}
|
||||||
|
>
|
||||||
|
Nutzer nachtragen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{art === 'admin' ? (
|
||||||
|
admin ? (
|
||||||
|
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reviveDead', character)}>
|
||||||
|
Zurückholen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="muted small">Nur ein Administrator</span>
|
||||||
|
)
|
||||||
|
) : art === 'gratis' ? (
|
||||||
|
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reactivateFree', character)}>
|
||||||
|
Gratis reaktivieren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-tiny" onClick={() => onAction('reactivatePaid', character)}>
|
||||||
|
Bezahlt reaktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
title="Aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz? Lässt sich nachträglich umbuchen."
|
||||||
|
onClick={() => onAction('redesignKind', character)}
|
||||||
|
>
|
||||||
|
Bezahlart
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
|
||||||
|
Verweise
|
||||||
|
</button>
|
||||||
|
{!alleinInKette && (
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={onDetach} title="Aus dieser Kette lösen und allein stellen">
|
||||||
|
Herauslösen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{aktiv && (
|
||||||
|
<button type="button" className="btn btn-tiny btn-danger" onClick={() => onAction('revert', character)}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{character.predecessorId !== null && !character.predecessor && (
|
||||||
|
<p className="muted small">Vorgänger liegt in einer anderen Kette.</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Notice from './Notice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückfragen im Stil der Anwendung statt als Browser-Dialog.
|
||||||
|
*
|
||||||
|
* `window.confirm` und `window.prompt` sehen nicht nur fremd aus - sie lassen
|
||||||
|
* sich im Browser abschalten. Danach liefern sie stillschweigend "nein"
|
||||||
|
* beziehungsweise nichts zurück, und eine Aktion passiert einfach nicht,
|
||||||
|
* ohne dass jemand erfährt warum. Das ist der eigentliche Grund, warum sie
|
||||||
|
* hier nichts zu suchen haben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Bestaetigung {
|
||||||
|
titel: string;
|
||||||
|
/// Kann fehlen: Eine Rückfrage, die sich aus dem Titel erklärt, braucht
|
||||||
|
/// keinen Absatz darunter.
|
||||||
|
text?: ReactNode;
|
||||||
|
/// Beschriftung des bestätigenden Knopfs.
|
||||||
|
knopf?: string;
|
||||||
|
/// Rot, wenn dabei etwas verloren geht.
|
||||||
|
gefahr?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Abfrage {
|
||||||
|
titel: string;
|
||||||
|
text?: ReactNode;
|
||||||
|
label: string;
|
||||||
|
platzhalter?: string;
|
||||||
|
vorgabe?: string;
|
||||||
|
knopf?: string;
|
||||||
|
/// Für Passwörter: Eingabe verdecken.
|
||||||
|
geheim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Wahl<T extends string> {
|
||||||
|
titel: string;
|
||||||
|
text?: ReactNode;
|
||||||
|
/// Die Möglichkeiten, jeweils Wert und Beschriftung.
|
||||||
|
optionen: { wert: T; label: string; hinweis?: string }[];
|
||||||
|
vorgabe?: T;
|
||||||
|
knopf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogDienst {
|
||||||
|
bestaetigen: (optionen: Bestaetigung) => Promise<boolean>;
|
||||||
|
abfragen: (optionen: Abfrage) => Promise<string | null>;
|
||||||
|
/**
|
||||||
|
* Eine Auswahl aus mehreren Möglichkeiten. Ein Ja/Nein-Dialog, dessen
|
||||||
|
* "Abbrechen" in Wahrheit die zweite Möglichkeit bedeutet, führt jeden in
|
||||||
|
* die Irre, der ihn zum ersten Mal sieht.
|
||||||
|
*/
|
||||||
|
waehlen: <T extends string>(optionen: Wahl<T>) => Promise<T | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = createContext<DialogDienst | null>(null);
|
||||||
|
|
||||||
|
type Offen =
|
||||||
|
| { art: 'bestaetigung'; optionen: Bestaetigung }
|
||||||
|
| { art: 'abfrage'; optionen: Abfrage }
|
||||||
|
| { art: 'wahl'; optionen: Wahl<string> }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [offen, setOffen] = useState<Offen>(null);
|
||||||
|
const [eingabe, setEingabe] = useState('');
|
||||||
|
|
||||||
|
// Die Antwort geht an den Aufrufer zurück, der auf sein Promise wartet.
|
||||||
|
const antwort = useRef<((wert: boolean | string | null) => void) | null>(null);
|
||||||
|
|
||||||
|
const schliessen = useCallback((wert: boolean | string | null) => {
|
||||||
|
antwort.current?.(wert);
|
||||||
|
antwort.current = null;
|
||||||
|
setOffen(null);
|
||||||
|
setEingabe('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bestaetigen = useCallback((optionen: Bestaetigung) => {
|
||||||
|
setOffen({ art: 'bestaetigung', optionen });
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
antwort.current = (wert) => resolve(wert === true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const abfragen = useCallback((optionen: Abfrage) => {
|
||||||
|
setOffen({ art: 'abfrage', optionen });
|
||||||
|
setEingabe(optionen.vorgabe ?? '');
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
antwort.current = (wert) => resolve(typeof wert === 'string' ? wert : null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const waehlen = useCallback(<T extends string>(optionen: Wahl<T>) => {
|
||||||
|
setOffen({ art: 'wahl', optionen: optionen as Wahl<string> });
|
||||||
|
setEingabe(optionen.vorgabe ?? optionen.optionen[0]?.wert ?? '');
|
||||||
|
return new Promise<T | null>((resolve) => {
|
||||||
|
antwort.current = (wert) => resolve(typeof wert === 'string' ? (wert as T) : null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dienst = useMemo<DialogDienst>(
|
||||||
|
() => ({ bestaetigen, abfragen, waehlen }),
|
||||||
|
[bestaetigen, abfragen, waehlen],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={dienst}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{offen?.art === 'bestaetigung' && (
|
||||||
|
<Modal title={offen.optionen.titel} onClose={() => schliessen(false)}>
|
||||||
|
{offen.optionen.text && (
|
||||||
|
<Notice kind={offen.optionen.gefahr ? 'warn' : 'info'}>{offen.optionen.text}</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => schliessen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={offen.optionen.gefahr ? 'btn btn-danger' : 'btn btn-primary'}
|
||||||
|
autoFocus
|
||||||
|
onClick={() => schliessen(true)}
|
||||||
|
>
|
||||||
|
{offen.optionen.knopf ?? 'Bestätigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offen?.art === 'wahl' && (
|
||||||
|
<Modal title={offen.optionen.titel} onClose={() => schliessen(null)}>
|
||||||
|
{offen.optionen.text && <Notice kind="info">{offen.optionen.text}</Notice>}
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
{offen.optionen.optionen.map((option) => (
|
||||||
|
<label key={option.wert} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="dialog-wahl"
|
||||||
|
checked={eingabe === option.wert}
|
||||||
|
onChange={() => setEingabe(option.wert)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
{option.hinweis && <span className="muted"> - {option.hinweis}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => schliessen(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!eingabe}
|
||||||
|
onClick={() => schliessen(eingabe)}
|
||||||
|
>
|
||||||
|
{offen.optionen.knopf ?? 'Übernehmen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{offen?.art === 'abfrage' && (
|
||||||
|
<Modal title={offen.optionen.titel} onClose={() => schliessen(null)}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (eingabe.trim()) schliessen(eingabe.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{offen.optionen.text && <Notice kind="info">{offen.optionen.text}</Notice>}
|
||||||
|
|
||||||
|
<label htmlFor="dialog-eingabe">{offen.optionen.label}</label>
|
||||||
|
<input
|
||||||
|
id="dialog-eingabe"
|
||||||
|
type={offen.optionen.geheim ? 'password' : 'text'}
|
||||||
|
value={eingabe}
|
||||||
|
placeholder={offen.optionen.platzhalter ?? ''}
|
||||||
|
onChange={(event) => setEingabe(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => schliessen(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={!eingabe.trim()}>
|
||||||
|
{offen.optionen.knopf ?? 'Übernehmen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialog(): DialogDienst {
|
||||||
|
const dienst = useContext(DialogContext);
|
||||||
|
if (!dienst) throw new Error('useDialog benötigt einen DialogProvider');
|
||||||
|
return dienst;
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
<NavLink to="/" end>
|
<NavLink to="/" end>
|
||||||
Spieler
|
Spieler
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
|
<NavLink to="/forum">Anträge</NavLink>
|
||||||
|
{can('EDITOR') && <NavLink to="/import">Import</NavLink>}
|
||||||
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -40,6 +41,13 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="content">{children}</main>
|
<main className="content">{children}</main>
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<img src="/harle.png" alt="" className="footer-mascot" />
|
||||||
|
<span>
|
||||||
|
Created by <strong>Harlekin</strong> <span className="credits-sep">|</span> CC-Head
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { useDialog } from './DialogProvider';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import type { Character, CharacterLink } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verweise an einem CC: das Addon des Erstellers, ein Modell, eine Absprache.
|
||||||
|
* Eingetragen wird ein Name und eine Adresse - in der Liste steht dann der
|
||||||
|
* Name als Link, damit man sieht, worum es geht, und nicht eine nackte
|
||||||
|
* Adresse lesen muss.
|
||||||
|
*/
|
||||||
|
export default function LinksModal({
|
||||||
|
character,
|
||||||
|
canEdit,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
canEdit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const { bestaetigen } = useDialog();
|
||||||
|
|
||||||
|
const [links, setLinks] = useState<CharacterLink[]>(character.links ?? []);
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [bearbeitet, setBearbeitet] = useState<CharacterLink | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const melden = (err: unknown) => {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Hat nicht geklappt');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hinzufuegen = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (bearbeitet) {
|
||||||
|
const geaendert = await api<CharacterLink>(`/api/characters/links/${bearbeitet.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { label: label.trim(), url: url.trim() },
|
||||||
|
});
|
||||||
|
setLinks((alt) => alt.map((l) => (l.id === geaendert.id ? geaendert : l)));
|
||||||
|
setBearbeitet(null);
|
||||||
|
} else {
|
||||||
|
const neu = await api<CharacterLink>(`/api/characters/${character.id}/links`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { label: label.trim(), url: url.trim() },
|
||||||
|
});
|
||||||
|
setLinks((alt) => [...alt, neu]);
|
||||||
|
}
|
||||||
|
setLabel('');
|
||||||
|
setUrl('');
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
melden(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entfernen = async (link: CharacterLink) => {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: 'Verweis entfernen',
|
||||||
|
text: `Soll der Verweis "${link.label}" entfernt werden?`,
|
||||||
|
knopf: 'Entfernen',
|
||||||
|
gefahr: true,
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/characters/links/${link.id}`, { method: 'DELETE' });
|
||||||
|
setLinks((alt) => alt.filter((l) => l.id !== link.id));
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
melden(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Verweise an "${character.name}"`} onClose={onClose}>
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="muted">Noch keine Verweise.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cc-list">
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<a href={link.url} target="_blank" rel="noreferrer" className="link-strong">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
<p className="muted small mono">{link.url}</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setBearbeitet(link);
|
||||||
|
setLabel(link.label);
|
||||||
|
setUrl(link.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ändern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-danger"
|
||||||
|
onClick={() => void entfernen(link)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<form onSubmit={hinzufuegen} style={{ marginTop: '1rem' }}>
|
||||||
|
<label htmlFor="link-label">Name</label>
|
||||||
|
<input
|
||||||
|
id="link-label"
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
placeholder="z. B. Ersteller-Addon"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="link-url">Adresse</label>
|
||||||
|
<input
|
||||||
|
id="link-url"
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
placeholder="https://steamcommunity.com/sharedfiles/…"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
{bearbeitet && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setBearbeitet(null);
|
||||||
|
setLabel('');
|
||||||
|
setUrl('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : bearbeitet ? 'Ändern' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useDialog } from './DialogProvider';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { REASON_LABEL, rueckkehrArt } from '../lib/format';
|
||||||
|
import type { Character, CharacterMember } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer einen CC nutzt. Bei einem Gruppen-CC kann ein Einzelner ausfallen,
|
||||||
|
* ohne die übrigen zu berühren - dann steht er hier, bis er zurückkehrt.
|
||||||
|
*/
|
||||||
|
export default function MemberList({
|
||||||
|
character,
|
||||||
|
currentPlayerId,
|
||||||
|
canEdit,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
currentPlayerId: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { bestaetigen, abfragen, waehlen } = useDialog();
|
||||||
|
const alle = character.members ?? [];
|
||||||
|
const aktiv = alle.filter((m) => !m.leftAt);
|
||||||
|
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht.
|
||||||
|
const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason);
|
||||||
|
const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason);
|
||||||
|
|
||||||
|
if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
|
||||||
|
|
||||||
|
const austragen = async (playerId: number, name: string) => {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: `"${name}" austragen`,
|
||||||
|
text: `Soll "${name}" den CC "${character.name}" nicht mehr nutzen? Verbrauchtes Guthaben gibt es dabei nicht zurück - bis hierher hat er den CC genutzt.`,
|
||||||
|
knopf: 'Austragen',
|
||||||
|
gefahr: true,
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zurueckholen = async (mitglied: CharacterMember) => {
|
||||||
|
const art = rueckkehrArt(mitglied.inactiveReason);
|
||||||
|
const name = mitglied.player.displayName;
|
||||||
|
|
||||||
|
let reason: string | undefined;
|
||||||
|
if (art === 'admin') {
|
||||||
|
const eingabe = await abfragen({
|
||||||
|
titel: `"${name}" zurückholen`,
|
||||||
|
text: 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator und will erklärt sein.',
|
||||||
|
label: 'Womit kehrt er zurück?',
|
||||||
|
platzhalter: 'z. B. Klon aus der Lagerhalle',
|
||||||
|
knopf: 'Zurückholen',
|
||||||
|
});
|
||||||
|
if (!eingabe) return;
|
||||||
|
reason = eingabe;
|
||||||
|
}
|
||||||
|
|
||||||
|
let useKind: string | undefined;
|
||||||
|
if (art === 'bezahlt') {
|
||||||
|
const gewaehlt = await waehlen({
|
||||||
|
titel: `"${name}" zurückholen`,
|
||||||
|
text: 'Diese Rückkehr kostet ein Guthaben. Woraus soll sie bezahlt werden?',
|
||||||
|
optionen: [
|
||||||
|
{ wert: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hinweis: 'vom Team gewährt' },
|
||||||
|
{ wert: 'REDESIGN_PAID', label: 'Bezahltes Redesign', hinweis: 'aus einem Kauf' },
|
||||||
|
],
|
||||||
|
knopf: 'Zurückholen',
|
||||||
|
});
|
||||||
|
if (!gewaehlt) return;
|
||||||
|
useKind = gewaehlt;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) },
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="member-list">
|
||||||
|
{aktiv.map((mitglied) => (
|
||||||
|
<span key={mitglied.id} className="member-chip">
|
||||||
|
{mitglied.playerId === currentPlayerId ? (
|
||||||
|
<strong>{mitglied.player.displayName}</strong>
|
||||||
|
) : (
|
||||||
|
<Link to={`/players/${mitglied.playerId}`}>{mitglied.player.displayName}</Link>
|
||||||
|
)}
|
||||||
|
{canEdit && aktiv.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="member-remove"
|
||||||
|
title="Austragen"
|
||||||
|
onClick={() => void austragen(mitglied.playerId, mitglied.player.displayName)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ruhend.map((mitglied) => (
|
||||||
|
<span key={mitglied.id} className="member-chip is-inactive">
|
||||||
|
<span className="muted">{mitglied.player.displayName}</span>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
rueckkehrArt(mitglied.inactiveReason) === 'gratis'
|
||||||
|
? 'badge-free'
|
||||||
|
: rueckkehrArt(mitglied.inactiveReason) === 'admin'
|
||||||
|
? 'badge-dead'
|
||||||
|
: 'badge-paid'
|
||||||
|
}`}
|
||||||
|
title={mitglied.inactiveNote ?? undefined}
|
||||||
|
>
|
||||||
|
{mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => void zurueckholen(mitglied)}
|
||||||
|
>
|
||||||
|
Zurückholen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{ehemalig.length > 0 && (
|
||||||
|
<span className="muted small">
|
||||||
|
früher:{' '}
|
||||||
|
{ehemalig
|
||||||
|
.map((m) =>
|
||||||
|
m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName,
|
||||||
|
)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function Modal({ title, onClose, children, footer }: ModalProps)
|
|||||||
>
|
>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
|
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schließen">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { useDialog } from './DialogProvider';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { formatDate } from '../lib/format';
|
||||||
|
import type { PendingRow } from '../lib/types';
|
||||||
|
|
||||||
|
const ZIEL_LABEL: Record<string, string> = {
|
||||||
|
CC_PACKAGE: 'CC-Paket',
|
||||||
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||||
|
IGNORE: 'Ohne Wirkung',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pakete dieses Spielers, die noch in einer offenen Import-Vorschau liegen.
|
||||||
|
*
|
||||||
|
* Damit lässt sich ein Spieler an einem Stück abarbeiten, statt seine Zeilen
|
||||||
|
* in der Import-Vorschau zwischen fremden zu suchen. Was hier gebucht wird,
|
||||||
|
* steht danach in keiner Vorschau mehr zur Buchung an - auch nicht, wenn
|
||||||
|
* dieselbe Bestellung in einem zweiten Import liegt.
|
||||||
|
*/
|
||||||
|
export default function PendingRowsBox({
|
||||||
|
rows,
|
||||||
|
canEdit,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
onFlash,
|
||||||
|
}: {
|
||||||
|
rows: PendingRow[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onFlash: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { bestaetigen } = useDialog();
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const umschalten = (id: number) => {
|
||||||
|
setGewaehlt((alt) => {
|
||||||
|
const neu = new Set(alt);
|
||||||
|
if (neu.has(id)) neu.delete(id);
|
||||||
|
else neu.add(id);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ausgewaehlt = rows.filter((row) => gewaehlt.has(row.id));
|
||||||
|
const betroffen = ausgewaehlt.length > 0 ? ausgewaehlt : rows;
|
||||||
|
|
||||||
|
const ausfuehren = async (pfad: 'book' | 'dismiss') => {
|
||||||
|
const anzahl = betroffen.length;
|
||||||
|
if (pfad === 'dismiss') {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: anzahl === 1 ? 'Paket weglegen' : `${anzahl} Pakete weglegen`,
|
||||||
|
text: 'Sie verschwinden aus der Vorschau, ohne gebucht zu werden - am Konto ändert sich nichts.',
|
||||||
|
knopf: 'Weglegen',
|
||||||
|
gefahr: true,
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api(`/api/imports/rows/${pfad}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { rowIds: betroffen.map((row) => row.id) },
|
||||||
|
});
|
||||||
|
onFlash(
|
||||||
|
pfad === 'book'
|
||||||
|
? `${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} gebucht.`
|
||||||
|
: `${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} weggelegt.`,
|
||||||
|
);
|
||||||
|
setGewaehlt(new Set());
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block block-flag">
|
||||||
|
<h2>Offene Pakete aus dem Abgleich ({rows.length})</h2>
|
||||||
|
<Notice kind="warn">
|
||||||
|
Diese Käufe liegen in einer Import-Vorschau und sind noch nicht gebucht. Hier gebucht,
|
||||||
|
verschwinden sie aus der Vorschau - doppelt buchen ist ausgeschlossen.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<ul className="cc-list">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={gewaehlt.has(row.id)}
|
||||||
|
onChange={() => umschalten(row.id)}
|
||||||
|
disabled={!canEdit || busy}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{row.productName ?? 'Ohne Bezeichnung'}</strong>
|
||||||
|
{row.target && <span className="badge">{ZIEL_LABEL[row.target]}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted small">
|
||||||
|
{row.purchasedAt ? `Gekauft ${formatDate(row.purchasedAt)}` : 'Ohne Kaufdatum'} ·
|
||||||
|
aus „{row.import.filename}"
|
||||||
|
{row.externalId && ` · ${row.externalId}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void ausfuehren('dismiss')}
|
||||||
|
>
|
||||||
|
{ausgewaehlt.length > 0 ? `${ausgewaehlt.length} weglegen` : 'Alle weglegen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void ausfuehren('book')}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? 'Buche …'
|
||||||
|
: ausgewaehlt.length > 0
|
||||||
|
? `${ausgewaehlt.length} buchen`
|
||||||
|
: `Alle ${rows.length} buchen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Fortschritt eines laufenden Abgleichs.
|
||||||
|
*
|
||||||
|
* Steht der Umfang noch nicht fest - beim Forum, solange nicht jedes Board
|
||||||
|
* seine Seitenzahl gemeldet hat -, läuft der Balken durch, statt einen
|
||||||
|
* erfundenen Anteil zu behaupten.
|
||||||
|
*/
|
||||||
|
export default function RunProgress({
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
current,
|
||||||
|
log,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
/// Woran gerade gearbeitet wird, fertig formuliert.
|
||||||
|
current: string | null;
|
||||||
|
log: string[];
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
const bekannt = total > 0;
|
||||||
|
const anteil = bekannt ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||||||
|
const letzte = log.slice(-6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block progress">
|
||||||
|
<div className="progress-head">
|
||||||
|
<strong>{bekannt ? `${done} von ${total}` : `Umfang wird ermittelt … (${done})`}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{current ?? 'wird gespeichert'}
|
||||||
|
{bekannt && ` · ${anteil} %`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className={bekannt ? 'progress-fill' : 'progress-fill is-unknown'}
|
||||||
|
style={bekannt ? { width: `${anteil}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{letzte.length > 0 && (
|
||||||
|
<ul className="progress-log">
|
||||||
|
{letzte.map((zeile, index) => (
|
||||||
|
<li key={`${index}-${zeile}`}>{zeile}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="muted small">{hint}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { CLASSIFICATION_LABEL } from '../lib/format';
|
||||||
|
import type {
|
||||||
|
Character,
|
||||||
|
ForumClassification,
|
||||||
|
ForumThread,
|
||||||
|
PlayerDetail,
|
||||||
|
PlayerSummary,
|
||||||
|
} from '../lib/types';
|
||||||
|
|
||||||
|
export function ClassifyModal({
|
||||||
|
thread,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
thread: ForumThread;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
|
||||||
|
const [name, setName] = useState(thread.title);
|
||||||
|
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
||||||
|
const [reviveId, setReviveId] = useState<number | null>(null);
|
||||||
|
// Der Regelfall ist das bezahlte Redesign. Kulanz ist die Ausnahme und
|
||||||
|
// wird bewusst vergeben - sonst entstehen Kulanz-Buchungen, die niemand
|
||||||
|
// gewollt hat.
|
||||||
|
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_PAID');
|
||||||
|
const [characters, setCharacters] = useState<Character[]>([]);
|
||||||
|
const [inactive, setInactive] = useState<Character[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!thread.playerId) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
|
||||||
|
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
|
||||||
|
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
|
||||||
|
} catch {
|
||||||
|
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [thread.playerId]);
|
||||||
|
|
||||||
|
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
|
||||||
|
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
|
||||||
|
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
|
||||||
|
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
|
||||||
|
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api(`/api/forum/threads/${thread.threadId}/classify`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
classification: choice,
|
||||||
|
...(needsName ? { characterName: name.trim() } : {}),
|
||||||
|
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
|
||||||
|
...(choice === 'REACTIVATION'
|
||||||
|
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Entscheidung fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Antrag entscheiden" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<Notice kind="info">
|
||||||
|
<strong>{thread.title}</strong>
|
||||||
|
<br />
|
||||||
|
von {thread.authorName ?? 'unbekannt'}
|
||||||
|
{thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'}
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'],
|
||||||
|
['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'],
|
||||||
|
['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'],
|
||||||
|
['REJECTED', 'Abgelehnt', 'keine Buchung'],
|
||||||
|
['IGNORE', 'Kein Antrag', 'keine Buchung'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, hint]) => (
|
||||||
|
<label key={value} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="classification"
|
||||||
|
checked={choice === value}
|
||||||
|
onChange={() => setChoice(value)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<span className="muted"> - {hint}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsPlayer && !thread.playerId && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen".
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsName && thread.playerId && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="cc-name">Name des CCs</label>
|
||||||
|
<input
|
||||||
|
id="cc-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{choice === 'REACTIVATION' && thread.playerId && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
|
||||||
|
<select
|
||||||
|
id="revive"
|
||||||
|
value={reviveId ?? ''}
|
||||||
|
onChange={(event) => setReviveId(Number(event.target.value) || null)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">- bitte wählen -</option>
|
||||||
|
{inactive.map((character) => (
|
||||||
|
<option key={character.id} value={character.id}>
|
||||||
|
{character.name}
|
||||||
|
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{inactive.length === 0 && (
|
||||||
|
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gewaehlterCC && !reaktivierungKostet && (
|
||||||
|
<Notice kind="success">
|
||||||
|
Kommt gratis zurück - der CC existiert unverändert weiter.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reaktivierungKostet && (
|
||||||
|
<>
|
||||||
|
<Notice kind="warn">
|
||||||
|
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
|
||||||
|
kostet ein Guthaben.
|
||||||
|
</Notice>
|
||||||
|
<label>Womit bezahlen?</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reviveKind"
|
||||||
|
checked={useKind === 'REDESIGN_PAID'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||||
|
/>
|
||||||
|
<span>Bezahltes Redesign</span>
|
||||||
|
</label>
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reviveKind"
|
||||||
|
checked={useKind === 'REDESIGN_KULANZ'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||||
|
/>
|
||||||
|
<span>Kulanz-Redesign</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{choice === 'REDESIGN' && thread.playerId && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
|
||||||
|
<select
|
||||||
|
id="vorgaenger"
|
||||||
|
value={predecessorId ?? ''}
|
||||||
|
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">- bitte wählen -</option>
|
||||||
|
{characters.map((character) => (
|
||||||
|
<option key={character.id} value={character.id}>
|
||||||
|
{character.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{characters.length === 0 && (
|
||||||
|
<p className="muted small neg">Dieser Spieler hat keine aktiven CCs.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label>Womit bezahlen?</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
checked={useKind === 'REDESIGN_PAID'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||||
|
/>
|
||||||
|
<span>Bezahltes Redesign</span>
|
||||||
|
</label>
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
checked={useKind === 'REDESIGN_KULANZ'}
|
||||||
|
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||||
|
/>
|
||||||
|
<span>Kulanz-Redesign</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 || (needsPlayer && !thread.playerId)}
|
||||||
|
>
|
||||||
|
{busy ? 'Speichere …' : 'Entscheiden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignModal({
|
||||||
|
thread,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
thread: ForumThread;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(thread.authorName ?? '');
|
||||||
|
const [results, setResults] = useState<PlayerSummary[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: query, take: '20' });
|
||||||
|
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
|
||||||
|
setResults(data.items);
|
||||||
|
} catch {
|
||||||
|
// Suchfehler blockieren den Dialog nicht.
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const assign = async (playerId: number) => {
|
||||||
|
try {
|
||||||
|
await api(`/api/forum/threads/${thread.threadId}/player`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { playerId },
|
||||||
|
});
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Zuordnen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Spieler zuordnen" onClose={onClose}>
|
||||||
|
<Notice kind="info">
|
||||||
|
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
|
||||||
|
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
|
||||||
|
ihn von allein.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="suche">Spieler suchen</label>
|
||||||
|
<input
|
||||||
|
id="suche"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
|
||||||
|
{results.map((player) => (
|
||||||
|
<li key={player.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<strong>{player.displayName}</strong>
|
||||||
|
{player.forumName && <span className="muted"> · Forum: {player.forumName}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-primary"
|
||||||
|
onClick={() => void assign(player.id)}
|
||||||
|
>
|
||||||
|
Zuordnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ClassifyModal } from './ThreadModals';
|
||||||
|
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
||||||
|
import type { ForumThread } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Forums-Anträge dieses Spielers, ältester zuerst - so wie sie
|
||||||
|
* abgearbeitet werden. Offene lassen sich hier direkt entscheiden, statt
|
||||||
|
* dafür auf die Anträge-Seite zu wechseln und ihn dort zu suchen.
|
||||||
|
*/
|
||||||
|
export default function ThreadsBox({
|
||||||
|
threads,
|
||||||
|
canEdit,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
onFlash,
|
||||||
|
}: {
|
||||||
|
threads: ForumThread[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onFlash: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [dialog, setDialog] = useState<ForumThread | null>(null);
|
||||||
|
|
||||||
|
const offen = threads.filter((thread) => thread.needsClassification);
|
||||||
|
const entschieden = threads.filter((thread) => !thread.needsClassification);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={offen.length > 0 ? 'block block-flag' : 'block'}>
|
||||||
|
<h2>Forums-Anträge ({threads.length})</h2>
|
||||||
|
|
||||||
|
{threads.length === 0 ? (
|
||||||
|
<p className="muted">
|
||||||
|
Keine Anträge zugeordnet. Zugeordnet wird über den Forum-Namen des Erstellers - steht
|
||||||
|
hier keiner, findet der Abgleich nichts.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="cc-list">
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<li
|
||||||
|
key={thread.threadId}
|
||||||
|
className={thread.needsClassification ? 'cc-item' : 'cc-item is-inactive'}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={thread.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="link-strong"
|
||||||
|
title="Im Forum öffnen"
|
||||||
|
>
|
||||||
|
{thread.title}
|
||||||
|
</a>
|
||||||
|
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||||
|
{thread.classification && (
|
||||||
|
<span className="badge badge-free">
|
||||||
|
{CLASSIFICATION_LABEL[thread.classification]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="muted small">
|
||||||
|
{thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '}
|
||||||
|
{thread.authorName ?? 'unbekannt'}
|
||||||
|
{thread.labels && ` · ${thread.labels}`}
|
||||||
|
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
|
||||||
|
{thread.classifiedAt && ` · entschieden ${formatDate(thread.classifiedAt)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && thread.needsClassification && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-primary"
|
||||||
|
onClick={() => setDialog(thread)}
|
||||||
|
>
|
||||||
|
Entscheiden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{entschieden.length > 0 && offen.length === 0 && (
|
||||||
|
<p className="muted small">Alle Anträge sind entschieden.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog && (
|
||||||
|
<ClassifyModal
|
||||||
|
thread={dialog}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
onDone={(message) => {
|
||||||
|
setDialog(null);
|
||||||
|
onFlash(message);
|
||||||
|
onChanged();
|
||||||
|
}}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Notice from './Notice';
|
||||||
|
import { ApiError, api } from '../lib/api';
|
||||||
|
import { formatDate } from '../lib/format';
|
||||||
|
import type { Resolution, UnmatchedPackage } from '../lib/types';
|
||||||
|
|
||||||
|
const CHOICES: { value: Resolution; label: string; hint: string }[] = [
|
||||||
|
{ value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' },
|
||||||
|
{ value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' },
|
||||||
|
{ value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' },
|
||||||
|
{ value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden.
|
||||||
|
* Sie werden bewusst nie automatisch gebucht, sondern hier entschieden.
|
||||||
|
*/
|
||||||
|
export default function UnmatchedBox({
|
||||||
|
items,
|
||||||
|
canResolve,
|
||||||
|
onResolved,
|
||||||
|
}: {
|
||||||
|
items: UnmatchedPackage[];
|
||||||
|
canResolve: boolean;
|
||||||
|
onResolved: () => void;
|
||||||
|
}) {
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const resolve = async (id: number, resolution: Resolution) => {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/scrape/unmatched/${id}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { resolution },
|
||||||
|
});
|
||||||
|
onResolved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="block block-flag">
|
||||||
|
<h2>Ungeklärte Einträge ({items.length})</h2>
|
||||||
|
<Notice kind="warn">
|
||||||
|
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
|
||||||
|
ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
||||||
|
sie hier einordnest.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
|
||||||
|
<ul className="cc-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<strong>{item.rawTitle}</strong>
|
||||||
|
<span className="badge">{item.rawTag}</span>
|
||||||
|
<p className="muted small">
|
||||||
|
{item.amount && `${item.amount} · `}
|
||||||
|
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canResolve && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
{CHOICES.map((choice) => (
|
||||||
|
<button
|
||||||
|
key={choice.value}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
|
||||||
|
title={choice.hint}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() => void resolve(item.id, choice.value)}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -62,7 +62,7 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
|||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||||
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
|
|||||||
+34
-4
@@ -9,18 +9,34 @@ export const KIND_LABEL: Record<CreditKind, string> = {
|
|||||||
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
||||||
CC_PACKAGE: 'CC-Paket',
|
CC_PACKAGE: 'CC-Paket',
|
||||||
REDESIGN_PACKAGE: 'Redesign-Paket',
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||||
|
/// Von Hand vergeben, ohne Kauf dahinter.
|
||||||
|
CC_GRANT: 'CC-Guthaben (von Hand)',
|
||||||
KULANZ: 'Kulanz',
|
KULANZ: 'Kulanz',
|
||||||
ADJUSTMENT: 'Korrektur',
|
ADJUSTMENT: 'Korrektur',
|
||||||
|
CONVERSION: 'Umwandlung',
|
||||||
CONSUME: 'Verbrauch',
|
CONSUME: 'Verbrauch',
|
||||||
REVERSAL: 'Storno',
|
REVERSAL: 'Storno',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const REASON_LABEL: Record<InactiveReason, string> = {
|
export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||||
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
|
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
|
||||||
REWORKED: 'Redesignt',
|
REWORKED: 'Redesignt',
|
||||||
OTHER: 'Sonstiger Grund',
|
OTHER: 'Anderer Grund',
|
||||||
|
NO_FREE_CLAIM: 'Entzogen',
|
||||||
|
DEAD: 'Im RP gestorben',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welchen Weg die Rückkehr nimmt. Gratis, solange der CC unverändert
|
||||||
|
* weiterexistiert; bezahlt, wenn er ersetzt oder entzogen wurde; und eine
|
||||||
|
* Entscheidung des Administrators, wenn er im Rollenspiel gestorben ist.
|
||||||
|
*/
|
||||||
|
export function rueckkehrArt(reason: InactiveReason | null): 'gratis' | 'bezahlt' | 'admin' {
|
||||||
|
if (reason === 'DEAD') return 'admin';
|
||||||
|
if (reason === 'REWORKED' || reason === 'NO_FREE_CLAIM') return 'bezahlt';
|
||||||
|
return 'gratis';
|
||||||
|
}
|
||||||
|
|
||||||
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
||||||
BOOK: 'Wird gebucht',
|
BOOK: 'Wird gebucht',
|
||||||
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
||||||
@@ -39,11 +55,25 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function formatDate(value: string | null | undefined): string {
|
export function formatDate(value: string | null | undefined): string {
|
||||||
if (!value) return '—';
|
if (!value) return '-';
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed);
|
return Number.isNaN(parsed.getTime()) ? '-' : dateFormat.format(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(amount: number): string {
|
export function formatAmount(amount: number): string {
|
||||||
return amount > 0 ? `+${amount}` : String(amount);
|
return amount > 0 ? `+${amount}` : String(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BOARD_LABEL: Record<string, string> = {
|
||||||
|
CUSTOM_CHARACTERS: 'Anträge',
|
||||||
|
AKTIVE_CCS: 'Aktive CCs',
|
||||||
|
CC_ARCHIV: 'Archiv',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLASSIFICATION_LABEL: Record<string, string> = {
|
||||||
|
NEW_CC: 'Angenommen - neuer CC',
|
||||||
|
REDESIGN: 'Angenommen - Redesign',
|
||||||
|
REACTIVATION: 'Angenommen - Reaktivierung',
|
||||||
|
REJECTED: 'Abgelehnt',
|
||||||
|
IGNORE: 'Ignoriert',
|
||||||
|
};
|
||||||
|
|||||||
+10
-4
@@ -6,6 +6,8 @@ import type { Role, SessionUser } from './types';
|
|||||||
interface SessionState {
|
interface SessionState {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/// Die Adressen der fremden Dienste, für Profil-Links.
|
||||||
|
gextensionBaseUrl: string | null;
|
||||||
setUser: (user: SessionUser | null) => void;
|
setUser: (user: SessionUser | null) => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
@@ -19,11 +21,15 @@ const SessionContext = createContext<SessionState | null>(null);
|
|||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [gextensionBaseUrl, setGextension] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api<{ user: SessionUser | null }>('/api/auth/me');
|
const result = await api<{ user: SessionUser | null; gextensionBaseUrl?: string }>(
|
||||||
|
'/api/auth/me',
|
||||||
|
);
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
|
setGextension(result.gextensionBaseUrl ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,8 +55,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo<SessionState>(
|
const value = useMemo<SessionState>(
|
||||||
() => ({ user, loading, setUser, refresh, logout, can }),
|
() => ({ user, loading, gextensionBaseUrl, setUser, refresh, logout, can }),
|
||||||
[user, loading, refresh, logout, can],
|
[user, loading, gextensionBaseUrl, refresh, logout, can],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
@@ -58,6 +64,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function useSession(): SessionState {
|
export function useSession(): SessionState {
|
||||||
const context = useContext(SessionContext);
|
const context = useContext(SessionContext);
|
||||||
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
|
if (!context) throw new Error('useSession benötigt einen SessionProvider');
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
+261
-1
@@ -3,12 +3,19 @@ export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
|||||||
export type CreditSource =
|
export type CreditSource =
|
||||||
| 'CC_PACKAGE'
|
| 'CC_PACKAGE'
|
||||||
| 'REDESIGN_PACKAGE'
|
| 'REDESIGN_PACKAGE'
|
||||||
|
| 'CC_GRANT'
|
||||||
| 'KULANZ'
|
| 'KULANZ'
|
||||||
| 'ADJUSTMENT'
|
| 'ADJUSTMENT'
|
||||||
|
| 'CONVERSION'
|
||||||
| 'CONSUME'
|
| 'CONSUME'
|
||||||
| 'REVERSAL';
|
| 'REVERSAL';
|
||||||
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||||
export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER';
|
export type InactiveReason =
|
||||||
|
| 'PLAYER_INACTIVITY'
|
||||||
|
| 'REWORKED'
|
||||||
|
| 'OTHER'
|
||||||
|
| 'NO_FREE_CLAIM'
|
||||||
|
| 'DEAD';
|
||||||
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
||||||
|
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
@@ -24,6 +31,8 @@ export interface Balances {
|
|||||||
REDESIGN_PAID: number;
|
REDESIGN_PAID: number;
|
||||||
REDESIGN_KULANZ: number;
|
REDESIGN_KULANZ: number;
|
||||||
redesignTotal: number;
|
redesignTotal: number;
|
||||||
|
/// Wie viel jemals in den Topf kam, abzüglich stornierter Zugänge.
|
||||||
|
granted?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerSummary {
|
export interface PlayerSummary {
|
||||||
@@ -31,18 +40,67 @@ export interface PlayerSummary {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
steamId: string | null;
|
steamId: string | null;
|
||||||
forumName: string | null;
|
forumName: string | null;
|
||||||
|
isProblem: boolean;
|
||||||
|
problemNote: string | null;
|
||||||
characterCount: number;
|
characterCount: number;
|
||||||
activeCharacterCount: number;
|
activeCharacterCount: number;
|
||||||
balances: Balances;
|
balances: Balances;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterLink {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Player {
|
export interface Player {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
steamId: string | null;
|
steamId: string | null;
|
||||||
forumName: string | null;
|
forumName: string | null;
|
||||||
|
/// Sein Forumsprofil, aus dem Abgleich übernommen.
|
||||||
|
forumProfileUrl: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
/// Ein Fall, der sich gerade nicht klären lässt. Reine Merkhilfe.
|
||||||
|
isProblem: boolean;
|
||||||
|
problemNote: string | null;
|
||||||
|
problemSince: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
gextensionUsage: number | null;
|
||||||
|
gextensionSyncedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||||
|
|
||||||
|
export interface UnmatchedPackage {
|
||||||
|
id: number;
|
||||||
|
externalId: string;
|
||||||
|
playerId: number | null;
|
||||||
|
steamId: string;
|
||||||
|
rawTitle: string;
|
||||||
|
rawTag: string;
|
||||||
|
amount: string | null;
|
||||||
|
purchasedAt: string | null;
|
||||||
|
scrapedAt: string;
|
||||||
|
resolved: boolean;
|
||||||
|
resolvedAs: string | null;
|
||||||
|
player: { id: number; displayName: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnmatchedOverview {
|
||||||
|
total: number;
|
||||||
|
players: { playerId: number; displayName: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrapeSummary {
|
||||||
|
importId: number | null;
|
||||||
|
scannedPlayers: number;
|
||||||
|
failedPlayers: { steamId: string; reason: string }[];
|
||||||
|
newRows: number;
|
||||||
|
duplicates: number;
|
||||||
|
awaitingConfirmation: number;
|
||||||
|
unclear: number;
|
||||||
|
usageCounters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CharacterRef {
|
export interface CharacterRef {
|
||||||
@@ -50,6 +108,32 @@ export interface CharacterRef {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Forums-Antrag, aus dem ein CC hervorgegangen ist.
|
||||||
|
export interface ThreadRef {
|
||||||
|
threadId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterMember {
|
||||||
|
id: number;
|
||||||
|
characterId: number;
|
||||||
|
playerId: number;
|
||||||
|
player: { id: number; displayName: string; steamId?: string | null };
|
||||||
|
joinedAt: string;
|
||||||
|
leftAt: string | null;
|
||||||
|
paidWith: CreditKind | null;
|
||||||
|
/// Der eigene CC, den der Spieler für den Platz aufgegeben hat.
|
||||||
|
predecessorId: number | null;
|
||||||
|
/// Der CC, in den er von hier aus gewechselt ist.
|
||||||
|
successorId: number | null;
|
||||||
|
successor?: CharacterRef | null;
|
||||||
|
/// Warum er den CC nicht mehr nutzt. Leer bei einem freiwilligen Austritt.
|
||||||
|
inactiveReason: InactiveReason | null;
|
||||||
|
inactiveNote: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
id: number;
|
id: number;
|
||||||
playerId: number;
|
playerId: number;
|
||||||
@@ -57,10 +141,19 @@ export interface Character {
|
|||||||
loreText: string | null;
|
loreText: string | null;
|
||||||
status: CharacterStatus;
|
status: CharacterStatus;
|
||||||
inactiveReason: InactiveReason | null;
|
inactiveReason: InactiveReason | null;
|
||||||
|
inactiveNote: string | null;
|
||||||
lineageId: string;
|
lineageId: string;
|
||||||
|
/// Platz in der Redesign-Kette, aufsteigend.
|
||||||
|
chainPosition: number;
|
||||||
predecessorId: number | null;
|
predecessorId: number | null;
|
||||||
predecessor: CharacterRef | null;
|
predecessor: CharacterRef | null;
|
||||||
successors: CharacterRef[];
|
successors: CharacterRef[];
|
||||||
|
forumThreads?: ThreadRef[];
|
||||||
|
links?: CharacterLink[];
|
||||||
|
/// Womit dieser Eintrag bezahlt wurde, aus Sicht des betrachteten Spielers.
|
||||||
|
/// Mehrere, wenn zusätzlich gebucht wurde - etwa bei einer Rückkehr.
|
||||||
|
fundedWith?: CreditKind[];
|
||||||
|
members?: CharacterMember[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +168,36 @@ export interface LedgerEntry {
|
|||||||
characterId: number | null;
|
characterId: number | null;
|
||||||
character: CharacterRef | null;
|
character: CharacterRef | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/// Wann es tatsächlich passierte - beim Import das Kaufdatum aus dem Shop.
|
||||||
|
occurredAt: string;
|
||||||
createdBy: { username: string } | null;
|
createdBy: { username: string } | null;
|
||||||
reversedBy: { id: number } | null;
|
reversedBy: { id: number } | null;
|
||||||
reversalOfId: number | null;
|
reversalOfId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AliasType = 'STEAM_ID' | 'FORUM_NAME';
|
||||||
|
|
||||||
|
export interface PlayerAlias {
|
||||||
|
id: number;
|
||||||
|
playerId: number;
|
||||||
|
type: AliasType;
|
||||||
|
value: string;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerDetail {
|
export interface PlayerDetail {
|
||||||
player: Player;
|
player: Player;
|
||||||
balances: Balances;
|
balances: Balances;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
ledger: LedgerEntry[];
|
ledger: LedgerEntry[];
|
||||||
ledgerTotal: number;
|
ledgerTotal: number;
|
||||||
|
unmatched: UnmatchedPackage[];
|
||||||
|
aliases: PlayerAlias[];
|
||||||
|
/// Forums-Anträge dieses Spielers, ältester zuerst.
|
||||||
|
threads: ForumThread[];
|
||||||
|
/// Pakete, die noch in einer offenen Import-Vorschau liegen.
|
||||||
|
pendingRows: PendingRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
@@ -97,6 +209,9 @@ export interface AdminUser {
|
|||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/// Der Zugang aus der Erstinstallation, solange er geschützt ist: an ihm
|
||||||
|
/// lässt sich nichts ändern, auch nicht durch ihn selbst.
|
||||||
|
isLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductMapping {
|
export interface ProductMapping {
|
||||||
@@ -154,3 +269,148 @@ export interface ImportRecord {
|
|||||||
importedBy: { username: string } | null;
|
importedBy: { username: string } | null;
|
||||||
rows?: ImportRow[];
|
rows?: ImportRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV';
|
||||||
|
export type ForumClassification =
|
||||||
|
| 'NEW_CC'
|
||||||
|
| 'REDESIGN'
|
||||||
|
| 'REACTIVATION'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'IGNORE';
|
||||||
|
|
||||||
|
export interface ForumThread {
|
||||||
|
threadId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
authorName: string | null;
|
||||||
|
authorForumId: number | null;
|
||||||
|
labels: string | null;
|
||||||
|
playerId: number | null;
|
||||||
|
player: { id: number; displayName: string } | null;
|
||||||
|
currentBoard: ForumBoard;
|
||||||
|
/// Wann der Antrag im Forum gestellt wurde.
|
||||||
|
postedAt: string | null;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
lastBoardChangeAt: string | null;
|
||||||
|
classification: ForumClassification | null;
|
||||||
|
needsClassification: boolean;
|
||||||
|
classifiedAt: string | null;
|
||||||
|
linkedCharacterId: number | null;
|
||||||
|
linkedCharacter: { id: number; name: string; status?: string } | null;
|
||||||
|
boardChangeNotice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumScanSummary {
|
||||||
|
scannedBoards: number;
|
||||||
|
scannedPages: number;
|
||||||
|
newThreads: number;
|
||||||
|
movedThreads: number;
|
||||||
|
boardChangeNotices: number;
|
||||||
|
skippedStubs: number;
|
||||||
|
skippedLabels: number;
|
||||||
|
unassignedThreads: number;
|
||||||
|
/// Threads, deren Ersteller sich geändert hat und die deshalb neu
|
||||||
|
/// zugeordnet wurden.
|
||||||
|
reassigned: number;
|
||||||
|
rateLimited: number;
|
||||||
|
matchedPlayers: number;
|
||||||
|
failedBoards: { board: string; reason: string; pagesRead: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnassignedThreads {
|
||||||
|
items: ForumThread[];
|
||||||
|
total: number;
|
||||||
|
take: number;
|
||||||
|
skip: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanState {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
currentBoard: string | null;
|
||||||
|
pagesRead: number;
|
||||||
|
/// Steht erst fest, wenn von jedem Board die erste Seite gelesen ist.
|
||||||
|
totalPages: number;
|
||||||
|
log: string[];
|
||||||
|
summary: ForumScanSummary | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadOrder = 'newest' | 'oldest';
|
||||||
|
|
||||||
|
export interface UntrackedAuthor {
|
||||||
|
authorName: string;
|
||||||
|
threadCount: number;
|
||||||
|
latestThreadId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine offene Paket-Zeile aus einem noch nicht bestätigten Import.
|
||||||
|
export interface PendingRow {
|
||||||
|
id: number;
|
||||||
|
importId: number;
|
||||||
|
productName: string | null;
|
||||||
|
purchasedAt: string | null;
|
||||||
|
target: ProductTarget | null;
|
||||||
|
externalId: string | null;
|
||||||
|
import: { id: number; filename: string; createdAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stand der wiederkehrenden Abgleiche.
|
||||||
|
export interface ScheduleRun {
|
||||||
|
name: string;
|
||||||
|
lastRunAt: string | null;
|
||||||
|
lastStatus: string | null;
|
||||||
|
lastNote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleName = 'gextension' | 'forum';
|
||||||
|
|
||||||
|
export interface ScheduleSetting {
|
||||||
|
name: ScheduleName;
|
||||||
|
enabled: boolean;
|
||||||
|
intervalMinutes: number;
|
||||||
|
/// Nur beim Käufe-Abgleich.
|
||||||
|
idleIntervalMinutes: number | null;
|
||||||
|
idleAfterDays: number | null;
|
||||||
|
/// Nur beim Forumslauf.
|
||||||
|
onlyAfterHour: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleState {
|
||||||
|
settings: ScheduleSetting[];
|
||||||
|
runs: ScheduleRun[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pakete, die noch in einer offenen Import-Vorschau liegen.
|
||||||
|
export interface PendingOverview {
|
||||||
|
total: number;
|
||||||
|
withoutPlayer: number;
|
||||||
|
players: { playerId: number; displayName: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrapeState {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
currentPlayer: string | null;
|
||||||
|
playersRead: number;
|
||||||
|
totalPlayers: number;
|
||||||
|
log: string[];
|
||||||
|
summary: ScrapeSummary | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis einer eingespielten Sicherung.
|
||||||
|
export interface BackupReport {
|
||||||
|
gelesen: number;
|
||||||
|
angelegt: Record<string, number>;
|
||||||
|
uebersprungen: Record<string, number>;
|
||||||
|
fehler: { zeile: number; grund: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Womit die Spielerliste eingegrenzt wird.
|
||||||
|
export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none';
|
||||||
|
/// Wonach sie sortiert wird.
|
||||||
|
export type PlayerSort = 'name' | 'characters' | 'newest';
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt neu, sobald der Tab wieder in den Vordergrund kommt.
|
||||||
|
*
|
||||||
|
* An dieser Anwendung arbeiten mehrere Leute gleichzeitig. Wer eine Seite
|
||||||
|
* offen hat, während ein anderer etwas einträgt, sieht sonst einen Stand von
|
||||||
|
* vor einer halben Stunde - und merkt es nicht. Beim Zurückkehren an den Tab
|
||||||
|
* zu laden trifft genau den Moment, in dem jemand wieder hinsieht, ohne im
|
||||||
|
* Hintergrund dauernd Anfragen zu schicken.
|
||||||
|
*/
|
||||||
|
export function useRefreshOnFocus(reload: () => void | Promise<void>): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const beiRueckkehr = () => {
|
||||||
|
if (document.visibilityState === 'visible') void reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', beiRueckkehr);
|
||||||
|
window.addEventListener('focus', beiRueckkehr);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', beiRueckkehr);
|
||||||
|
window.removeEventListener('focus', beiRueckkehr);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ApiError, api } from './api';
|
||||||
|
import type { ScrapeState, ScrapeSummary } from './types';
|
||||||
|
|
||||||
|
/** Was am Ende eines Laufs in der Meldung steht. */
|
||||||
|
export function scrapeBericht(result: ScrapeSummary): string {
|
||||||
|
const parts = [`${result.scannedPlayers} Profile geprüft`];
|
||||||
|
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`);
|
||||||
|
if (result.awaitingConfirmation > 0) parts.push(`${result.awaitingConfirmation} warten schon`);
|
||||||
|
if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
|
||||||
|
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
|
||||||
|
if (result.failedPlayers.length > 0) {
|
||||||
|
parts.push(`${result.failedPlayers.length} nicht erreichbar`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offen = result.newRows + result.awaitingConfirmation;
|
||||||
|
return (
|
||||||
|
parts.join(', ') +
|
||||||
|
(offen > 0 ? '. Beim Spieler oder unter Import buchen, damit die Guthaben entstehen.' : '.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen GExtension-Abgleich an und verfolgt ihn.
|
||||||
|
*
|
||||||
|
* Der Lauf geht über dutzende Profile, zwischen denen gewartet wird - er
|
||||||
|
* läuft deshalb im Hintergrund weiter, auch wenn die Seite gewechselt wird.
|
||||||
|
* Hier wird nur nachgefragt, wie weit er ist.
|
||||||
|
*/
|
||||||
|
export function useScrape(options: {
|
||||||
|
onDone: (bericht: string) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onFinished?: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { onDone, onError, onFinished } = options;
|
||||||
|
const [state, setState] = useState<ScrapeState | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
// Ein Lauf aus einer anderen Sitzung - oder vom Zeitplan - soll hier
|
||||||
|
// ebenfalls sichtbar sein.
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const aktuell = await api<ScrapeState>('/api/scrape/status');
|
||||||
|
setState(aktuell);
|
||||||
|
if (aktuell.running) setRunning(true);
|
||||||
|
} catch {
|
||||||
|
// Ohne Stand bleibt der Knopf einfach bedienbar.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
|
||||||
|
const abfragen = async () => {
|
||||||
|
try {
|
||||||
|
const aktuell = await api<ScrapeState>('/api/scrape/status');
|
||||||
|
setState(aktuell);
|
||||||
|
if (aktuell.running) return;
|
||||||
|
|
||||||
|
setRunning(false);
|
||||||
|
if (aktuell.error) onError(aktuell.error);
|
||||||
|
else if (aktuell.summary) onDone(scrapeBericht(aktuell.summary));
|
||||||
|
await onFinished?.();
|
||||||
|
} catch (err) {
|
||||||
|
setRunning(false);
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => void abfragen(), 1500);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running, onDone, onError, onFinished]);
|
||||||
|
|
||||||
|
const start = useCallback(
|
||||||
|
async (pfad: string) => {
|
||||||
|
try {
|
||||||
|
setState(await api<ScrapeState>(pfad, { method: 'POST', body: {} }));
|
||||||
|
setRunning(true);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { state, running, start };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { DialogProvider } from './components/DialogProvider';
|
||||||
import { SessionProvider } from './lib/session';
|
import { SessionProvider } from './lib/session';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ createRoot(container).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
<DialogProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</DialogProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user