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.
539 lines
17 KiB
Plaintext
539 lines
17 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)
|
|
|
|
/// 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?
|
|
|
|
/// 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])
|
|
}
|
|
|
|
/// 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
|
|
}
|