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.
464 lines
14 KiB
Plaintext
464 lines
14 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?
|
|
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())
|
|
predecessorId Int?
|
|
predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
|
successors Character[] @relation("CharacterLineage")
|
|
|
|
ledgerEntries CreditLedger[]
|
|
forumThreads ForumThread[]
|
|
members CharacterMember[]
|
|
/// Beitritte, für die dieser CC aufgegeben wurde.
|
|
givenUpFor CharacterMember[] @relation("MemberPredecessor")
|
|
|
|
createdById Int?
|
|
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([playerId])
|
|
@@index([playerId, status])
|
|
@@index([lineageId])
|
|
}
|
|
|
|
model Import {
|
|
id Int @id @default(autoincrement())
|
|
filename String
|
|
status ImportStatus @default(PENDING)
|
|
rowCount Int @default(0)
|
|
bookedCount Int @default(0)
|
|
mapping Json?
|
|
createdAt DateTime @default(now())
|
|
committedAt DateTime?
|
|
|
|
importedById Int?
|
|
importedBy User? @relation(fields: [importedById], references: [id], onDelete: SetNull)
|
|
|
|
rows ImportRow[]
|
|
orders ImportedOrder[]
|
|
ledgerEntries CreditLedger[]
|
|
|
|
@@index([status])
|
|
}
|
|
|
|
model ImportRow {
|
|
id Int @id @default(autoincrement())
|
|
importId Int
|
|
import Import @relation(fields: [importId], references: [id], onDelete: Cascade)
|
|
|
|
lineNo Int
|
|
externalId String?
|
|
playerKey String?
|
|
playerName String?
|
|
productName String?
|
|
purchasedAt DateTime?
|
|
|
|
target ProductTarget?
|
|
resolvedPlayerId Int?
|
|
|
|
/// Was beim Buchen passieren 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?
|
|
/// 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 ausgestiegen ist. Der Eintrag bleibt stehen,
|
|
/// damit nachvollziehbar ist, wer den CC wann genutzt hat.
|
|
leftAt DateTime?
|
|
|
|
/// 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)
|
|
|
|
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
|
|
}
|