diff --git a/server/prisma/migrations/20250101000000_init/migration.sql b/server/prisma/migrations/20250101000000_init/migration.sql new file mode 100644 index 0000000..db25572 --- /dev/null +++ b/server/prisma/migrations/20250101000000_init/migration.sql @@ -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; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 0000000..87e07c0 --- /dev/null +++ b/server/prisma/schema.prisma @@ -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]) +}