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.
387 lines
11 KiB
Plaintext
387 lines
11 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 {
|
|
PLAYER_INACTIVITY
|
|
REWORKED
|
|
OTHER
|
|
}
|
|
|
|
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[]
|
|
|
|
@@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[]
|
|
|
|
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])
|
|
}
|