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 { /// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit. CC_PACKAGE REDESIGN_PACKAGE /// Ein von Hand vergebener Platz, ohne das Redesign aus einem Paket. /// Für Einzelfälle, in denen jemand einen CC bekommt, ohne dafür gekauft /// zu haben - der Abgleich mit dem Shop bleibt davon unberührt. CC_GRANT KULANZ ADJUSTMENT /// Ein CC-Guthaben, das in ein Redesign-Guthaben umgewandelt wurde. /// Immer ein Paar: eine Zeile ab im CC-Topf, eine Zeile zu im Redesign-Topf. CONVERSION 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? /// Die Liste dieses Spielers, in der der CC für ihn steht, und seine /// Stelle darin. /// /// Ein Gruppen-CC wird für jeden Nutzer einzeln gerechnet: Für den einen /// kann er der gekaufte Platz sein, für den anderen ein Redesign seines /// vorherigen CCs. Deshalb hängt die Kette am Nutzer, nicht am CC. lineageId String @default(uuid()) chainPosition Int @default(0) /// 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]) @@index([lineageId]) } /// 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 }