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. +

+ + +