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") 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 ausgestiegen ist. Der Eintrag bleibt stehen, /// damit nachvollziehbar ist, wer den CC wann genutzt hat. leftAt DateTime? /// 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) 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 }