Files
CC-Manager/server/prisma/schema.prisma
T
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

509 lines
16 KiB
Plaintext

generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
EDITOR
VIEWER
}
enum CreditKind {
CC
REDESIGN_PAID
REDESIGN_KULANZ
}
enum CreditSource {
CC_PACKAGE
REDESIGN_PACKAGE
KULANZ
ADJUSTMENT
CONSUME
REVERSAL
}
enum CharacterStatus {
ACTIVE
INACTIVE
}
enum InactiveReason {
/// 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)
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?
/// 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])
}
/// 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])
}