Files
CC-Manager/server/prisma/schema.prisma
T
Harlekin 724f68644b Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
2026-09-18 16:57:39 +02:00

293 lines
7.9 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 {
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])
}