From 751f8bfe8e5e12fed374939980d8af9120619e27 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 17:43:25 +0200 Subject: [PATCH] Schema-Aenderungen als eigene Migration nachziehen Die erste Migration wurde nachtraeglich mehrfach erweitert, statt Aenderungen anzuhaengen. Auf einer bestehenden Installation gilt sie als angewendet und wird uebersprungen - die seither hinzugekommenen Tabellen fehlten dort also schlicht, ohne dass beim Start etwas auffiel. Die Antragsseite lief dadurch in einen internen Fehler. Die neue Migration ergaenzt alles Fehlende und ist durchgehend mit IF NOT EXISTS geschrieben, weil bestehende Datenbanken je nach Zeitpunkt der Einrichtung unterschiedliche Staende haben. Geprueft gegen eine frische Datenbank, gegen eine auf altem Stand und auf mehrfaches Anwenden. Der Testharness wendet jetzt alle Migrationen der Reihe nach an statt nur der ersten - sonst faellt so etwas beim naechsten Mal wieder nicht auf. Ausserdem scheiterte das Buchen komplett, wenn eine Bestellung zwischenzeitlich ueber einen anderen Import gebucht worden war: die eindeutige Bestell-ID kippte die gesamte Transaktion. Solche Zeilen werden jetzt uebersprungen und gezaehlt, die uebrigen laufen durch. Dazu fehlten in der Oberflaeche die Wege zum Bearbeiten und Loeschen eines Spielers, obwohl es die Endpunkte gab. Beides ist jetzt auf der Spielerseite erreichbar; das Loeschen verlangt den Namen als Bestaetigung und benennt vorher, was mit verschwindet. --- .../migration.sql | 103 +++++++++ server/src/services/imports.ts | 19 +- server/test/harness.ts | 16 +- server/test/imports.test.ts | 40 ++++ web/src/pages/Import.tsx | 10 +- web/src/pages/PlayerDetail.tsx | 197 +++++++++++++++++- 6 files changed, 374 insertions(+), 11 deletions(-) create mode 100644 server/prisma/migrations/20250102000000_forum_und_abgleich/migration.sql diff --git a/server/prisma/migrations/20250102000000_forum_und_abgleich/migration.sql b/server/prisma/migrations/20250102000000_forum_und_abgleich/migration.sql new file mode 100644 index 0000000..108a788 --- /dev/null +++ b/server/prisma/migrations/20250102000000_forum_und_abgleich/migration.sql @@ -0,0 +1,103 @@ +-- Ergaenzt alles, was nach der ersten Fassung dazugekommen ist. +-- +-- Diese Migration ist bewusst durchgehend mit IF NOT EXISTS geschrieben: die +-- erste Migration wurde zwischenzeitlich nachtraeglich erweitert, weshalb +-- bestehende Installationen je nach Zeitpunkt der Einrichtung einen anderen +-- Stand haben. So laeuft sie auf einer frischen wie auf einer aelteren +-- Datenbank durch und legt nur an, was tatsaechlich fehlt. + +-- --- Enums --- + +DO $$ BEGIN + CREATE TYPE "ForumBoard" AS ENUM ('CUSTOM_CHARACTERS', 'AKTIVE_CCS', 'CC_ARCHIV'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE "ForumClassification" AS ENUM ('NEW_CC', 'REDESIGN', 'REJECTED', 'IGNORE'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- --- Klartext-Grund einer Deaktivierung --- + +ALTER TABLE "Character" ADD COLUMN IF NOT EXISTS "inactiveNote" TEXT; + +-- --- GExtension-Abgleich --- + +ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "gextensionUsage" INTEGER; +ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "gextensionSyncedAt" TIMESTAMP(3); + +CREATE TABLE IF NOT EXISTS "UnmatchedPackage" ( + "id" SERIAL NOT NULL, + "externalId" TEXT NOT NULL, + "playerId" INTEGER, + "steamId" TEXT NOT NULL, + "rawTitle" TEXT NOT NULL, + "rawTag" TEXT NOT NULL, + "amount" TEXT, + "purchasedAt" TIMESTAMP(3), + "scrapedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved" BOOLEAN NOT NULL DEFAULT false, + "resolvedAs" TEXT, + "resolvedById" INTEGER, + "resolvedAt" TIMESTAMP(3), + CONSTRAINT "UnmatchedPackage_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "UnmatchedPackage_externalId_key" ON "UnmatchedPackage"("externalId"); +CREATE INDEX IF NOT EXISTS "UnmatchedPackage_resolved_idx" ON "UnmatchedPackage"("resolved"); +CREATE INDEX IF NOT EXISTS "UnmatchedPackage_playerId_idx" ON "UnmatchedPackage"("playerId"); + +DO $$ BEGIN + ALTER TABLE "UnmatchedPackage" ADD CONSTRAINT "UnmatchedPackage_playerId_fkey" + FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- --- Forums-Antraege --- + +CREATE TABLE IF NOT EXISTS "ForumThread" ( + "threadId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "url" TEXT NOT NULL, + "authorName" TEXT, + "authorForumId" INTEGER, + "playerId" INTEGER, + "currentBoard" "ForumBoard" NOT NULL, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastBoardChangeAt" TIMESTAMP(3), + "classification" "ForumClassification", + "needsClassification" BOOLEAN NOT NULL DEFAULT true, + "classifiedById" INTEGER, + "classifiedAt" TIMESTAMP(3), + "linkedCharacterId" INTEGER, + "boardChangeNotice" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("threadId") +); + +CREATE INDEX IF NOT EXISTS "ForumThread_needsClassification_idx" ON "ForumThread"("needsClassification"); +CREATE INDEX IF NOT EXISTS "ForumThread_playerId_idx" ON "ForumThread"("playerId"); +CREATE INDEX IF NOT EXISTS "ForumThread_currentBoard_idx" ON "ForumThread"("currentBoard"); + +DO $$ BEGIN + ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_playerId_fkey" + FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_linkedCharacterId_fkey" + FOREIGN KEY ("linkedCharacterId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS "ForumThreadBoard" ( + "id" SERIAL NOT NULL, + "threadId" INTEGER NOT NULL, + "board" "ForumBoard" NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ForumThreadBoard_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "ForumThreadBoard_threadId_idx" ON "ForumThreadBoard"("threadId"); + +DO $$ BEGIN + ALTER TABLE "ForumThreadBoard" ADD CONSTRAINT "ForumThreadBoard_threadId_fkey" + FOREIGN KEY ("threadId") REFERENCES "ForumThread"("threadId") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index 9ace8fb..9d40c51 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -378,8 +378,24 @@ export async function commitImport(importId: number, ctx: ActionContext) { // Mehrere Zeilen können denselben neuen Spieler betreffen. const createdPlayers = new Map(); let booked = 0; + let skipped = 0; for (const row of bookable) { + // Zwischen Vorschau und Bestätigung kann dieselbe Bestellung über einen + // anderen Import gebucht worden sein. Dann diese Zeile überspringen, + // statt den gesamten Import an der eindeutigen Bestell-ID scheitern zu + // lassen - die übrigen Zeilen sind ja in Ordnung. + if (row.externalId) { + const bereitsGebucht = await tx.importedOrder.findUnique({ + where: { externalId: row.externalId }, + select: { id: true }, + }); + if (bereitsGebucht) { + skipped += 1; + continue; + } + } + const key = (row.playerKey ?? '').trim(); let playerId = row.resolvedPlayerId; @@ -444,11 +460,12 @@ export async function commitImport(importId: number, ctx: ActionContext) { details: { filename: record.filename, booked, + skipped, createdPlayers: createdPlayers.size, }, }); - return { booked, createdPlayers: createdPlayers.size, import: updated }; + return { booked, skipped, createdPlayers: createdPlayers.size, import: updated }; }); // Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht. diff --git a/server/test/harness.ts b/server/test/harness.ts index c8190f8..7cbd5e4 100644 --- a/server/test/harness.ts +++ b/server/test/harness.ts @@ -1,11 +1,19 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PGlite } from '@electric-sql/pglite'; import { PGLiteSocketServer } from '@electric-sql/pglite-socket'; const here = dirname(fileURLToPath(import.meta.url)); -const MIGRATION = join(here, '..', 'prisma', 'migrations', '20250101000000_init', 'migration.sql'); +const MIGRATIONS_DIR = join(here, '..', 'prisma', 'migrations'); + +/** Alle Migrationen in der Reihenfolge, in der Prisma sie anwenden würde. */ +function migrationFiles(): string[] { + return readdirSync(MIGRATIONS_DIR) + .filter((entry) => /^\d/.test(entry)) + .sort() + .map((entry) => join(MIGRATIONS_DIR, entry, 'migration.sql')); +} export interface TestDatabase { url: string; @@ -19,7 +27,9 @@ export interface TestDatabase { */ export async function startTestDatabase(port: number): Promise { const db = await PGlite.create(); - await db.exec(readFileSync(MIGRATION, 'utf8')); + for (const file of migrationFiles()) { + await db.exec(readFileSync(file, 'utf8')); + } const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' }); await server.start(); diff --git a/server/test/imports.test.ts b/server/test/imports.test.ts index 962ba32..d6565b7 100644 --- a/server/test/imports.test.ts +++ b/server/test/imports.test.ts @@ -201,3 +201,43 @@ describe('Vorschau und Buchung', () => { ); }); }); + +describe('Kollisionen beim Buchen', () => { + it('überspringt eine Bestellung, die inzwischen anderswo gebucht wurde', async () => { + const csv = ['Bestellnummer;Steam-ID;Produkt', 'K-1;76561198000000077;CC-Paket'].join('\n'); + const mapping = { + playerKey: 'Steam-ID', + product: 'Produkt', + orderId: 'Bestellnummer', + createMissingPlayers: true, + }; + + // Zwei Vorschauen mit derselben Bestellung - so entstanden durch mehrfachen Abgleich. + const ersteVorschau = await imports.createImport( + 'erste.csv', + imports.parseUpload('erste.csv', Buffer.from(csv, 'utf8')), + ctx, + ); + const zweiteVorschau = await imports.createImport( + 'zweite.csv', + imports.parseUpload('zweite.csv', Buffer.from(csv, 'utf8')), + ctx, + ); + await imports.applyMapping(ersteVorschau, mapping, ctx); + await imports.applyMapping(zweiteVorschau, mapping, ctx); + + const erste = await imports.commitImport(ersteVorschau, ctx); + assert.equal(erste.booked, 1); + + // Früher scheiterte hier der gesamte Import an der eindeutigen Bestell-ID. + const zweite = await imports.commitImport(zweiteVorschau, ctx); + assert.equal(zweite.booked, 0); + assert.equal(zweite.skipped, 1, 'die Zeile wird übersprungen, nicht der Import abgebrochen'); + + const player = await db.prisma.player.findUniqueOrThrow({ + where: { steamId: '76561198000000077' }, + }); + const balances = await credits.getBalances(player.id); + assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt'); + }); +}); diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx index d8e9b1f..fa84375 100644 --- a/web/src/pages/Import.tsx +++ b/web/src/pages/Import.tsx @@ -157,14 +157,14 @@ export default function Import() { setBusy(true); setError(null); try { - const result = await api<{ booked: number; createdPlayers: number }>( + const result = await api<{ booked: number; skipped: number; createdPlayers: number }>( `/api/imports/${upload.importId}/commit`, { method: 'POST', body: {} }, ); - setFlash( - `${result.booked} Buchungen angelegt` + - (result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'), - ); + const teile = [`${result.booked} Buchungen angelegt`]; + if (result.createdPlayers > 0) teile.push(`${result.createdPlayers} Spieler neu erfasst`); + if (result.skipped > 0) teile.push(`${result.skipped} bereits gebucht, übersprungen`); + setFlash(teile.join(', ') + '.'); reset(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen'); diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index badca77..0342d9e 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import { Link, useNavigate, useParams } from 'react-router-dom'; import BalanceCards from '../components/BalanceCards'; import UnmatchedBox from '../components/UnmatchedBox'; import Modal from '../components/Modal'; @@ -22,12 +22,15 @@ type DialogState = | { type: 'deactivate'; character: Character } | { type: 'reactivatePaid'; character: Character } | { type: 'revert'; character: Character } - | { type: 'reverse'; entry: LedgerEntry }; + | { type: 'reverse'; entry: LedgerEntry } + | { type: 'editPlayer' } + | { type: 'deletePlayer' }; export default function PlayerDetail() { const { id } = useParams<{ id: string }>(); const playerId = Number(id); const { can } = useSession(); + const navigate = useNavigate(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); @@ -125,7 +128,29 @@ export default function PlayerDetail() { {detail.player.steamId ?? 'keine SteamID'} {detail.player.forumName && ` · Forum: ${detail.player.forumName}`}

+ {detail.player.notes &&

{detail.player.notes}

} + + {editor && ( +
+ + {can('ADMIN') && ( + + )} +
+ )} {error && setError(null)}>{error}} @@ -498,6 +523,40 @@ export default function PlayerDetail() { /> )} + {dialog.type === 'editPlayer' && ( + setDialog({ type: 'none' })} + onSubmit={(values) => + void run( + () => api(`/api/players/${playerId}`, { method: 'PATCH', body: values }), + 'Spielerdaten gespeichert.', + ) + } + /> + )} + + {dialog.type === 'deletePlayer' && ( + setDialog({ type: 'none' })} + onConfirm={async () => { + setBusy(true); + try { + await api(`/api/players/${playerId}`, { method: 'DELETE' }); + navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen'); + setBusy(false); + } + }} + /> + )} + {dialog.type === 'reverse' && ( ); } + +function EditPlayerModal({ + player, + busy, + onClose, + onSubmit, +}: { + player: Detail['player']; + busy: boolean; + onClose: () => void; + onSubmit: (values: Record) => void; +}) { + const [displayName, setDisplayName] = useState(player.displayName); + const [steamId, setSteamId] = useState(player.steamId ?? ''); + const [forumName, setForumName] = useState(player.forumName ?? ''); + const [notes, setNotes] = useState(player.notes ?? ''); + + return ( + +
{ + event.preventDefault(); + onSubmit({ + displayName: displayName.trim(), + steamId: steamId.trim() || null, + forumName: forumName.trim() || null, + notes: notes.trim() || null, + }); + }} + > + + setDisplayName(event.target.value)} + autoFocus + required + /> + + + setSteamId(event.target.value)} + placeholder="76561198... oder STEAM_0:1:..." + /> +

Leer lassen entfernt die Zuordnung zu GExtension.

+ + + setForumName(event.target.value)} + /> +

+ Muss dem Forumsnamen entsprechen, damit Anträge automatisch zugeordnet werden. +

+ + +