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.
424 lines
12 KiB
Plaintext
424 lines
12 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
|
|
|
|
/// 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])
|
|
}
|
|
|
|
/// 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())
|
|
|
|
@@index([playerId])
|
|
@@index([playerId, kind])
|
|
@@index([groupId])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
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[]
|
|
|
|
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
|
|
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?
|
|
|
|
createdById Int?
|
|
|
|
/// Ein Spieler kann demselben CC nicht zweimal angehören.
|
|
@@unique([characterId, playerId])
|
|
@@index([playerId])
|
|
@@index([characterId])
|
|
}
|