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 (
+
+
+
+ );
+}
+
+function DeletePlayerModal({
+ player,
+ characterCount,
+ ledgerCount,
+ busy,
+ onClose,
+ onConfirm,
+}: {
+ player: Detail['player'];
+ characterCount: number;
+ ledgerCount: number;
+ busy: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}) {
+ // Tippen statt klicken: das hier lässt sich nicht rückgängig machen.
+ const [confirmation, setConfirmation] = useState('');
+ const passt = confirmation.trim() === player.displayName;
+
+ return (
+
+
+ Dabei verschwinden {characterCount}{' '}
+ {characterCount === 1 ? 'CC' : 'CCs'} und {ledgerCount}{' '}
+ {ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den
+ Details früherer Protokolleinträge werden entfernt. Das lässt sich nicht rückgängig
+ machen — für eine Fehleingabe ist eher das Bearbeiten gedacht.
+
+
+
+ setConfirmation(event.target.value)}
+ autoFocus
+ />
+
+
+
+
+
+
+ );
+}