Compare commits
39
Commits
c242e73c38
...
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 |
+34
-4
@@ -1,20 +1,50 @@
|
||||
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
|
||||
|
||||
# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen.
|
||||
DB_PASSWORD=hier-ein-langes-zufaelliges-passwort
|
||||
# Passwort des Datenbank-Nutzers. Lang und zufällig wählen.
|
||||
# Achtung: Ein $ im Wert wird von Docker Compose als Variable gedeutet und
|
||||
# verschluckt. Entweder $ vermeiden oder als $$ schreiben.
|
||||
DB_PASSWORD=hier-ein-langes-zufälliges-passwort
|
||||
|
||||
# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
||||
# Schlüssel für Sitzungen und CSRF-Token, mindestens 32 Zeichen.
|
||||
# Erzeugen z. B. mit: openssl rand -base64 48
|
||||
SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen
|
||||
|
||||
# Erster Administrator, wird nur angelegt solange noch kein Konto existiert.
|
||||
# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig
|
||||
# ins Container-Log. In beiden Faellen ist ein Wechsel beim ersten Login Pflicht.
|
||||
# ins Container-Log. In beiden Fällen ist ein Wechsel beim ersten Login Pflicht.
|
||||
BOOTSTRAP_ADMIN_USER=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=
|
||||
|
||||
# --- Optional ---
|
||||
|
||||
# Name des Docker-Netzwerks, in dem der Reverse Proxy 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).
|
||||
# SESSION_TTL_HOURS=12
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# --- Build ---
|
||||
FROM node:22-slim AS build
|
||||
|
||||
# Prisma benoetigt OpenSSL fuer seine Query-Engine.
|
||||
# Prisma benötigt OpenSSL für seine Query-Engine.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -17,14 +17,14 @@ Jeder Spieler hat drei Guthaben-Töpfe:
|
||||
Zwei Regeln sind fest verdrahtet:
|
||||
|
||||
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück.
|
||||
2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||
gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||
Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
|
||||
Redesign- oder CC-Guthaben.
|
||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID - ein Storno nimmt beide zurück.
|
||||
2. **Der Grund entscheidet über die Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||
gratis zurück - gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||
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
|
||||
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
|
||||
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
|
||||
|
||||
@@ -33,7 +33,212 @@ Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -59,7 +264,7 @@ docker compose logs -f app
|
||||
```
|
||||
|
||||
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
||||
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der
|
||||
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log - dort steht es in der
|
||||
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
||||
Seiten gesperrt.
|
||||
|
||||
@@ -72,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`
|
||||
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`
|
||||
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
||||
|
||||
@@ -80,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
|
||||
`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:
|
||||
|
||||
@@ -93,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:
|
||||
|
||||
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
|
||||
Spaltennamen werden vorausgewählt.
|
||||
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
||||
@@ -103,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
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||
|
||||
- **Viewer** — darf nur lesen.
|
||||
- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren.
|
||||
- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen.
|
||||
Der Zugang aus der **Erstinstallation** ist geschützt: Weder seine Rolle noch sein Zustand
|
||||
lassen sich ändern, sein Passwort nicht von außen zurücksetzen, und löschen lässt er sich nicht -
|
||||
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*
|
||||
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||
@@ -134,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
|
||||
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
|
||||
npm test
|
||||
@@ -148,9 +592,10 @@ und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
|
||||
```
|
||||
server/
|
||||
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/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP
|
||||
src/lib/ SteamID-Umrechnung, GExtension-Parser
|
||||
test/ Integrationstests gegen eine echte Postgres-Instanz
|
||||
web/
|
||||
src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung
|
||||
@@ -163,9 +608,11 @@ Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
|
||||
Nach Änderungen am Datenmodell:
|
||||
|
||||
```bash
|
||||
npm run migrate:dev -- --name beschreibung-der-aenderung
|
||||
npm run migrate:dev -- --name kurze-beschreibung
|
||||
```
|
||||
|
||||
Der Name wird zum Verzeichnisnamen der Migration - dort also besser bei ASCII bleiben.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen.
|
||||
|
||||
@@ -11,13 +11,26 @@ services:
|
||||
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||
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"
|
||||
# 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:
|
||||
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
|
||||
- "3000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default # App zur Datenbank
|
||||
- proxy # Reverse Proxy zur App
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
@@ -53,5 +66,17 @@ services:
|
||||
sleep 86400;
|
||||
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:
|
||||
cc_db:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Schema aktuell halten, bevor die App startet. Bereits angewandte
|
||||
# Migrationen werden dabei uebersprungen.
|
||||
# Migrationen werden dabei übersprungen.
|
||||
echo "Wende Datenbank-Migrationen an ..."
|
||||
npx prisma migrate deploy --schema server/prisma/schema.prisma
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 785 KiB |
Generated
+7
@@ -2584,6 +2584,12 @@
|
||||
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5024,6 +5030,7 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"csv-stringify": "^6.8.3",
|
||||
"fastify": "^5.2.1",
|
||||
"otplib": "^13.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.
|
||||
|
||||
DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager
|
||||
@@ -7,7 +7,7 @@ SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen.
|
||||
# Ohne HTTPS müssen die Cookies unsicher ausgeliefert werden dürfen.
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Erstadministrator beim ersten Start.
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@
|
||||
"migrate": "prisma migrate deploy",
|
||||
"migrate:dev": "prisma migrate dev",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +24,7 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"csv-stringify": "^6.8.3",
|
||||
"fastify": "^5.2.1",
|
||||
"otplib": "^13.5.0",
|
||||
"prisma": "^6.5.0",
|
||||
|
||||
@@ -19,6 +19,12 @@ CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
|
||||
-- CreateEnum
|
||||
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
|
||||
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
|
||||
|
||||
@@ -198,6 +204,38 @@ CREATE TABLE "AuditLog" (
|
||||
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
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
@@ -273,6 +311,18 @@ CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "tar
|
||||
-- CreateIndex
|
||||
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
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -318,3 +368,12 @@ ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN
|
||||
-- AddForeignKey
|
||||
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");
|
||||
+270
-6
@@ -21,10 +21,18 @@ enum CreditKind {
|
||||
}
|
||||
|
||||
enum CreditSource {
|
||||
/// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit.
|
||||
CC_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
|
||||
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
|
||||
REVERSAL
|
||||
}
|
||||
@@ -35,9 +43,16 @@ enum CharacterStatus {
|
||||
}
|
||||
|
||||
enum InactiveReason {
|
||||
/// Vom Server geputzt, weil der Spieler länger weg war. Kommt gratis zurück.
|
||||
PLAYER_INACTIVITY
|
||||
/// Durch ein Redesign ersetzt; das Original existiert nicht mehr.
|
||||
REWORKED
|
||||
/// Sonstiger Grund ohne Folgen, etwa eine zeitweise entfernte Einheit.
|
||||
OTHER
|
||||
/// Entzogen ohne Anspruch auf kostenlose Rückkehr.
|
||||
NO_FREE_CLAIM
|
||||
/// Im Rollenspiel gestorben. Rückkehr nur durch einen Administrator.
|
||||
DEAD
|
||||
}
|
||||
|
||||
enum ProductTarget {
|
||||
@@ -46,6 +61,26 @@ enum ProductTarget {
|
||||
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 {
|
||||
PENDING
|
||||
COMMITTED
|
||||
@@ -61,6 +96,13 @@ model User {
|
||||
totpEnabled Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -93,10 +135,21 @@ model Player {
|
||||
steamId String? @unique
|
||||
forumName 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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Von GExtension gemeldeter Verbrauchszaehler. Reine Anzeige zum Abgleich,
|
||||
/// 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?
|
||||
@@ -105,12 +158,16 @@ model Player {
|
||||
characters Character[]
|
||||
orders ImportedOrder[]
|
||||
unmatched UnmatchedPackage[]
|
||||
forumThreads ForumThread[]
|
||||
aliases PlayerAlias[]
|
||||
memberships CharacterMember[]
|
||||
|
||||
@@index([displayName])
|
||||
@@index([isProblem])
|
||||
}
|
||||
|
||||
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
|
||||
/// Guthaben-Art sicher zuordnen laesst. Wird nie automatisch gebucht,
|
||||
/// Guthaben-Art sicher zuordnen lässt. Wird nie automatisch gebucht,
|
||||
/// sondern im CC-Manager von Hand entschieden.
|
||||
model UnmatchedPackage {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -146,7 +203,7 @@ model CreditLedger {
|
||||
source CreditSource
|
||||
|
||||
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
|
||||
/// mit derselben groupId, damit ein Storno beide zurueckholt.
|
||||
/// mit derselben groupId, damit ein Storno beide zurückholt.
|
||||
groupId String @default(uuid())
|
||||
|
||||
characterId Int?
|
||||
@@ -157,7 +214,7 @@ model CreditLedger {
|
||||
|
||||
note String?
|
||||
|
||||
/// Storno erfolgt als Gegenbuchung, nicht als Loeschung. Das @unique
|
||||
/// Storno erfolgt als Gegenbuchung, nicht als Löschung. Das @unique
|
||||
/// verhindert, dass eine Zeile zweimal storniert wird.
|
||||
reversalOfId Int? @unique
|
||||
reversalOf CreditLedger? @relation("LedgerReversal", fields: [reversalOfId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
@@ -167,10 +224,17 @@ model CreditLedger {
|
||||
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
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, kind])
|
||||
@@index([groupId])
|
||||
@@index([createdAt])
|
||||
@@index([occurredAt])
|
||||
}
|
||||
|
||||
model Character {
|
||||
@@ -181,16 +245,27 @@ model Character {
|
||||
loreText String?
|
||||
status CharacterStatus @default(ACTIVE)
|
||||
inactiveReason InactiveReason?
|
||||
/// Klartext zum Grund, etwa "Einheit bis Maerz entfernt". Nur zur Anzeige.
|
||||
/// Klartext zum Grund, etwa "Einheit bis März entfernt". Nur zur Anzeige.
|
||||
inactiveNote String?
|
||||
|
||||
/// Alle CCs einer Redesign-Kette teilen sich die lineageId.
|
||||
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?
|
||||
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
successors Character[] @relation("CharacterLineage")
|
||||
|
||||
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?
|
||||
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
@@ -200,6 +275,7 @@ model Character {
|
||||
@@index([playerId])
|
||||
@@index([playerId, status])
|
||||
@@index([lineageId])
|
||||
@@index([lineageId, chainPosition])
|
||||
}
|
||||
|
||||
model Import {
|
||||
@@ -237,7 +313,7 @@ model ImportRow {
|
||||
target ProductTarget?
|
||||
resolvedPlayerId Int?
|
||||
|
||||
/// Was beim Buchen passieren wuerde: BOOK, CREATE_PLAYER_AND_BOOK,
|
||||
/// Was beim Buchen passieren würde: BOOK, CREATE_PLAYER_AND_BOOK,
|
||||
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
|
||||
plan String
|
||||
raw Json
|
||||
@@ -290,3 +366,191 @@ model AuditLog {
|
||||
@@index([targetType, targetId])
|
||||
@@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
|
||||
}
|
||||
|
||||
+34
-7
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
@@ -18,6 +18,7 @@ import { characterRoutes } from './routes/characters.routes.js';
|
||||
import { importRoutes } from './routes/imports.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));
|
||||
// Im Container liegt das gebaute Frontend neben dem kompilierten Server.
|
||||
@@ -79,6 +80,20 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
app.addHook('onRequest', enforceCsrf);
|
||||
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) => {
|
||||
if (error instanceof AppError) {
|
||||
reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
||||
@@ -106,7 +121,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
||||
reply
|
||||
.code(statusCode)
|
||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,16 +138,28 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(importRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(scraperRoutes);
|
||||
await app.register(forumRoutes);
|
||||
|
||||
if (existsSync(webRoot)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: webRoot,
|
||||
prefix: '/',
|
||||
// Gehashte Assets duerfen lange liegen bleiben, index.html nie.
|
||||
maxAge: '1y',
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
// maxAge würde den Header für *alle* Dateien setzen und dabei auch die
|
||||
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
||||
// sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche.
|
||||
// Deshalb hier abschalten und beides unten selbst setzen.
|
||||
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']);
|
||||
|
||||
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
|
||||
// Ohne diese Ausnahme könnte sich niemand anmelden: vor dem Login gibt es
|
||||
// weder Session noch ableitbares CSRF-Token.
|
||||
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const blockUntilPasswordChanged: preHandlerHookHandler = async (request)
|
||||
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
||||
const path = request.url.split('?')[0] ?? '';
|
||||
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
||||
throw forbidden('Bitte zuerst das Passwort aendern');
|
||||
throw forbidden('Bitte zuerst das Passwort ändern');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
|
||||
// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1.
|
||||
const options = {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
@@ -27,7 +27,7 @@ export function validatePasswordStrength(plain: string): string | null {
|
||||
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
||||
}
|
||||
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
||||
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
|
||||
return 'Passwort muss Groß- und Kleinbuchstaben enthalten';
|
||||
}
|
||||
if (!/[0-9]/.test(plain)) {
|
||||
return 'Passwort muss mindestens eine Ziffer enthalten';
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function destroyAllSessionsFor(userId: number): Promise<void> {
|
||||
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> {
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import QRCode from 'qrcode';
|
||||
|
||||
const ISSUER = 'CC-Manager';
|
||||
|
||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
|
||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht
|
||||
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
||||
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||
|
||||
|
||||
+13
-4
@@ -4,10 +4,10 @@ import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
import { hashPassword } from './auth/password.js';
|
||||
|
||||
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||
// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
||||
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide
|
||||
// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht
|
||||
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide
|
||||
// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht
|
||||
// das Redesign-Muster vorn.
|
||||
const DEFAULT_MAPPINGS = [
|
||||
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
|
||||
@@ -42,6 +42,7 @@ export async function bootstrap(log: {
|
||||
passwordHash: await hashPassword(password),
|
||||
role: 'ADMIN',
|
||||
mustChangePassword: true,
|
||||
isProtected: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,10 +53,18 @@ export async function bootstrap(log: {
|
||||
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
||||
log.warn(`Einmal-Passwort: ${password}`);
|
||||
}
|
||||
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
|
||||
log.warn('Das Passwort muss bei der ersten Anmeldung geändert werden.');
|
||||
log.warn('─'.repeat(60));
|
||||
}
|
||||
|
||||
// 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();
|
||||
if (mappingCount === 0) {
|
||||
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
||||
|
||||
+23
-4
@@ -15,19 +15,38 @@ const schema = z.object({
|
||||
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
||||
|
||||
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
|
||||
// Hinter einem Reverse Proxy nötig, damit Client-IPs im Audit-Log stimmen.
|
||||
TRUST_PROXY: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true' || v === '1'),
|
||||
|
||||
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
|
||||
// Muss in Produktion true sein (Cookies nur über HTTPS).
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||
|
||||
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
||||
// 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),
|
||||
|
||||
@@ -38,7 +57,7 @@ const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||
console.error(`Konfiguration unvollstaendig:\n${issues}`);
|
||||
console.error(`Konfiguration unvollständig:\n${issues}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AppError extends Error {
|
||||
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
||||
export const unauthorized = (message = 'Nicht angemeldet') =>
|
||||
new AppError(401, 'UNAUTHORIZED', message);
|
||||
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
|
||||
export const forbidden = (message = 'Keine Berechtigung für diese Aktion') =>
|
||||
new AppError(403, 'FORBIDDEN', message);
|
||||
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
|
||||
export const conflict = (code: string, message: string) => new AppError(409, code, message);
|
||||
|
||||
+22
-3
@@ -2,6 +2,8 @@ import { buildApp } from './app.js';
|
||||
import { bootstrap } from './bootstrap.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 { env } from './env.js';
|
||||
|
||||
@@ -22,24 +24,41 @@ async function main(): Promise<void> {
|
||||
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(() => {
|
||||
cleanupExpiredSessions()
|
||||
.then((count) => {
|
||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufräumen der Sitzungen fehlgeschlagen'));
|
||||
|
||||
cleanupOldAuditEntries()
|
||||
.then((count) => {
|
||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`);
|
||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen'));
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen'));
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanup.unref();
|
||||
|
||||
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> => {
|
||||
app.log.info(`${signal} empfangen, fahre herunter`);
|
||||
clearInterval(cleanup);
|
||||
stopSchedule();
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
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
|
||||
* Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||
* Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
|
||||
* gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag
|
||||
* gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag
|
||||
* und den Storno-Status.
|
||||
*/
|
||||
|
||||
@@ -91,11 +92,11 @@ export type Classification =
|
||||
| { kind: 'unclear' }
|
||||
| { kind: 'ignore' };
|
||||
|
||||
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
|
||||
// Nur der SCP-RP-Bereich führt CC-relevante Pakete.
|
||||
const RELEVANT_BUNDLE = 'scp-rp';
|
||||
|
||||
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
|
||||
// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||
// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten
|
||||
// zu werden.
|
||||
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
||||
|
||||
@@ -104,8 +105,8 @@ const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
|
||||
|
||||
/**
|
||||
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
||||
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
|
||||
* Redesign zaehlen, nicht als CC-Paket.
|
||||
* bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als
|
||||
* Redesign zählen, nicht als CC-Paket.
|
||||
*/
|
||||
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||
if (pkg.revoked) return { kind: 'ignore' };
|
||||
@@ -132,37 +133,78 @@ export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
|
||||
const PROFILE_URL = 'https://gextension.senseigaming.de/index.php?t=user&id=';
|
||||
// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft GExtension
|
||||
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
|
||||
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
|
||||
|
||||
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
|
||||
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
|
||||
const MIN_REQUEST_GAP_MS = 1000;
|
||||
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 + MIN_REQUEST_GAP_MS - Date.now();
|
||||
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
const wait = lastRequestAt + requestGapMs - Date.now();
|
||||
if (wait > 0) await sleep(wait);
|
||||
lastRequestAt = Date.now();
|
||||
}
|
||||
|
||||
export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise<ScrapedProfile> {
|
||||
const steamId64 = normalizeSteamId(steamId);
|
||||
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
|
||||
/** 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(`${PROFILE_URL}${steamId64}`, {
|
||||
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 fuer das CC-Team)',
|
||||
'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})`);
|
||||
}
|
||||
@@ -170,10 +212,11 @@ export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise<
|
||||
return parseProfile(await response.text(), steamId64);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils');
|
||||
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
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));
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
||||
const STEAM64 = /^\d{17}$/;
|
||||
|
||||
/**
|
||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert
|
||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert
|
||||
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||
*/
|
||||
export function normalizeSteamId(input: string): string | null {
|
||||
@@ -37,14 +37,14 @@ export function normalizeSteamId(input: string): string | null {
|
||||
|
||||
if (STEAM64.test(value)) {
|
||||
const numeric = BigInt(value);
|
||||
// Alles unterhalb des Offsets waere keine Nutzer-ID.
|
||||
// Alles unterhalb des Offsets wäre keine Nutzer-ID.
|
||||
return numeric > BASE ? numeric.toString() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */
|
||||
/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */
|
||||
export function toLegacySteamId(steamId64: string): string | null {
|
||||
if (!STEAM64.test(steamId64)) return null;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requireAdmin } from '../auth/guard.js';
|
||||
import { badRequest, conflict, notFound } from '../errors.js';
|
||||
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
|
||||
import { destroyAllSessionsFor } from '../auth/session.js';
|
||||
import { env } from '../env.js';
|
||||
import { recordAudit } from '../services/audit.js';
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
@@ -16,10 +17,33 @@ const userSelect = {
|
||||
isActive: true,
|
||||
totpEnabled: true,
|
||||
mustChangePassword: true,
|
||||
isProtected: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
} 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.
|
||||
async function assertNotLastAdmin(userId: number): Promise<void> {
|
||||
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' }],
|
||||
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) => {
|
||||
@@ -101,6 +134,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) throw notFound('Nutzer');
|
||||
assertNichtGeschuetzt(target);
|
||||
|
||||
const losesAdmin =
|
||||
(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 } });
|
||||
if (!target) throw notFound('Nutzer');
|
||||
assertNichtGeschuetzt(target);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
@@ -160,11 +195,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.delete('/api/admin/users/:id', async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
if (id === request.user?.id) {
|
||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
|
||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht gelöscht werden');
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) throw notFound('Nutzer');
|
||||
assertNichtGeschuetzt(target);
|
||||
await assertNotLastAdmin(id);
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
@@ -266,6 +302,35 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// --- 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) => {
|
||||
const query = parseWith(
|
||||
z.object({
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
});
|
||||
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
||||
// Muss für das Double-Submit-Verfahren lesbar sein.
|
||||
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
@@ -91,7 +91,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const body = parseWith(loginSchema, request.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
|
||||
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const passwordOk = await verifyPassword(digest, body.password);
|
||||
|
||||
@@ -198,7 +198,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.get('/api/auth/me', async (request) => {
|
||||
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 ---
|
||||
@@ -250,7 +256,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||
const secret = generateSecret();
|
||||
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||
// Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||
await prisma.user.update({
|
||||
where: { id: request.user!.id },
|
||||
data: { totpSecret: secret, totpEnabled: false },
|
||||
|
||||
@@ -1,19 +1,62 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
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 {
|
||||
addMember,
|
||||
createCharacter,
|
||||
deactivateCharacter,
|
||||
listMembers,
|
||||
removeMember,
|
||||
reviveDeadCharacter,
|
||||
reactivateFree,
|
||||
reactivatePaid,
|
||||
redesignCharacter,
|
||||
revertCharacter,
|
||||
updateCharacter,
|
||||
deactivateMember,
|
||||
reactivateMember,
|
||||
redesignMembership,
|
||||
addLink,
|
||||
updateLink,
|
||||
removeLink,
|
||||
moveInChain,
|
||||
detachFromChain,
|
||||
changeFunding,
|
||||
addMemberToChain,
|
||||
rebuildFunding,
|
||||
settleWithKulanz,
|
||||
} 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';
|
||||
|
||||
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]);
|
||||
|
||||
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),
|
||||
loreText: z.string().trim().max(5000).optional(),
|
||||
note: optionalNote,
|
||||
forumThread: z.string().trim().max(300).optional(),
|
||||
}),
|
||||
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);
|
||||
return result;
|
||||
});
|
||||
@@ -38,6 +84,8 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(160).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,
|
||||
);
|
||||
@@ -52,17 +100,26 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
loreText: z.string().trim().max(5000).optional(),
|
||||
useKind: redesignKind,
|
||||
note: optionalNote,
|
||||
forumThread: z.string().trim().max(300).optional(),
|
||||
}),
|
||||
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) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
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,
|
||||
}),
|
||||
request.body,
|
||||
@@ -84,10 +141,325 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
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) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||
request.body,
|
||||
);
|
||||
return revertCharacter(id, body.reason, contextOf(request));
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { CreditKind } from '@prisma/client';
|
||||
import { CreditKind, CreditSource } from '@prisma/client';
|
||||
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
||||
import {
|
||||
bookAdjustment,
|
||||
bookCcPackage,
|
||||
bookKulanz,
|
||||
bookRedesignPackage,
|
||||
convertCcToRedesign,
|
||||
grantCcSlot,
|
||||
removeLastGrant,
|
||||
reverseGroup,
|
||||
} from '../services/credits.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 });
|
||||
|
||||
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) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(noteBody, request.body ?? {});
|
||||
return bookCcPackage(id, contextOf(request), { note: body.note });
|
||||
return grantCcSlot(id, contextOf(request), { note: body.note });
|
||||
});
|
||||
|
||||
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 body = parseWith(noteBody, request.body ?? {});
|
||||
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) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
kind: z.nativeEnum(CreditKind),
|
||||
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
|
||||
note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500),
|
||||
note: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
@@ -52,7 +115,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||
request.body,
|
||||
);
|
||||
return reverseGroup(id, body.reason, contextOf(request));
|
||||
|
||||
@@ -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 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> {
|
||||
const result = schema.safeParse(input);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '../auth/guard.js';
|
||||
import { requireAdmin, requireEditor } from '../auth/guard.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import {
|
||||
applyMapping,
|
||||
@@ -10,21 +10,25 @@ import {
|
||||
getImport,
|
||||
listImports,
|
||||
parseUpload,
|
||||
bookRows,
|
||||
dismissRows,
|
||||
pendingOverview,
|
||||
} from '../services/imports.js';
|
||||
import { exportAll, importAll } from '../services/backup.js';
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
const mappingBody = z.object({
|
||||
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
|
||||
playerKey: z.string().min(1, 'Spalte für die Spieler-Kennung fehlt'),
|
||||
playerName: z.string().optional(),
|
||||
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
|
||||
product: z.string().min(1, 'Spalte für den Produktnamen fehlt'),
|
||||
orderId: z.string().optional(),
|
||||
purchasedAt: z.string().optional(),
|
||||
createMissingPlayers: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
|
||||
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
||||
app.post('/api/imports', { preHandler: requireEditor }, async (request, reply) => {
|
||||
const file = await request.file();
|
||||
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() };
|
||||
});
|
||||
|
||||
app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => {
|
||||
app.get('/api/imports/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
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 body = parseWith(mappingBody, request.body);
|
||||
const summary = await applyMapping(id, body, contextOf(request));
|
||||
@@ -58,12 +90,34 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
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);
|
||||
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);
|
||||
await discardImport(id, contextOf(request));
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { CreditKind } from '@prisma/client';
|
||||
import { AliasType } from '@prisma/client';
|
||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||
import { listLedger } from '../services/credits.js';
|
||||
import {
|
||||
addAlias,
|
||||
createPlayer,
|
||||
deletePlayer,
|
||||
getPlayerDetail,
|
||||
markProblem,
|
||||
removeAlias,
|
||||
searchPlayers,
|
||||
updatePlayer,
|
||||
} from '../services/players.js';
|
||||
@@ -17,6 +21,8 @@ const playerBody = z.object({
|
||||
steamId: z.string().trim().max(64).nullish(),
|
||||
forumName: z.string().trim().max(120).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> {
|
||||
@@ -26,10 +32,18 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
q: z.string().trim().max(120).default(''),
|
||||
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||
skip: z.coerce.number().int().min(0).default(0),
|
||||
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,
|
||||
);
|
||||
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) => {
|
||||
@@ -59,10 +73,51 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
|
||||
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));
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
await deletePlayer(id, contextOf(request));
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin, requireAuth } from '../auth/guard.js';
|
||||
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,
|
||||
@@ -15,10 +18,10 @@ 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 ergaenzen, nicht fremde
|
||||
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||
* Profile auf Vorrat einsammeln.
|
||||
*/
|
||||
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
|
||||
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 ?? {},
|
||||
@@ -29,23 +32,33 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||
? all.filter((entry) => body.playerIds?.includes(entry.playerId))
|
||||
: all;
|
||||
|
||||
return scrapePlayers(targets, contextOf(request));
|
||||
return startScrape(targets, contextOf(request));
|
||||
});
|
||||
|
||||
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
|
||||
app.post('/api/scrape/player/:id', { preHandler: requireAdmin }, async (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, steamId: true },
|
||||
});
|
||||
const player = await prisma.player.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
if (!player.steamId) {
|
||||
|
||||
const steamIds = await steamIdsOf(id);
|
||||
if (steamIds.length === 0) {
|
||||
throw notFound('SteamID des Spielers');
|
||||
}
|
||||
|
||||
return scrapePlayers([{ playerId: player.id, steamId: player.steamId }], contextOf(request));
|
||||
return startScrape(
|
||||
steamIds.map((steamId) => ({ playerId: id, steamId })),
|
||||
contextOf(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
export interface ActionContext {
|
||||
// Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||
// Beide null für Vorgänge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
||||
actorId: number | null;
|
||||
actorName: string | null;
|
||||
@@ -38,9 +38,9 @@ export async function recordAudit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die
|
||||
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer
|
||||
* bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll.
|
||||
* Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die
|
||||
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für
|
||||
* bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll.
|
||||
*/
|
||||
export async function cleanupOldAuditEntries(): Promise<number> {
|
||||
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2440
-104
File diff suppressed because it is too large
Load Diff
+212
-18
@@ -8,29 +8,80 @@ export interface Balances {
|
||||
CC: number;
|
||||
REDESIGN_PAID: number;
|
||||
REDESIGN_KULANZ: number;
|
||||
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
||||
// Für ein Redesign zählen beide Redesign-Töpfe zusammen.
|
||||
redesignTotal: number;
|
||||
/**
|
||||
* 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 paid = sums.REDESIGN_PAID ?? 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> {
|
||||
const rows = await prisma.creditLedger.groupBy({
|
||||
by: ['kind'],
|
||||
// Für den Stand genügte eine Summe je Topf. Für "von wie vielen" muss
|
||||
// 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 },
|
||||
_sum: { amount: true },
|
||||
select: {
|
||||
kind: true,
|
||||
amount: true,
|
||||
source: true,
|
||||
reversalOf: { select: { source: true, amount: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const sums: Partial<Record<CreditKind, number>> = {};
|
||||
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
|
||||
return assemble(sums);
|
||||
const granted: Partial<Record<CreditKind, number>> = {};
|
||||
|
||||
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>> {
|
||||
@@ -68,12 +119,14 @@ export interface PackageRowParams {
|
||||
note?: string | null;
|
||||
importId?: 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
|
||||
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
|
||||
* Eingabe und Import nicht auseinanderlaufen koennen.
|
||||
* immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle
|
||||
* Eingabe und Import nicht auseinanderlaufen können.
|
||||
*/
|
||||
export function buildPackageRows(
|
||||
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||
@@ -85,6 +138,7 @@ export function buildPackageRows(
|
||||
note: params.note ?? null,
|
||||
importId: params.importId ?? null,
|
||||
createdById: params.actorId,
|
||||
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
|
||||
};
|
||||
|
||||
if (target === 'REDESIGN_PACKAGE') {
|
||||
@@ -102,7 +156,7 @@ export function buildPackageRows(
|
||||
/**
|
||||
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
||||
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
||||
* gemeinsam zurueckholt.
|
||||
* gemeinsam zurückholt.
|
||||
*/
|
||||
export async function bookCcPackage(
|
||||
playerId: number,
|
||||
@@ -134,6 +188,87 @@ export async function bookCcPackage(
|
||||
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(
|
||||
playerId: number,
|
||||
ctx: ActionContext,
|
||||
@@ -196,7 +331,7 @@ export async function bookKulanz(
|
||||
return { groupId, balances: await getBalances(playerId) };
|
||||
}
|
||||
|
||||
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
||||
/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */
|
||||
export async function bookAdjustment(
|
||||
playerId: number,
|
||||
kind: CreditKind,
|
||||
@@ -236,10 +371,69 @@ export async function bookAdjustment(
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
||||
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
||||
* Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie
|
||||
* bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||
* über die zugehörige CC-Aktion zurückgenommen.
|
||||
*/
|
||||
/// 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(
|
||||
ledgerId: number,
|
||||
reason: string,
|
||||
@@ -251,7 +445,7 @@ export async function reverseGroup(
|
||||
if (anchor.source === CreditSource.CONSUME) {
|
||||
throw conflict(
|
||||
'CONSUME_NOT_REVERSIBLE',
|
||||
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
||||
'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert',
|
||||
);
|
||||
}
|
||||
if (anchor.source === CreditSource.REVERSAL) {
|
||||
@@ -309,7 +503,7 @@ export async function listLedger(filter: LedgerFilter) {
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.creditLedger.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
|
||||
take: filter.take ?? 100,
|
||||
skip: filter.skip ?? 0,
|
||||
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 },
|
||||
});
|
||||
}
|
||||
+298
-17
@@ -34,7 +34,7 @@ export interface ParsedFile {
|
||||
|
||||
const MAX_ROWS = 20000;
|
||||
|
||||
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
||||
// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma.
|
||||
function detectDelimiter(sample: string): string {
|
||||
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||
const candidates = [',', ';', '\t', '|'];
|
||||
@@ -78,7 +78,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
} catch {
|
||||
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||
}
|
||||
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
||||
// Manche Exporte verpacken die Liste in ein Hüllobjekt.
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: typeof data === 'object' && data !== null
|
||||
@@ -86,7 +86,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
: undefined;
|
||||
|
||||
if (!Array.isArray(list)) {
|
||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen');
|
||||
}
|
||||
rows = list
|
||||
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||
@@ -107,7 +107,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen');
|
||||
if (rows.length > MAX_ROWS) {
|
||||
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||
}
|
||||
@@ -165,9 +165,9 @@ function parseDate(value: string | undefined): Date | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich
|
||||
* zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse,
|
||||
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft,
|
||||
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsächlich
|
||||
* zugeordnet wurden. Ein Shop-Export enthält oft deutlich mehr - Mailadresse,
|
||||
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Läuft,
|
||||
* sobald ein Import abgeschlossen oder verworfen ist.
|
||||
*/
|
||||
async function pruneRawRows(
|
||||
@@ -204,8 +204,8 @@ async function pruneRawRows(
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
||||
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
||||
* Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der
|
||||
* Bestätigung angezeigt und beim Commit unverändert ausgeführt.
|
||||
*/
|
||||
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||
const record = await prisma.import.findUnique({
|
||||
@@ -229,7 +229,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
||||
raw: row.raw as Record<string, string>,
|
||||
}));
|
||||
|
||||
// Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt.
|
||||
// Die Datei kann eine andere Schreibweise liefern als die Datenbank führt.
|
||||
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
|
||||
const playerKeys = [
|
||||
...new Set(rawKeys.flatMap((key) => {
|
||||
@@ -238,14 +238,25 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
||||
})),
|
||||
];
|
||||
|
||||
const players = await prisma.player.findMany({
|
||||
const [players, aliases] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
where: {
|
||||
OR: [{ steamId: { in: playerKeys } }, { displayName: { in: playerKeys } }],
|
||||
},
|
||||
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 orderIds = mapping.orderId
|
||||
@@ -262,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 updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||
const summary: Record<Plan, number> = {
|
||||
@@ -356,7 +367,7 @@ export async function getImport(importId: number, includeRows = true) {
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||
/** Führt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||
export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
const record = await prisma.import.findUnique({
|
||||
where: { id: importId },
|
||||
@@ -375,11 +386,27 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
||||
// Mehrere Zeilen können denselben neuen Spieler betreffen.
|
||||
const createdPlayers = new Map<string, number>();
|
||||
let booked = 0;
|
||||
let skipped = 0;
|
||||
|
||||
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();
|
||||
let playerId = row.resolvedPlayerId;
|
||||
|
||||
@@ -410,6 +437,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
|
||||
importId: record.id,
|
||||
actorId: ctx.actorId,
|
||||
occurredAt: row.purchasedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -444,11 +472,12 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
details: {
|
||||
filename: record.filename,
|
||||
booked,
|
||||
skipped,
|
||||
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.
|
||||
@@ -457,6 +486,232 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
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> {
|
||||
const record = await prisma.import.findUnique({ where: { id: importId } });
|
||||
if (!record) throw notFound('Import');
|
||||
@@ -480,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) {
|
||||
await schliesseLeere();
|
||||
return prisma.import.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
|
||||
+305
-20
@@ -4,6 +4,7 @@ import { conflict, notFound } from '../errors.js';
|
||||
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
||||
import { listCharacters } from './characters.js';
|
||||
import { recordAudit, type ActionContext } from './audit.js';
|
||||
import { AliasType } from '@prisma/client';
|
||||
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
||||
|
||||
export interface PlayerInput {
|
||||
@@ -11,16 +12,74 @@ export interface PlayerInput {
|
||||
steamId?: string | null;
|
||||
forumName?: 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();
|
||||
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
|
||||
// 64-Bit-Form gespeichert ist.
|
||||
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
|
||||
|
||||
const where: Prisma.PlayerWhereInput =
|
||||
trimmed.length === 0
|
||||
// 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: [
|
||||
@@ -28,12 +87,20 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||
{ steamId: { contains: asSteamId ?? 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([
|
||||
prisma.player.findMany({
|
||||
where,
|
||||
orderBy: { displayName: 'asc' },
|
||||
orderBy,
|
||||
take,
|
||||
skip,
|
||||
include: { _count: { select: { characters: true } } },
|
||||
@@ -56,6 +123,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||
displayName: player.displayName,
|
||||
steamId: player.steamId,
|
||||
forumName: player.forumName,
|
||||
isProblem: player.isProblem,
|
||||
problemNote: player.problemNote,
|
||||
characterCount: player._count.characters,
|
||||
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
|
||||
balances: balances.get(player.id) as Balances,
|
||||
@@ -67,7 +136,13 @@ export async function getPlayerDetail(playerId: number) {
|
||||
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
|
||||
const [balances, characters, ledger, unmatched] = 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),
|
||||
listCharacters(playerId),
|
||||
listLedger({ playerId, take: 200 }),
|
||||
@@ -76,6 +151,13 @@ export async function getPlayerDetail(playerId: number) {
|
||||
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 {
|
||||
@@ -85,12 +167,15 @@ export async function getPlayerDetail(playerId: number) {
|
||||
ledger: ledger.items,
|
||||
ledgerTotal: ledger.total,
|
||||
unmatched,
|
||||
aliases,
|
||||
threads,
|
||||
pendingRows,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
||||
* Spieler zwei Datensaetze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||
* Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||
* andere die 64-Bit-Zahl.
|
||||
*/
|
||||
function canonicalSteamId(input: string | null | undefined): string | null {
|
||||
@@ -107,24 +192,69 @@ function canonicalSteamId(input: string | null | undefined): string | null {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
|
||||
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
|
||||
if (existing && existing.id !== exceptId) {
|
||||
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
|
||||
/**
|
||||
* 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) {
|
||||
const steamId = canonicalSteamId(input.steamId);
|
||||
if (steamId) await assertSteamIdFree(steamId);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.player.create({
|
||||
data: {
|
||||
displayName: input.displayName.trim(),
|
||||
steamId,
|
||||
forumName: input.forumName?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
...(input.isProblem
|
||||
? {
|
||||
isProblem: true,
|
||||
problemNote: input.problemNote?.trim() || null,
|
||||
problemSince: new Date(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,11 +262,24 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||
action: 'player.create',
|
||||
targetType: 'player',
|
||||
targetId: created.id,
|
||||
details: { displayName: created.displayName, steamId: created.steamId },
|
||||
details: {
|
||||
displayName: created.displayName,
|
||||
steamId: created.steamId,
|
||||
isProblem: created.isProblem,
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -146,7 +289,7 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
||||
const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
|
||||
if (steamId) await assertSteamIdFree(steamId, playerId);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.player.update({
|
||||
where: { id: playerId },
|
||||
data: {
|
||||
@@ -166,13 +309,20 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (updated.forumName && updated.forumName !== existing.forumName) {
|
||||
const { claimThreadsForPlayer } = await import('./forum.js');
|
||||
await claimThreadsForPlayer(playerId, [updated.forumName]);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den
|
||||
* Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt,
|
||||
* damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt.
|
||||
* Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
|
||||
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
|
||||
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
|
||||
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||
*/
|
||||
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||
const existing = await prisma.player.findUnique({
|
||||
@@ -198,7 +348,7 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
},
|
||||
});
|
||||
|
||||
// Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||
await tx.auditLog.updateMany({
|
||||
where: { targetType: 'player', targetId: String(playerId) },
|
||||
data: { details: { redacted: true } },
|
||||
@@ -210,8 +360,8 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
action: 'player.delete',
|
||||
targetType: 'player',
|
||||
targetId: playerId,
|
||||
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
|
||||
// nicht die geloeschten Daten konservieren.
|
||||
// Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen,
|
||||
// nicht die gelöschten Daten konservieren.
|
||||
details: {
|
||||
removedCharacters: existing._count.characters,
|
||||
removedLedgerRows: existing._count.ledger,
|
||||
@@ -219,3 +369,138 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
+212
-15
@@ -1,23 +1,68 @@
|
||||
import { ImportStatus, type Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db.js';
|
||||
import { badRequest } from '../errors.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';
|
||||
|
||||
/** SteamIDs, die im CC-Manager bereits erfasst sind. */
|
||||
/**
|
||||
* 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 = await prisma.player.findMany({
|
||||
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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
return players.map((player) => ({ playerId: player.id, steamId: player.steamId as string }));
|
||||
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 {
|
||||
@@ -26,6 +71,8 @@ export interface ScrapeSummary {
|
||||
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;
|
||||
}
|
||||
@@ -34,14 +81,113 @@ export interface ScrapeSummary {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
|
||||
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
|
||||
* 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,
|
||||
@@ -49,6 +195,7 @@ export async function scrapePlayers(
|
||||
failedPlayers: [],
|
||||
newRows: 0,
|
||||
duplicates: 0,
|
||||
awaitingConfirmation: 0,
|
||||
unclear: 0,
|
||||
usageCounters: 0,
|
||||
};
|
||||
@@ -56,16 +203,43 @@ export async function scrapePlayers(
|
||||
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);
|
||||
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
|
||||
summary.scannedPlayers += 1;
|
||||
} catch (error) {
|
||||
summary.failedPlayers.push({
|
||||
steamId: target.steamId,
|
||||
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -111,6 +285,12 @@ export async function scrapePlayers(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (alreadyPending.has(externalId)) {
|
||||
summary.awaitingConfirmation += 1;
|
||||
continue;
|
||||
}
|
||||
alreadyPending.add(externalId);
|
||||
|
||||
lineNo += 1;
|
||||
rows.push({
|
||||
lineNo,
|
||||
@@ -137,8 +317,22 @@ export async function scrapePlayers(
|
||||
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: {
|
||||
@@ -153,6 +347,8 @@ export async function scrapePlayers(
|
||||
summary.importId = created.id;
|
||||
}
|
||||
|
||||
scrapeState.summary = summary;
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: 'scrape.run',
|
||||
targetType: 'import',
|
||||
@@ -161,6 +357,7 @@ export async function scrapePlayers(
|
||||
scannedPlayers: summary.scannedPlayers,
|
||||
newRows: summary.newRows,
|
||||
duplicates: summary.duplicates,
|
||||
awaitingConfirmation: summary.awaitingConfirmation,
|
||||
unclear: summary.unclear,
|
||||
failed: summary.failedPlayers.length,
|
||||
},
|
||||
@@ -169,7 +366,7 @@ export async function scrapePlayers(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// --- Handpruefung der unklaren Eintraege ---
|
||||
// --- Handprüfung der unklaren Einträge ---
|
||||
|
||||
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||
|
||||
@@ -181,7 +378,7 @@ export async function listUnmatched(onlyOpen = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
|
||||
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
|
||||
export async function unmatchedOverview() {
|
||||
const open = await prisma.unmatchedPackage.findMany({
|
||||
where: { resolved: false },
|
||||
@@ -207,7 +404,7 @@ export async function unmatchedOverview() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
||||
* ein Redesign mitbringt.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { fileURLToPath } from 'node:url';
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { PGLiteSocketServer } from '@electric-sql/pglite-socket';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql');
|
||||
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 {
|
||||
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.
|
||||
* So laufen die Tests ohne Docker oder installiertes Postgres.
|
||||
*/
|
||||
export async function startTestDatabase(port: number): Promise<TestDatabase> {
|
||||
const db = await PGlite.create();
|
||||
await db.exec(readFileSync(MIGRATION, 'utf8'));
|
||||
for (const file of migrationFiles()) {
|
||||
await db.exec(readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' });
|
||||
await server.start();
|
||||
// Die Testdateien laufen nebeneinander und bekommen feste Ports. Hängt aus
|
||||
// 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 {
|
||||
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung
|
||||
// gleichzeitig bedient. Echtes Postgres braucht die Einschraenkung nicht.
|
||||
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`,
|
||||
// gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht.
|
||||
url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`,
|
||||
stop: async () => {
|
||||
await server.stop();
|
||||
await db.close();
|
||||
|
||||
+173
-10
@@ -81,7 +81,7 @@ describe('Anmeldung', () => {
|
||||
assert.equal(response.statusCode, 401);
|
||||
});
|
||||
|
||||
it('verraet nicht, ob es den Benutzernamen gibt', async () => {
|
||||
it('verrät nicht, ob es den Benutzernamen gibt', async () => {
|
||||
const unknownUser = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
@@ -97,7 +97,7 @@ describe('Anmeldung', () => {
|
||||
assert.equal(unknownUser.json().message, wrongPassword.json().message);
|
||||
});
|
||||
|
||||
it('laesst ein gesperrtes Konto nicht herein', async () => {
|
||||
it('lässt ein gesperrtes Konto nicht herein', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
@@ -129,7 +129,7 @@ describe('CSRF-Schutz', () => {
|
||||
assert.equal(response.json().error, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
it('blockt einen gefaelschten Token', async () => {
|
||||
it('blockt einen gefälschten Token', async () => {
|
||||
const credentials = await login('team');
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -142,7 +142,7 @@ describe('CSRF-Schutz', () => {
|
||||
assert.equal(response.statusCode, 403);
|
||||
});
|
||||
|
||||
it('laesst die Anfrage mit passendem Token durch', async () => {
|
||||
it('lässt die Anfrage mit passendem Token durch', async () => {
|
||||
const credentials = await login('team');
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -155,7 +155,7 @@ describe('CSRF-Schutz', () => {
|
||||
assert.equal(response.statusCode, 201);
|
||||
});
|
||||
|
||||
it('laesst lesende Anfragen ohne Token zu', async () => {
|
||||
it('lässt lesende Anfragen ohne Token zu', async () => {
|
||||
const credentials = await login('gast');
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -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', () => {
|
||||
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
||||
const credentials = await login('neu');
|
||||
@@ -227,7 +336,7 @@ describe('Erstpasswort', () => {
|
||||
method: 'POST',
|
||||
url: '/api/players',
|
||||
headers: authHeaders(credentials),
|
||||
payload: { displayName: 'Zu frueh' },
|
||||
payload: { displayName: 'Zu früh' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
@@ -251,8 +360,8 @@ describe('Erstpasswort', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guthaben ueber die API', () => {
|
||||
it('ein CC-Paket schlaegt in beiden Toepfen auf', async () => {
|
||||
describe('Guthaben über die API', () => {
|
||||
it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => {
|
||||
const credentials = await login('team');
|
||||
|
||||
const created = await app.inject({
|
||||
@@ -271,11 +380,14 @@ describe('Guthaben ueber die API', () => {
|
||||
});
|
||||
|
||||
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, {
|
||||
CC: 1,
|
||||
REDESIGN_PAID: 1,
|
||||
REDESIGN_PAID: 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');
|
||||
});
|
||||
});
|
||||
|
||||
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 = [
|
||||
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
|
||||
'A-1001;76561198000000001;Rabe;CC-Paket Gross;2025-01-15',
|
||||
'A-1001;76561198000000001;Rabe;CC-Paket Groß;2025-01-15',
|
||||
'A-1002;76561198000000002;Fuchs;Redesign Gutschein;2025-01-16',
|
||||
'A-1003;76561198000000001;Rabe;Spielerfarbe Blau;2025-01-17',
|
||||
'A-1004;76561198000000003;Dachs;CC-Paket Klein;2025-01-18',
|
||||
@@ -92,7 +92,7 @@ describe('Vorschau und Buchung', () => {
|
||||
|
||||
const summary = await imports.applyMapping(importId, MAPPING, ctx);
|
||||
|
||||
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gueltigem Produkt');
|
||||
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt');
|
||||
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
|
||||
assert.equal(summary.SKIP_DUPLICATE, 0);
|
||||
});
|
||||
@@ -165,7 +165,7 @@ describe('Vorschau und Buchung', () => {
|
||||
assert.equal(summary.SKIP_DUPLICATE, 1);
|
||||
});
|
||||
|
||||
it('ueberspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
||||
it('überspringt unbekannte Spieler, wenn sie nicht angelegt werden sollen', async () => {
|
||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'D-1;76561198000009999;CC-Paket'].join('\n');
|
||||
const parsed = imports.parseUpload('fremd.csv', Buffer.from(csv, 'utf8'));
|
||||
const importId = await imports.createImport('fremd.csv', parsed, ctx);
|
||||
@@ -184,7 +184,7 @@ describe('Vorschau und Buchung', () => {
|
||||
assert.equal(summary.SKIP_NO_PLAYER, 1);
|
||||
});
|
||||
|
||||
it('ein abgeschlossener Import laesst sich nicht erneut buchen', async () => {
|
||||
it('ein abgeschlossener Import lässt sich nicht erneut buchen', async () => {
|
||||
const csv = ['Bestellnummer;Steam-ID;Produkt', 'E-1;76561198000000001;CC-Paket'].join('\n');
|
||||
const parsed = imports.parseUpload('nochmal.csv', Buffer.from(csv, 'utf8'));
|
||||
const importId = await imports.createImport('nochmal.csv', parsed, ctx);
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ let audit: Audit;
|
||||
let db: Db;
|
||||
let ctx: { actorId: number; actorName: string };
|
||||
|
||||
// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht.
|
||||
// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht.
|
||||
const CSV = [
|
||||
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||
@@ -45,7 +45,7 @@ before(async () => {
|
||||
audit = await import('../src/services/audit.js');
|
||||
|
||||
const user = await db.prisma.user.create({
|
||||
data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' },
|
||||
data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' },
|
||||
});
|
||||
ctx = { actorId: user.id, actorName: user.username };
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('Datenminimierung beim Import', () => {
|
||||
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
||||
});
|
||||
|
||||
it('raeumt auch einen verworfenen Import auf', async () => {
|
||||
it('räumt auch einen verworfenen Import auf', async () => {
|
||||
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
||||
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
||||
await imports.applyMapping(importId, MAPPING, ctx);
|
||||
@@ -116,7 +116,7 @@ describe('Fehlgeschlagene Anmeldung', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loeschung eines Spielers', () => {
|
||||
describe('Löschung eines Spielers', () => {
|
||||
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
||||
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
||||
const csv = [
|
||||
@@ -152,7 +152,7 @@ describe('Loeschung eines Spielers', () => {
|
||||
const auditEntries = await db.prisma.auditLog.findMany({
|
||||
where: { targetType: 'player', targetId: String(player.id) },
|
||||
});
|
||||
assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar');
|
||||
assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar');
|
||||
for (const entry of auditEntries) {
|
||||
const details = JSON.stringify(entry.details ?? {});
|
||||
assert.ok(
|
||||
@@ -164,7 +164,7 @@ describe('Loeschung eines Spielers', () => {
|
||||
});
|
||||
|
||||
describe('Aufbewahrung des Audit-Logs', () => {
|
||||
it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => {
|
||||
it('löscht Einträge jenseits der Frist, neuere bleiben', async () => {
|
||||
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
||||
|
||||
const veraltet = await db.prisma.auditLog.create({
|
||||
|
||||
+155
-17
@@ -40,7 +40,7 @@ after(async () => {
|
||||
await database.stop();
|
||||
});
|
||||
|
||||
describe('Guthaben-Toepfe', () => {
|
||||
describe('Guthaben-Töpfe', () => {
|
||||
it('ein CC-Paket erzeugt CC UND bezahltes Redesign', async () => {
|
||||
const playerId = await freshPlayer('Paket-Spieler');
|
||||
const { balances } = await credits.bookCcPackage(playerId, ctx);
|
||||
@@ -58,7 +58,7 @@ describe('Guthaben-Toepfe', () => {
|
||||
assert.equal(balances.REDESIGN_PAID, 1);
|
||||
});
|
||||
|
||||
it('Kulanz landet im eigenen Topf und zaehlt zur Redesign-Summe', async () => {
|
||||
it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => {
|
||||
const playerId = await freshPlayer('Kulanz-Spieler');
|
||||
const { balances } = await credits.bookKulanz(playerId, ctx);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('Guthaben-Toepfe', () => {
|
||||
assert.equal(balances.redesignTotal, 1);
|
||||
});
|
||||
|
||||
it('ein ueberzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
||||
it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => {
|
||||
const playerId = await freshPlayer('Minus-Spieler');
|
||||
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('CC-Lebenszyklus', () => {
|
||||
assert.equal(
|
||||
result.character.lineageId,
|
||||
first.character.lineageId,
|
||||
'die Kette behaelt ihre lineageId',
|
||||
'die Kette behält ihre lineageId',
|
||||
);
|
||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||
});
|
||||
@@ -130,7 +130,7 @@ describe('CC-Lebenszyklus', () => {
|
||||
});
|
||||
|
||||
describe('Reaktivierung', () => {
|
||||
it('ein wegen Inaktivitaet entfernter CC kommt gratis zurueck', async () => {
|
||||
it('ein wegen Inaktivität entfernter CC kommt gratis zurück', async () => {
|
||||
const playerId = await freshPlayer('Inaktiver');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
const created = await characters.createCharacter(playerId, { name: 'Geputzt' }, ctx);
|
||||
@@ -144,7 +144,7 @@ describe('Reaktivierung', () => {
|
||||
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||
});
|
||||
|
||||
it('ein reworkter CC kommt NICHT gratis zurueck', async () => {
|
||||
it('ein reworkter CC kommt NICHT gratis zurück', async () => {
|
||||
const playerId = await freshPlayer('Rework-Spieler');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
|
||||
@@ -160,7 +160,7 @@ describe('Reaktivierung', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('ein reworkter CC kommt gegen CC-Guthaben zurueck', async () => {
|
||||
it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => {
|
||||
const playerId = await freshPlayer('Wiederbeleber');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
@@ -178,7 +178,7 @@ describe('Reaktivierung', () => {
|
||||
assert.equal(result.balances.CC, before.CC - 1);
|
||||
});
|
||||
|
||||
it('auch ein aus anderem Grund entfernter CC kommt gratis zurueck', async () => {
|
||||
it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => {
|
||||
const playerId = await freshPlayer('Einheit-weg');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
|
||||
@@ -187,19 +187,19 @@ describe('Reaktivierung', () => {
|
||||
created.character.id,
|
||||
'OTHER',
|
||||
ctx,
|
||||
'Einheit bis Maerz entfernt',
|
||||
'Einheit bis März entfernt',
|
||||
);
|
||||
|
||||
const deactivated = await db.prisma.character.findUniqueOrThrow({
|
||||
where: { id: created.character.id },
|
||||
});
|
||||
assert.equal(deactivated.inactiveNote, 'Einheit bis Maerz entfernt', 'der Grund bleibt lesbar');
|
||||
assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar');
|
||||
|
||||
const before = await credits.getBalances(playerId);
|
||||
const result = await characters.reactivateFree(created.character.id, ctx);
|
||||
|
||||
assert.equal(result.character.status, 'ACTIVE');
|
||||
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurueckholen geloescht');
|
||||
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht');
|
||||
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('Reaktivierung', () => {
|
||||
});
|
||||
|
||||
describe('Korrekturen', () => {
|
||||
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurueck', async () => {
|
||||
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => {
|
||||
const playerId = await freshPlayer('Storno-Spieler');
|
||||
const booked = await credits.bookCcPackage(playerId, ctx);
|
||||
|
||||
@@ -236,7 +236,7 @@ describe('Korrekturen', () => {
|
||||
assert.equal(result.balances.REDESIGN_PAID, 0);
|
||||
});
|
||||
|
||||
it('dieselbe Buchung laesst sich kein zweites Mal stornieren', async () => {
|
||||
it('dieselbe Buchung lässt sich kein zweites Mal stornieren', async () => {
|
||||
const playerId = await freshPlayer('Doppel-Storno');
|
||||
const booked = await credits.bookKulanz(playerId, ctx);
|
||||
const entry = await db.prisma.creditLedger.findFirstOrThrow({
|
||||
@@ -266,8 +266,8 @@ describe('Korrekturen', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('eine Ruecknahme bucht das Guthaben zurueck und reaktiviert den Vorgaenger', async () => {
|
||||
const playerId = await freshPlayer('Ruecknehmer');
|
||||
it('eine Rücknahme bucht das Guthaben zurück und reaktiviert den Vorgänger', async () => {
|
||||
const playerId = await freshPlayer('Rücknehmer');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
const first = await characters.createCharacter(playerId, { name: 'Original' }, ctx);
|
||||
const redesigned = await characters.redesignCharacter(
|
||||
@@ -292,7 +292,7 @@ describe('Korrekturen', () => {
|
||||
assert.equal(restored.inactiveReason, null);
|
||||
});
|
||||
|
||||
it('eine Ruecknahme mitten in der Kette wird abgelehnt', async () => {
|
||||
it('eine Rücknahme mitten in der Kette wird abgelehnt', async () => {
|
||||
const playerId = await freshPlayer('Ketten-Spieler');
|
||||
await credits.bookCcPackage(playerId, ctx);
|
||||
await credits.bookRedesignPackage(playerId, ctx);
|
||||
@@ -309,8 +309,146 @@ describe('Korrekturen', () => {
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.revertCharacter(second.character.id, 'Zu spaet', ctx),
|
||||
() => characters.revertCharacter(second.character.id, 'Zu spät', ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'HAS_SUCCESSORS',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Profil auslesen', () => {
|
||||
assert.equal(cc.purchasedAt?.getDate(), 6);
|
||||
});
|
||||
|
||||
it('erkennt stornierte Kaeufe', () => {
|
||||
it('erkennt stornierte Käufe', () => {
|
||||
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
|
||||
assert.equal(revoked?.revoked, true);
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe('Profil auslesen', () => {
|
||||
'donations_data[9004] = {kaputt"id"',
|
||||
);
|
||||
const result = parseProfile(broken, '76561198000000999');
|
||||
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile faellt weg');
|
||||
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile fällt weg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,21 +73,21 @@ describe('Pakete zuordnen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('zaehlt "Custom Character Redesign" als Redesign, nicht als CC', () => {
|
||||
it('zählt "Custom Character Redesign" als Redesign, nicht als CC', () => {
|
||||
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
|
||||
kind: 'package',
|
||||
target: 'REDESIGN_PACKAGE',
|
||||
});
|
||||
});
|
||||
|
||||
it('liest den Verbrauchszaehler samt Anzahl', () => {
|
||||
it('liest den Verbrauchszähler samt Anzahl', () => {
|
||||
const result = classifyPackage(
|
||||
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
|
||||
);
|
||||
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
|
||||
});
|
||||
|
||||
it('ignoriert Merch und Raenge trotz SCP-RP-Tag', () => {
|
||||
it('ignoriert Merch und Ränge trotz SCP-RP-Tag', () => {
|
||||
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
|
||||
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
|
||||
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
|
||||
@@ -105,7 +105,7 @@ describe('Pakete zuordnen', () => {
|
||||
assert.equal(result.kind, 'ignore');
|
||||
});
|
||||
|
||||
it('schiebt unklare CC-Titel in die Handpruefung statt zu raten', () => {
|
||||
it('schiebt unklare CC-Titel in die Handprüfung statt zu raten', () => {
|
||||
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
|
||||
});
|
||||
});
|
||||
@@ -116,13 +116,13 @@ describe('SteamID-Schreibweisen', () => {
|
||||
const modern = '[U:1:39735271]';
|
||||
const sixtyFour = '76561198000000999';
|
||||
|
||||
it('fuehrt alle drei Formate auf dieselbe ID zusammen', () => {
|
||||
it('führt alle drei Formate auf dieselbe ID zusammen', () => {
|
||||
assert.equal(normalizeSteamId(legacy), sixtyFour);
|
||||
assert.equal(normalizeSteamId(modern), sixtyFour);
|
||||
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
|
||||
});
|
||||
|
||||
it('rechnet zurueck in die Legacy-Form', () => {
|
||||
it('rechnet zurück in die Legacy-Form', () => {
|
||||
assert.equal(toLegacySteamId(sixtyFour), legacy);
|
||||
});
|
||||
|
||||
@@ -140,3 +140,69 @@ describe('SteamID-Schreibweisen', () => {
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
</head>
|
||||
<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 Admin from './pages/Admin';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Forum from './pages/Forum';
|
||||
import Import from './pages/Import';
|
||||
import Login from './pages/Login';
|
||||
import PlayerDetail from './pages/PlayerDetail';
|
||||
@@ -69,10 +70,21 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/forum"
|
||||
element={
|
||||
<Protected>
|
||||
<Layout>
|
||||
<Forum />
|
||||
</Layout>
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/import"
|
||||
element={
|
||||
<Protected minimum="ADMIN">
|
||||
<Protected minimum="EDITOR">
|
||||
<Layout>
|
||||
<Import />
|
||||
</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';
|
||||
|
||||
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 (
|
||||
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
|
||||
<span className="balance-label">{label}</span>
|
||||
<span className="balance-value">{value}</span>
|
||||
{hint && <span className="balance-hint">{hint}</span>}
|
||||
<span className="balance-value">
|
||||
{value}
|
||||
{granted > 0 && <span className="balance-of"> von {granted}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,22 +21,31 @@ function Card({ label, value, hint }: { label: string; value: number; hint?: str
|
||||
export default function BalanceCards({ balances }: { balances: Balances }) {
|
||||
const canCreate = balances.CC >= 1;
|
||||
const canRedesign = balances.redesignTotal >= 1;
|
||||
const granted = balances.granted ?? { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 };
|
||||
|
||||
return (
|
||||
<section className="balances" aria-label="Guthaben">
|
||||
<div className="balance-grid">
|
||||
<Card label="CC-Guthaben" value={balances.CC} />
|
||||
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} />
|
||||
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} />
|
||||
<Card label="CC-Guthaben" value={balances.CC} granted={granted.CC} />
|
||||
<Card
|
||||
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 className="eligibility">
|
||||
<span className={canCreate ? 'ok' : 'no'}>
|
||||
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
|
||||
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'möglich' : 'nicht möglich'}
|
||||
</span>
|
||||
<span className={canRedesign ? 'ok' : 'no'}>
|
||||
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
|
||||
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
|
||||
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'}
|
||||
{canRedesign && ` (${balances.redesignTotal} verfügbar)`}
|
||||
</span>
|
||||
</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>
|
||||
Spieler
|
||||
</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>}
|
||||
</nav>
|
||||
|
||||
@@ -40,6 +41,13 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
</header>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -47,10 +47,10 @@ export default function UnmatchedBox({
|
||||
|
||||
return (
|
||||
<section className="block block-flag">
|
||||
<h2>Ungeklaerte Eintraege ({items.length})</h2>
|
||||
<h2>Ungeklärte Einträge ({items.length})</h2>
|
||||
<Notice kind="warn">
|
||||
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
|
||||
liessen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
||||
ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
|
||||
sie hier einordnest.
|
||||
</Notice>
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||
/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
+33
-3
@@ -9,18 +9,34 @@ export const KIND_LABEL: Record<CreditKind, string> = {
|
||||
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
||||
CC_PACKAGE: 'CC-Paket',
|
||||
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||
/// Von Hand vergeben, ohne Kauf dahinter.
|
||||
CC_GRANT: 'CC-Guthaben (von Hand)',
|
||||
KULANZ: 'Kulanz',
|
||||
ADJUSTMENT: 'Korrektur',
|
||||
CONVERSION: 'Umwandlung',
|
||||
CONSUME: 'Verbrauch',
|
||||
REVERSAL: 'Storno',
|
||||
};
|
||||
|
||||
export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
|
||||
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
|
||||
REWORKED: 'Redesignt',
|
||||
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> = {
|
||||
BOOK: 'Wird gebucht',
|
||||
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 {
|
||||
if (!value) return '—';
|
||||
if (!value) return '-';
|
||||
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 {
|
||||
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 {
|
||||
user: SessionUser | null;
|
||||
loading: boolean;
|
||||
/// Die Adressen der fremden Dienste, für Profil-Links.
|
||||
gextensionBaseUrl: string | null;
|
||||
setUser: (user: SessionUser | null) => void;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
@@ -19,11 +21,15 @@ const SessionContext = createContext<SessionState | null>(null);
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gextensionBaseUrl, setGextension] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
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);
|
||||
setGextension(result.gextensionBaseUrl ?? null);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
@@ -49,8 +55,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const value = useMemo<SessionState>(
|
||||
() => ({ user, loading, setUser, refresh, logout, can }),
|
||||
[user, loading, refresh, logout, can],
|
||||
() => ({ user, loading, gextensionBaseUrl, setUser, refresh, logout, can }),
|
||||
[user, loading, gextensionBaseUrl, refresh, logout, can],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
@@ -58,6 +64,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function useSession(): SessionState {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
|
||||
if (!context) throw new Error('useSession benötigt einen SessionProvider');
|
||||
return context;
|
||||
}
|
||||
|
||||
+225
-1
@@ -3,12 +3,19 @@ export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||
export type CreditSource =
|
||||
| 'CC_PACKAGE'
|
||||
| 'REDESIGN_PACKAGE'
|
||||
| 'CC_GRANT'
|
||||
| 'KULANZ'
|
||||
| 'ADJUSTMENT'
|
||||
| 'CONVERSION'
|
||||
| 'CONSUME'
|
||||
| 'REVERSAL';
|
||||
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 interface SessionUser {
|
||||
@@ -24,6 +31,8 @@ export interface Balances {
|
||||
REDESIGN_PAID: number;
|
||||
REDESIGN_KULANZ: 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 {
|
||||
@@ -31,17 +40,31 @@ export interface PlayerSummary {
|
||||
displayName: string;
|
||||
steamId: string | null;
|
||||
forumName: string | null;
|
||||
isProblem: boolean;
|
||||
problemNote: string | null;
|
||||
characterCount: number;
|
||||
activeCharacterCount: number;
|
||||
balances: Balances;
|
||||
}
|
||||
|
||||
export interface CharacterLink {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: number;
|
||||
displayName: string;
|
||||
steamId: string | null;
|
||||
forumName: string | null;
|
||||
/// Sein Forumsprofil, aus dem Abgleich übernommen.
|
||||
forumProfileUrl: 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;
|
||||
gextensionUsage: number | null;
|
||||
gextensionSyncedAt: string | null;
|
||||
@@ -75,6 +98,7 @@ export interface ScrapeSummary {
|
||||
failedPlayers: { steamId: string; reason: string }[];
|
||||
newRows: number;
|
||||
duplicates: number;
|
||||
awaitingConfirmation: number;
|
||||
unclear: number;
|
||||
usageCounters: number;
|
||||
}
|
||||
@@ -84,6 +108,32 @@ export interface CharacterRef {
|
||||
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 {
|
||||
id: number;
|
||||
playerId: number;
|
||||
@@ -93,9 +143,17 @@ export interface Character {
|
||||
inactiveReason: InactiveReason | null;
|
||||
inactiveNote: string | null;
|
||||
lineageId: string;
|
||||
/// Platz in der Redesign-Kette, aufsteigend.
|
||||
chainPosition: number;
|
||||
predecessorId: number | null;
|
||||
predecessor: CharacterRef | null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -110,11 +168,24 @@ export interface LedgerEntry {
|
||||
characterId: number | null;
|
||||
character: CharacterRef | null;
|
||||
createdAt: string;
|
||||
/// Wann es tatsächlich passierte - beim Import das Kaufdatum aus dem Shop.
|
||||
occurredAt: string;
|
||||
createdBy: { username: string } | null;
|
||||
reversedBy: { id: 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 {
|
||||
player: Player;
|
||||
balances: Balances;
|
||||
@@ -122,6 +193,11 @@ export interface PlayerDetail {
|
||||
ledger: LedgerEntry[];
|
||||
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 {
|
||||
@@ -133,6 +209,9 @@ export interface AdminUser {
|
||||
mustChangePassword: boolean;
|
||||
lastLoginAt: string | null;
|
||||
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 {
|
||||
@@ -190,3 +269,148 @@ export interface ImportRecord {
|
||||
importedBy: { username: string } | null;
|
||||
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 { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { DialogProvider } from './components/DialogProvider';
|
||||
import { SessionProvider } from './lib/session';
|
||||
import './styles.css';
|
||||
|
||||
@@ -12,7 +13,9 @@ createRoot(container).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<SessionProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</SessionProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
+20
-12
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Notice from '../components/Notice';
|
||||
import { useDialog } from '../components/DialogProvider';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { useSession } from '../lib/session';
|
||||
|
||||
export default function Account() {
|
||||
const { user, refresh } = useSession();
|
||||
const { abfragen } = useDialog();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [current, setCurrent] = useState('');
|
||||
@@ -23,7 +25,7 @@ export default function Account() {
|
||||
setError(null);
|
||||
|
||||
if (next !== repeat) {
|
||||
setError('Die beiden neuen Passwoerter stimmen nicht ueberein');
|
||||
setError('Die beiden neuen Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,10 +39,10 @@ export default function Account() {
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
await refresh();
|
||||
setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.');
|
||||
setFlash('Passwort geändert. Andere Sitzungen wurden beendet.');
|
||||
if (user?.mustChangePassword) navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen');
|
||||
setError(err instanceof ApiError ? err.message : 'Änderung fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -77,7 +79,13 @@ export default function Account() {
|
||||
};
|
||||
|
||||
const disableTotp = async () => {
|
||||
const password = window.prompt('Zum Deaktivieren bitte das Passwort eingeben:');
|
||||
const password = await abfragen({
|
||||
titel: 'Zwei-Faktor-Anmeldung abschalten',
|
||||
text: 'Danach genügt das Passwort allein für die Anmeldung.',
|
||||
label: 'Zur Bestätigung dein Passwort',
|
||||
geheim: true,
|
||||
knopf: 'Abschalten',
|
||||
});
|
||||
if (!password) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -97,8 +105,8 @@ export default function Account() {
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<Notice kind="warn">
|
||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
|
||||
uebrigen Seiten sind bis dahin gesperrt.
|
||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern - die
|
||||
übrigen Seiten sind bis dahin gesperrt.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
@@ -106,7 +114,7 @@ export default function Account() {
|
||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||
|
||||
<section className="block">
|
||||
<h2>Passwort aendern</h2>
|
||||
<h2>Passwort ändern</h2>
|
||||
<form onSubmit={changePassword}>
|
||||
<label htmlFor="current">Aktuelles Passwort</label>
|
||||
<input
|
||||
@@ -128,7 +136,7 @@ export default function Account() {
|
||||
required
|
||||
/>
|
||||
<p className="muted small">
|
||||
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
|
||||
Mindestens 12 Zeichen, Groß- und Kleinbuchstaben sowie eine Ziffer.
|
||||
</p>
|
||||
|
||||
<label htmlFor="repeat">Neues Passwort wiederholen</label>
|
||||
@@ -142,7 +150,7 @@ export default function Account() {
|
||||
/>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Speichere …' : 'Passwort aendern'}
|
||||
{busy ? 'Speichere …' : 'Passwort ändern'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -152,7 +160,7 @@ export default function Account() {
|
||||
|
||||
{user?.totpEnabled ? (
|
||||
<>
|
||||
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
|
||||
<Notice kind="success">Zwei-Faktor-Anmeldung ist für dieses Konto aktiv.</Notice>
|
||||
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
|
||||
Deaktivieren
|
||||
</button>
|
||||
@@ -164,7 +172,7 @@ export default function Account() {
|
||||
</p>
|
||||
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
|
||||
<p className="muted small">
|
||||
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
|
||||
Falls Scannen nicht geht, Schlüssel manuell eintragen:{' '}
|
||||
<code className="mono">{totpSetup.secret}</code>
|
||||
</p>
|
||||
|
||||
@@ -190,7 +198,7 @@ export default function Account() {
|
||||
) : (
|
||||
<>
|
||||
<p className="muted">
|
||||
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
|
||||
Schützt das Konto zusätzlich mit einem Einmalcode. Für Administratoren empfohlen.
|
||||
</p>
|
||||
<button type="button" className="btn" onClick={() => void startTotp()}>
|
||||
Einrichten
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user