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.
532 lines
17 KiB
Plaintext
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
|
|
}
|