Files
CC-Manager/server/prisma/schema.prisma
T
Harlekin 029d380abf Einzelne Gruppenmitglieder, Austritt per Redesign, Ladebalken beim Abgleich
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich
nur die obere bedienen. Jetzt beide:

Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt
einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein
Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an
seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der
Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign
der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben.

Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei
einem Trio kann einer im Rollenspiel sterben, waehrend die anderen
weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und
Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt
denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen
Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer
ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es
nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt
davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet
neu.

Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen,
fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt
dieselbe Anzeige.

Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt
starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt
jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
2026-09-21 12:50:40 +02:00

480 lines
15 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")
/// 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])
}
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 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
}