Compare commits

..
50 Commits
Author SHA1 Message Date
Harlekin b93ff2fb0e Ein Eintrag kostet genau einmal, die Rueckkehr ist ein weiteres Glied
An einem Eintrag konnten zwei Buchungen haengen - etwa Kulanz und ein
gekauftes Redesign nebeneinander -, und aufloesen liess sich das
nirgends. Jetzt raeumt sowohl das Nachrechnen als auch das Umstellen der
Bezahlart auf: es bleibt die aelteste passende, der Rest wird storniert.

Die Rueckkehr eines redesignten oder gestorbenen CCs bucht deshalb nicht
mehr zusaetzlich, sondern erschafft ihn noch einmal: derselbe CC als
weiteres Glied ueber dem alten, und das kostet ein Redesign wie jedes
andere. Mit einem CC-Guthaben beginnt er stattdessen eine eigene Liste
und kann neben seinem Nachfolger laufen.

Ein entzogener Platz nimmt seine Buchung mit - sonst waere die Rueckkehr
umsonst oder stuende als zweite Buchung am selben Eintrag.
2026-09-22 14:31:29 +02:00
Harlekin 367f1aace4 Jedes Glied einer Liste kostet, auch in einem Gruppen-CC
Bisher zahlte nur, wer neu einstieg: wer in einer Kette mitgezogen
wurde oder einen Gruppen-CC mitbenutzte, stand darin ohne eigene
Buchung. Damit ging die Rechnung nicht auf - verbrauchte Redesigns sind
alle Glieder aller Listen minus die Anzahl der Listen, und das gilt fuer
jeden Nutzer einzeln.

Jetzt bucht jedes Glied: das unterste einen Platz, jedes weitere ein
Redesign. Wo eine Buchung fehlt, traegt der Abgleich sie nach, und das
Redesign eines Gruppen-CCs kostet jeden Nutzer sein eigenes.

Am Pfeil stehen alle offenen Buchungen eines Eintrags, nicht nur die
juengste - sonst bleibt eine zweite unsichtbar.
2026-09-22 13:01:52 +02:00
Harlekin fee4a4f73a CC-Guthaben wird vorher umgewandelt, nicht am CC eingesetzt
Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafuer
einsetzen will, tauscht es oben um und setzt es danach ein wie jedes
andere Redesign. Damit faellt die Wahl CC-Guthaben an den CC-Aktionen
weg; was ein Eintrag kostet, entscheidet seine Stelle in der Liste.

Die Bezahlart gilt jetzt je Nutzer: bei einem Gruppen-CC hat jeder seine
eigene Buchung, und umstellen liess sich bisher nur die des Erstellers.

Die Guthaben-Historie laesst sich nach Topf filtern.
2026-09-22 12:42:48 +02:00
Harlekin 1f4034b397 Jede Liste gehoert einem Spieler, CC-Guthaben wird umgewandelt
Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen
kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines
vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft
statt am CC, und verschieben ordnet nur die eigene Liste.

Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben
dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes
Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch
Slot-Pruefung und Kettenrechnung zog.

Beim Start werden Buchungen einmalig an die Listen angeglichen.
2026-09-22 12:21:53 +02:00
Harlekin 9b024d343d K oder R an den Kettenpfeilen anzeigen 2026-09-22 10:15:18 +02:00
Harlekin 823439f28d Hinweis an der CC-Wahl im Redesign-Dialog streichen
Neben dem Guthaben steht bereits, wie viel davon verfuegbar ist; der Zusatz
daneben sagte nur noch einmal, was die Wahl ohnehin bedeutet.
2026-09-22 09:46:15 +02:00
Harlekin 2515dcbed7 Rueckfrage beim Zuruecknehmen ohne Erklaerungstext
Die Frage steht im Titel, der Absatz darunter wiederholte nur, was der Knopf
ohnehin tut. Der Text ist deshalb optional geworden - eine Rueckfrage, die
sich selbst erklaert, braucht keinen.
2026-09-22 09:43:31 +02:00
Harlekin 34819d44cc Rueckfragen im Stil der Anwendung, Kulanz-Ausgleich, Redesign aus dem CC-Topf
window.confirm und window.prompt sind raus - alle vierzehn Stellen. Sie sahen
nicht nur fremd aus: Wer sie im Browser abschaltet, bekommt stillschweigend
"nein" zurueck, und eine Aktion passiert einfach nicht, ohne dass jemand
erfaehrt warum. Stattdessen gibt es einen Dialog-Dienst mit drei Formen -
bestaetigen, abfragen, waehlen -, der sich wie der Rest der Anwendung
anfuehlt.

Die Wahl kam dazu, weil eine Stelle ein Ja/Nein fuer eine Auswahl
missbrauchte: "OK = Kulanz, Abbrechen = bezahltes Redesign". Das fuehrt jeden
in die Irre, der es zum ersten Mal sieht.

Neu: Steht der bezahlte Redesign-Topf im Minus, waehrend Kulanz uebrig ist,
erscheint "Minus mit Kulanz ausgleichen". Es bucht so viele Uebergaenge um,
wie beide Seiten hergeben - die aeltesten zuerst, damit das Ergebnis
wiederholbar ist. Unterm Strich aendert sich nichts, nur die Verteilung.

Ebenfalls neu: Ein Redesign laesst sich ausnahmsweise aus dem CC-Topf
bezahlen. Damit das Ausrichten an den Ketten so eine Entscheidung nicht beim
naechsten Umsortieren wieder einebnet, tragen von Hand gewaehlte Buchungen
jetzt einen Merker.

Dabei aufgefallen: Die Tests wurden gar nicht typgeprueft - tsconfig schloss
nur src ein. Sieben Aufrufe von bookKulanz hatten vertauschte Argumente und
liefen trotzdem durch, weil JavaScript das hinnimmt; sie pruefte damit etwas
anderes als gedacht. Tests haengen jetzt in der Typpruefung.
2026-09-22 09:39:57 +02:00
Harlekin 9b93844be2 Bei einer belegten SteamID sagen, wem sie gehoert
"Diese SteamID ist bereits einem Spieler zugeordnet" liess offen, welchem -
und damit blieb nur, den Bestand von Hand durchzusuchen. Die Meldung nennt
jetzt den Namen und sagt auch, ob die Kennung dort die aktuelle ist oder als
frueheres Konto haengt.

Dabei fiel auf, dass die Pruefung nur die Hauptangaben ansah. Eine Kennung,
die bei jemandem als frueheres Konto eingetragen war, liess sich bei einem
anderen als aktuelle vergeben - und danach wuesste weder der Abgleich noch
der Import, wem ein Kauf gehoert. Beides wird jetzt geprueft, in beide
Richtungen: beim Anlegen und Bearbeiten eines Spielers ebenso wie beim
Nachtragen eines frueheren Kontos.
2026-09-21 23:58:10 +02:00
Harlekin ae5c67eea6 Von Hand vergebenes CC-Guthaben ohne Redesign
Der Knopf trug bisher die Regel des Shops: ein CC-Paket ist ein Platz plus
ein Redesign. Fuer Einzelfaelle - jemand bekommt einen Platz als Ausgleich,
aus einer Absprache, weil etwas schiefging - war das zu viel.

"+ CC-Guthaben" schreibt jetzt nur den Platz gut. Wer ein volles Paket
nachtragen will, klickt zusaetzlich auf "+ Redesign-Paket". Der Abgleich mit
dem Shop bleibt unberuehrt: dort ist ein Paket weiterhin beides, und die
Regel dafuer steht unveraendert an einer Stelle.

In der Historie steht "CC-Guthaben (von Hand)" statt "CC-Paket" - eine eigene
Quelle, damit zu sehen ist, dass kein Kauf dahintersteht. Das Minus daneben
nimmt entsprechend diese Zugabe zurueck.
2026-09-21 23:43:26 +02:00
Harlekin e57bfbabc7 Der eigene CC steht in der eigenen Kette
Ein CC landete unter "Frueher genutzt", obwohl er dem Spieler gehoert - seine
Mitgliedschaft daran war beendet, und danach allein richtete sich die
Einordnung. Damit stand sein eigener CC in einem Abschnitt, der ausdruecklich
fuer fremde gedacht ist, und liess sich aus der Ferne kaum in eine Kette
ziehen.

Jetzt entscheidet bei einem eigenen CC der Besitz: Wem er gehoert, dem gehoert
er, auch wenn die Mitgliedschaft beendet wurde. Es bleibt seine Geschichte.
Bei fremden CCs entscheidet weiterhin die Mitgliedschaft.

Zwei Dinge fielen dabei auf:

- Beim Ziehen scrollt der Browser nicht von selbst. Wer einen Eintrag von
  ganz unten in eine Kette weiter oben bringen wollte, sah das Ziel gar
  nicht. Jetzt scrollt die Seite, solange am Rand gezogen wird.
- Der Besitzer konnte sich an seinem eigenen CC nicht ohne neue Buchung
  wieder eintragen, obwohl seine Buchung seit dem Anlegen daran haengt. Fuer
  ihn ist die Guthaben-Angabe jetzt freigestellt; von einem Fremden wird sie
  weiterhin verlangt.
2026-09-21 19:53:29 +02:00
Harlekin f0213fe4bc Die Rechnung auf eine Regel bringen, plus Knopf zum Nachrechnen
Die Regel lautet: so viele CC-Pakete wie Ketten, so viele Redesigns wie
Eintraege minus Ketten. Anders gesagt - wer keinen Vorgaenger hat, ist der
Anfang einer Kette und damit ein gekaufter CC; wer einen hat, ist dessen
Redesign. In welcher Linie ein Eintrag gefuehrt wird, spielt keine Rolle.

Ich hatte das mit einer Sonderbehandlung fuer "Herkunft ausserhalb der Linie"
gebaut. Die faellt weg: Sie war ein Umweg um dieselbe Regel und hat sie an
einer Stelle verfehlt. Jetzt steht sie direkt im Code, und der Fall mit dem
fremden Gruppen-CC folgt von selbst daraus - dessen eigener Strang hat einen
Vorgaenger, ist also kein Kauf.

Fuer Bestaende, die aus einer Zeit stammen, in der eine Buchung blieb, wo sie
beim Anlegen entstand, gibt es "Buchungen nachrechnen" auf der Spielerseite.
Es richtet alle Buchungen eines Spielers nach dieser Regel aus, fasst nur an,
was nicht passt, bucht ueber Gegenbuchungen um und laesst Kulanz-
Entscheidungen stehen.

Vier Tests halten die Rechnung fest, einer davon genau in der Form, in der
sie formuliert wurde: Ketten zaehlen, Eintraege zaehlen, Buchungen zaehlen,
vergleichen.
2026-09-21 19:26:00 +02:00
Harlekin 46e339fdc4 Fremde Gruppen-CCs in der Kette bleiben voll bedienbar
Der Eintrag am Fuss einer Kette - der Gruppen-CC, aus dem sich jemand
herausredesignt hat - liess sich weder ziehen noch bearbeiten. Das war meine
Entscheidung mit der Begruendung, es sei der Platz eines anderen. Beim
Nacherfassen ist das zu eng: Dort steht erst am Ende fest, welches Redesign
wohin fuehrte, und bis dahin muss sich auch einordnen lassen, was einem nicht
gehoert.

Er ist jetzt ein Kettenglied wie jedes andere - ziehen, bearbeiten,
Bezahlart, Verweise, Herausloesen - und traegt nur zusaetzlich das
Kennzeichen "war dabei" mit Zeitraum und Besitzer. Wer ihn an den Anfang
seiner Kette zieht, macht aus seiner Beitrittsbuchung den Kauf und aus dem
Herausredesignen ein Redesign; sein Platz bleibt dabei einer.

Dass nur Beteiligte etwas einreihen duerfen, gilt unveraendert.
2026-09-21 19:16:07 +02:00
Harlekin 1b643cb758 Ketten mit Vorgeschichte: unterstes Glied ist nicht immer ein Kauf
Ein Spieler mit drei Paketen und drei Plaetzen stand bei "-1 von 3". Der
dritte Platz kam aus einem fremden Gruppen-CC: beigetreten mit einem
CC-Guthaben, spaeter per Redesign ausgestiegen und als eigener CC
weitergefuehrt. Damit beginnt zwar eine neue Kette, aber kein neuer Platz -
bezahlt ist er laengst, naemlich mit dem Beitritt.

Die Automatik sah nur "unterstes Glied" und buchte es auf CC-Paket um. Der
Platz stand damit zweimal im Konto, einmal fuer den Beitritt und einmal fuer
den eigenen CC. Jetzt zaehlt, ob der Kette etwas vorausging: Zeigt ihr erstes
Glied auf einen Vorgaenger ausserhalb, ist auch dieses Glied ein Redesign.

Dabei fiel ein zweiter Fehler auf: Das Neuverketten loeschte genau diesen
Verweis, weil es die Verkettung innerhalb der Linie normalisierte. Damit ging
die einzige Spur zur Herkunft verloren - und mit ihr der Eintrag "war dabei"
am Fuss der Kette. Ein Vorgaenger ausserhalb bleibt jetzt stehen.

Fuer die Korrektur bereits falsch gebuchter Eintraege kann "Bezahlart" jetzt
auch zwischen CC-Paket und Redesign wechseln, nicht nur zwischen bezahlt und
Kulanz. Der Knopf steht an jedem Eintrag, nicht nur an Redesigns.
2026-09-21 19:07:29 +02:00
Harlekin f7b48a9891 Guthaben: "von wie vielen" anzeigen und Zugaben zuruecknehmen
In den Kaesten steht jetzt neben dem offenen Stand, wie viel jemals in den
Topf kam: "0 von 3" liest sich anders als "0 von 0" - das eine heisst
aufgebraucht, das andere nie etwas gehabt. Gezaehlt werden dabei nur echte
Zugaenge: Die Ruecknahme eines Verbrauchs zaehlt dorthin, wo die stornierte
Zeile zaehlte, sonst saehe ein zurueckgenommenes Redesign wie ein
zusaetzliches Paket aus.

Neben jedem Plus steht ein Minus, das die zuletzt von Hand eingetragene
Zugabe dieser Art zurueckholt - fuer den vertippten oder doppelten Klick,
ohne in der Historie nach der richtigen Zeile zu suchen. Zurueckgenommen wird
ueber eine Gegenbuchung; bei einem CC-Paket gehen beide Zeilen mit. Aus einem
Import gebuchte Pakete bleiben aussen vor: die gehoeren zu einer Bestellung
und werden dort storniert, wo der Zusammenhang sichtbar ist. Die Rechte
folgen dem Eintragen - Kulanz bleibt bei den Admins.

Nebenbei zwei wacklige Tests festgezurrt: Die Testdatenbanken weichen auf den
naechsten Port aus, wenn einer aus einem frueheren Lauf noch haengt, und der
Sperr-Test des Abgleichs klopft nicht mehr bei GExtension an, sondern laeuft
gegen eine tote Adresse.
2026-09-21 18:50:27 +02:00
Harlekin 2d1c008c0a Die Stelle in der Kette bestimmt die Buchung
Bisher blieb eine Buchung, wie sie beim Anlegen entstand - auch wenn der
Eintrag danach an eine ganz andere Stelle wanderte. Damit stand ein CC, der
in eine Kette geschoben wurde, weiter als gekauftes Paket im Konto, obwohl er
inzwischen ein Redesign war.

Jetzt folgt die Buchung der Position: Das unterste Glied ist der gekaufte CC
und verbraucht ein CC-Paket, alles darueber sind Redesigns. Wandert ein
Eintrag, wandert seine Bedeutung mit - in beide Richtungen. Wer mitten in eine
Kette rutscht, wird zum Redesign und gibt sein CC-Guthaben zurueck; wer an den
Anfang kommt oder herausgeloest wird, belegt einen eigenen Platz und kostet
wieder ein Paket.

Umgebucht wird ueber eine Gegenbuchung, nicht durch Ueberschreiben. Reicht das
Guthaben nicht, geht das Konto ins Minus und zeigt es an - wie bei jedem
Vorab-CC. Zwischen bezahltem Redesign und Kulanz wird nicht gewechselt: das
ist eine Entscheidung, keine Folge der Reihenfolge. Eintraege ohne Buchung
bleiben unangetastet.

Dabei zaehlt die Buchung dessen, dem die Kette gehoert - der Spieler, der
unten steht. Bei einem fremden Gruppen-CC in seiner Kette ist das seine
Beitrittsbuchung; die des Erstellers bleibt, denn fuer den ist es sein eigener
Platz in seiner eigenen Kette.

Damit liess sich der Gruppen-CC ueberhaupt erst einreihen: Der Besitzer-Check
fragte nach dem Ersteller, und der ist bei einem Gruppen-CC ein anderer als
der, dessen Seite man gerade ordnet. Jetzt entscheidet die Beteiligung - wer
an beidem beteiligt war, darf einreihen, ein Unbeteiligter nicht. Fehler beim
Ziehen stehen ausserdem neben den Ketten statt am Seitenkopf: wer unten zieht,
sieht dort oben nichts.
2026-09-21 18:38:11 +02:00
Harlekin 2b78a972bc Ersteller statt letztem Antwortendem lesen
Ein Antrag hing bei einem Spieler, der ihn weder geschrieben noch
kommentiert hatte. Die Ursache steht im HTML der Boarduebersicht: Der
Ersteller "-Daniel-" hat sein Konto geloescht und steht dort ohne
Profil-Link. Der einzige Profil-Link der Zeile gehoerte damit dem letzten
Antwortenden - und genau den nahm der Parser, weil er nach dem ersten Treffer
im ganzen Zeilenblock suchte.

Jetzt wird die Zeile vor der Spalte "letzter Beitrag" abgeschnitten und
zuerst in der Zelle gesucht, die den Ersteller fuehrt. Erst wenn dort kein
Link steht, gilt der blosse Text - so wie es bei einem geloeschten Konto der
Fall ist. Auf vier Stichprobenseiten des Archivs trifft das fuenf von achtzig
Threads; hochgerechnet liegen im Bestand also rund drei Dutzend falsch.

Die falschen Zuordnungen bleiben nicht stehen: Aendert sich der Ersteller
eines bekannten Threads, wird seine Zuordnung neu bestimmt - aber nur, wenn
die bisherige von selbst zustande kam. Wer von Hand zugeordnet hat, wusste
mehr als der Abgleich, das bleibt unangetastet. Die Abschlussmeldung nennt,
wie viele Threads dabei gewandert sind.
2026-09-21 17:06:55 +02:00
Harlekin fe89032c03 Nacherfassung an alten Kettengliedern, Filter fuer die Spielerliste
Beim Durcharbeiten von Spieler A stellt sich heraus, dass er schon vor drei
Redesigns bei einem Gruppen-CC von Spieler B dabei war. Das ging bisher nicht:
"Einem inaktiven CC kann niemand beitreten" - richtig fuer den laufenden
Betrieb, falsch fuers Nacherfassen. Dort geht es nicht um jetzt, sondern um
eine Geschichte, die laengst gelaufen ist.

An einem stillgelegten CC wird deshalb nachgetragen statt beigetreten: mit
Zeitraum, und die Guthaben-Angabe ist freigestellt, weil die Buchung im Konto
stehen kann. 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. Fuer alle anderen bleibt die Angabe Pflicht.
Damit vier Kettenglieder nicht vier Klicks bedeuten, traegt ein Aufruf einen
Spieler ab einem Glied in die ganze weitere Kette ein; gebucht wird einmal.

Ein Test spielt den ganzen Fall durch: B fertig, A nachgetragen, C steigt in
der Mitte ein und wieder aus, D kommt erst am Ende dazu - und am Schluss sieht
jeder seine eigene Geschichte.

