Datenmodell fuer Spieler, Guthaben-Ledger und CCs

Guthaben werden als Kontobuch gefuehrt: jede Bewegung ist eine Zeile, der
Stand eines Topfes ist deren Summe. Zusammengehoerige Zeilen teilen sich eine
groupId, damit ein CC-Paket als Ganzes storniert werden kann; ein unique
Constraint auf reversalOfId verhindert doppelte Stornos.

CCs bilden ueber lineageId und predecessorId eine Redesign-Kette. Der Grund
einer Deaktivierung entscheidet spaeter ueber die Reaktivierungskosten.
This commit is contained in:
2026-09-18 11:33:25 +02:00
parent d7e7a3b186
commit a1b764155a
2 changed files with 545 additions and 0 deletions
@@ -0,0 +1,286 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
-- CreateEnum
CREATE TYPE "CreditKind" AS ENUM ('CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ');
-- CreateEnum
CREATE TYPE "CreditSource" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'KULANZ', 'ADJUSTMENT', 'CONSUME', 'REVERSAL');
-- CreateEnum
CREATE TYPE "CharacterStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "InactiveReason" AS ENUM ('PLAYER_INACTIVITY', 'REWORKED', 'OTHER');
-- CreateEnum
CREATE TYPE "ProductTarget" AS ENUM ('CC_PACKAGE', 'REDESIGN_PACKAGE', 'IGNORE');
-- CreateEnum
CREATE TYPE "ImportStatus" AS ENUM ('PENDING', 'COMMITTED', 'DISCARDED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'EDITOR',
"totpSecret" TEXT,
"totpEnabled" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip" TEXT,
"userAgent" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" SERIAL NOT NULL,
"displayName" TEXT NOT NULL,
"steamId" TEXT,
"forumName" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CreditLedger" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"kind" "CreditKind" NOT NULL,
"amount" INTEGER NOT NULL,
"source" "CreditSource" NOT NULL,
"groupId" TEXT NOT NULL,
"characterId" INTEGER,
"importId" INTEGER,
"note" TEXT,
"reversalOfId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CreditLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Character" (
"id" SERIAL NOT NULL,
"playerId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"loreText" TEXT,
"status" "CharacterStatus" NOT NULL DEFAULT 'ACTIVE',
"inactiveReason" "InactiveReason",
"lineageId" TEXT NOT NULL,
"predecessorId" INTEGER,
"createdById" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Character_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Import" (
"id" SERIAL NOT NULL,
"filename" TEXT NOT NULL,
"status" "ImportStatus" NOT NULL DEFAULT 'PENDING',
"rowCount" INTEGER NOT NULL DEFAULT 0,
"bookedCount" INTEGER NOT NULL DEFAULT 0,
"mapping" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"committedAt" TIMESTAMP(3),
"importedById" INTEGER,
CONSTRAINT "Import_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportRow" (
"id" SERIAL NOT NULL,
"importId" INTEGER NOT NULL,
"lineNo" INTEGER NOT NULL,
"externalId" TEXT,
"playerKey" TEXT,
"playerName" TEXT,
"productName" TEXT,
"purchasedAt" TIMESTAMP(3),
"target" "ProductTarget",
"resolvedPlayerId" INTEGER,
"plan" TEXT NOT NULL,
"raw" JSONB NOT NULL,
CONSTRAINT "ImportRow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImportedOrder" (
"id" SERIAL NOT NULL,
"externalId" TEXT NOT NULL,
"importId" INTEGER NOT NULL,
"playerId" INTEGER NOT NULL,
"target" "ProductTarget" NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImportedOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductMapping" (
"id" SERIAL NOT NULL,
"matchPattern" TEXT NOT NULL,
"target" "ProductTarget" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"actorUserId" INTEGER,
"actorName" TEXT,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"details" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Player_steamId_key" ON "Player"("steamId");
-- CreateIndex
CREATE INDEX "Player_displayName_idx" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "CreditLedger_reversalOfId_key" ON "CreditLedger"("reversalOfId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_idx" ON "CreditLedger"("playerId");
-- CreateIndex
CREATE INDEX "CreditLedger_playerId_kind_idx" ON "CreditLedger"("playerId", "kind");
-- CreateIndex
CREATE INDEX "CreditLedger_groupId_idx" ON "CreditLedger"("groupId");
-- CreateIndex
CREATE INDEX "CreditLedger_createdAt_idx" ON "CreditLedger"("createdAt");
-- CreateIndex
CREATE INDEX "Character_playerId_idx" ON "Character"("playerId");
-- CreateIndex
CREATE INDEX "Character_playerId_status_idx" ON "Character"("playerId", "status");
-- CreateIndex
CREATE INDEX "Character_lineageId_idx" ON "Character"("lineageId");
-- CreateIndex
CREATE INDEX "Import_status_idx" ON "Import"("status");
-- CreateIndex
CREATE INDEX "ImportRow_importId_idx" ON "ImportRow"("importId");
-- CreateIndex
CREATE UNIQUE INDEX "ImportedOrder_externalId_key" ON "ImportedOrder"("externalId");
-- CreateIndex
CREATE INDEX "ImportedOrder_playerId_idx" ON "ImportedOrder"("playerId");
-- CreateIndex
CREATE INDEX "ProductMapping_priority_idx" ON "ProductMapping"("priority");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "AuditLog_actorUserId_idx" ON "AuditLog"("actorUserId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_reversalOfId_fkey" FOREIGN KEY ("reversalOfId") REFERENCES "CreditLedger"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "CreditLedger" ADD CONSTRAINT "CreditLedger_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_predecessorId_fkey" FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "Character" ADD CONSTRAINT "Character_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Import" ADD CONSTRAINT "Import_importedById_fkey" FOREIGN KEY ("importedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportRow" ADD CONSTRAINT "ImportRow_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_importId_fkey" FOREIGN KEY ("importId") REFERENCES "Import"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImportedOrder" ADD CONSTRAINT "ImportedOrder_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+259
View File
@@ -0,0 +1,259 @@
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
ledger CreditLedger[]
characters Character[]
orders ImportedOrder[]
@@index([displayName])
}
/// 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 zurueckholt.
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 Loeschung. 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?
/// 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 wuerde: 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])
}