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 { PLAYER_INACTIVITY REWORKED OTHER } enum ProductTarget { CC_PACKAGE REDESIGN_PACKAGE IGNORE } 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 /// 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[] @@index([displayName]) } /// 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()) @@index([playerId]) @@index([playerId, kind]) @@index([groupId]) @@index([createdAt]) } 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[] 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]) }