Leere Import-Vorschauen blieben in der Liste stehen, sobald alle Zeilen beim
Spieler gebucht waren. Bestaetigen liess sich so eine Huelle nicht ("nichts zu
buchen"), sie war nur im Weg. Jetzt gilt sie als erledigt - sie ist es ja.
Auch der Bestand wird beim naechsten Aufruf der Liste abgeraeumt.

Und die Spielerliste laesst sich eingrenzen: nur mit aktivem CC, nur mit
ausschliesslich stillgelegten, nur Beteiligte eines Gruppen-CCs, nur ohne
jeden CC, nur Problemfaelle. Sortiert wird nach Namen, nach der Zahl der
CC-Eintraege oder nach Anlage.
2026-09-21 16:17:00 +02:00
Harlekin 2527f6a240 Erstzugang schuetzen, Kettenstatus richtigstellen, CCs nachtraeglich aendern
Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch
Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und
loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein
Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes
Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true
hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den
Namen kennt nur er, die Migration nicht.

Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt",
obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger,
der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger
hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht
dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch.

Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines
CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt -
sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst
eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign
bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben
wird nichts.

Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite
offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den
Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht,
ohne im Hintergrund dauernd zu fragen.
2026-09-21 14:36:26 +02:00
Harlekin c82391e73f Vergangene Mitgliedschaften sichtbar, Antworten nicht mehr aus dem Cache
Wer sich aus einem Gruppen-CC herausredesignt hat, war trotzdem darin - der
CC verschwand aber aus seiner Liste. Jetzt steht er am Fuss der Kette, in die
der Spieler von dort aus gewechselt ist, gekennzeichnet als "war dabei" und
mit dem Hinweis, wem er gehoert und wohin es weiterging. Verschieben oder
entscheiden laesst sich daran nichts: es ist der Platz eines anderen.
Frueheres, das zu keinem heutigen Platz fuehrt, steht unter "Frueher
genutzt". Dasselbe gilt fuer einen gewoehnlichen Austritt.

Neu eingetragene CCs tauchten manchmal erst nach einem Neuladen auf. Die
Antworten der Schnittstelle trugen keinen Cache-Header, und ohne Angabe darf
ein Browser selbst entscheiden, wie lange er eine GET-Antwort aufhebt - dann
steht nach dem Anlegen die alte Liste auf dem Schirm. Sie tragen jetzt
no-store, was nebenbei verhindert, dass Spielerdaten im Cache liegen bleiben.

Ausserdem: die Pfeile zwischen den Kettengliedern sind groesser und tragen
die Akzentfarbe - sie sind das Einzige, was die Leserichtung verraet. Und
"Zurueknehmen" heisst jetzt "Loeschen", weil genau das passiert.
2026-09-21 13:35:48 +02:00
Harlekin 6094e4eaef Zeitplan in der Oberflaeche einstellbar
Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
2026-09-21 13:25:56 +02:00
Harlekin db37d3388a Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle
Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
2026-09-21 13:18:11 +02:00
Harlekin 029d380abf Einzelne Gruppenmitglieder, Austritt per Redesign, Ladebalken beim Abgleich
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich
nur die obere bedienen. Jetzt beide:

Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt
einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein
Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an
seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der
Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign
der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben.

Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei
einem Trio kann einer im Rollenspiel sterben, waehrend die anderen
weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und
Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt
denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen
Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer
ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es
nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt
davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet
neu.

Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen,
fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt
dieselbe Anzeige.

Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt
starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt
jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
2026-09-21 12:50:40 +02:00
Harlekin dbde5b7d11 Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch
Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign
tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt
galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein
redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend
sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket.

Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer
Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist
die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung
im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung
ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der
Spielerseite.

Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei
Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal
nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der
Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der
Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit
ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann
zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung
warten und bei wem.

Ausserdem:

- Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den
  Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen.
- Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten.
- Die Hinweisbox ueber den Antraegen beim Spieler faellt weg.
- Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem
  Tabellenlayout gefallen, daher die versetzte Trennlinie.
- Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier"
  nach UTC und im Sommer zwei Stunden daneben.
2026-09-21 12:35:36 +02:00
Harlekin 293d882cb4 Antraege durchsuchbar, Fortschritt sichtbar, Spieler an einem Stueck
Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die
erste Seite - danach steht der Umfang fest, und der Balken zeigt einen
ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer
Seite mit.

Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten
oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten
Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten
fuenfzig zu zeigen und den Rest zu verschweigen.

Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller
zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf,
waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt
den Spieler mit bereits eingetragenem Forum-Namen an.

Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei
Seiten zu wechseln:

- Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder
  als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben
  Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen.
- Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene
  Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen.
  Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt,
  auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein
  Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren.

Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide
Seiten sie brauchen.
2026-09-21 00:49:38 +02:00
Harlekin e65fdfc0ad Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle
Vier Punkte aus dem Betrieb.

Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie
angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt
reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch
einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde
schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer
Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet.

Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID
ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit
unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt
hat. Jetzt werden alle Kennungen des Spielers abgefragt.

Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben.
Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade
nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden,
welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv
gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt
weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt
am Beitritt und bleibt nachvollziehbar.

Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne
auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben
- koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit
Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist
folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die
Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen.

Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim
Test.
2026-09-21 00:19:48 +02:00
Harlekin 15d683c59e Antraege vollstaendig erfassen und zum Forum verlinken
Threads ohne zuordenbaren Spieler wurden bisher verworfen. Von 502 Antraegen
im Forum kamen dadurch nur die wenigen an, deren Ersteller schon als Spieler
mit Forum-Namen erfasst war - der Rest war schlicht unsichtbar. Sie werden
jetzt erfasst und stehen unter "Ohne zugeordneten Spieler", durchsuchbar nach
Titel und Ersteller, aber ausserhalb der Arbeitsliste: ohne Spieler laesst
sich nichts buchen.

Wird spaeter ein Spieler mit passendem Forum-Namen angelegt, der Name
nachgetragen oder ein frueheres Konto ergaenzt, wandern seine wartenden
Antraege von selbst hinueber. Ein erneuter Abgleich ist dafuer nicht noetig,
und eine bereits getroffene Zuordnung bleibt unberuehrt.

Der Name eines CCs fuehrt auf der Spielerseite jetzt zum Forumsbeitrag, sofern
einer verknuepft ist.

Weitere Korrekturen am Abgleich:

- Wer sein Forumskonto geloescht hat, stand ohne Profil-Link in der Zeile und
  hatte damit keinen Autor. Solche Antraege liessen sich niemandem zuordnen.
- Bei einer abgewiesenen Anfrage brach das ganze Board ab; die bis dahin
  gelesenen Seiten gingen mit verloren. Jetzt wird die genannte Wartezeit
  eingehalten, das Tempo fuer den Rest des Laufs gedrosselt und die Seite
  erneut versucht. Was gelesen wurde, bleibt in jedem Fall erhalten.
- Derselbe Schutz im GExtension-Abgleich, der sonst den Spieler uebersprungen
  haette.
- Der Board-Filter fragte nach einem Feld "board", das es nicht gibt.

Ein voller Lauf dauert rund eine halbe Minute und laenger, wenn das Forum
bremst. Er laeuft deshalb im Hintergrund weiter und meldet seinen Fortschritt,
statt an einer HTTP-Zeitgrenze zu sterben. Gelesen und geschrieben wird
gebuendelt statt Thread fuer Thread.
2026-09-21 00:06:33 +02:00
Harlekin e78a57e35e Tote CCs, Entzug und Gruppen-CCs
Bisher entschied allein das Redesign darueber, ob eine Rueckkehr etwas kostet.
Es fehlten zwei Faelle: ein Entzug ohne Anspruch auf kostenlose Rueckkehr, und
der Tod im Rollenspiel. Ein toter CC laesst sich weder gratis noch gegen
Guthaben auf dem normalen Weg zurueckholen - das entscheidet ein Administrator
ueber einen eigenen Weg, mit Pflicht-Begruendung, und waehlt dabei, ob ein
Guthaben fliesst. Schon das Eintragen des Todes verlangt eine Notiz.

Dazu koennen sich mehrere Spieler einen CC teilen. Die Zugehoerigkeit laeuft
ab jetzt durchgehend ueber eine eigene Tabelle, auch bei Einzel-CCs; die
Migration traegt bestehende CCs als erstes Mitglied ihres Erstellers nach.
Ohne das waere nach dem Update kein CC mehr beim Spieler aufgetaucht.

Fuer jeden Mitnutzer gelten dieselben Regeln wie fuer einen eigenen CC: der
Platz kostet ein eigenes Guthaben, wahlweise aus dem CC-Paket oder ueber ein
Redesign. Ein Austritt gibt nichts zurueck, denn bis dahin wurde der CC
genutzt; fuer eine Fehleingabe ist das Storno der richtige Weg. Der letzte
Nutzer laesst sich nicht austragen - dann wird der CC deaktiviert. Ein
Redesign nimmt die Gruppe mit.
2026-09-20 23:03:22 +02:00
Harlekin 3a55ef1b2f tja 2026-09-18 21:32:58 +02:00
Harlekin 6c50d32d2b Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten
Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der
Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen
Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war
damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam.

Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen
Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import,
GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz
zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil
dabei Kulanz entstehen kann.

Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen.
Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung
nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht
wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
2026-09-18 20:07:56 +02:00
Harlekin 3344592c6e Maskottchen als Symbol und Credits
harle.png steht jetzt gross auf der Anmeldeseite und klein in der Fusszeile,
dazu die Zeile "Created by Harlekin | CC-Head".

Aus der Vorlage entstehen drei Groessen: das volle Bild fuer die Anzeige und
ein Ausschnitt des Kopfes fuer Favicon und Startsymbol - ganzfigurig waere die
Figur bei 32 Pixeln nicht mehr zu erkennen.

Dabei fiel auf, dass die Cache-Regel zu grob war: sie gab allem ausser HTML
ein Jahr immutable, also auch Bildern. Deren Name aendert sich beim Austausch
aber nicht, ein neues Bild waere ein Jahr lang nicht angekommen. Jetzt gilt
das lange Ablaufdatum nur fuer die gebauten Assets, die einen Inhalts-Hash im
Namen tragen; alles andere haelt einen Tag und fragt dann nach.
2026-09-18 19:44:29 +02:00
Harlekin bebb89c10e Antraege eingrenzen, Reaktivierung und Bestaetigungen
Sechs Ruecklaeufer aus dem ersten Praxistest:

Das Archiv reicht ueber 25 Seiten und enthaelt vieles, was kein CC-Antrag ist.
Aufgenommen werden jetzt nur Threads von Spielern, die im CC-Manager mit
passendem Forum-Namen erfasst sind. Angepinnte Ankuendigungen tragen die
Labels "Wichtig" oder "Information" und fallen darueber raus.

Der Titel wird bewusst nirgends ausgewertet - weder zum Erkennen noch zum
Aussortieren. Antraege heissen mal "CC Antrag | Name", mal "The Duke Rang
Update"; daran ist nichts abzulesen.

Eine Reaktivierung laeuft ebenfalls ueber einen Forums-Antrag und fehlte als
Entscheidung. Sie ist jetzt waehlbar und kostet nichts, solange der CC nicht
redesignt wurde - die bestehende Regel entscheidet das, nicht der Dialog.

Der Titel in der Liste verlinkt jetzt auf den Thread im Forum. Die Labels
stehen als Kontext daneben, ohne die Entscheidung vorwegzunehmen.

Wer einen Antrag von Hand eintraegt, kann beim Anlegen oder Redesignen den
Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht nicht
erneut als offen auf.

Manuelle Guthaben-Buchungen fragen vorher nach: bisher buchte ein einzelner
Klick sofort, ohne zu sagen, was genau passiert. Der Dialog nennt die Wirkung
und nimmt eine Notiz entgegen.
2026-09-18 18:06:37 +02:00
Harlekin 751f8bfe8e Schema-Aenderungen als eigene Migration nachziehen
Die erste Migration wurde nachtraeglich mehrfach erweitert, statt Aenderungen
anzuhaengen. Auf einer bestehenden Installation gilt sie als angewendet und
wird uebersprungen - die seither hinzugekommenen Tabellen fehlten dort also
schlicht, ohne dass beim Start etwas auffiel. Die Antragsseite lief dadurch
in einen internen Fehler.

Die neue Migration ergaenzt alles Fehlende und ist durchgehend mit
IF NOT EXISTS geschrieben, weil bestehende Datenbanken je nach Zeitpunkt der
Einrichtung unterschiedliche Staende haben. Geprueft gegen eine frische
Datenbank, gegen eine auf altem Stand und auf mehrfaches Anwenden.

Der Testharness wendet jetzt alle Migrationen der Reihe nach an statt nur der
ersten - sonst faellt so etwas beim naechsten Mal wieder nicht auf.

Ausserdem scheiterte das Buchen komplett, wenn eine Bestellung zwischenzeitlich
ueber einen anderen Import gebucht worden war: die eindeutige Bestell-ID kippte
die gesamte Transaktion. Solche Zeilen werden jetzt uebersprungen und gezaehlt,
die uebrigen laufen durch.

Dazu fehlten in der Oberflaeche die Wege zum Bearbeiten und Loeschen eines
Spielers, obwohl es die Endpunkte gab. Beides ist jetzt auf der Spielerseite
erreichbar; das Loeschen verlangt den Namen als Bestaetigung und benennt
vorher, was mit verschwindet.
2026-09-18 17:43:25 +02:00
Harlekin 664a0fd5ee Forums-Anträge erfassen und von Hand entscheiden
Der GExtension-Abgleich liefert die Käufe, der Verbrauch steht im Forum. Die
App liest jetzt die drei CC-Boards und sammelt die Anträge.

Gelesen wird nur die Board-Übersicht: sie führt bereits den Ersteller, nicht
nur den letzten Antwortenden. Damit bleibt ein Lauf bei wenigen Anfragen statt
einer je Thread. Unterforen und globale Kästen tragen ebenfalls Thread-Links,
deshalb liest der Parser strikt entlang der Listeneinträge - sonst landen
Changelogs und Bewerbungen in der Antragsliste.

Klassifiziert wird ausschließlich von Hand. Weder Titel noch Board sind
verlässlich genug: Threads werden nicht immer verschoben, ein genehmigter
Antrag kann weiter im Antrags-Board liegen. Das Board steht deshalb nur als
Hinweis an der Zeile.

Angenommene Anträge rufen dieselben Aktionen wie die Knöpfe auf der
Spielerseite, es gibt keinen zweiten Buchungsweg mit eigenen Regeln. Wandert
ein bereits entschiedener Thread später in ein anderes Board, erscheint das
als Hinweis zum Abhaken - der CC-Status ändert sich nie von allein.

Die Thread-ID bleibt beim Verschieben stabil und traegt deshalb die
Wiedererkennung; die Board-Historie wird mitgeschrieben. Zurückgelassene
Verweise erkennt der Parser und zählt sie nicht als eigenen Antrag.
2026-09-18 17:32:58 +02:00
Harlekin 6b49b34eef Offene Vorschauen sichtbar machen und doppelt aufnehmen verhindern
Der Abgleich bucht bewusst nichts, sondern legt eine Vorschau zur Bestaetigung
an. Nur fuehrte von dort kein Weg weiter: die Import-Seite zeigte allein den
Datei-Upload, und die Rueckmeldung sagte lediglich "abgeglichen". Die
gefundenen Pakete lagen damit unsichtbar herum und der Knopf wirkte, als tue
er nichts.

Die Import-Seite listet jetzt alle Importe, die noch auf Bestaetigung warten,
und oeffnet sie in derselben Vorschau wie einen frischen Upload. Die Meldung
nach dem Abgleich nennt die Zahl der Funde und den naechsten Schritt.

Ausserdem beruecksichtigt der Abgleich jetzt offene Vorschauen: bisher pruefte
er nur gegen bereits gebuchte Pakete. Ein zweiter Lauf legte dieselben Zeilen
deshalb ein weiteres Mal an, und beim Buchen kollidierten beide Importe an der
eindeutigen Bestell-ID - der zweite schlug komplett fehl.
2026-09-18 17:12:06 +02:00
Harlekin 724f68644b Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
2026-09-18 16:57:39 +02:00
Harlekin b9364151b0 index.html wurde ein Jahr lang zwischengespeichert
Die maxAge-Option von fastify-static setzt den Cache-Header fuer jede Datei,
auch fuer die index.html. Der setHeaders-Callback, der das fuer HTML wieder
aufheben sollte, kam dagegen nicht an: ausgeliefert wurde durchgehend
public, max-age=31536000.

Damit bekam ein Browser nach einem Update weiter die alte Oberflaeche - ein
Jahr lang, ohne Fehlermeldung, obwohl der Server laengst neue Dateien hatte.
Genau so ist der Abgleich-Knopf nach dem Deploy unsichtbar geblieben.

Jetzt setzt die App beide Faelle selbst: HTML immer neu holen, Assets
unbegrenzt behalten. Deren Dateinamen enthalten einen Inhalts-Hash, sie
aendern sich bei neuem Inhalt ohnehin.

Drei Tests pruefen die Header, weil ein Fehler an dieser Stelle nirgends
auffaellt - er sieht aus, als waere das Update nicht angekommen.
2026-09-18 16:31:08 +02:00
Harlekin f19e596ee4 Proxy-Netzwerk in die compose.yaml aufnehmen
Der app-Service haengt jetzt zusaetzlich im Netzwerk des Reverse Proxy, damit
dieser ihn erreicht. Das stand bisher nur in der lokalen Fassung auf dem
Server und musste nach jedem Pull von Hand nachgezogen werden.

Der Name ist ueber PROXY_NETWORK einstellbar und faellt auf nginx_default
zurueck, wie es Nginx Proxy Manager anlegt. Wer ohne Proxy in Docker
arbeitet, entfernt den Block - der Kommentar sagt, was dann noetig ist.

Dazu ein Hinweis auf eine unauffaellige Falle: Docker Compose deutet ein $ im
Wert als Variable und ersetzt es durch nichts. Ein Passwort mit $ kommt
dadurch gekuerzt an, ohne dass etwas fehlschlaegt.
2026-09-18 16:17:25 +02:00
Harlekin 7876e29b90 Abgleich in der README beschreiben und Adresse konfigurierbar machen
Der Abgleich liest GExtension-Profile und ist auf deren Seitenaufbau gebaut.
Damit niemand in einer anderen Community lange sucht, warum nichts passiert,
steht das jetzt als Warnhinweis am Anfang des Abschnitts: bei einer anderen
GExtension-Instanz reicht GEXTENSION_BASE_URL, bei einem voellig anderen
Shop-System muesste der Parser angepasst werden. Der Rest der App laeuft
unabhaengig davon.

Die Adresse war bisher fest im Code verdrahtet und kommt jetzt aus der
Umgebung. Der Parser selbst bleibt frei von Konfiguration und Datenbank,
damit er ohne beides testbar ist - die Adresse reicht der Service durch.
2026-09-18 15:48:21 +02:00
Harlekin c242e73c38 GExtension-Abgleich mit Handpruefung fuer unklare Pakete
Die Profilseiten sind ohne Login abrufbar und betten die Kaeufe als JSON ins
HTML ein. Gelesen wird dieses JSON statt der gerenderten Tabelle: das ist
unempfindlich gegen Layout-Aenderungen und liefert zusaetzlich Kaufdatum,
Betrag und den Storno-Status. Ein Headless-Browser ist nicht noetig.

Der Abgleich bucht nichts von sich aus. Eindeutige Treffer landen als
regulaerer Import in der bestehenden Vorschau, die per package_bought_id
dedupliziert. Titel, die nach CC klingen, sich aber keiner Guthaben-Art
sicher zuordnen lassen, gehen in eine Handpruefung statt geraten zu werden -
sichtbar als Hinweis auf der Uebersicht und als Box beim Spieler.

Beachtet werden dabei: stornierte Kaeufe werden uebersprungen, der Web-
Bereich enthaelt nie CC-Pakete, und ein Redesign-Titel zaehlt als Redesign,
auch wenn Custom Character darin vorkommt. Letzteres war auch in den
Standard-Mappings des Datei-Imports falsch herum einsortiert.

Der Lauf geht bewusst nur ueber bereits erfasste Spieler und haelt eine
Sekunde Abstand zwischen den Anfragen, da die Seite fremde Infrastruktur ist.

Voraussetzung dafuer ist, dass SteamIDs auf eine Schreibweise gebracht
werden: die Legacy-Form, die Steam3-Form und die 64-Bit-Zahl bezeichnen
dieselbe Person, wurden bisher aber als verschiedene Spieler gefuehrt.
Gespeichert wird jetzt immer die 64-Bit-Form, Eingabe und Suche akzeptieren
alle drei.
2026-09-18 15:42:31 +02:00
Harlekin bc86e2df26 Datenminimierung: Rohdaten, Protokoll und Loeschung
Drei Befunde aus einer Durchsicht der gespeicherten personenbezogenen Daten:

Der Import legte bisher jede Spalte der hochgeladenen Datei dauerhaft ab,
auch Mailadressen oder Rechnungsdaten, die nie zugeordnet wurden. Nach dem
Buchen oder Verwerfen bleiben jetzt nur noch die gemappten Spalten stehen.

Ein fehlgeschlagener Login protokollierte den eingetippten Benutzernamen.
Wer sein Passwort ins Namensfeld tippt, hatte es damit im Klartext im
Audit-Log. Der Name wird nur noch gespeichert, wenn das Konto existiert.

Das Loeschen eines Spielers liess Spuren in den Import-Zeilen und in den
Details frueherer Audit-Eintraege zurueck. Beides wird jetzt mit abgeraeumt;
die Vorgaenge bleiben als Eintrag bestehen, nur ohne Inhalt.

Dazu eine Aufbewahrungsfrist fuer das Audit-Log (Standard 12 Monate, ueber
AUDIT_RETENTION_DAYS einstellbar). Die Guthaben-Historie ist davon nicht
betroffen - sie bleibt die Quelle der Wahrheit fuer bezahlte Ansprueche.
2026-09-18 15:22:02 +02:00
Harlekin f775b0a594 Reaktivierung haengt nur noch am Redesign, nicht am Grund
Ein CC, der nicht redesignt wurde, existiert unveraendert weiter und kommt
deshalb immer gratis zurueck - egal ob der Spieler inaktiv war oder die
Einheit voruebergehend aus dem Spiel genommen wurde. Bisher kostete der Fall
OTHER ein Guthaben, was die Regel unnoetig verkompliziert hat.

Damit der zweite Fall brauchbar wird, haelt inactiveNote den Grund im Klartext
fest ("Einheit bis Maerz entfernt"); die Notiz steht an der CC-Karte und wird
beim Reaktivieren wieder geleert.
2026-09-18 14:09:59 +02:00
Harlekin 3ec0ff4df1 Container-Setup, Backups und Dokumentation
Mehrstufiges Image: gebaut wird mit allen Abhaengigkeiten, ausgeliefert wird
nur die Laufzeit. Migrationen laufen beim Containerstart. Der App-Port wird
bewusst nicht nach aussen gemappt - davor gehoert ein Reverse Proxy, der HTTPS
terminiert.

Der Backup-Service schreibt taeglich einen pg_dump und raeumt Dumps aelter als
14 Tage weg.
2026-09-18 11:55:06 +02:00
Harlekin b988a82765 Integrationstests gegen eine Postgres-Instanz im Speicher
Die Tests starten Postgres per PGlite und sprechen es ueber einen lokalen Port
an, laufen also ohne Docker und ohne installierte Datenbank.

Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierung,
Stornos und Ruecknahmen, Anmeldung mit CSRF- und Rollenpruefung sowie der
Import mit Dedup.
2026-09-18 11:37:43 +02:00
Harlekin 245666d363 Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler,
rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign
gerade moeglich ist.

Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an -
gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an
erster Stelle, damit er zuerst aufgebraucht wird.

Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach
Bestaetigung.
2026-09-18 11:37:43 +02:00
Harlekin a7228e63d1 HTTP-Endpunkte und Anwendungsaufbau
Eingaben werden mit zod validiert, jede aendernde Aktion schreibt ins
Audit-Log. Beim ersten Start entsteht ein Administrator samt Startwerten fuer
die Produkterkennung; solange ein Erstpasswort gilt, bleiben schreibende Wege
bis auf den Passwortwechsel gesperrt.

Nicht-API-Pfade fallen auf die SPA zurueck, damit Deep-Links funktionieren.
2026-09-18 11:37:31 +02:00
Harlekin f3e31cdd17 Fachlogik fuer Guthaben, CC-Lebenszyklus und Import
Die beiden zentralen Regeln stehen jeweils an genau einer Stelle:

Ein CC-Paket erzeugt immer CC +1 und REDESIGN_PAID +1. Die Zeilen dafuer baut
buildPackageRows, das sowohl die manuelle Eingabe als auch der Import nutzen -
so koennen beide Wege nicht auseinanderlaufen.

Ein CC kommt nur dann gratis zurueck, wenn er wegen Spieler-Inaktivitaet
entfernt wurde. Ein reworkter CC existiert im Original nicht mehr und kostet
deshalb ein Redesign- oder CC-Guthaben.

Ueberzogene Toepfe werden zugelassen statt blockiert; die Aktion meldet das
ueber ein overdrawn-Flag zurueck. Storniert wird per Gegenbuchung, nie durch
Loeschen. Der Import berechnet den Plan vorab und dedupliziert ueber die
Bestell-ID, sowohl gegen bereits gebuchte als auch innerhalb einer Datei.
2026-09-18 11:37:31 +02:00
Harlekin 3d2b87297e Authentifizierung mit serverseitigen Sitzungen, CSRF und Rollen
Passwoerter mit Argon2id, im Cookie steht nur ein Token und in der Datenbank
dessen Hash. Das CSRF-Token wird per HMAC aus dem Sitzungstoken abgeleitet
statt separat gespeichert - ohne das httpOnly-Cookie ist es nicht herstellbar.

TOTP ist optional und wird erst nach einem bestaetigten Code scharf geschaltet,
damit sich niemand bei der Einrichtung aussperrt.
2026-09-18 11:33:25 +02:00
Harlekin a1b764155a Datenmodell fuer Spieler, Guthaben-Ledger und CCs
Guthaben werden als Kontobuch gefuehrt: jede Bewegung ist eine Zeile, der
Stand eines Topfes ist deren Summe. Zusammengehoerige Zeilen teilen sich eine
groupId, damit ein CC-Paket als Ganzes storniert werden kann; ein unique
Constraint auf reversalOfId verhindert doppelte Stornos.

CCs bilden ueber lineageId und predecessorId eine Redesign-Kette. Der Grund
einer Deaktivierung entscheidet spaeter ueber die Reaktivierungskosten.
2026-09-18 11:33:25 +02:00
Harlekin d7e7a3b186 Projektgeruest mit Workspaces fuer Server und Weboberflaeche
Fastify mit Prisma im Server-Workspace, React mit Vite im Web-Workspace.
Das Frontend baut direkt nach server/public, damit am Ende ein einzelnes
Image API und Oberflaeche ausliefert.
2026-09-18 11:31:13 +02:00
117 changed files with 32495 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
**/node_modules
**/dist
server/public
.git
.gitignore
*.md
.env
.env.*
!.env.example
**/*.log
coverage
.idea
.vscode
+54
View File
@@ -0,0 +1,54 @@
# Kopie als .env neben die compose.yaml legen und Werte ersetzen.
# 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
# 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 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
# Nur abschalten, wenn ohne HTTPS getestet wird.
# COOKIE_SECURE=true
# LOG_LEVEL=info
+40
View File
@@ -0,0 +1,40 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
*.tsbuildinfo
.vite/
# Frontend-Bundle, wird beim Build erzeugt
server/public/
# Environment
.env
.env.local
.env.*.local
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime data
uploads/
tmp/
coverage/
# Editor / OS
.DS_Store
Thumbs.db
.idea/
*.swp
*~
# Local notes
notes/
+54
View File
@@ -0,0 +1,54 @@
# --- Build ---
FROM node:22-slim AS build
# 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/*
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm ci
COPY server/prisma ./server/prisma
RUN npx prisma generate --schema server/prisma/schema.prisma
COPY server ./server
COPY web ./web
# Das Frontend landet laut vite.config.ts direkt in server/public.
RUN npm run build --workspace=web \
&& npx tsc -p server/tsconfig.json
# --- Laufzeit ---
FROM node:22-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY web/package.json ./web/
RUN npm ci --omit=dev && npm cache clean --force
COPY server/prisma ./server/prisma
RUN npx prisma generate --schema server/prisma/schema.prisma
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/server/public ./server/public
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh && chown -R node:node /app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server/dist/index.js"]
+628 -1
View File
@@ -1,3 +1,630 @@
# CC-Manager
For managing Custom Characters on a Server
Selbst gehostete Web-App zur Verwaltung von Custom Characters (CCs) auf einem SCP-RP-Server.
Sie führt pro Spieler ein Konto über gekaufte Ansprüche und deren Verbrauch, damit die Frage
„darf der gerade einen neuen CC?" nicht mehr von Hand abgeglichen werden muss.
## Das Prinzip
Jeder Spieler hat drei Guthaben-Töpfe:
| Topf | Wird gefüllt durch | Wird verbraucht durch |
|------|--------------------|-----------------------|
| CC-Guthaben | jedes CC-Paket (+1) | ein neuer CC |
| Redesign-Guthaben (bezahlt) | jedes CC-Paket (+1, immer inklusive) und jedes Redesign-Paket (+1) | ein Redesign |
| Kulanz-Redesign | manuell eingetragene Kulanz (+1) | ein Redesign |
Zwei Regeln sind fest verdrahtet:
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
in derselben Transaktion und mit gemeinsamer Gruppen-ID - ein Storno nimmt beide zurück.
2. **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
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
Ein Redesign legt einen **neuen** CC-Datensatz an und verkettet ihn mit dem alten. So bleibt die
Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig: Will man später
A zurück, ist A ein redesignter CC und kostet entsprechend.
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie. Vier Gründe stehen
zur Wahl:
| Grund | Rückkehr |
|---|---|
| Spieler-Inaktivität | gratis |
| Anderer Grund (z. B. Einheit zeitweise entfernt) | gratis |
| Entzogen | kostet ein Guthaben |
| Im Rollenspiel gestorben | nur durch einen Administrator, mit Begründung |
## Ketten: ein Platz, viele Redesigns
Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte: erster CC, Redesign,
noch ein Redesign. Auf der Spielerseite steht je Platz eine Spalte, **von unten nach oben**
gelesen - unten der erste CC, darüber seine Redesigns, die Pfeile zeigen in Richtung des
jüngsten Eintrags.
Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später auf, weil sein Antrag in
einem anderen Board lag oder weil der Spieler es für den Beitritt zu einem Gruppen-CC benutzt
hat, dessen Antrag jemand anders geschrieben hat. Dann steht in der Kette schon „Redesign 3 →
Redesign 5", und Redesign 4 fehlt.
**Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen.** Beim Ziehen erscheinen
zwischen den Einträgen Ablagestellen - auch mitten in einer Kette und in jeder anderen Kette
desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo er hingehört.
*Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in
diese Kette gehörte.
**Die Stelle in der Kette bestimmt, was ein Eintrag kostet**, und die Rechnung ist schlicht:
> So viele **CC-Pakete** wie Ketten. So viele **Redesigns** wie Einträge minus Ketten.
Drei Ketten mit zusammen sechzehn Einträgen sind also drei Pakete und dreizehn Redesigns. Anders
gesagt: Wer keinen Vorgänger hat, ist der Anfang einer Kette und damit ein gekaufter CC; wer
einen hat, ist dessen Redesign. In welcher Linie ein Eintrag geführt wird, spielt dabei keine
Rolle - die Kette ist die Kette. Wandert ein Eintrag, wandert seine Bedeutung mit: Ein eigenständiger CC, den
du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthaben kommt zurück.
Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und
kostet wieder ein CC-Paket.
Daraus folgt auch der Fall mit dem fremden Gruppen-CC: Wer aus einem herausredesignt, dessen
eigener Strang hat einen Vorgänger - den Gruppen-CC. Also ist sein erstes Glied kein Kauf,
sondern ein Redesign. Bezahlt wurde der Platz beim Beitritt, und zwar einmal.
Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt
sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an.
Zwischen bezahltem Redesign und Kulanz wird dabei **nicht** gewechselt: Das ist eine
Entscheidung, keine Folge der Reihenfolge, und steht unter *Bezahlart*. Einträge ganz ohne
Buchung bleiben unangetastet.
Eines folgt daraus von selbst: **Wer einen Nachfolger hat, wurde durch ihn ersetzt.** Anders ist
eine Kette nicht zu lesen. Deshalb trägt jedes Glied außer dem obersten den Vermerk *Redesignt*,
und wer ans Ende rückt, verliert ihn wieder - er stammte ja von einem Nachfolger, der jetzt
woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadurch wieder aktiv,
muss er gedeckt sein wie jeder andere.
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen -
die Thread-Nummer genügt, die volle Adresse geht auch. Es gibt zwei Ausnahmen von dieser Regel, beide bewusst zu treffen:
- Ein **Redesign aus dem CC-Topf**. Möglich, aber nicht der Regelfall - es verbraucht einen
ganzen Platz statt eines Redesigns. Im Dialog steht das dabei.
- Eine **von Hand geänderte Bezahlart** über den Knopf *Bezahlart*.
Beide werden als bewusste Entscheidung vermerkt, und das Ausrichten an den Ketten lässt sie
stehen. Sonst würde jede Ausnahme beim nächsten Umsortieren wieder eingeebnet.
Stimmt ein Bestand nicht mehr - etwa weil er aus einer Zeit stammt, in der eine Buchung blieb,
wo sie beim Anlegen entstand -, richtet **Buchungen nachrechnen** auf der Spielerseite alles
nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine
Gegenbuchung, und zwischen bezahltem Redesign und Kulanz wird nichts gewechselt.
Über *Bezahlart* wird umgestellt, woraus ein Eintrag
bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; umgebucht wird über
eine Gegenbuchung, in der Historie bleibt beides sichtbar.
Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kette einreiht, stellt
damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es
sein eigener Platz in seiner eigenen Kette. Einreihen darf, wer an beidem beteiligt war.
**Was einem Spieler gehört, steht immer in seinen Ketten** - auch wenn seine Mitgliedschaft
daran beendet wurde. Es bleibt sein CC und seine Geschichte. Unter *Früher genutzt* landen nur
CCs, die jemand anderem gehören und zu keinem seiner Plätze mehr führen.
Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich
der Spieler herausredesignt hat. Er trägt das Kennzeichen *war dabei* samt Zeitraum und
Besitzer, ist aber **genauso bedienbar wie jeder andere Eintrag**: ziehen, bearbeiten,
Bezahlart, Verweise. Beim Nacherfassen steht erst am Ende fest, welches Redesign wohin führte -
da muss sich auch einordnen lassen, was einem nicht gehört. Frühere Mitgliedschaften, die zu
keinem seiner Plätze führen, stehen darunter unter *Früher genutzt*.
### Verweise an einem CC
An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Erstellers, ein Modell,
eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der
Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*.
## Guthaben von Hand eintragen
Auf der Spielerseite stehen drei Paare: **+ CC-Guthaben**, **+ Redesign-Paket**, **+ Kulanz** -
und daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt.
Für den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen.
**Von Hand vergeben heißt nur der Platz.** Ein im Shop gekauftes Paket bringt ein Redesign mit,
und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand einen Platz
bekommt, ohne dafür gekauft zu haben. Wer ein volles Paket nachtragen will, klickt zusätzlich
auf *+ Redesign-Paket*. In der Historie steht *CC-Guthaben (von Hand)*, damit zu sehen ist, dass
kein Kauf dahintersteht.
Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem
CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign.
**Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und
werden dort storniert, wo dieser Zusammenhang sichtbar ist.
Steht der **bezahlte Redesign-Topf im Minus, während Kulanz übrig ist**, erscheint *Minus mit
Kulanz ausgleichen*. Es bucht so viele Übergänge von „bezahlt" auf „Kulanz" um, wie beide Seiten
hergeben - die ältesten zuerst. Unterm Strich ändert sich nichts, nur die Verteilung auf die
Töpfe. Das ist derselbe Vorgang wie ein Dutzend Mal *Bezahlart* zu drücken, nur in einem Zug.
In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest
sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt.
## Gruppen-CCs
Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen
eigenen CC: **der Platz kostet ein eigenes Guthaben** - entweder aus dem CC-Paket oder über ein
Redesign, je nachdem, worüber der Spieler hinzukommt.
### CC-Pakete sind Plätze
Ein CC-Paket ist ein **Platz für einen gleichzeitig aktiven CC**. Zwei Pakete sind zwei Plätze,
und so weiter. Ein Redesign **tauscht** einen CC aus, es schafft keinen zusätzlichen Platz: fünf
Millionen Redesigns auf ein Paket bleiben ein gleichzeitig aktiver CC.
Geprüft wird das an einer Stelle, für alle Wege zugleich - erschaffen, redesignen, reaktivieren,
zurückholen, einem Gruppen-CC beitreten. Jeder aktive CC braucht eine eigene Buchung im CC-Topf.
Wer einen Platz belegen will, der nicht gedeckt ist, bekommt eine Absage mit dem Hinweis, welcher
CC dafür weichen müsste.
Ein **überzogenes Konto** bleibt davon unberührt: Einen CC einzutragen, bevor das Paket verbucht
ist, geht weiter - die Buchung ist ja da, nur das Guthaben fehlt noch, und das Minus steht
sichtbar auf der Spielerseite. Der Unterschied ist, ob ein Platz gebucht wurde oder aus dem
Nichts entsteht.
Über *Nutzer aufnehmen* auf der Spielerseite kommt jemand dazu; das Guthaben wird von seinem
Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird über das ✕ an seinem
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg.
Welcher Weg hineinführt, macht einen Unterschied:
| Bezahlt über | Was passiert |
| --- | --- |
| **CC-Paket** | Ein Platz kommt hinzu. Bestehende CCs des Spielers bleiben unberührt. |
| **Redesign** (bezahlt oder Kulanz) | Der Platz **ersetzt** einen eigenen CC, genau wie jedes andere Redesign. Welcher aufgegeben wird, muss angegeben werden. |
Das ist dieselbe Regel wie oben, nur an einem Gruppen-CC: über ein Redesign entsteht kein
zusätzlicher Platz. Der aufgegebene CC muss dem Spieler gehören, **aktiv sein muss er nicht**: auch ein
längst stillgelegter CC lässt sich redesignen. Ist er noch aktiv, wird er dabei inaktiv gesetzt
(Grund *Redesignt*); war er es schon nicht mehr, bleibt er, wie er ist. Wer gar keinen eigenen CC
hat, kann nicht über ein Redesign beitreten - dafür braucht es ein CC-Paket.
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
### Nachtragen, was längst passiert ist
Beim Nacherfassen kommt heraus, dass ein Spieler schon vor drei Redesigns bei einem fremden
Gruppen-CC dabei war. Dafür gilt: **An einem stillgelegten CC wird nachgetragen, nicht
beigetreten.** Der Knopf heißt dort *Nutzer nachtragen*, und die Guthaben-Angabe ist
freigestellt - die Buchung kann längst im Konto stehen. Ein Zeitraum lässt sich mitgeben.
Wer weiter unten in derselben Kette schon steht, kommt auch an einem **laufenden** CC ohne neue
Buchung dazu: Sein Platz wurde einmal bezahlt, und ein Redesign tauscht ihn aus, statt einen
zweiten zu verlangen. Genau das tut ein Redesign im laufenden Betrieb auch - es nimmt die Gruppe
mit. Für alle anderen bleibt die Angabe Pflicht.
Damit das nicht vier Klicks für vier Kettenglieder bedeutet, trägt ein Aufruf den Spieler ab
einem Glied in die **ganze weitere Kette** ein. Gebucht wird dabei genau einmal, beim Einstieg.
### Einzelner Nutzer oder ganze Gruppe
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist:
| Knopf | Wirkung |
| --- | --- |
| **Anteil redesignen** | Dieser Spieler steigt aus und bekommt einen eigenen CC. Die Gruppe läuft unberührt weiter. |
| **Gruppe redesignen** | Der ganze CC wandert in einen neuen; alle bisherigen Nutzer sind auch dort dabei. |
| **Nur diesen Nutzer** | Dieser Platz fällt weg - geputzt, entzogen oder im Rollenspiel gestorben. Der CC bleibt bestehen. |
| **Deaktivieren** | Der ganze CC wird inaktiv, für alle. |
Ein Ausstieg per Redesign ist ein Tausch wie jeder andere: Der Platz in der Gruppe geht weg, der
eigene CC kommt an seine Stelle - **es kommt keiner hinzu**. Wohin jemand gewechselt ist, steht
danach bei den früheren Nutzern („jetzt …").
Einzelne Nutzer können **unabhängig voneinander** ausfallen. Bei einem Trio kann einer im
Rollenspiel sterben, während die beiden anderen weiterspielen; ebenso können alle nacheinander
wegfallen. Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC: geputzt kommt
gratis zurück, entzogen kostet ein Guthaben, und über einen Gestorbenen entscheidet ein
Administrator mit Begründung. Wer ausgefallen ist, sieht seinen CC weiterhin auf seiner
Spielerseite - sonst gäbe es nichts, worüber er zurückkehren könnte.
**Ein freiwilliger Austritt ist etwas anderes.** Wer über das ✕ ausgetragen wird, hat keinen
Anspruch auf Rückkehr; ein Wiedereintritt ist ein neuer Beitritt und kostet ein Guthaben.
Ein Redesign nimmt die Gruppe mit: Wer zum Zeitpunkt des Redesigns dabei war, ist es danach auch.
Bezahlt wird es von dem, der es beantragt.
## Schnellstart
Vorausgesetzt wird Docker mit Compose-Plugin (oder DockGe).
```bash
git clone https://git.harlelux.de/Harlekin/CC-Manager.git
cd CC-Manager
cp .env.example .env
```
In der `.env` mindestens `DB_PASSWORD` und `SESSION_SECRET` setzen:
```bash
openssl rand -base64 48 # für SESSION_SECRET
```
Dann starten:
```bash
docker compose up -d --build
docker compose logs -f app
```
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log - dort steht es in der
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
Seiten gesperrt.
Für DockGe: Repo-Inhalt als Stack anlegen, `.env` daneben, fertig. Datenbank-Migrationen laufen
bei jedem Containerstart automatisch mit.
## HTTPS
Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Reverse Proxy
(Nginx Proxy Manager, Traefik oder Caddy), der HTTPS terminiert und intern auf `app:3000`
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
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.
## Backups
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
Ansprüche ab - das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
Wiederherstellen:
```bash
gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
docker compose exec -T db psql -U ccuser -d ccmanager
```
## Import
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
1. Datei hochladen - Spalten und Beispielzeilen werden angezeigt.
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
Spaltennamen werden vorausgewählt.
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
4. Buchen.
Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter *Verwaltung →
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
jederzeit ergänzen, ohne den Code anzufassen.
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut -
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen.
## 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.
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.
## Entwicklung
Gebraucht werden Node 20+ und eine erreichbare PostgreSQL-Instanz.
```bash
npm install
cp server/.env.example server/.env # DATABASE_URL auf die lokale DB zeigen lassen
npm run migrate:dev
npm run dev # API auf :3000, Frontend auf :5173
```
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
funktionieren also wie in Produktion.
Die Tests brauchen **keine** laufende Datenbank - sie starten eine Postgres-Instanz im Speicher:
```bash
npm test
```
Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierungslogik, Korrekturen
und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup.
### Aufbau
```
server/
prisma/schema.prisma Datenmodell und Migrationen
src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import, 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
src/components/ Wiederverwendete Bausteine
```
Backend ist Fastify mit Prisma, Frontend eine React-SPA. Gebaut wird beides in ein einziges
Image: Fastify liefert die API und die fertigen Frontend-Dateien aus.
Nach Änderungen am Datenmodell:
```bash
npm run migrate:dev -- --name 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.
- Sitzungen serverseitig; im Cookie steht nur ein Token, in der Datenbank dessen Hash.
Cookie ist `httpOnly`, `SameSite=Lax` und in Produktion `Secure`.
- CSRF-Schutz für alle ändernden Anfragen über ein aus der Sitzung abgeleitetes Token.
- Alle Eingaben werden validiert, alle Datenbankzugriffe laufen parametrisiert über das ORM.
- Rate-Limit auf dem Login, generische Fehlermeldung ohne Hinweis auf existierende Konten.
- Sicherheits-Header inklusive Content-Security-Policy.
- Ein gesperrtes Konto verliert seine offenen Sitzungen sofort; der letzte aktive Administrator
kann sich nicht selbst entfernen.
## Lizenz
GPL-3.0-or-later, siehe [LICENSE](LICENSE).
+82
View File
@@ -0,0 +1,82 @@
services:
app:
build: .
# Alternativ ein vorgebautes Image verwenden:
# image: ghcr.io/harlekin/cc-manager:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://ccuser:${DB_PASSWORD}@db:5432/ccmanager
SESSION_SECRET: ${SESSION_SECRET}
NODE_ENV: production
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
# 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
restart: unless-stopped
environment:
POSTGRES_USER: ccuser
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ccmanager
volumes:
- cc_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ccuser -d ccmanager"]
interval: 10s
timeout: 5s
retries: 5
backup:
image: postgres:16-alpine
restart: unless-stopped
environment:
PGPASSWORD: ${DB_PASSWORD}
volumes:
- ./backups:/backups
depends_on:
db:
condition: service_healthy
# Dump beim Start und danach alle 24 Stunden, Aufbewahrung 14 Tage.
entrypoint: >
sh -c 'while true; do
pg_dump -h db -U ccuser -d ccmanager | gzip > /backups/ccmanager-$$(date +%F-%H%M).sql.gz &&
echo "Backup geschrieben: $$(date +%F-%H%M)";
find /backups -name "ccmanager-*.sql.gz" -mtime +14 -delete;
sleep 86400;
done'
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:
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Schema aktuell halten, bevor die App startet. Bereits angewandte
# Migrationen werden dabei übersprungen.
echo "Wende Datenbank-Migrationen an ..."
npx prisma migrate deploy --schema server/prisma/schema.prisma
exec "$@"
Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

+5066
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cc-manager",
"version": "1.0.0",
"private": true,
"description": "Tracking von Custom Characters, Paketen und Redesigns",
"license": "GPL-3.0-or-later",
"workspaces": [
"server",
"web"
],
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "concurrently -n api,web -c blue,magenta \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "npm run dev --workspace=server",
"dev:web": "npm run dev --workspace=web",
"build": "npm run build --workspace=web && npm run build --workspace=server",
"start": "npm run start --workspace=server",
"migrate": "npm run migrate --workspace=server",
"migrate:dev": "npm run migrate:dev --workspace=server",
"generate": "npm run generate --workspace=server",
"typecheck": "npm run typecheck --workspace=server && npm run typecheck --workspace=web",
"test": "npm run test --workspace=server"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}
+15
View File
@@ -0,0 +1,15 @@
# 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
SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen
NODE_ENV=development
PORT=3000
# Ohne HTTPS müssen die Cookies unsicher ausgeliefert werden dürfen.
COOKIE_SECURE=false
# Erstadministrator beim ersten Start.
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=EntwicklungPasswort1
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@cc-manager/server",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"build": "prisma generate && tsc -p tsconfig.json",
"start": "node dist/index.js",
"generate": "prisma generate",
"migrate": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"studio": "prisma studio",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.2.2",
"@fastify/static": "^8.1.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/client": "^6.5.0",
"csv-parse": "^5.6.0",
"csv-stringify": "^6.8.3",
"fastify": "^5.2.1",
"otplib": "^13.5.0",
"prisma": "^6.5.0",
"qrcode": "^1.5.4",
"zod": "^3.24.2"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite-socket": "^0.2.11",
"@types/node": "^22.13.10",
"@types/qrcode": "^1.5.5",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
@@ -0,0 +1,379 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- CreateEnum
CREATE TYPE "CreditKind" AS ENUM ('CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ');
-- CreateEnum
CREATE TYPE "CreditSource" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'KULANZ', 'ADJUSTMENT', 'CONSUME', 'REVERSAL');
-- CreateEnum
CREATE TYPE "CharacterStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
-- CreateEnum
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
-- CreateEnum
CREATE TYPE "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');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'EDITOR',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" SERIAL NOT NULL,
"displayName" TEXT NOT NULL,
"steamId" TEXT,
"forumName" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"gextensionUsage" INTEGER,
"gextensionSyncedAt" TIMESTAMP(3),
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UnmatchedPackage" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"playerId" INTEGER,
"steamId" TEXT NOT NULL,
"rawTitle" TEXT NOT NULL,
"rawTag" TEXT NOT NULL,
"amount" TEXT,
"purchasedAt" TIMESTAMP(3),
"scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolved" BOOLEAN NOT NULL DEFAULT false,
"resolvedAs" TEXT,
"resolvedById" INTEGER,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CreditLedger" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"kind" "CreditKind" NOT NULL,
"amount" INTEGER NOT NULL,
"source" "CreditSource" NOT NULL,
"groupId" TEXT NOT NULL,
"characterId" INTEGER,
"importId" INTEGER,
"note" TEXT,
"reversalOfId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CreditLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Character" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"loreText" TEXT,
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
"inactiveReason" "InactiveReason",
"inactiveNote" TEXT,
"lineageId" TEXT NOT NULL,
"predecessorId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Import" (
"id" SERIAL NOT NULL,
"filename" TEXT NOT NULL,
"status" "ImportStatus" NOT NULL DEFAULT 'PENDING',
"rowCount" INTEGER NOT NULL DEFAULT 0,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"mapping" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"committedAt" TIMESTAMP(3),
"importedById" INTEGER,
CONSTRAINT "Import_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportRow" (
"id" SERIAL NOT NULL,
"importId" INTEGER NOT NULL,
"lineNo" INTEGER NOT NULL,
"externalId" TEXT,
"playerKey" TEXT,
"playerName" TEXT,
"productName" TEXT,
"purchasedAt" TIMESTAMP(3),
"target" "ProductTarget",
"resolvedPlayerId" INTEGER,
"plan" TEXT NOT NULL,
"raw" JSONB NOT NULL,
CONSTRAINT "ImportRow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportedOrder" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"importId" INTEGER NOT NULL,
"playerId" INTEGER NOT NULL,
"target" "ProductTarget" NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImportedOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductMapping" (
"id" SERIAL NOT NULL,
"matchPattern" TEXT NOT NULL,
"target" "ProductTarget" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"actorUserId" INTEGER,
"actorName" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"details" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- 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");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
-- CreateIndex
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved");
-- CreateIndex
CREATE INDEX "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId");
-- CreateIndex
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_idx" ON "CreditLedger"("playerId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_kind_idx" ON "CreditLedger"("playerId", "kind");
-- CreateIndex
CREATE INDEX "CreditLedger_groupId_idx" ON "CreditLedger"("groupId");
-- CreateIndex
CREATE INDEX "CreditLedger_createdAt_idx" ON "CreditLedger"("createdAt");
-- CreateIndex
CREATE INDEX "Character_playerId_idx" ON "Character"("playerId");
-- CreateIndex
CREATE INDEX "Character_playerId_status_idx" ON "Character"("playerId", "status");
-- CreateIndex
CREATE INDEX "Character_lineageId_idx" ON "Character"("lineageId");
-- CreateIndex
CREATE INDEX "Import_status_idx" ON "Import"("status");
-- CreateIndex
CREATE INDEX "ImportRow_importId_idx" ON "ImportRow"("importId");
-- CreateIndex
CREATE UNIQUE INDEX "ImportedOrder_externalId_key" ON "ImportedOrder"("externalId");
-- CreateIndex
CREATE INDEX "ImportedOrder_playerId_idx" ON "ImportedOrder"("playerId");
-- CreateIndex
CREATE INDEX "ProductMapping_priority_idx" ON "ProductMapping"("priority");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- 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;
-- AddForeignKey
ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_reversalOfId_fkey" FOREIGN KEY ("reversalOfId") REFERENCES "CreditLedger"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Import" ADD CONSTRAINT "Import_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportRow" ADD CONSTRAINT "ImportRow_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- 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");
+556
View File
@@ -0,0 +1,556 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
EDITOR
VIEWER
}
enum CreditKind {
CC
REDESIGN_PAID
REDESIGN_KULANZ
}
enum CreditSource {
/// 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
}
enum CharacterStatus {
ACTIVE
INACTIVE
}
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 {
CC_PACKAGE
REDESIGN_PACKAGE
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
DISCARDED
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
role Role @default(EDITOR)
totpSecret String?
totpEnabled Boolean @default(false)
isActive Boolean @default(true)
mustChangePassword Boolean @default(false)
/// 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
sessions Session[]
auditEntries AuditLog[]
ledgerEntries CreditLedger[] @relation("LedgerCreatedBy")
charactersCreated Character[] @relation("CharacterCreatedBy")
imports Import[]
}
model Session {
id String @id @default(uuid())
tokenHash String @unique
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
ip String?
userAgent String?
@@index([userId])
@@index([expiresAt])
}
model Player {
id Int @id @default(autoincrement())
displayName String
steamId String? @unique
forumName String?
notes String?
/// 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
/// Ein Fall, der sich gerade nicht sauber klären lässt: ein Antrag ohne
/// auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
/// Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht
/// verloren geht - geklärt wird, wenn es darauf ankommt.
isProblem Boolean @default(false)
problemNote String?
problemSince DateTime?
/// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich,
/// wird nie automatisch mit dem Ledger verrechnet.
gextensionUsage Int?
gextensionSyncedAt DateTime?
ledger CreditLedger[]
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 lässt. Wird nie automatisch gebucht,
/// sondern im CC-Manager von Hand entschieden.
model UnmatchedPackage {
id Int @id @default(autoincrement())
externalId String @unique
playerId Int?
player Player? @relation(fields: [playerId], references: [id], onDelete: Cascade)
steamId String
rawTitle String
rawTag String
amount String?
purchasedAt DateTime?
scrapedAt DateTime @default(now())
resolved Boolean @default(false)
/// CC_PACKAGE | REDESIGN_PACKAGE | REDESIGN_KULANZ | IGNORE
resolvedAs String?
resolvedById Int?
resolvedAt DateTime?
@@index([resolved])
@@index([playerId])
}
/// Kontobuch: jede Guthaben-Bewegung ist eine Zeile (+1 Zugang, -1 Verbrauch).
/// Der offene Stand eines Topfes ist die Summe der amounts je kind.
model CreditLedger {
id Int @id @default(autoincrement())
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
playerId Int
kind CreditKind
amount Int
source CreditSource
/// Klammert zusammengehoerige Zeilen: ein CC-Paket schreibt zwei Zeilen
/// mit derselben groupId, damit ein Storno beide zurückholt.
groupId String @default(uuid())
characterId Int?
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
importId Int?
import Import? @relation(fields: [importId], references: [id], onDelete: SetNull)
note String?
/// Storno erfolgt als Gegenbuchung, nicht als 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)
reversedBy CreditLedger? @relation("LedgerReversal")
createdById Int?
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 {
id Int @id @default(autoincrement())
playerId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
name String
loreText String?
status CharacterStatus @default(ACTIVE)
inactiveReason InactiveReason?
/// 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)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([playerId])
@@index([playerId, status])
@@index([lineageId])
@@index([lineageId, chainPosition])
}
model Import {
id Int @id @default(autoincrement())
filename String
status ImportStatus @default(PENDING)
rowCount Int @default(0)
bookedCount Int @default(0)
mapping Json?
createdAt DateTime @default(now())
committedAt DateTime?
importedById Int?
importedBy User? @relation(fields: [importedById], references: [id], onDelete: SetNull)
rows ImportRow[]
orders ImportedOrder[]
ledgerEntries CreditLedger[]
@@index([status])
}
model ImportRow {
id Int @id @default(autoincrement())
importId Int
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
lineNo Int
externalId String?
playerKey String?
playerName String?
productName String?
purchasedAt DateTime?
target ProductTarget?
resolvedPlayerId Int?
/// Was beim Buchen passieren würde: BOOK, CREATE_PLAYER_AND_BOOK,
/// SKIP_DUPLICATE, SKIP_IGNORED, SKIP_UNMAPPED, SKIP_NO_PLAYER
plan String
raw Json
@@index([importId])
}
/// Dedup-Anker: eine bereits gebuchte Bestell-ID wird nie ein zweites Mal gebucht.
model ImportedOrder {
id Int @id @default(autoincrement())
externalId String @unique
importId Int
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
playerId Int
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
target ProductTarget
groupId String
createdAt DateTime @default(now())
@@index([playerId])
}
model ProductMapping {
id Int @id @default(autoincrement())
matchPattern String
target ProductTarget
priority Int @default(100)
isActive Boolean @default(true)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([priority])
}
model AuditLog {
id Int @id @default(autoincrement())
actorUserId Int?
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
/// Name wird mitgeschrieben, damit der Eintrag lesbar bleibt, wenn der Account weg ist.
actorName String?
action String
targetType String
targetId String?
details Json?
ip String?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([targetType, targetId])
@@index([actorUserId])
}
/// 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
}
+182
View File
@@ -0,0 +1,182 @@
import { existsSync } from 'node:fs';
import { dirname, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance } from 'fastify';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import { Prisma } from '@prisma/client';
import { env } from './env.js';
import { AppError } from './errors.js';
import { attachUser, blockUntilPasswordChanged, enforceCsrf } from './auth/guard.js';
import { authRoutes } from './routes/auth.routes.js';
import { playerRoutes } from './routes/players.routes.js';
import { creditRoutes } from './routes/credits.routes.js';
import { characterRoutes } from './routes/characters.routes.js';
import { importRoutes } from './routes/imports.routes.js';
import { adminRoutes } from './routes/admin.routes.js';
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.
const webRoot = join(here, '..', 'public');
function readStatusCode(error: unknown): number | undefined {
const value = (error as { statusCode?: unknown }).statusCode;
return typeof value === 'number' ? value : undefined;
}
function readMessage(error: unknown): string | undefined {
const value = (error as { message?: unknown }).message;
return typeof value === 'string' ? value : undefined;
}
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
redact: ['req.headers.cookie', 'req.headers.authorization'],
},
trustProxy: env.TRUST_PROXY,
bodyLimit: 2 * 1024 * 1024,
});
await app.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
// Der QR-Code der 2FA-Einrichtung kommt als data-URL.
imgSrc: ["'self'", 'data:'],
styleSrc: ["'self'", "'unsafe-inline'"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: env.isProduction ? { maxAge: 31536000, includeSubDomains: true } : false,
});
await app.register(cookie, { secret: env.SESSION_SECRET });
await app.register(rateLimit, {
global: true,
max: 300,
timeWindow: '1 minute',
allowList: () => !env.isProduction,
});
await app.register(multipart, {
limits: { fileSize: 10 * 1024 * 1024, files: 1 },
});
app.addHook('onRequest', attachUser);
app.addHook('onRequest', enforceCsrf);
app.addHook('preHandler', blockUntilPasswordChanged);
/**
* 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 });
return;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
reply.code(409).send({ error: 'UNIQUE_VIOLATION', message: 'Dieser Wert ist bereits vergeben' });
return;
}
if (error.code === 'P2025') {
reply.code(404).send({ error: 'NOT_FOUND', message: 'Datensatz nicht gefunden' });
return;
}
}
const statusCode = readStatusCode(error);
if (statusCode === 429) {
reply.code(429).send({ error: 'RATE_LIMITED', message: 'Zu viele Anfragen, bitte kurz warten' });
return;
}
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
reply
.code(statusCode)
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' });
return;
}
request.log.error({ err: error }, 'Unbehandelter Fehler');
reply.code(500).send({ error: 'INTERNAL', message: 'Interner Fehler' });
});
app.get('/api/health', async () => ({ status: 'ok' }));
await app.register(authRoutes);
await app.register(playerRoutes);
await app.register(creditRoutes);
await app.register(characterRoutes);
await app.register(importRoutes);
await app.register(adminRoutes);
await app.register(scraperRoutes);
await app.register(forumRoutes);
if (existsSync(webRoot)) {
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
// 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');
}
},
});
}
// Alle nicht-API-Pfade gehen an die SPA, damit Deep-Links funktionieren.
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'NOT_FOUND', message: 'Unbekannter Endpunkt' });
return;
}
if (existsSync(webRoot)) {
reply.sendFile('index.html');
return;
}
reply.code(404).send({ error: 'NOT_FOUND', message: 'Frontend ist nicht gebaut' });
});
return app;
}
+72
View File
@@ -0,0 +1,72 @@
import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type { Role } from '@prisma/client';
import { forbidden, unauthorized } from '../errors.js';
import { CSRF_HEADER, SESSION_COOKIE, csrfMatches, resolveSession } from './session.js';
import type { SessionUser } from './session.js';
declare module 'fastify' {
interface FastifyRequest {
user?: SessionUser;
sessionToken?: string;
}
}
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Ohne diese Ausnahme 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']);
export async function attachUser(request: FastifyRequest): Promise<void> {
const token = request.cookies[SESSION_COOKIE];
if (!token) return;
const user = await resolveSession(token);
if (user) {
request.user = user;
request.sessionToken = token;
}
}
export async function enforceCsrf(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
if (SAFE_METHODS.has(request.method)) return;
if (CSRF_EXEMPT.has(request.url.split('?')[0] ?? '')) return;
const token = request.cookies[SESSION_COOKIE];
if (!token) return; // Ohne Session greift ohnehin requireAuth.
const header = request.headers[CSRF_HEADER];
const presented = Array.isArray(header) ? header[0] : header;
if (!csrfMatches(token, presented)) {
throw forbidden('CSRF-Token fehlt oder passt nicht');
}
}
export const requireAuth: preHandlerHookHandler = async (request) => {
if (!request.user) throw unauthorized();
};
export function requireRole(minimum: Role): preHandlerHookHandler {
return async (request) => {
if (!request.user) throw unauthorized();
if (RANK[request.user.role] < RANK[minimum]) {
throw forbidden();
}
};
}
// Solange ein Erstpasswort nicht gewechselt ist, bleiben schreibende Wege zu.
export const blockUntilPasswordChanged: preHandlerHookHandler = async (request) => {
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
const path = request.url.split('?')[0] ?? '';
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
throw forbidden('Bitte zuerst das Passwort ändern');
}
}
};
export const requireEditor = requireRole('EDITOR');
export const requireAdmin = requireRole('ADMIN');
+36
View File
@@ -0,0 +1,36 @@
import { hash, verify } from '@node-rs/argon2';
// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1.
const options = {
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(plain: string): Promise<string> {
return hash(plain, options);
}
export async function verifyPassword(digest: string, plain: string): Promise<boolean> {
try {
return await verify(digest, plain, options);
} catch {
// Kaputter oder fremdformatiger Hash gilt als nicht passend.
return false;
}
}
const MIN_LENGTH = 12;
export function validatePasswordStrength(plain: string): string | null {
if (plain.length < MIN_LENGTH) {
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
}
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
return 'Passwort muss Groß- und Kleinbuchstaben enthalten';
}
if (!/[0-9]/.test(plain)) {
return 'Passwort muss mindestens eine Ziffer enthalten';
}
return null;
}
+115
View File
@@ -0,0 +1,115 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Role, User } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
export const SESSION_COOKIE = 'ccm_sid';
export const CSRF_COOKIE = 'ccm_csrf';
export const CSRF_HEADER = 'x-csrf-token';
export interface SessionUser {
id: number;
username: string;
role: Role;
mustChangePassword: boolean;
totpEnabled: boolean;
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
// Das CSRF-Token wird aus dem Session-Token abgeleitet statt separat gespeichert.
// Ohne Kenntnis des httpOnly-Cookies und des SESSION_SECRET ist es nicht herstellbar.
export function csrfTokenFor(sessionToken: string): string {
return createHmac('sha256', env.SESSION_SECRET).update(sessionToken).digest('hex');
}
export function csrfMatches(sessionToken: string, presented: string | undefined): boolean {
if (!presented) return false;
const expected = csrfTokenFor(sessionToken);
const a = Buffer.from(expected);
const b = Buffer.from(presented);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function toSessionUser(user: User): SessionUser {
return {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
};
}
export async function createSession(
userId: number,
ip: string | undefined,
userAgent: string | undefined,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 3600 * 1000);
await prisma.session.create({
data: {
tokenHash: hashToken(token),
userId,
expiresAt,
ip: ip ?? null,
userAgent: userAgent?.slice(0, 255) ?? null,
},
});
return { token, expiresAt };
}
export async function resolveSession(token: string): Promise<SessionUser | null> {
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) {
await prisma.session.deleteMany({ where: { userId: session.userId } });
return null;
}
// Nicht bei jedem Request schreiben, sonst erzeugt jede Anfrage einen Write.
const staleAfter = 5 * 60 * 1000;
if (Date.now() - session.lastSeenAt.getTime() > staleAfter) {
await prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
}
return toSessionUser(session.user);
}
export async function destroySession(token: string): Promise<void> {
await prisma.session.deleteMany({ where: { tokenHash: hashToken(token) } });
}
export async function destroyAllSessionsFor(userId: number): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
/** Beendet alle Sitzungen des Nutzers 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) } },
});
}
export async function cleanupExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
return result.count;
}
+33
View File
@@ -0,0 +1,33 @@
import { generateSecret as createSecret, generateURI, verify } from 'otplib';
import QRCode from 'qrcode';
const ISSUER = 'CC-Manager';
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht
// abweichende Uhren zwischen Server und Authenticator-App ab.
const EPOCH_TOLERANCE_SECONDS = 30;
export function generateSecret(): string {
return createSecret();
}
export async function verifyTotp(secret: string, token: string): Promise<boolean> {
try {
const result = await verify({
secret,
token: token.replace(/\s/g, ''),
epochTolerance: EPOCH_TOLERANCE_SECONDS,
});
return result.valid;
} catch {
return false;
}
}
export function otpauthUrl(username: string, secret: string): string {
return generateURI({ issuer: ISSUER, label: username, secret });
}
export function qrDataUrl(otpauth: string): Promise<string> {
return QRCode.toDataURL(otpauth, { margin: 1, width: 240 });
}
+76
View File
@@ -0,0 +1,76 @@
import { randomBytes } from 'node:crypto';
import { ProductTarget } from '@prisma/client';
import { prisma } from './db.js';
import { env } from './env.js';
import { hashPassword } from './auth/password.js';
// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich
// anpassbar, sobald die echten Produktnamen bekannt sind.
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide
// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht
// das Redesign-Muster vorn.
const DEFAULT_MAPPINGS = [
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
{ matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 },
{ matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 },
{ matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 },
{ matchPattern: 'custom charakter', target: ProductTarget.CC_PACKAGE, priority: 13 },
];
export interface BootstrapResult {
createdAdmin: { username: string; password: string } | null;
createdMappings: number;
}
/**
* Legt beim allerersten Start einen Administrator an. Ohne gesetztes
* Bootstrap-Passwort wird eines erzeugt und einmalig ins Log geschrieben.
*/
export async function bootstrap(log: {
info: (msg: string) => void;
warn: (msg: string) => void;
}): Promise<BootstrapResult> {
const result: BootstrapResult = { createdAdmin: null, createdMappings: 0 };
const userCount = await prisma.user.count();
if (userCount === 0) {
const password = env.BOOTSTRAP_ADMIN_PASSWORD ?? randomBytes(12).toString('base64url');
await prisma.user.create({
data: {
username: env.BOOTSTRAP_ADMIN_USER,
passwordHash: await hashPassword(password),
role: 'ADMIN',
mustChangePassword: true,
isProtected: true,
},
});
result.createdAdmin = { username: env.BOOTSTRAP_ADMIN_USER, password };
log.warn('─'.repeat(60));
log.warn(`Administrator angelegt: ${env.BOOTSTRAP_ADMIN_USER}`);
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
log.warn(`Einmal-Passwort: ${password}`);
}
log.warn('Das Passwort muss bei der ersten Anmeldung 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 });
result.createdMappings = created.count;
log.info(`${created.count} Produkt-Mappings als Startwerte angelegt`);
}
return result;
}
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { env } from './env.js';
export const prisma = new PrismaClient({
log: env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
export type Tx = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
export async function disconnect(): Promise<void> {
await prisma.$disconnect();
}
+73
View File
@@ -0,0 +1,73 @@
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
HOST: z.string().default('0.0.0.0'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL fehlt'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET muss mindestens 32 Zeichen lang sein'),
// Lebensdauer einer Session in Stunden.
SESSION_TTL_HOURS: z.coerce.number().int().positive().default(12),
// Wird beim ersten Start angelegt, solange noch kein Account existiert.
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
// Hinter einem Reverse Proxy 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 über HTTPS).
COOKIE_SECURE: z
.string()
.optional()
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
// Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau
// zugeschnitten; eine völlig andere Software ließe sich damit nicht lesen.
GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'),
// Basis-Adresse des Forums für den Antrags-Abgleich. Der Parser ist auf
// WoltLab (Burning Board) zugeschnitten.
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
// Der Zugang aus der Erstinstallation ist ab Werk geschützt: weder Rolle
// noch Zustand lassen sich ändern, sein Passwort nicht von aussen
// zurücksetzen, und löschen lässt er sich nicht - auch nicht durch sich
// selbst. So bleibt ein Weg in die Anwendung offen, wenn sich jemand aus
// Versehen die Rechte entzieht. Auf "true" gesetzt, gilt er wie jeder
// andere Zugang.
BOOTSTRAP_ADMIN_EDITABLE: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
});
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 unvollständig:\n${issues}`);
process.exit(1);
}
const raw = parsed.data;
export const env = {
...raw,
isProduction: raw.NODE_ENV === 'production',
// In Produktion ohne expliziten Wert: sichere Cookies erzwingen.
cookieSecure: raw.COOKIE_SECURE ?? raw.NODE_ENV === 'production',
};
export type Env = typeof env;
+18
View File
@@ -0,0 +1,18 @@
export class AppError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
export const unauthorized = (message = 'Nicht angemeldet') =>
new AppError(401, 'UNAUTHORIZED', message);
export const forbidden = (message = 'Keine Berechtigung 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);
+76
View File
@@ -0,0 +1,76 @@
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';
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
async function main(): Promise<void> {
const app = await buildApp();
try {
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
app.log.error({ err: error }, 'Keine Verbindung zur Datenbank');
process.exit(1);
}
await bootstrap({
info: (msg) => app.log.info(msg),
warn: (msg) => app.log.warn(msg),
});
// 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 }, 'Aufräumen der Sitzungen fehlgeschlagen'));
cleanupOldAuditEntries()
.then((count) => {
if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`);
})
.catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen'));
}, CLEANUP_INTERVAL_MS);
cleanup.unref();
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);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
await app.listen({ port: env.PORT, host: env.HOST });
}
main().catch((error) => {
console.error('Start fehlgeschlagen:', error);
process.exit(1);
});
+165
View File
@@ -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(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/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;
}
+222
View File
@@ -0,0 +1,222 @@
import { ProductTarget } from '@prisma/client';
import { badRequest } from '../errors.js';
import { isRateLimited, retryAfterMs, sleep } from './ratelimit.js';
import { normalizeSteamId } from './steamid.js';
/**
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
* Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
* gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag
* und den Storno-Status.
*/
export interface ScrapedPackage {
packageBoughtId: number;
packageName: string;
serverBundle: string;
purchasedAt: Date | null;
amount: string;
gateway: string;
revoked: boolean;
}
export interface ScrapedProfile {
steamId64: string;
nickname: string | null;
packages: ScrapedPackage[];
}
const DONATION_ENTRY = /donations_data\[(\d+)\]\s*=\s*(\{.*?\});/gs;
// "06.10.2025 20:54"
const GERMAN_DATE = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?$/;
function parseGermanDate(value: unknown): Date | null {
if (typeof value !== 'string') return null;
const match = GERMAN_DATE.exec(value.trim());
if (!match) return null;
const [, day, month, year, hour, minute] = match;
const parsed = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour ?? 0),
Number(minute ?? 0),
);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function parseProfile(html: string, steamId64: string): ScrapedProfile {
const packages: ScrapedPackage[] = [];
for (const match of html.matchAll(DONATION_ENTRY)) {
const raw = match[2];
if (!raw) continue;
let entry: Record<string, unknown>;
try {
entry = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Einzelne kaputte Zeile darf nicht den ganzen Lauf kippen.
continue;
}
const name = typeof entry.package_name === 'string' ? entry.package_name.trim() : '';
if (!name) continue;
packages.push({
packageBoughtId: Number(entry.package_bought_id ?? match[1]),
packageName: name,
serverBundle: typeof entry.serverbundle_name === 'string' ? entry.serverbundle_name : '',
purchasedAt: parseGermanDate(entry.date),
amount: typeof entry.amount === 'string' ? entry.amount : '',
gateway: typeof entry.gateway === 'string' ? entry.gateway : '',
revoked: entry.revoked === 1 || entry.revoked === true || entry.status === 0,
});
}
const nickname = extractNickname(html);
return { steamId64, nickname, packages };
}
function extractNickname(html: string): string | null {
const match = /Nickname\s*<\/td>\s*<td[^>]*>([^<]+)</i.exec(html);
return match?.[1]?.trim() || null;
}
export type Classification =
| { kind: 'package'; target: ProductTarget }
| { kind: 'usage_counter'; times: number }
| { kind: 'unclear' }
| { kind: 'ignore' };
// Nur der SCP-RP-Bereich führt CC-relevante Pakete.
const RELEVANT_BUNDLE = 'scp-rp';
// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten
// zu werden.
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
const USAGE_COUNTER = /cc[\s-]*paket.*genutzt/i;
const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
/**
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
* bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als
* Redesign zählen, nicht als CC-Paket.
*/
export function classifyPackage(pkg: ScrapedPackage): Classification {
if (pkg.revoked) return { kind: 'ignore' };
if (pkg.serverBundle.toLowerCase() !== RELEVANT_BUNDLE) return { kind: 'ignore' };
const title = pkg.packageName;
if (USAGE_COUNTER.test(title)) {
const times = USAGE_TIMES.exec(title);
return { kind: 'usage_counter', times: times ? Number(times[1]) : 1 };
}
if (/redesign/i.test(title)) {
return { kind: 'package', target: ProductTarget.REDESIGN_PACKAGE };
}
if (/custom\s*charac?k?ter/i.test(title)) {
return { kind: 'package', target: ProductTarget.CC_PACKAGE };
}
// Klingt nach CC, passt aber in kein Muster: lieber nachfragen als raten.
if (CC_HINT.test(title)) return { kind: 'unclear' };
return { kind: 'ignore' };
}
// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft GExtension
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
const MIN_REQUEST_GAP_MS = 1000;
const MAX_REQUEST_GAP_MS = 10000;
const MAX_RETRIES = 2;
let lastRequestAt = 0;
/// Wächst, sobald die Gegenseite bremst. Einmal oben, bleibt es oben - bei
/// einem Abgleich über viele Spieler wäre jedes Zurücksetzen ein Rückfall in
/// dasselbe Tempo, das gerade abgewiesen wurde.
let requestGapMs = MIN_REQUEST_GAP_MS;
async function throttle(): Promise<void> {
const wait = lastRequestAt + requestGapMs - Date.now();
if (wait > 0) await sleep(wait);
lastRequestAt = Date.now();
}
/** Setzt das Tempo zurück, etwa zu Beginn eines neuen Abgleichs. */
export function resetRequestPace(): void {
requestGapMs = MIN_REQUEST_GAP_MS;
}
export interface FetchOptions {
baseUrl?: string;
timeoutMs?: number;
}
export async function fetchProfile(
steamId: string,
options: FetchOptions = {},
): Promise<ScrapedProfile> {
const steamId64 = normalizeSteamId(steamId);
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gültige SteamID: ${steamId}`);
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
const timeoutMs = options.timeoutMs ?? 15000;
for (let versuch = 0; ; versuch += 1) {
await throttle();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, {
signal: controller.signal,
headers: {
// Ehrliche Kennzeichnung statt Browser-Tarnung.
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)',
accept: 'text/html',
},
});
// Bremst die Gegenseite, wird gewartet statt den Spieler zu verlieren.
if (isRateLimited(response.status)) {
requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS);
if (versuch >= MAX_RETRIES) {
throw badRequest(
'PROFILE_RATE_LIMITED',
`GExtension bremst den Abruf aus (HTTP ${response.status})`,
);
}
await sleep(retryAfterMs(response.headers.get('retry-after'), versuch));
continue;
}
if (!response.ok) {
throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`);
}
return parseProfile(await response.text(), steamId64);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
}
throw error;
} finally {
clearTimeout(timer);
}
}
}
+30
View File
@@ -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));
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Steam kennt dieselbe Person in mehreren Schreibweisen. Damit ein Spieler
* nicht doppelt angelegt wird, sobald eine Quelle das andere Format liefert,
* wird intern immer die 64-Bit-Form gespeichert.
*
* STEAM_0:1:12345678 (Legacy, was Garry's Mod anzeigt)
* [U:1:24691357] (SteamID3)
* 76561198024691357 (SteamID64, die kanonische Form)
*/
// Offset zwischen Account-ID und SteamID64.
const BASE = 76561197960265728n;
const LEGACY = /^STEAM_[0-5]:([01]):(\d+)$/i;
const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
const STEAM64 = /^\d{17}$/;
/**
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert
* nach keiner bekannten Steam-Schreibweise aussieht.
*/
export function normalizeSteamId(input: string): string | null {
const value = input.trim();
if (value.length === 0) return null;
const legacy = LEGACY.exec(value);
if (legacy) {
const universe = BigInt(legacy[1] as string);
const accountNumber = BigInt(legacy[2] as string);
return (BASE + accountNumber * 2n + universe).toString();
}
const modern = STEAM3.exec(value);
if (modern) {
return (BASE + BigInt(modern[1] as string)).toString();
}
if (STEAM64.test(value)) {
const numeric = BigInt(value);
// Alles unterhalb des Offsets wäre keine Nutzer-ID.
return numeric > BASE ? numeric.toString() : null;
}
return null;
}
/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */
export function toLegacySteamId(steamId64: string): string | null {
if (!STEAM64.test(steamId64)) return null;
const numeric = BigInt(steamId64);
if (numeric <= BASE) return null;
const offset = numeric - BASE;
return `STEAM_0:${offset % 2n}:${offset / 2n}`;
}
export function looksLikeSteamId(input: string): boolean {
const value = input.trim();
return LEGACY.test(value) || STEAM3.test(value) || STEAM64.test(value);
}
+375
View File
@@ -0,0 +1,375 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ProductTarget, Role, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { requireAdmin } from '../auth/guard.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
import { destroyAllSessionsFor } from '../auth/session.js';
import { env } from '../env.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, idParam, parseWith } from './helpers.js';
const userSelect = {
id: true,
username: true,
role: true,
isActive: true,
totpEnabled: true,
mustChangePassword: true,
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 } });
if (!target || target.role !== Role.ADMIN || !target.isActive) return;
const activeAdmins = await prisma.user.count({ where: { role: Role.ADMIN, isActive: true } });
if (activeAdmins <= 1) {
throw conflict('LAST_ADMIN', 'Der letzte aktive Administrator kann nicht entfernt werden');
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', requireAdmin);
// --- Nutzerverwaltung ---
app.get('/api/admin/users', async () => {
const items = await prisma.user.findMany({
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
select: userSelect,
});
// 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) => {
const body = parseWith(
z.object({
username: z
.string()
.trim()
.min(3)
.max(64)
.regex(/^[a-zA-Z0-9._-]+$/, 'Erlaubt sind Buchstaben, Ziffern, Punkt, Strich, Unterstrich'),
password: z.string().min(1),
role: z.nativeEnum(Role).default(Role.EDITOR),
}),
request.body,
);
const weak = validatePasswordStrength(body.password);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const exists = await prisma.user.findUnique({
where: { username: body.username },
select: { id: true },
});
if (exists) throw conflict('USERNAME_TAKEN', 'Dieser Benutzername ist vergeben');
const created = await prisma.user.create({
data: {
username: body.username,
passwordHash: await hashPassword(body.password),
role: body.role,
mustChangePassword: true,
},
select: userSelect,
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_create',
targetType: 'user',
targetId: created.id,
details: { username: created.username, role: created.role },
});
reply.code(201);
return created;
});
app.patch('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
role: z.nativeEnum(Role).optional(),
isActive: z.boolean().optional(),
}),
request.body,
);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
assertNichtGeschuetzt(target);
const losesAdmin =
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
if (losesAdmin) await assertNotLastAdmin(id);
const updated = await prisma.user.update({
where: { id },
data: {
...(body.role !== undefined ? { role: body.role } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
},
select: userSelect,
});
// Ein gesperrter Account verliert seine offenen Sitzungen sofort.
if (body.isActive === false) await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_update',
targetType: 'user',
targetId: id,
details: { username: updated.username, role: updated.role, isActive: updated.isActive },
});
return updated;
});
app.post('/api/admin/users/:id/reset-password', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(z.object({ newPassword: z.string().min(1) }), request.body);
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) throw notFound('Nutzer');
assertNichtGeschuetzt(target);
await prisma.user.update({
where: { id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: true,
},
});
await destroyAllSessionsFor(id);
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_reset_password',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
app.delete('/api/admin/users/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
if (id === request.user?.id) {
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht 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 } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.user_delete',
targetType: 'user',
targetId: id,
details: { username: target.username },
});
return { ok: true };
});
// --- Produkt-Mappings ---
app.get('/api/admin/product-mappings', async () => {
const items = await prisma.productMapping.findMany({
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
});
return { items };
});
app.post('/api/admin/product-mappings', async (request, reply) => {
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200),
target: z.nativeEnum(ProductTarget),
priority: z.number().int().min(0).max(9999).default(100),
note: z.string().trim().max(300).optional(),
}),
request.body,
);
const created = await prisma.productMapping.create({
data: {
matchPattern: body.matchPattern,
target: body.target,
priority: body.priority,
note: body.note ?? null,
},
});
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_create',
targetType: 'product_mapping',
targetId: created.id,
details: { matchPattern: created.matchPattern, target: created.target },
});
reply.code(201);
return created;
});
app.patch('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
matchPattern: z.string().trim().min(1).max(200).optional(),
target: z.nativeEnum(ProductTarget).optional(),
priority: z.number().int().min(0).max(9999).optional(),
isActive: z.boolean().optional(),
note: z.string().trim().max(300).nullable().optional(),
}),
request.body,
);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
const updated = await prisma.productMapping.update({ where: { id }, data: body });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_update',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: updated.matchPattern, target: updated.target },
});
return updated;
});
app.delete('/api/admin/product-mappings/:id', async (request) => {
const { id } = parseWith(idParam, request.params);
const existing = await prisma.productMapping.findUnique({ where: { id } });
if (!existing) throw notFound('Mapping');
await prisma.productMapping.delete({ where: { id } });
await recordAudit(prisma, contextOf(request), {
action: 'admin.mapping_delete',
targetType: 'product_mapping',
targetId: id,
details: { matchPattern: existing.matchPattern },
});
return { ok: true };
});
// --- Audit-Log ---
/** 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({
action: z.string().trim().max(80).optional(),
targetType: z.string().trim().max(40).optional(),
targetId: z.string().trim().max(40).optional(),
take: z.coerce.number().int().min(1).max(200).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
const where: Prisma.AuditLogWhereInput = {};
if (query.action) where.action = { contains: query.action, mode: 'insensitive' };
if (query.targetType) where.targetType = query.targetType;
if (query.targetId) where.targetId = query.targetId;
const [items, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: query.take,
skip: query.skip,
include: { actor: { select: { username: true } } },
}),
prisma.auditLog.count({ where }),
]);
return { items, total };
});
app.get('/api/admin/stats', async () => {
const [players, characters, activeCharacters, users, imports] = await Promise.all([
prisma.player.count(),
prisma.character.count(),
prisma.character.count({ where: { status: 'ACTIVE' } }),
prisma.user.count({ where: { isActive: true } }),
prisma.import.count({ where: { status: 'COMMITTED' } }),
]);
return { players, characters, activeCharacters, users, imports };
});
}
+318
View File
@@ -0,0 +1,318 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest, unauthorized } from '../errors.js';
import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js';
import {
CSRF_COOKIE,
SESSION_COOKIE,
createSession,
csrfTokenFor,
destroyAllSessionsFor,
destroyOtherSessionsFor,
destroySession,
} from '../auth/session.js';
import { requireAuth } from '../auth/guard.js';
import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, parseWith } from './helpers.js';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert,
// damit kein halb angemeldeter Zustand in der Datenbank liegt.
function signChallenge(userId: number): string {
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
const payload = `${userId}.${expiresAt}`;
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
return `${payload}.${signature}`;
}
function verifyChallenge(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [rawUserId, rawExpiry, signature] = parts as [string, string, string];
const expected = createHmac('sha256', env.SESSION_SECRET)
.update(`${rawUserId}.${rawExpiry}`)
.digest('base64url');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
const expiresAt = Number(rawExpiry);
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
const userId = Number(rawUserId);
return Number.isInteger(userId) ? userId : null;
}
const loginSchema = z.object({
username: z.string().trim().min(1).max(64),
password: z.string().min(1).max(256),
});
const totpSchema = z.object({
challenge: z.string().min(1),
code: z.string().trim().min(6).max(10),
});
export async function authRoutes(app: FastifyInstance): Promise<void> {
const loginLimit = {
config: {
rateLimit: {
max: 10,
timeWindow: '5 minutes',
},
},
};
function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void {
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
// Muss für das Double-Submit-Verfahren lesbar sein.
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
path: '/',
httpOnly: false,
secure: env.cookieSecure,
sameSite: 'lax',
expires: expiresAt,
});
}
app.post('/api/auth/login', loginLimit, async (request, reply) => {
const body = parseWith(loginSchema, request.body);
const user = await prisma.user.findUnique({ where: { username: body.username } });
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const passwordOk = await verifyPassword(digest, body.password);
if (!user || !passwordOk || !user.isActive) {
// Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich
// gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort
// im Klartext im Audit-Log.
await recordAudit(
prisma,
{ actorId: null, actorName: user ? user.username : null, ip: request.ip },
{
action: 'auth.login_failed',
targetType: 'user',
targetId: user?.id ?? null,
details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' },
},
).catch(() => undefined);
throw unauthorized('Benutzername oder Passwort ist falsch');
}
if (user.totpEnabled && user.totpSecret) {
return { totpRequired: true, challenge: signChallenge(user.id) };
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
totpRequired: false,
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/login/totp', loginLimit, async (request, reply) => {
const body = parseWith(totpSchema, request.body);
const userId = verifyChallenge(body.challenge);
if (userId === null) {
throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten');
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.isActive || !user.totpSecret) throw unauthorized();
if (!(await verifyTotp(user.totpSecret, body.code))) {
await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, {
action: 'auth.totp_failed',
targetType: 'user',
targetId: user.id,
}).catch(() => undefined);
throw unauthorized('Der Code stimmt nicht');
}
const { token, expiresAt } = await createSession(
user.id,
request.ip,
request.headers['user-agent'],
);
issueSession(reply, token, expiresAt);
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await recordAudit(
{ auditLog: prisma.auditLog },
{ actorId: user.id, actorName: user.username, ip: request.ip },
{ action: 'auth.login', targetType: 'user', targetId: user.id },
);
return {
user: {
id: user.id,
username: user.username,
role: user.role,
mustChangePassword: user.mustChangePassword,
totpEnabled: user.totpEnabled,
},
};
});
app.post('/api/auth/logout', async (request, reply) => {
if (request.sessionToken) {
await destroySession(request.sessionToken);
}
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
app.get('/api/auth/me', async (request) => {
if (!request.user) return { user: null };
// 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 ---
app.post(
'/api/account/password',
{ preHandler: requireAuth },
async (request) => {
const body = parseWith(
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(1),
}),
request.body,
);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.currentPassword))) {
throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch');
}
const weak = validatePasswordStrength(body.newPassword);
if (weak) throw badRequest('WEAK_PASSWORD', weak);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await hashPassword(body.newPassword),
mustChangePassword: false,
},
});
// Andere Sitzungen beenden, die eigene bleibt bestehen.
if (request.sessionToken) {
await destroyOtherSessionsFor(user.id, request.sessionToken);
}
await recordAudit(prisma, contextOf(request), {
action: 'account.password_changed',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
},
);
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
const secret = generateSecret();
// Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
await prisma.user.update({
where: { id: request.user!.id },
data: { totpSecret: secret, totpEnabled: false },
});
const url = otpauthUrl(request.user!.username, secret);
return { secret, otpauth: url, qr: await qrDataUrl(url) };
});
app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user?.totpSecret) {
throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten');
}
if (!(await verifyTotp(user.totpSecret, body.code))) {
throw badRequest('WRONG_CODE', 'Der Code stimmt nicht');
}
await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } });
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_enabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => {
const body = parseWith(z.object({ password: z.string().min(1) }), request.body);
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
if (!user) throw unauthorized();
if (!(await verifyPassword(user.passwordHash, body.password))) {
throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch');
}
await prisma.user.update({
where: { id: user.id },
data: { totpEnabled: false, totpSecret: null },
});
await recordAudit(prisma, contextOf(request), {
action: 'account.totp_disabled',
targetType: 'user',
targetId: user.id,
});
return { ok: true };
});
app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => {
await destroyAllSessionsFor(request.user!.id);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(CSRF_COOKIE, { path: '/' });
return { ok: true };
});
}
+467
View File
@@ -0,0 +1,467 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, InactiveReason } from '@prisma/client';
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';
/** 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> {
app.post('/api/players/:id/characters', { preHandler: requireEditor }, async (request, reply) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
request.body,
);
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;
});
app.patch('/api/characters/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160).optional(),
loreText: z.string().trim().max(5000).nullable().optional(),
/// Thread-Nummer oder volle Adresse; leer löst die Verknüpfung.
threadRef: z.string().trim().max(500).nullable().optional(),
}),
request.body,
);
return updateCharacter(id, body, contextOf(request));
});
app.post('/api/characters/:id/redesign', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
useKind: redesignKind,
note: optionalNote,
forumThread: z.string().trim().max(300).optional(),
}),
request.body,
);
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,
InactiveReason.NO_FREE_CLAIM,
InactiveReason.DEAD,
]),
note: optionalNote,
}),
request.body,
);
return deactivateCharacter(id, body.reason, contextOf(request), body.note);
});
app.post('/api/characters/:id/reactivate-free', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return reactivateFree(id, contextOf(request));
});
app.post('/api/characters/:id/reactivate-paid', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ useKind: reviveKind, note: optionalNote }),
request.body,
);
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
});
/**
* 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 Begründung ist Pflicht').max(500) }),
request.body,
);
return revertCharacter(id, body.reason, contextOf(request));
});
}
+123
View File
@@ -0,0 +1,123 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, CreditSource } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import {
bookAdjustment,
bookKulanz,
bookRedesignPackage,
convertCcToRedesign,
grantCcSlot,
removeLastGrant,
reverseGroup,
} from '../services/credits.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const noteBody = z.object({ note: optionalNote });
export async function creditRoutes(app: FastifyInstance): Promise<void> {
/**
* 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 grantCcSlot(id, contextOf(request), { note: body.note });
});
app.post(
'/api/players/:id/credits/redesign-package',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookRedesignPackage(id, contextOf(request), { note: body.note });
},
);
// 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 Begründung ist Pflicht').max(500),
}),
request.body,
);
return bookAdjustment(id, body.kind, body.amount, body.note, contextOf(request));
});
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
request.body,
);
return reverseGroup(id, body.reason, contextOf(request));
});
}
+116
View File
@@ -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));
});
}
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyRequest } from 'fastify';
import { z } from 'zod';
import { badRequest, unauthorized } from '../errors.js';
import type { ActionContext } from '../services/audit.js';
// z.output statt z.input: nur so 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) {
const message = result.error.issues
.map((issue) => {
const path = issue.path.join('.');
return path ? `${path}: ${issue.message}` : issue.message;
})
.join('; ');
throw badRequest('VALIDATION_FAILED', message);
}
return result.data;
}
export function contextOf(request: FastifyRequest): ActionContext {
if (!request.user) throw unauthorized();
return {
actorId: request.user.id,
actorName: request.user.username,
ip: request.ip,
};
}
export const idParam = z.object({ id: z.coerce.number().int().positive() });
export const optionalNote = z.string().trim().max(500).optional();
+125
View File
@@ -0,0 +1,125 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import { badRequest } from '../errors.js';
import {
applyMapping,
commitImport,
createImport,
discardImport,
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 für die Spieler-Kennung fehlt'),
playerName: z.string().optional(),
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 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');
const content = await file.toBuffer();
const parsed = parseUpload(file.filename, content);
const importId = await createImport(file.filename, parsed, contextOf(request));
reply.code(201);
return {
importId,
columns: parsed.columns,
rowCount: parsed.rows.length,
sample: parsed.rows.slice(0, 5),
};
});
/**
* 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: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return getImport(id);
});
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));
const record = await getImport(id);
return { summary, import: record };
});
app.post('/api/imports/:id/commit', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return commitImport(id, contextOf(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 };
});
}
+126
View File
@@ -0,0 +1,126 @@
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';
import { contextOf, idParam, parseWith } from './helpers.js';
const playerBody = z.object({
displayName: z.string().trim().min(1).max(120),
steamId: z.string().trim().max(64).nullish(),
forumName: z.string().trim().max(120).nullish(),
notes: z.string().trim().max(2000).nullish(),
isProblem: z.boolean().optional(),
problemNote: z.string().trim().max(1000).nullish(),
});
export async function playerRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/players', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({
q: z.string().trim().max(120).default(''),
take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0),
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,
);
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) => {
const { id } = parseWith(idParam, request.params);
return getPlayerDetail(id);
});
app.get('/api/players/:id/ledger', { preHandler: requireAuth }, async (request) => {
const { id } = parseWith(idParam, request.params);
const query = parseWith(
z.object({
kind: z.nativeEnum(CreditKind).optional(),
take: z.coerce.number().int().min(1).max(500).default(100),
skip: z.coerce.number().int().min(0).default(0),
}),
request.query,
);
return listLedger({ playerId: id, ...query });
});
app.post('/api/players', { preHandler: requireEditor }, async (request, reply) => {
const body = parseWith(playerBody, request.body);
const created = await createPlayer(body, contextOf(request));
reply.code(201);
return created;
});
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
// 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));
return { ok: true };
});
}
+88
View File
@@ -0,0 +1,88 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { notFound } from '../errors.js';
import { prisma } from '../db.js';
import {
getScrapeState,
knownSteamIds,
startScrape,
steamIdsOf,
listUnmatched,
resolveUnmatched,
scrapePlayers,
unmatchedOverview,
} from '../services/scraper.js';
import { contextOf, idParam, parseWith } from './helpers.js';
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
/**
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
* Profile auf Vorrat einsammeln.
*/
app.post('/api/scrape/run', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ playerIds: z.array(z.number().int().positive()).max(500).optional() }),
request.body ?? {},
);
const all = await knownSteamIds();
const targets = body.playerIds
? all.filter((entry) => body.playerIds?.includes(entry.playerId))
: all;
return startScrape(targets, contextOf(request));
});
app.get('/api/scrape/status', { preHandler: requireAuth }, async () => {
return getScrapeState();
});
/**
* Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus.
* Abgefragt werden alle Konten des Spielers, nicht nur das hinterlegte -
* sonst fehlte das Guthaben, das unter einem früheren Konto gekauft wurde.
*/
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
const player = await prisma.player.findUnique({ where: { id }, select: { id: true } });
if (!player) throw notFound('Spieler');
const steamIds = await steamIdsOf(id);
if (steamIds.length === 0) {
throw notFound('SteamID des Spielers');
}
return startScrape(
steamIds.map((steamId) => ({ playerId: id, steamId })),
contextOf(request),
);
});
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({ all: z.enum(['true', 'false']).default('false') }),
request.query,
);
return { items: await listUnmatched(query.all === 'false') };
});
app.get('/api/scrape/unmatched/overview', { preHandler: requireAuth }, async () => {
return unmatchedOverview();
});
app.post('/api/scrape/unmatched/:id/resolve', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(
z.object({
resolution: z.enum(['CC_PACKAGE', 'REDESIGN_PACKAGE', 'REDESIGN_KULANZ', 'IGNORE']),
}),
request.body,
);
await resolveUnmatched(id, body.resolution, contextOf(request));
return { ok: true };
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
export interface ActionContext {
// 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;
ip?: string | undefined;
}
export interface AuditEntry {
action: string;
targetType: string;
targetId?: string | number | null;
details?: Prisma.InputJsonValue;
}
type Client = Pick<typeof prisma, 'auditLog'>;
export async function recordAudit(
client: Client,
ctx: ActionContext,
entry: AuditEntry,
): Promise<void> {
await client.auditLog.create({
data: {
actorUserId: ctx.actorId,
actorName: ctx.actorName ?? null,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId == null ? null : String(entry.targetId),
details: entry.details ?? undefined,
ip: ctx.ip ?? null,
},
});
}
/**
* Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für
* bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll.
*/
export async function cleanupOldAuditEntries(): Promise<number> {
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
const cutoff = new Date(Date.now() - env.AUDIT_RETENTION_DAYS * 24 * 3600 * 1000);
const result = await prisma.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
return result.count;
}
+467
View File
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
import { randomUUID } from 'node:crypto';
import { CreditKind, CreditSource, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface Balances {
CC: number;
REDESIGN_PAID: number;
REDESIGN_KULANZ: number;
// 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,
granted: { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
};
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,
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> {
// 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 },
select: {
kind: true,
amount: true,
source: true,
reversalOf: { select: { source: true, amount: true } },
},
});
const sums: Partial<Record<CreditKind, number>> = {};
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>> {
const result = new Map<number, Balances>();
if (playerIds.length === 0) return result;
const rows = await prisma.creditLedger.groupBy({
by: ['playerId', 'kind'],
where: { playerId: { in: playerIds } },
_sum: { amount: true },
});
const grouped = new Map<number, Partial<Record<CreditKind, number>>>();
for (const row of rows) {
const entry = grouped.get(row.playerId) ?? {};
entry[row.kind] = row._sum.amount ?? 0;
grouped.set(row.playerId, entry);
}
for (const id of playerIds) {
const sums = grouped.get(id);
result.set(id, sums ? assemble(sums) : { ...EMPTY });
}
return result;
}
async function requirePlayer(playerId: number): Promise<void> {
const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
if (!exists) throw notFound('Spieler');
}
export interface PackageRowParams {
playerId: number;
groupId: string;
note?: string | null;
importId?: number | null;
actorId: number | null;
/// 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 enthält, steht nur hier, damit manuelle
* Eingabe und Import nicht auseinanderlaufen können.
*/
export function buildPackageRows(
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
params: PackageRowParams,
): Prisma.CreditLedgerCreateManyInput[] {
const base = {
playerId: params.playerId,
groupId: params.groupId,
note: params.note ?? null,
importId: params.importId ?? null,
createdById: params.actorId,
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
};
if (target === 'REDESIGN_PACKAGE') {
return [
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE },
];
}
return [
{ ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE },
{ ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE },
];
}
/**
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
* gemeinsam zurückholt.
*/
export async function bookCcPackage(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = options.groupId ?? randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.createMany({
data: buildPackageRows('CC_PACKAGE', {
playerId,
groupId,
note: options.note ?? null,
importId: options.importId ?? null,
actorId: ctx.actorId,
}),
});
await recordAudit(tx, ctx, {
action: 'credit.cc_package',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
/**
* 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,
options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = options.groupId ?? randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.createMany({
data: buildPackageRows('REDESIGN_PACKAGE', {
playerId,
groupId,
note: options.note ?? null,
importId: options.importId ?? null,
actorId: ctx.actorId,
}),
});
await recordAudit(tx, ctx, {
action: 'credit.redesign_package',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null, importId: options.importId ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
export async function bookKulanz(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.REDESIGN_KULANZ,
amount: 1,
source: CreditSource.KULANZ,
groupId,
note: options.note ?? null,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'credit.kulanz',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */
export async function bookAdjustment(
playerId: number,
kind: CreditKind,
amount: number,
note: string,
ctx: ActionContext,
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
if (!Number.isInteger(amount) || amount === 0) {
throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein');
}
const groupId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId,
kind,
amount,
source: CreditSource.ADJUSTMENT,
groupId,
note,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'credit.adjustment',
targetType: 'player',
targetId: playerId,
details: { groupId, kind, amount, note },
});
});
return { groupId, balances: await getBalances(playerId) };
}
/**
* Storniert eine Buchung als Gegenbuchung, nicht als 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,
ctx: ActionContext,
): Promise<{ balances: Balances; reversed: number }> {
const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } });
if (!anchor) throw notFound('Buchung');
if (anchor.source === CreditSource.CONSUME) {
throw conflict(
'CONSUME_NOT_REVERSIBLE',
'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert',
);
}
if (anchor.source === CreditSource.REVERSAL) {
throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden');
}
const entries = await prisma.creditLedger.findMany({
where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } },
include: { reversedBy: { select: { id: true } } },
});
const open = entries.filter((e) => e.reversedBy === null);
if (open.length === 0) {
throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert');
}
await prisma.$transaction(async (tx) => {
for (const entry of open) {
await tx.creditLedger.create({
data: {
playerId: entry.playerId,
kind: entry.kind,
amount: -entry.amount,
source: CreditSource.REVERSAL,
groupId: entry.groupId,
note: reason,
reversalOfId: entry.id,
createdById: ctx.actorId,
},
});
}
await recordAudit(tx, ctx, {
action: 'credit.reverse',
targetType: 'player',
targetId: anchor.playerId,
details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason },
});
});
return { balances: await getBalances(anchor.playerId), reversed: open.length };
}
export interface LedgerFilter {
playerId: number;
kind?: CreditKind;
take?: number;
skip?: number;
}
export async function listLedger(filter: LedgerFilter) {
const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId };
if (filter.kind) where.kind = filter.kind;
const [items, total] = await Promise.all([
prisma.creditLedger.findMany({
where,
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
take: filter.take ?? 100,
skip: filter.skip ?? 0,
include: {
createdBy: { select: { username: true } },
character: { select: { id: true, name: true } },
reversedBy: { select: { id: true } },
},
}),
prisma.creditLedger.count({ where }),
]);
return { items, total };
}
+896
View File
@@ -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 },
});
}
+770
View File
@@ -0,0 +1,770 @@
import { randomUUID } from 'node:crypto';
import { parse } from 'csv-parse/sync';
import { ImportStatus, ProductTarget, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { buildPackageRows } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js';
import { normalizeSteamId } from '../lib/steamid.js';
export const PLANS = {
BOOK: 'BOOK',
CREATE_PLAYER_AND_BOOK: 'CREATE_PLAYER_AND_BOOK',
SKIP_DUPLICATE: 'SKIP_DUPLICATE',
SKIP_IGNORED: 'SKIP_IGNORED',
SKIP_UNMAPPED: 'SKIP_UNMAPPED',
SKIP_NO_PLAYER: 'SKIP_NO_PLAYER',
} as const;
export type Plan = (typeof PLANS)[keyof typeof PLANS];
export interface ColumnMapping {
playerKey: string;
playerName?: string | undefined;
product: string;
orderId?: string | undefined;
purchasedAt?: string | undefined;
createMissingPlayers: boolean;
}
export interface ParsedFile {
columns: string[];
rows: Record<string, string>[];
}
const MAX_ROWS = 20000;
// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma.
function detectDelimiter(sample: string): string {
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
const candidates = [',', ';', '\t', '|'];
let best = ',';
let bestCount = 0;
for (const candidate of candidates) {
const count = firstLine.split(candidate).length - 1;
if (count > bestCount) {
best = candidate;
bestCount = count;
}
}
return best;
}
function normalizeRow(row: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(row)) {
if (value === null || value === undefined) {
out[key] = '';
} else if (typeof value === 'object') {
out[key] = JSON.stringify(value);
} else {
out[key] = String(value).trim();
}
}
return out;
}
export function parseUpload(filename: string, content: Buffer): ParsedFile {
const text = content.toString('utf8').replace(/^/, '');
if (text.trim().length === 0) throw badRequest('EMPTY_FILE', 'Die Datei ist leer');
const isJson = filename.toLowerCase().endsWith('.json') || /^\s*[[{]/.test(text);
let rows: Record<string, string>[];
if (isJson) {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
}
// Manche Exporte verpacken die Liste in ein Hüllobjekt.
const list = Array.isArray(data)
? data
: typeof data === 'object' && data !== null
? Object.values(data).find((v): v is unknown[] => Array.isArray(v))
: undefined;
if (!Array.isArray(list)) {
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen');
}
rows = list
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
.map(normalizeRow);
} else {
try {
const parsed = parse(text, {
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
delimiter: detectDelimiter(text),
bom: true,
}) as Record<string, unknown>[];
rows = parsed.map(normalizeRow);
} catch {
throw badRequest('INVALID_CSV', 'Die CSV-Datei konnte nicht gelesen werden');
}
}
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen');
if (rows.length > MAX_ROWS) {
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
}
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
return { columns, rows };
}
export async function createImport(
filename: string,
parsed: ParsedFile,
ctx: ActionContext,
): Promise<number> {
const created = await prisma.import.create({
data: {
filename,
status: ImportStatus.PENDING,
rowCount: parsed.rows.length,
importedById: ctx.actorId,
rows: {
create: parsed.rows.map((raw, index) => ({
lineNo: index + 1,
plan: PLANS.SKIP_UNMAPPED,
raw: raw as Prisma.InputJsonValue,
})),
},
},
});
await recordAudit(prisma, ctx, {
action: 'import.upload',
targetType: 'import',
targetId: created.id,
details: { filename, rowCount: parsed.rows.length },
});
return created.id;
}
function matchTarget(
productName: string,
mappings: { matchPattern: string; target: ProductTarget }[],
): ProductTarget | null {
const haystack = productName.toLowerCase();
for (const mapping of mappings) {
if (haystack.includes(mapping.matchPattern.toLowerCase())) return mapping.target;
}
return null;
}
function parseDate(value: string | undefined): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* 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(
client: Pick<typeof prisma, 'importRow'>,
importId: number,
mapping: ColumnMapping | null,
): Promise<void> {
const keep = new Set(
[mapping?.playerKey, mapping?.playerName, mapping?.product, mapping?.orderId, mapping?.purchasedAt].filter(
(column): column is string => Boolean(column),
),
);
const rows = await client.importRow.findMany({
where: { importId },
select: { id: true, raw: true },
});
for (const row of rows) {
const raw = row.raw as Record<string, string> | null;
if (!raw) continue;
const reduced: Record<string, string> = {};
for (const column of keep) {
const value = raw[column];
if (value !== undefined) reduced[column] = value;
}
await client.importRow.update({
where: { id: row.id },
data: { raw: reduced as Prisma.InputJsonValue },
});
}
}
/**
* Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der
* Bestätigung angezeigt und beim Commit unverändert ausgeführt.
*/
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
const record = await prisma.import.findUnique({
where: { id: importId },
include: { rows: { orderBy: { lineNo: 'asc' } } },
});
if (!record) throw notFound('Import');
if (record.status !== ImportStatus.PENDING) {
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
}
const productMappings = await prisma.productMapping.findMany({
where: { isActive: true },
orderBy: [{ priority: 'asc' }, { id: 'asc' }],
select: { matchPattern: true, target: true },
});
const rawRows = record.rows.map((row) => ({
id: row.id,
lineNo: row.lineNo,
raw: row.raw as Record<string, string>,
}));
// 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) => {
const normalized = normalizeSteamId(key);
return normalized ? [key, normalized] : [key];
})),
];
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<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
? [...new Set(rawRows.map((r) => (r.raw[mapping.orderId as string] ?? '').trim()).filter(Boolean))]
: [];
const alreadyBooked = new Set(
orderIds.length > 0
? (
await prisma.importedOrder.findMany({
where: { externalId: { in: orderIds } },
select: { externalId: true },
})
).map((o) => o.externalId)
: [],
);
// Fängt Dubletten innerhalb derselben Datei ab.
const seenInFile = new Set<string>();
const updates: Prisma.PrismaPromise<unknown>[] = [];
const summary: Record<Plan, number> = {
BOOK: 0,
CREATE_PLAYER_AND_BOOK: 0,
SKIP_DUPLICATE: 0,
SKIP_IGNORED: 0,
SKIP_UNMAPPED: 0,
SKIP_NO_PLAYER: 0,
};
for (const row of rawRows) {
const playerKey = (row.raw[mapping.playerKey] ?? '').trim();
const playerName = mapping.playerName ? (row.raw[mapping.playerName] ?? '').trim() : '';
const productName = (row.raw[mapping.product] ?? '').trim();
const externalId = mapping.orderId ? (row.raw[mapping.orderId] ?? '').trim() : '';
const purchasedAt = parseDate(mapping.purchasedAt ? row.raw[mapping.purchasedAt] : undefined);
const target = productName ? matchTarget(productName, productMappings) : null;
const canonicalKey = normalizeSteamId(playerKey);
const resolvedPlayerId =
(canonicalKey ? bySteamId.get(canonicalKey) : undefined) ??
bySteamId.get(playerKey) ??
byName.get(playerKey.toLowerCase()) ??
null;
let plan: Plan;
if (target === null) {
plan = PLANS.SKIP_UNMAPPED;
} else if (target === ProductTarget.IGNORE) {
plan = PLANS.SKIP_IGNORED;
} else if (externalId && (alreadyBooked.has(externalId) || seenInFile.has(externalId))) {
plan = PLANS.SKIP_DUPLICATE;
} else if (resolvedPlayerId !== null) {
plan = PLANS.BOOK;
} else if (mapping.createMissingPlayers && (playerKey || playerName)) {
plan = PLANS.CREATE_PLAYER_AND_BOOK;
} else {
plan = PLANS.SKIP_NO_PLAYER;
}
if (externalId && (plan === PLANS.BOOK || plan === PLANS.CREATE_PLAYER_AND_BOOK)) {
seenInFile.add(externalId);
}
summary[plan] += 1;
updates.push(
prisma.importRow.update({
where: { id: row.id },
data: {
playerKey: playerKey || null,
playerName: playerName || null,
productName: productName || null,
externalId: externalId || null,
purchasedAt,
target,
resolvedPlayerId,
plan,
},
}),
);
}
await prisma.$transaction([
...updates,
prisma.import.update({
where: { id: importId },
data: { mapping: mapping as unknown as Prisma.InputJsonValue },
}),
]);
await recordAudit(prisma, ctx, {
action: 'import.preview',
targetType: 'import',
targetId: importId,
details: { mapping: mapping as unknown as Prisma.InputJsonValue, summary },
});
return summary;
}
export async function getImport(importId: number, includeRows = true) {
const record = await prisma.import.findUnique({
where: { id: importId },
include: {
importedBy: { select: { username: true } },
...(includeRows ? { rows: { orderBy: { lineNo: 'asc' as const } } } : {}),
},
});
if (!record) throw notFound('Import');
return record;
}
/** 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 },
include: { rows: { orderBy: { lineNo: 'asc' } } },
});
if (!record) throw notFound('Import');
if (record.status !== ImportStatus.PENDING) {
throw conflict('NOT_PENDING', 'Dieser Import wurde bereits abgeschlossen');
}
const bookable = record.rows.filter(
(row) => row.plan === PLANS.BOOK || row.plan === PLANS.CREATE_PLAYER_AND_BOOK,
);
if (bookable.length === 0) {
throw conflict('NOTHING_TO_BOOK', 'Es gibt keine Zeilen zum Buchen');
}
const result = await prisma.$transaction(async (tx) => {
// Mehrere Zeilen 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;
if (playerId === null) {
const cacheKey = key.toLowerCase();
const cached = createdPlayers.get(cacheKey);
if (cached !== undefined) {
playerId = cached;
} else {
const created = await tx.player.create({
data: {
displayName: row.playerName?.trim() || key || `Unbekannt ${row.lineNo}`,
steamId: normalizeSteamId(key),
},
});
createdPlayers.set(cacheKey, created.id);
playerId = created.id;
}
}
const target = row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
const groupId = randomUUID();
await tx.creditLedger.createMany({
data: buildPackageRows(target, {
playerId,
groupId,
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
importId: record.id,
actorId: ctx.actorId,
occurredAt: row.purchasedAt,
}),
});
if (row.externalId) {
await tx.importedOrder.create({
data: {
externalId: row.externalId,
importId: record.id,
playerId,
target: row.target ?? ProductTarget.CC_PACKAGE,
groupId,
},
});
}
booked += 1;
}
const updated = await tx.import.update({
where: { id: importId },
data: {
status: ImportStatus.COMMITTED,
committedAt: new Date(),
bookedCount: booked,
},
});
await recordAudit(tx, ctx, {
action: 'import.commit',
targetType: 'import',
targetId: importId,
details: {
filename: record.filename,
booked,
skipped,
createdPlayers: createdPlayers.size,
},
});
return { booked, skipped, createdPlayers: createdPlayers.size, import: updated };
});
// Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht.
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
return result;
}
/**
* 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');
if (record.status === ImportStatus.COMMITTED) {
throw conflict('ALREADY_COMMITTED', 'Ein gebuchter Import kann nicht verworfen werden');
}
await prisma.import.update({
where: { id: importId },
data: { status: ImportStatus.DISCARDED },
});
// Ein verworfener Import braucht die Rohdaten erst recht nicht mehr.
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
await recordAudit(prisma, ctx, {
action: 'import.discard',
targetType: 'import',
targetId: importId,
details: { filename: record.filename },
});
}
/**
* 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,
include: { importedBy: { select: { username: true } } },
});
}
+506
View File
@@ -0,0 +1,506 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { conflict, notFound } from '../errors.js';
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
import { listCharacters } from './characters.js';
import { recordAudit, type ActionContext } from './audit.js';
import { AliasType } from '@prisma/client';
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
export interface PlayerInput {
displayName: string;
steamId?: string | null;
forumName?: string | null;
notes?: string | null;
/// Anlegen, obwohl etwas nicht stimmt - siehe markProblem().
isProblem?: boolean;
problemNote?: string | null;
}
/// 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;
// 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: [
{ displayName: { contains: trimmed, mode: 'insensitive' } },
{ 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,
take,
skip,
include: { _count: { select: { characters: true } } },
}),
prisma.player.count({ where }),
]);
const balances = await getBalancesForMany(players.map((p) => p.id));
const activeCounts = await prisma.character.groupBy({
by: ['playerId'],
where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' },
_count: { _all: true },
});
const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all]));
return {
total,
items: players.map((player) => ({
id: player.id,
displayName: player.displayName,
steamId: player.steamId,
forumName: player.forumName,
isProblem: player.isProblem,
problemNote: player.problemNote,
characterCount: player._count.characters,
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
balances: balances.get(player.id) as Balances,
})),
};
}
export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
// 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 }),
prisma.unmatchedPackage.findMany({
where: { playerId, resolved: false },
orderBy: { scrapedAt: 'desc' },
include: { player: { select: { id: true, displayName: true } } },
}),
prisma.playerAlias.findMany({
where: { playerId },
orderBy: [{ type: 'asc' }, { value: 'asc' }],
}),
// Ältester zuerst: Anträge werden der Reihe nach abgearbeitet.
listThreads({ playerId, order: 'oldest' }),
listPendingRowsFor(playerId),
]);
return {
player,
balances,
characters,
ledger: ledger.items,
ledgerTotal: ledger.total,
unmatched,
aliases,
threads,
pendingRows,
};
}
/**
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
* 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 {
const value = input?.trim();
if (!value) return null;
const normalized = normalizeSteamId(value);
if (!normalized) {
throw conflict(
'INVALID_STEAMID',
`"${value}" sieht nach keiner SteamID aus. Erlaubt sind STEAM_0:1:..., [U:1:...] oder die 17-stellige Zahl.`,
);
}
return normalized;
}
/**
* Wer diese SteamID schon führt - als Hauptangabe oder als früheres Konto.
*
* Beides muss geprüft werden: Eine Kennung, die bei einem Spieler als
* früheres Konto hängt, gehört ihm genauso. Stünde sie daneben bei einem
* anderen als Hauptangabe, wüsste weder der Abgleich noch der Import, wem
* ein Kauf gehört.
*/
async function findeSteamIdInhaber(
steamId: string,
exceptId?: number,
): Promise<{ id: number; displayName: string; alsAlias: boolean } | null> {
const haupt = await prisma.player.findUnique({
where: { steamId },
select: { id: true, displayName: true },
});
if (haupt && haupt.id !== exceptId) return { ...haupt, alsAlias: false };
const alias = await prisma.playerAlias.findUnique({
where: { type_value: { type: AliasType.STEAM_ID, value: steamId } },
select: { playerId: true, player: { select: { displayName: true } } },
});
if (alias && alias.playerId !== exceptId) {
return { id: alias.playerId, displayName: alias.player.displayName, alsAlias: true };
}
return null;
}
/**
* Stellt sicher, dass die Kennung frei ist - und sagt sonst, bei wem sie
* liegt. Ohne den Namen sucht man von Hand durch den ganzen Bestand.
*/
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
const inhaber = await findeSteamIdInhaber(steamId, exceptId);
if (!inhaber) return;
throw conflict(
'STEAMID_TAKEN',
inhaber.alsAlias
? `Diese SteamID ist bei "${inhaber.displayName}" als früheres Konto eingetragen`
: `Diese SteamID gehört bereits zu "${inhaber.displayName}"`,
);
}
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId);
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(),
}
: {}),
},
});
await recordAudit(tx, ctx, {
action: 'player.create',
targetType: 'player',
targetId: created.id,
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) {
const existing = await prisma.player.findUnique({ where: { id: playerId } });
if (!existing) throw notFound('Spieler');
const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId, playerId);
const updated = await prisma.$transaction(async (tx) => {
const updated = await tx.player.update({
where: { id: playerId },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
...(steamId !== undefined ? { steamId } : {}),
...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}),
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
},
});
await recordAudit(tx, ctx, {
action: 'player.update',
targetType: 'player',
targetId: playerId,
details: { displayName: updated.displayName, steamId: updated.steamId },
});
return updated;
});
if (updated.forumName && updated.forumName !== existing.forumName) {
const { claimThreadsForPlayer } = await import('./forum.js');
await claimThreadsForPlayer(playerId, [updated.forumName]);
}
return updated;
}
/**
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
*/
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
const existing = await prisma.player.findUnique({
where: { id: playerId },
include: { _count: { select: { characters: true, ledger: true } } },
});
if (!existing) throw notFound('Spieler');
await prisma.$transaction(async (tx) => {
// Rohzeilen der Importe verlieren ihren Personenbezug.
await tx.importRow.updateMany({
where: {
OR: [
{ resolvedPlayerId: playerId },
...(existing.steamId ? [{ playerKey: existing.steamId }] : []),
],
},
data: {
playerKey: null,
playerName: null,
resolvedPlayerId: null,
raw: {},
},
});
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
await tx.auditLog.updateMany({
where: { targetType: 'player', targetId: String(playerId) },
data: { details: { redacted: true } },
});
await tx.player.delete({ where: { id: playerId } });
await recordAudit(tx, ctx, {
action: 'player.delete',
targetType: 'player',
targetId: playerId,
// 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,
},
});
});
}
/**
* 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 },
});
}
+341
View File
@@ -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);
};
}
+453
View File
@@ -0,0 +1,453 @@
import { ImportStatus, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest, conflict } from '../errors.js';
import {
classifyPackage,
fetchProfile,
resetRequestPace,
type ScrapedProfile,
} from '../lib/gextension.js';
import { PLANS } from './imports.js';
import { recordAudit, type ActionContext } from './audit.js';
/**
* Alle Steam-Kennungen, die im CC-Manager erfasst sind - die aktuelle je
* Spieler und frühere Konten. Wer den Account gewechselt hat, hat seine
* älteren Käufe unter der alten Kennung stehen.
*/
export async function knownSteamIds(): Promise<{ playerId: number; steamId: string }[]> {
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: { steamId: { not: null } },
select: { id: true, steamId: true },
orderBy: { id: 'asc' },
}),
prisma.playerAlias.findMany({
where: { type: 'STEAM_ID' },
select: { playerId: true, value: true },
}),
]);
const alle = [
...players.map((player) => ({ playerId: player.id, steamId: player.steamId as string })),
...aliases.map((alias) => ({ playerId: alias.playerId, steamId: alias.value })),
];
// Dieselbe Kennung nicht zweimal abrufen.
const gesehen = new Set<string>();
return alle.filter((eintrag) => {
if (gesehen.has(eintrag.steamId)) return false;
gesehen.add(eintrag.steamId);
return true;
});
}
/**
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
*/
export async function steamIdsOf(playerId: number): Promise<string[]> {
const [player, aliases] = await Promise.all([
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
prisma.playerAlias.findMany({
where: { playerId, type: 'STEAM_ID' },
select: { value: true },
orderBy: { id: 'asc' },
}),
]);
const alle = [
...(player?.steamId ? [player.steamId] : []),
...aliases.map((alias) => alias.value),
];
return [...new Set(alle)];
}
export interface ScrapeSummary {
importId: number | null;
scannedPlayers: number;
failedPlayers: { steamId: string; reason: string }[];
newRows: number;
duplicates: number;
/// Lag schon in einer offenen Vorschau und wurde deshalb nicht erneut aufgenommen.
awaitingConfirmation: number;
unclear: number;
usageCounters: number;
}
// Eigener Namensraum, damit sich Scraper und Datei-Import beim Dedup nicht
// in die Quere kommen.
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
export interface ScrapeState {
running: boolean;
startedAt: string | null;
finishedAt: string | null;
/// Wessen Profil gerade gelesen wird.
currentPlayer: string | null;
playersRead: number;
totalPlayers: number;
log: string[];
summary: ScrapeSummary | null;
error: string | null;
}
/**
* Ein Abgleich über alle Spieler geht über dutzende Profile, zwischen denen
* bewusst gewartet wird - das überlebt keine HTTP-Anfrage. Deshalb läuft er
* im Hintergrund, und die Oberfläche fragt den Stand ab.
*/
const scrapeState: ScrapeState = {
running: false,
startedAt: null,
finishedAt: null,
currentPlayer: null,
playersRead: 0,
totalPlayers: 0,
log: [],
summary: null,
error: null,
};
const MAX_LOG_LINES = 200;
function protokoll(zeile: string): void {
scrapeState.log.push(zeile);
if (scrapeState.log.length > MAX_LOG_LINES) scrapeState.log.shift();
}
export function getScrapeState(): ScrapeState {
return { ...scrapeState };
}
/**
* Stößt einen Abgleich an und kehrt sofort zurück.
*
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scrapePlayers selbst.
* Zwei Läufe nebeneinander würden dieselben Profile doppelt abrufen und den
* fremden Server ohne Not belasten.
*/
export function startScrape(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): ScrapeState {
if (scrapeState.running) {
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
}
if (targets.length === 0) {
throw badRequest('NO_TARGETS', 'Es gibt niemanden abzugleichen');
}
void scrapePlayers(targets, ctx).catch(() => {
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
});
return getScrapeState();
}
/**
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
* automatisch gebucht.
*/
export async function scrapePlayers(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
if (scrapeState.running) {
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
}
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
scrapeState.running = true;
scrapeState.startedAt = new Date().toISOString();
scrapeState.finishedAt = null;
scrapeState.currentPlayer = null;
scrapeState.playersRead = 0;
scrapeState.totalPlayers = targets.length;
scrapeState.log = [];
scrapeState.summary = null;
scrapeState.error = null;
try {
return await scrapeIntern(targets, ctx);
} catch (error) {
scrapeState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
throw error;
} finally {
scrapeState.running = false;
scrapeState.currentPlayer = null;
scrapeState.finishedAt = new Date().toISOString();
}
}
async function scrapeIntern(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
const summary: ScrapeSummary = {
importId: null,
scannedPlayers: 0,
failedPlayers: [],
newRows: 0,
duplicates: 0,
awaitingConfirmation: 0,
unclear: 0,
usageCounters: 0,
};
const rows: Prisma.ImportRowCreateManyImportInput[] = [];
let lineNo = 0;
// Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst,
// wenn GExtension es verlangt.
resetRequestPace();
// Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne
// diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren
// Import, und beim Buchen kollidieren sie miteinander.
const pending = await prisma.importRow.findMany({
where: { externalId: { not: null }, import: { status: ImportStatus.PENDING } },
select: { externalId: true },
});
const alreadyPending = new Set(pending.map((row) => row.externalId as string));
// Die Namen einmal holen, damit das Protokoll lesbar ist statt SteamIDs
// aneinanderzureihen.
const namen = new Map(
(
await prisma.player.findMany({
where: { id: { in: [...new Set(targets.map((t) => t.playerId))] } },
select: { id: true, displayName: true },
})
).map((p) => [p.id, p.displayName]),
);
for (const target of targets) {
const name = namen.get(target.playerId) ?? target.steamId;
scrapeState.currentPlayer = name;
let profile: ScrapedProfile;
try {
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
summary.scannedPlayers += 1;
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
summary.failedPlayers.push({ steamId: target.steamId, reason: grund });
scrapeState.playersRead += 1;
protokoll(`${name}: nicht abrufbar (${grund})`);
continue;
}
for (const pkg of profile.packages) {
const classification = classifyPackage(pkg);
const externalId = externalIdFor(pkg.packageBoughtId);
if (classification.kind === 'usage_counter') {
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionUsage: classification.times, gextensionSyncedAt: new Date() },
});
summary.usageCounters += 1;
continue;
}
if (classification.kind === 'unclear') {
await prisma.unmatchedPackage.upsert({
where: { externalId },
create: {
externalId,
playerId: target.playerId,
steamId: profile.steamId64,
rawTitle: pkg.packageName,
rawTag: pkg.serverBundle,
amount: pkg.amount || null,
purchasedAt: pkg.purchasedAt,
},
update: {},
});
summary.unclear += 1;
continue;
}
if (classification.kind === 'ignore') continue;
const alreadyBooked = await prisma.importedOrder.findUnique({
where: { externalId },
select: { id: true },
});
if (alreadyBooked) {
summary.duplicates += 1;
continue;
}
if (alreadyPending.has(externalId)) {
summary.awaitingConfirmation += 1;
continue;
}
alreadyPending.add(externalId);
lineNo += 1;
rows.push({
lineNo,
externalId,
playerKey: profile.steamId64,
playerName: profile.nickname,
productName: pkg.packageName,
purchasedAt: pkg.purchasedAt,
target: classification.target,
resolvedPlayerId: target.playerId,
plan: PLANS.BOOK,
raw: {
package_name: pkg.packageName,
serverbundle_name: pkg.serverBundle,
amount: pkg.amount,
gateway: pkg.gateway,
} as Prisma.InputJsonValue,
});
summary.newRows += 1;
}
// Auch ohne neue Pakete festhalten, dass das Profil gesehen wurde.
await prisma.player.update({
where: { id: target.playerId },
data: { gextensionSyncedAt: new Date() },
});
scrapeState.playersRead += 1;
protokoll(
`${name}: ${profile.packages.length} ${
profile.packages.length === 1 ? 'Eintrag' : 'Einträge'
} gelesen`,
);
}
scrapeState.currentPlayer = null;
protokoll(
summary.newRows > 0
? `${summary.newRows} neue Pakete warten auf die Bestätigung`
: 'Keine neuen Pakete',
);
if (rows.length > 0) {
const created = await prisma.import.create({
data: {
filename: `GExtension-Abgleich ${new Date().toLocaleDateString('de-DE')}`,
status: ImportStatus.PENDING,
rowCount: rows.length,
importedById: ctx.actorId,
mapping: { source: 'gextension' } as Prisma.InputJsonValue,
rows: { createMany: { data: rows } },
},
});
summary.importId = created.id;
}
scrapeState.summary = summary;
await recordAudit(prisma, ctx, {
action: 'scrape.run',
targetType: 'import',
targetId: summary.importId,
details: {
scannedPlayers: summary.scannedPlayers,
newRows: summary.newRows,
duplicates: summary.duplicates,
awaitingConfirmation: summary.awaitingConfirmation,
unclear: summary.unclear,
failed: summary.failedPlayers.length,
},
});
return summary;
}
// --- Handprüfung der unklaren Einträge ---
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
export async function listUnmatched(onlyOpen = true) {
return prisma.unmatchedPackage.findMany({
where: onlyOpen ? { resolved: false } : {},
orderBy: [{ scrapedAt: 'desc' }, { id: 'desc' }],
include: { player: { select: { id: true, displayName: true } } },
});
}
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
export async function unmatchedOverview() {
const open = await prisma.unmatchedPackage.findMany({
where: { resolved: false },
select: { playerId: true, player: { select: { displayName: true } } },
});
const byPlayer = new Map<number, { playerId: number; displayName: string; count: number }>();
for (const entry of open) {
if (entry.playerId === null) continue;
const current = byPlayer.get(entry.playerId);
if (current) {
current.count += 1;
} else {
byPlayer.set(entry.playerId, {
playerId: entry.playerId,
displayName: entry.player?.displayName ?? 'Unbekannt',
count: 1,
});
}
}
return { total: open.length, players: [...byPlayer.values()] };
}
/**
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
* ein Redesign mitbringt.
*/
export async function resolveUnmatched(
id: number,
resolution: Resolution,
ctx: ActionContext,
): Promise<void> {
const entry = await prisma.unmatchedPackage.findUnique({ where: { id } });
if (!entry) throw badRequest('NOT_FOUND', 'Eintrag nicht gefunden');
if (entry.resolved) throw badRequest('ALREADY_RESOLVED', 'Dieser Eintrag ist schon entschieden');
if (resolution !== 'IGNORE') {
if (entry.playerId === null) {
throw badRequest('NO_PLAYER', 'Dem Eintrag ist kein Spieler zugeordnet');
}
const { bookCcPackage, bookKulanz, bookRedesignPackage } = await import('./credits.js');
const note = `GExtension: ${entry.rawTitle}`;
if (resolution === 'CC_PACKAGE') {
await bookCcPackage(entry.playerId, ctx, { note });
} else if (resolution === 'REDESIGN_PACKAGE') {
await bookRedesignPackage(entry.playerId, ctx, { note });
} else {
await bookKulanz(entry.playerId, ctx, { note });
}
}
await prisma.unmatchedPackage.update({
where: { id },
data: {
resolved: true,
resolvedAs: resolution,
resolvedById: ctx.actorId,
resolvedAt: new Date(),
},
});
await recordAudit(prisma, ctx, {
action: 'scrape.resolve',
targetType: 'unmatched_package',
targetId: id,
details: { rawTitle: entry.rawTitle, resolution, playerId: entry.playerId },
});
}
+194
View File
@@ -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');
});
});
+564
View File
@@ -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);
});
});
+229
View File
@@ -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',
);
});
});
+123
View File
@@ -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/&amp;labelIDs=1" class="badge label red jsTooltip" title="Label">Wichtig</a></li>
<li><a href="index.php?board/1/&amp;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/&amp;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/&amp;pageNo=2" class="pageNavigationLink">2</a></li>
<li><a href="index.php?board/66-custom-characters/&amp;pageNo=3" class="pageNavigationLink">3</a></li>
</ul></nav>
</body></html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<!-- Nachbau der Struktur einer GExtension-Profilseite mit erfundenen Daten.
Die echte Seite bettet die Kaeufe genauso als donations_data-Zuweisungen ein. -->
<html lang="de">
<head><title>Profil</title></head>
<body>
<div class="box"><h3>ALLGEMEIN</h3>
<table><tr><td>Nickname</td><td>Testspieler</td></tr>
<tr><td>SteamID64</td><td>76561198000000999</td></tr></table>
</div>
<div class="box"><h3>Pakete</h3><div id="packages"></div></div>
<script>
var donations_data = [];
donations_data[9001] = {"id":"","package_bought_id":9001,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:54","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9002] = {"id":"","package_bought_id":9002,"amount":"15.00 EUR","package":10,"package_name":"Custom Character Redesign","steamid64_buyer":"","nick_buyer":"","date":"07.10.2025 10:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9003] = {"id":"","package_bought_id":9003,"amount":"0.00 EUR","package":11,"package_name":"Custom Charakter-Beantragung","steamid64_buyer":"","nick_buyer":"","date":"08.10.2025 11:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9004] = {"id":"","package_bought_id":9004,"amount":"0.00 EUR","package":70,"package_name":"FG-Tasse | Merch","steamid64_buyer":"","nick_buyer":"","date":"06.10.2025 20:53","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9005] = {"id":"","package_bought_id":9005,"amount":"49.67 EUR","package":80,"package_name":"Ablassbrief (01.04.2026)","steamid64_buyer":"","nick_buyer":"","date":"01.04.2026 09:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"Web","serverbundle":0,"gateway":"Paypal","revoked":0,"status":1};
donations_data[9006] = {"id":"","package_bought_id":9006,"amount":"0.00 EUR","package":12,"package_name":"CC-Paket oder Redesign genutzt | 1 Mal","steamid64_buyer":"","nick_buyer":"","date":"09.10.2025 12:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
donations_data[9007] = {"id":"","package_bought_id":9007,"amount":"0.00 EUR","package":9,"package_name":"Custom Character","steamid64_buyer":"","nick_buyer":"","date":"10.10.2025 13:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":1,"status":0};
donations_data[9008] = {"id":"","package_bought_id":9008,"amount":"0.00 EUR","package":99,"package_name":"CC Sonderaktion Winter","steamid64_buyer":"","nick_buyer":"","date":"11.10.2025 14:00","expires":"01.01.2037 00:00","expired":false,"days":0,"serverbundle_name":"SCP-RP","serverbundle":1,"gateway":"","revoked":0,"status":1};
</script>
</body>
</html>
+160
View File
@@ -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);
});
});
+872
View File
@@ -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');
});
});
+68
View File
@@ -0,0 +1,68 @@
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 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;
stop: () => Promise<void>;
}
/**
* 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();
for (const file of migrationFiles()) {
await db.exec(readFileSync(file, 'utf8'));
}
// 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 Einschränkung nicht.
url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`,
stop: async () => {
await server.stop();
await db.close();
},
};
}
+451
View File
@@ -0,0 +1,451 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let app: FastifyInstance;
let db: Db;
const PASSWORD = 'TestPasswort123';
interface Credentials {
session: string;
csrf: string;
}
async function login(username: string, password = PASSWORD): Promise<Credentials> {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username, password },
});
assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`);
const session = response.cookies.find((c) => c.name === 'ccm_sid');
const csrf = response.cookies.find((c) => c.name === 'ccm_csrf');
assert.ok(session && csrf, 'Sitzungs-Cookies fehlen');
return { session: session.value, csrf: csrf.value };
}
function authHeaders(credentials: Credentials, withCsrf = true) {
return {
cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`,
...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}),
};
}
before(async () => {
database = await startTestDatabase(54330);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
process.env.COOKIE_SECURE = 'false';
db = await import('../src/db.js');
const { hashPassword } = await import('../src/auth/password.js');
const { buildApp } = await import('../src/app.js');
const passwordHash = await hashPassword(PASSWORD);
await db.prisma.user.createMany({
data: [
{ username: 'chef', passwordHash, role: 'ADMIN' },
{ username: 'team', passwordHash, role: 'EDITOR' },
{ username: 'gast', passwordHash, role: 'VIEWER' },
{ username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true },
{ username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false },
],
});
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
await db.prisma.$disconnect();
await database.stop();
});
describe('Anmeldung', () => {
it('weist ein falsches Passwort ab', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(response.statusCode, 401);
});
it('verrät nicht, ob es den Benutzernamen gibt', async () => {
const unknownUser = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gibtesnicht', password: 'falsch' },
});
const wrongPassword = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'chef', password: 'falsch' },
});
assert.equal(unknownUser.statusCode, wrongPassword.statusCode);
assert.equal(unknownUser.json().message, wrongPassword.json().message);
});
it('lässt ein gesperrtes Konto nicht herein', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'gesperrt', password: PASSWORD },
});
assert.equal(response.statusCode, 401);
});
it('setzt bei Erfolg beide Cookies', async () => {
const credentials = await login('chef');
assert.ok(credentials.session.length > 20);
assert.ok(credentials.csrf.length > 20);
});
});
describe('CSRF-Schutz', () => {
it('blockt eine schreibende Anfrage ohne Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials, false),
payload: { displayName: 'Ohne Token' },
});
assert.equal(response.statusCode, 403);
assert.equal(response.json().error, 'FORBIDDEN');
});
it('blockt einen gefälschten Token', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) },
payload: { displayName: 'Falscher Token' },
});
assert.equal(response.statusCode, 403);
});
it('lässt die Anfrage mit passendem Token durch', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Mit Token' },
});
assert.equal(response.statusCode, 201);
});
it('lässt lesende Anfragen ohne Token zu', async () => {
const credentials = await login('gast');
const response = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials, false),
});
assert.equal(response.statusCode, 200);
});
});
describe('Rollen', () => {
it('ein Viewer darf lesen, aber nichts anlegen', async () => {
const credentials = await login('gast');
const read = await app.inject({
method: 'GET',
url: '/api/players',
headers: authHeaders(credentials),
});
const write = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Verboten' },
});
assert.equal(read.statusCode, 200);
assert.equal(write.statusCode, 403);
});
it('ein Editor kommt nicht in den Admin-Bereich', async () => {
const credentials = await login('team');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 403);
});
it('ein Admin kommt in den Admin-Bereich', async () => {
const credentials = await login('chef');
const response = await app.inject({
method: 'GET',
url: '/api/admin/users',
headers: authHeaders(credentials),
});
assert.equal(response.statusCode, 200);
assert.ok(Array.isArray(response.json().items));
});
it('ohne Anmeldung ist alles zu', async () => {
const response = await app.inject({ method: 'GET', url: '/api/players' });
assert.equal(response.statusCode, 401);
});
});
describe('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');
const response = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'Zu früh' },
});
assert.equal(response.statusCode, 403);
assert.match(response.json().message, /Passwort/);
});
it('erlaubt genau den Passwortwechsel', async () => {
const credentials = await login('neu');
const response = await app.inject({
method: 'POST',
url: '/api/account/password',
headers: authHeaders(credentials),
payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' },
});
assert.equal(response.statusCode, 200);
const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } });
assert.equal(user.mustChangePassword, false);
});
});
describe('Guthaben über die API', () => {
it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => {
const credentials = await login('team');
const created = await app.inject({
method: 'POST',
url: '/api/players',
headers: authHeaders(credentials),
payload: { displayName: 'API-Spieler' },
});
const playerId = created.json().id;
const booked = await app.inject({
method: 'POST',
url: `/api/players/${playerId}/credits/cc-package`,
headers: authHeaders(credentials),
payload: {},
});
assert.equal(booked.statusCode, 200);
// 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: 0,
REDESIGN_KULANZ: 0,
redesignTotal: 0,
granted: { CC: 1, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
});
});
it('eine unbekannte API-Route liefert sauberes JSON', async () => {
const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' });
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, 'NOT_FOUND');
});
});
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/);
});
});
+435
View File
@@ -0,0 +1,435 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Imports = typeof import('../src/services/imports.js');
type Credits = typeof import('../src/services/credits.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let imports: Imports;
let credits: Credits;
let db: Db;
let ctx: { actorId: number; actorName: string };
const CSV = [
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
'A-1001;76561198000000001;Rabe;CC-Paket 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',
].join('\n');
const MAPPING = {
playerKey: 'Steam-ID',
playerName: 'Spielername',
product: 'Produkt',
orderId: 'Bestellnummer',
purchasedAt: 'Kaufdatum',
createMissingPlayers: true,
};
before(async () => {
database = await startTestDatabase(54332);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
imports = await import('../src/services/imports.js');
credits = await import('../src/services/credits.js');
const user = await db.prisma.user.create({
data: { username: 'importeur', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
await db.prisma.productMapping.createMany({
data: [
{ matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
{ matchPattern: 'redesign', target: 'REDESIGN_PACKAGE', priority: 20 },
{ matchPattern: 'spielerfarbe', target: 'IGNORE', priority: 30 },
],
});
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Datei einlesen', () => {
it('erkennt Semikolon als Trennzeichen', () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
assert.equal(parsed.rows.length, 4);
assert.deepEqual(parsed.columns, [
'Bestellnummer',
'Steam-ID',
'Spielername',
'Produkt',
'Kaufdatum',
]);
});
it('liest auch JSON-Listen', () => {
const json = JSON.stringify([{ order: 'B-1', product: 'CC-Paket', user: '765' }]);
const parsed = imports.parseUpload('export.json', Buffer.from(json, 'utf8'));
assert.equal(parsed.rows.length, 1);
assert.equal(parsed.rows[0]?.product, 'CC-Paket');
});
it('weist eine leere Datei ab', () => {
assert.throws(() => imports.parseUpload('leer.csv', Buffer.from('', 'utf8')));
});
});
describe('Vorschau und Buchung', () => {
it('ordnet jede Zeile ihrem Plan zu', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('export.csv', parsed, ctx);
const summary = await imports.applyMapping(importId, MAPPING, ctx);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 3, 'drei neue Spieler mit gültigem Produkt');
assert.equal(summary.SKIP_IGNORED, 1, 'die Spielerfarbe wird ignoriert');
assert.equal(summary.SKIP_DUPLICATE, 0);
});
it('bucht CC-Pakete mit beiden Guthaben und legt Spieler an', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('lauf1.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
const result = await imports.commitImport(importId, ctx);
assert.equal(result.booked, 3);
assert.equal(result.createdPlayers, 3);
const rabe = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000001' },
});
const balances = await credits.getBalances(rabe.id);
assert.equal(balances.CC, 1, 'CC-Paket gibt einen CC');
assert.equal(balances.REDESIGN_PAID, 1, 'und das inklusive Redesign');
const fuchs = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000002' },
});
const fuchsBalances = await credits.getBalances(fuchs.id);
assert.equal(fuchsBalances.CC, 0, 'ein Redesign-Paket gibt keinen CC');
assert.equal(fuchsBalances.REDESIGN_PAID, 1);
});
it('bucht dieselbe Bestell-ID kein zweites Mal', async () => {
const parsed = imports.parseUpload('export.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('lauf2.csv', parsed, ctx);
const summary = await imports.applyMapping(importId, MAPPING, ctx);
assert.equal(summary.SKIP_DUPLICATE, 3, 'alle bereits gebuchten Bestellungen');
assert.equal(summary.BOOK, 0);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 0);
await assert.rejects(
() => imports.commitImport(importId, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_BOOK',
);
});
it('erkennt Dubletten auch innerhalb einer Datei', async () => {
const doubled = [
'Bestellnummer;Steam-ID;Produkt',
'C-1;76561198000000009;CC-Paket',
'C-1;76561198000000009;CC-Paket',
].join('\n');
const parsed = imports.parseUpload('doppelt.csv', Buffer.from(doubled, 'utf8'));
const importId = await imports.createImport('doppelt.csv', parsed, ctx);
const summary = await imports.applyMapping(
importId,
{
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: true,
},
ctx,
);
assert.equal(summary.CREATE_PLAYER_AND_BOOK, 1);
assert.equal(summary.SKIP_DUPLICATE, 1);
});
it('ü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);
const summary = await imports.applyMapping(
importId,
{
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: false,
},
ctx,
);
assert.equal(summary.SKIP_NO_PLAYER, 1);
});
it('ein abgeschlossener Import 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);
await imports.applyMapping(
importId,
{ playerKey: 'Steam-ID', product: 'Produkt', orderId: 'Bestellnummer', createMissingPlayers: false },
ctx,
);
await imports.commitImport(importId, ctx);
await assert.rejects(
() => imports.commitImport(importId, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_PENDING',
);
});
});
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
+192
View File
@@ -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',
);
});
});
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Imports = typeof import('../src/services/imports.js');
type Players = typeof import('../src/services/players.js');
type Characters = typeof import('../src/services/characters.js');
type Credits = typeof import('../src/services/credits.js');
type Audit = typeof import('../src/services/audit.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let imports: Imports;
let players: Players;
let characters: Characters;
let credits: Credits;
let audit: Audit;
let db: Db;
let ctx: { actorId: number; actorName: string };
// Ein Export, wie ihn ein Shop liefern könnte: mehr Spalten als die App braucht.
const CSV = [
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
].join('\n');
const MAPPING = {
playerKey: 'Steam-ID',
product: 'Produkt',
orderId: 'Bestellnummer',
createMissingPlayers: true,
};
before(async () => {
database = await startTestDatabase(54333);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
imports = await import('../src/services/imports.js');
players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js');
credits = await import('../src/services/credits.js');
audit = await import('../src/services/audit.js');
const user = await db.prisma.user.create({
data: { username: 'datenschützer', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
await db.prisma.productMapping.create({
data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
});
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Datenminimierung beim Import', () => {
it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => {
const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('shop.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const rawBefore = before.raw as Record<string, string>;
assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da');
await imports.commitImport(importId, ctx);
const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const rawAfter = after.raw as Record<string, string>;
assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg');
assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg');
assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg');
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
});
it('räumt auch einen verworfenen Import auf', async () => {
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
const importId = await imports.createImport('shop2.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
await imports.discardImport(importId, ctx);
const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
const raw = row.raw as Record<string, string>;
assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen');
});
});
describe('Fehlgeschlagene Anmeldung', () => {
it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => {
// Genau der Fall: jemand tippt sein Passwort ins Namensfeld.
const versehen = 'MeinGeheimesPasswort123';
await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, {
action: 'auth.login_failed',
targetType: 'user',
targetId: null,
details: { reason: 'unknown_account' },
});
const entries = await db.prisma.auditLog.findMany({
where: { action: 'auth.login_failed' },
});
for (const entry of entries) {
assert.notEqual(entry.actorName, versehen);
assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer');
}
});
});
describe('Löschung eines Spielers', () => {
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
const csv = [
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
].join('\n');
const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8'));
const importId = await imports.createImport('shop3.csv', parsed, ctx);
await imports.applyMapping(importId, MAPPING, ctx);
await imports.commitImport(importId, ctx);
const player = await db.prisma.player.findUniqueOrThrow({
where: { steamId: '76561198000000501' },
});
await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx);
await players.deletePlayer(player.id, ctx);
const gone = await db.prisma.player.findUnique({ where: { id: player.id } });
assert.equal(gone, null, 'der Spieler ist weg');
const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } });
assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg');
const rows = await db.prisma.importRow.findMany({ where: { importId } });
for (const row of rows) {
assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile');
assert.equal(row.resolvedPlayerId, null);
assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert');
}
const auditEntries = await db.prisma.auditLog.findMany({
where: { targetType: 'player', targetId: String(player.id) },
});
assert.ok(auditEntries.length > 0, 'die Vorgänge selbst bleiben nachvollziehbar');
for (const entry of auditEntries) {
const details = JSON.stringify(entry.details ?? {});
assert.ok(
!details.includes('76561198000000501'),
`SteamID steckt noch in ${entry.action}: ${details}`,
);
}
});
});
describe('Aufbewahrung des Audit-Logs', () => {
it('löscht Einträge jenseits der Frist, neuere bleiben', async () => {
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
const veraltet = await db.prisma.auditLog.create({
data: { action: 'test.alt', targetType: 'test', createdAt: alt },
});
const frisch = await db.prisma.auditLog.create({
data: { action: 'test.frisch', targetType: 'test' },
});
const removed = await audit.cleanupOldAuditEntries();
assert.ok(removed >= 1);
assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null);
assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } }));
});
});
+454
View File
@@ -0,0 +1,454 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
// Die Module lesen beim Laden die Umgebung, deshalb werden sie erst nach dem
// Start der Testdatenbank dynamisch importiert.
type Credits = typeof import('../src/services/credits.js');
type Characters = typeof import('../src/services/characters.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let credits: Credits;
let characters: Characters;
let db: Db;
let ctx: { actorId: number; actorName: string };
async function freshPlayer(name: string): Promise<number> {
const player = await db.prisma.player.create({ data: { displayName: name } });
return player.id;
}
before(async () => {
database = await startTestDatabase(54329);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
credits = await import('../src/services/credits.js');
characters = await import('../src/services/characters.js');
const user = await db.prisma.user.create({
data: { username: 'tester', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
describe('Guthaben-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);
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 1);
assert.equal(balances.REDESIGN_KULANZ, 0);
});
it('ein Redesign-Paket erzeugt nur Redesign-Guthaben', async () => {
const playerId = await freshPlayer('Redesign-Spieler');
const { balances } = await credits.bookRedesignPackage(playerId, ctx);
assert.equal(balances.CC, 0);
assert.equal(balances.REDESIGN_PAID, 1);
});
it('Kulanz landet im eigenen Topf und zählt zur Redesign-Summe', async () => {
const playerId = await freshPlayer('Kulanz-Spieler');
const { balances } = await credits.bookKulanz(playerId, ctx);
assert.equal(balances.REDESIGN_KULANZ, 1);
assert.equal(balances.REDESIGN_PAID, 0);
assert.equal(balances.redesignTotal, 1);
});
it('ein überzogenes Konto wird zugelassen, nicht blockiert', async () => {
const playerId = await freshPlayer('Minus-Spieler');
const result = await characters.createCharacter(playerId, { name: 'Vorab-CC' }, ctx);
assert.equal(result.balances.CC, -1);
assert.equal(result.overdrawn, true);
});
});
describe('CC-Lebenszyklus', () => {
it('ein neuer CC verbraucht genau ein CC-Guthaben', async () => {
const playerId = await freshPlayer('Ersteller');
await credits.bookCcPackage(playerId, ctx);
const result = await characters.createCharacter(playerId, { name: 'Dr. Bright' }, ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.balances.CC, 0);
assert.equal(result.balances.REDESIGN_PAID, 1, 'das inklusive Redesign bleibt erhalten');
assert.equal(result.overdrawn, false);
});
it('ein Redesign setzt den alten CC auf REWORKED und verkettet den neuen', async () => {
const playerId = await freshPlayer('Redesigner');
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
const result = await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal(result.predecessor.status, 'INACTIVE');
assert.equal(result.predecessor.inactiveReason, 'REWORKED');
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.predecessorId, first.character.id);
assert.equal(
result.character.lineageId,
first.character.lineageId,
'die Kette behält ihre lineageId',
);
assert.equal(result.balances.REDESIGN_PAID, 0);
});
it('ein Redesign kann auch aus dem Kulanz-Topf bezahlt werden', async () => {
const playerId = await freshPlayer('Kulanz-Redesigner');
await credits.bookCcPackage(playerId, ctx);
await credits.bookKulanz(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
const result = await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_KULANZ' },
ctx,
);
assert.equal(result.balances.REDESIGN_KULANZ, 0);
assert.equal(result.balances.REDESIGN_PAID, 1, 'der bezahlte Topf bleibt unangetastet');
});
});
describe('Reaktivierung', () => {
it('ein wegen 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);
await characters.deactivateCharacter(created.character.id, 'PLAYER_INACTIVITY', ctx);
const before = await credits.getBalances(playerId);
const result = await characters.reactivateFree(created.character.id, ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.inactiveReason, null);
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
});
it('ein reworkter CC kommt NICHT gratis zurück', async () => {
const playerId = await freshPlayer('Rework-Spieler');
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
await assert.rejects(
() => characters.reactivateFree(first.character.id, ctx),
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
);
});
it('ein reworkter CC kommt gegen CC-Guthaben zurück', async () => {
const playerId = await freshPlayer('Wiederbeleber');
await credits.bookCcPackage(playerId, ctx);
await credits.bookCcPackage(playerId, ctx);
const first = await characters.createCharacter(playerId, { name: 'Alt' }, ctx);
await characters.redesignCharacter(
first.character.id,
{ name: 'Neu', useKind: 'REDESIGN_PAID' },
ctx,
);
const before = await credits.getBalances(playerId);
const result = await characters.reactivatePaid(first.character.id, 'CC', ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.balances.CC, before.CC - 1);
});
it('auch ein aus anderem Grund entfernter CC kommt gratis zurück', async () => {
const playerId = await freshPlayer('Einheit-weg');
await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'MTF-Operativer' }, ctx);
await characters.deactivateCharacter(
created.character.id,
'OTHER',
ctx,
'Einheit bis März entfernt',
);
const deactivated = await db.prisma.character.findUniqueOrThrow({
where: { id: created.character.id },
});
assert.equal(deactivated.inactiveNote, 'Einheit bis März entfernt', 'der Grund bleibt lesbar');
const before = await credits.getBalances(playerId);
const result = await characters.reactivateFree(created.character.id, ctx);
assert.equal(result.character.status, 'ACTIVE');
assert.equal(result.character.inactiveNote, null, 'die Notiz wird beim Zurückholen gelöscht');
assert.deepEqual(result.balances, before, 'kein Guthaben wurde bewegt');
});
it('eine bezahlte Reaktivierung wird bei jedem Gratis-Fall verweigert', async () => {
const playerId = await freshPlayer('Gratis-Fall');
await credits.bookCcPackage(playerId, ctx);
await credits.bookCcPackage(playerId, ctx);
for (const reason of ['PLAYER_INACTIVITY', 'OTHER'] as const) {
const created = await characters.createCharacter(playerId, { name: `CC-${reason}` }, ctx);
await characters.deactivateCharacter(created.character.id, reason, ctx);
await assert.rejects(
() => characters.reactivatePaid(created.character.id, 'CC', ctx),
(error: Error & { code?: string }) => error.code === 'IS_FREE',
`Grund ${reason} darf nichts kosten`,
);
}
});
});
describe('Korrekturen', () => {
it('ein Paket-Storno nimmt beide Zeilen gemeinsam zurück', async () => {
const playerId = await freshPlayer('Storno-Spieler');
const booked = await credits.bookCcPackage(playerId, ctx);
const entry = await db.prisma.creditLedger.findFirstOrThrow({
where: { groupId: booked.groupId },
});
const result = await credits.reverseGroup(entry.id, 'Doppelt erfasst', ctx);
assert.equal(result.reversed, 2, 'beide Zeilen des Pakets');
assert.equal(result.balances.CC, 0);
assert.equal(result.balances.REDESIGN_PAID, 0);
});
it('dieselbe Buchung 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({
where: { groupId: booked.groupId },
});
await credits.reverseGroup(entry.id, 'Erste Korrektur', ctx);
await assert.rejects(
() => credits.reverseGroup(entry.id, 'Nochmal', ctx),
(error: Error & { code?: string }) => error.code === 'ALREADY_REVERSED',
);
});
it('eine Verbrauchsbuchung wird nicht direkt storniert', async () => {
const playerId = await freshPlayer('Verbrauch-Storno');
await credits.bookCcPackage(playerId, ctx);
const created = await characters.createCharacter(playerId, { name: 'CC' }, ctx);
const consume = await db.prisma.creditLedger.findFirstOrThrow({
where: { characterId: created.character.id, source: 'CONSUME' },
});
await assert.rejects(
() => credits.reverseGroup(consume.id, 'Versuch', ctx),
(error: Error & { code?: string }) => error.code === 'CONSUME_NOT_REVERSIBLE',
);
});
it('eine 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(
first.character.id,
{ name: 'Versehen', useKind: 'REDESIGN_PAID' },
ctx,
);
const result = await characters.revertCharacter(
redesigned.character.id,
'Falscher Spieler',
ctx,
);
assert.equal(result.restoredPredecessorId, first.character.id);
assert.equal(result.balances.REDESIGN_PAID, 1, 'das Redesign-Guthaben ist wieder da');
const restored = await db.prisma.character.findUniqueOrThrow({
where: { id: first.character.id },
});
assert.equal(restored.status, 'ACTIVE');
assert.equal(restored.inactiveReason, null);
});
it('eine 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);
const first = await characters.createCharacter(playerId, { name: 'A' }, ctx);
const second = await characters.redesignCharacter(
first.character.id,
{ name: 'B', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.redesignCharacter(
second.character.id,
{ name: 'C', useKind: 'REDESIGN_PAID' },
ctx,
);
await assert.rejects(
() => characters.revertCharacter(second.character.id, 'Zu 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');
});
});
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { classifyPackage, parseProfile, type ScrapedPackage } from '../src/lib/gextension.js';
import { looksLikeSteamId, normalizeSteamId, toLegacySteamId } from '../src/lib/steamid.js';
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE = readFileSync(join(here, 'fixtures', 'profil.html'), 'utf8');
function pkg(overrides: Partial<ScrapedPackage>): ScrapedPackage {
return {
packageBoughtId: 1,
packageName: 'Custom Character',
serverBundle: 'SCP-RP',
purchasedAt: null,
amount: '0.00 EUR',
gateway: '',
revoked: false,
...overrides,
};
}
describe('Profil auslesen', () => {
const profile = parseProfile(FIXTURE, '76561198000000999');
it('findet alle Pakete', () => {
assert.equal(profile.packages.length, 8);
});
it('liest Titel, Bereich und Kaufdatum', () => {
const cc = profile.packages.find((p) => p.packageBoughtId === 9001);
assert.ok(cc);
assert.equal(cc.packageName, 'Custom Character');
assert.equal(cc.serverBundle, 'SCP-RP');
assert.equal(cc.purchasedAt?.getFullYear(), 2025);
assert.equal(cc.purchasedAt?.getMonth(), 9, 'Oktober');
assert.equal(cc.purchasedAt?.getDate(), 6);
});
it('erkennt stornierte Käufe', () => {
const revoked = profile.packages.find((p) => p.packageBoughtId === 9007);
assert.equal(revoked?.revoked, true);
});
it('liest den Nicknamen aus der Allgemein-Box', () => {
assert.equal(profile.nickname, 'Testspieler');
});
it('kommt mit kaputtem HTML klar, ohne alles zu verlieren', () => {
const broken = FIXTURE.replace(
'donations_data[9004] = {"id"',
'donations_data[9004] = {kaputt"id"',
);
const result = parseProfile(broken, '76561198000000999');
assert.equal(result.packages.length, 7, 'nur die kaputte Zeile fällt weg');
});
});
describe('Pakete zuordnen', () => {
it('erkennt ein CC-Paket', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('erkennt die Schreibweise mit K', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Charakter-Beantragung' })), {
kind: 'package',
target: 'CC_PACKAGE',
});
});
it('zählt "Custom Character Redesign" als Redesign, nicht als CC', () => {
assert.deepEqual(classifyPackage(pkg({ packageName: 'Custom Character Redesign' })), {
kind: 'package',
target: 'REDESIGN_PACKAGE',
});
});
it('liest den Verbrauchszähler samt Anzahl', () => {
const result = classifyPackage(
pkg({ packageName: 'CC-Paket oder Redesign genutzt | 3 Mal' }),
);
assert.deepEqual(result, { kind: 'usage_counter', times: 3 });
});
it('ignoriert Merch und Ränge trotz SCP-RP-Tag', () => {
assert.equal(classifyPackage(pkg({ packageName: 'FG-Tasse | Merch' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: '25 Ingame-Level' })).kind, 'ignore');
assert.equal(classifyPackage(pkg({ packageName: 'VIP (12 Monate)' })).kind, 'ignore');
});
it('ignoriert alles aus dem Web-Bereich', () => {
const result = classifyPackage(
pkg({ packageName: 'Ablassbrief (01.04.2026)', serverBundle: 'Web' }),
);
assert.equal(result.kind, 'ignore');
});
it('bucht ein storniertes Paket nicht', () => {
const result = classifyPackage(pkg({ packageName: 'Custom Character', revoked: true }));
assert.equal(result.kind, 'ignore');
});
it('schiebt unklare CC-Titel in die Handprüfung statt zu raten', () => {
assert.equal(classifyPackage(pkg({ packageName: 'CC Sonderaktion Winter' })).kind, 'unclear');
});
});
describe('SteamID-Schreibweisen', () => {
// Dieselbe (erfundene) Kennung in drei Formaten.
const legacy = 'STEAM_0:1:19867635';
const modern = '[U:1:39735271]';
const sixtyFour = '76561198000000999';
it('führt alle drei Formate auf dieselbe ID zusammen', () => {
assert.equal(normalizeSteamId(legacy), sixtyFour);
assert.equal(normalizeSteamId(modern), sixtyFour);
assert.equal(normalizeSteamId(sixtyFour), sixtyFour);
});
it('rechnet zurück in die Legacy-Form', () => {
assert.equal(toLegacySteamId(sixtyFour), legacy);
});
it('beherrscht auch das ungerade Bit', () => {
const odd = 'STEAM_0:1:12345678';
const asNumber = normalizeSteamId(odd);
assert.ok(asNumber);
assert.equal(toLegacySteamId(asNumber), odd);
});
it('weist Unsinn ab', () => {
assert.equal(normalizeSteamId('Darkbrine44'), null);
assert.equal(normalizeSteamId(''), null);
assert.equal(normalizeSteamId('12345'), null);
assert.equal(looksLikeSteamId('Darkbrine44'), false);
});
});
// Der Ablauf mit Datenbank: was passiert bei einem zweiten Abgleich?
describe('Mehrfacher Abgleich', () => {
it('nimmt Pakete aus einer offenen Vorschau nicht erneut auf', async () => {
const { startTestDatabase } = await import('./harness.js');
const database = await startTestDatabase(54334);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
const db = await import('../src/db.js');
const { PLANS } = await import('../src/services/imports.js');
const user = await db.prisma.user.create({
data: { username: 'abgleicher', passwordHash: 'x', role: 'ADMIN' },
});
const player = await db.prisma.player.create({
data: { displayName: 'Testspieler', steamId: '76561198000000999' },
});
// Erster Lauf: ein Paket landet in einer offenen Vorschau.
const first = await db.prisma.import.create({
data: {
filename: 'Abgleich 1',
status: 'PENDING',
rowCount: 1,
importedById: user.id,
rows: {
create: [
{
lineNo: 1,
externalId: 'gext:9001',
playerKey: '76561198000000999',
productName: 'Custom Character',
target: 'CC_PACKAGE',
resolvedPlayerId: player.id,
plan: PLANS.BOOK,
raw: {},
},
],
},
},
});
// Genau diese Pruefung macht der zweite Abgleich.
const pending = await db.prisma.importRow.findMany({
where: { externalId: { not: null }, import: { status: 'PENDING' } },
select: { externalId: true },
});
const wartend = new Set(pending.map((row) => row.externalId as string));
assert.ok(wartend.has('gext:9001'), 'das Paket wird als wartend erkannt');
assert.equal(wartend.size, 1);
// Nach dem Buchen zaehlt stattdessen die Dedup ueber ImportedOrder.
await db.prisma.import.update({ where: { id: first.id }, data: { status: 'COMMITTED' } });
const danach = await db.prisma.importRow.findMany({
where: { externalId: { not: null }, import: { status: 'PENDING' } },
select: { externalId: true },
});
assert.equal(danach.length, 0, 'ein gebuchter Import blockiert nicht mehr');
await db.prisma.$disconnect();
await database.stop();
});
});
+282
View File
@@ -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);
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+11
View File
@@ -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"]
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<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>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@cc-manager/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.3.0"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.1"
}
}
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

+109
View File
@@ -0,0 +1,109 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout';
import { useSession } from './lib/session';
import type { Role } from './lib/types';
import Account from './pages/Account';
import Admin from './pages/Admin';
import Dashboard from './pages/Dashboard';
import Forum from './pages/Forum';
import Import from './pages/Import';
import Login from './pages/Login';
import PlayerDetail from './pages/PlayerDetail';
function Protected({ children, minimum }: { children: React.ReactNode; minimum?: Role }) {
const { user, loading, can } = useSession();
if (loading) return <div className="boot">Lade …</div>;
if (!user) return <Navigate to="/login" replace />;
// Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln.
if (user.mustChangePassword) return <Navigate to="/account" replace />;
if (minimum && !can(minimum)) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
const { user, loading } = useSession();
return (
<Routes>
<Route
path="/login"
element={loading ? <div className="boot">Lade …</div> : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route
path="/account"
element={
loading ? (
<div className="boot">Lade …</div>
) : !user ? (
<Navigate to="/login" replace />
) : (
<Layout>
<Account />
</Layout>
)
}
/>
<Route
path="/"
element={
<Protected>
<Layout>
<Dashboard />
</Layout>
</Protected>
}
/>
<Route
path="/players/:id"
element={
<Protected>
<Layout>
<PlayerDetail />
</Layout>
</Protected>
}
/>
<Route
path="/forum"
element={
<Protected>
<Layout>
<Forum />
</Layout>
</Protected>
}
/>
<Route
path="/import"
element={
<Protected minimum="EDITOR">
<Layout>
<Import />
</Layout>
</Protected>
}
/>
<Route
path="/admin"
element={
<Protected minimum="ADMIN">
<Layout>
<Admin />
</Layout>
</Protected>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+185
View File
@@ -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>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Balances } from '../lib/types';
/**
* 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}
{granted > 0 && <span className="balance-of"> von {granted}</span>}
</span>
</div>
);
}
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} 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 ? 'möglich' : 'nicht möglich'}
</span>
<span className={canRedesign ? 'ok' : 'no'}>
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'möglich' : 'nicht möglich'}
{canRedesign && ` (${balances.redesignTotal} verfügbar)`}
</span>
</div>
{(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && (
<p className="overdrawn-hint">
Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht
erfasstes Paket hin.
</p>
)}
</section>
);
}
+652
View File
@@ -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>
);
}
+211
View File
@@ -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;
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useSession } from '../lib/session';
export default function Layout({ children }: { children: ReactNode }) {
const { user, logout, can } = useSession();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
return (
<div className="shell">
<header className="topbar">
<div className="topbar-inner">
<NavLink to="/" className="brand">
CC-Manager
</NavLink>
<nav className="nav">
<NavLink to="/" end>
Spieler
</NavLink>
<NavLink to="/forum">Anträge</NavLink>
{can('EDITOR') && <NavLink to="/import">Import</NavLink>}
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
</nav>
<div className="topbar-right">
<NavLink to="/account" className="user-chip">
{user?.username}
<span className="role-tag">{user?.role}</span>
</NavLink>
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
Abmelden
</button>
</div>
</div>
</header>
<main className="content">{children}</main>
<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>
);
}
+169
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import type { ReactNode } from 'react';
interface ModalProps {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
export default function Modal({ title, onClose, children, footer }: ModalProps) {
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="modal-backdrop" onMouseDown={onClose}>
<div
className="modal"
role="dialog"
aria-modal="true"
aria-label={title}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-head">
<h2>{title}</h2>
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schließen">
✕
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
interface NoticeProps {
kind: 'error' | 'success' | 'info' | 'warn';
children: React.ReactNode;
onDismiss?: () => void;
}
export default function Notice({ kind, children, onDismiss }: NoticeProps) {
return (
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
<span>{children}</span>
{onDismiss && (
<button type="button" className="notice-close" onClick={onDismiss} aria-label="Ausblenden">
✕
</button>
)}
</div>
);
}
+146
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+331
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import type { Resolution, UnmatchedPackage } from '../lib/types';
const CHOICES: { value: Resolution; label: string; hint: string }[] = [
{ value: 'CC_PACKAGE', label: 'Gekauftes CC-Paket', hint: '+1 CC und +1 Redesign' },
{ value: 'REDESIGN_PACKAGE', label: 'Gekauftes Redesign', hint: '+1 Redesign (bezahlt)' },
{ value: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hint: '+1 Kulanz' },
{ value: 'IGNORE', label: 'Nichts davon', hint: 'ohne Buchung abhaken' },
];
/**
* Pakete, die nach CC klingen, aber von keiner Regel sicher zugeordnet wurden.
* Sie werden bewusst nie automatisch gebucht, sondern hier entschieden.
*/
export default function UnmatchedBox({
items,
canResolve,
onResolved,
}: {
items: UnmatchedPackage[];
canResolve: boolean;
onResolved: () => void;
}) {
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
if (items.length === 0) return null;
const resolve = async (id: number, resolution: Resolution) => {
setBusyId(id);
setError(null);
try {
await api(`/api/scrape/unmatched/${id}/resolve`, {
method: 'POST',
body: { resolution },
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Konnte nicht gespeichert werden');
} finally {
setBusyId(null);
}
};
return (
<section className="block block-flag">
<h2>Ungeklärte Einträge ({items.length})</h2>
<Notice kind="warn">
Diese Pakete stammen aus dem GExtension-Abgleich und klingen nach CC oder Redesign,
ließen sich aber keiner Guthaben-Art sicher zuordnen. Sie werden erst gebucht, wenn du
sie hier einordnest.
</Notice>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
<ul className="cc-list">
{items.map((item) => (
<li key={item.id} className="cc-item">
<div>
<strong>{item.rawTitle}</strong>
<span className="badge">{item.rawTag}</span>
<p className="muted small">
{item.amount && `${item.amount} · `}
Gekauft {formatDate(item.purchasedAt)} · gefunden {formatDate(item.scrapedAt)}
</p>
</div>
{canResolve && (
<div className="cc-actions">
{CHOICES.map((choice) => (
<button
key={choice.value}
type="button"
className={`btn btn-small${choice.value === 'IGNORE' ? ' btn-ghost' : ''}`}
title={choice.hint}
disabled={busyId !== null}
onClick={() => void resolve(item.id, choice.value)}
>
{choice.label}
</button>
))}
</div>
)}
</li>
))}
</ul>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More