Files
CC-Manager/server/prisma/schema.prisma
T
Harlekin 6094e4eaef Zeitplan in der Oberflaeche einstellbar
Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
2026-09-21 13:25:56 +02:00

532 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)
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
}