From db37d3388abb3c01b362afbb77ab3e40f9456438 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Mon, 21 Sep 2026 13:18:11 +0200 Subject: [PATCH] Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv", sondern als das, was sie sind: je Platz eine Kette, von unten nach oben gelesen. Unten der erste CC, darueber seine Redesigns. Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf, weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und aendert keinen Status. Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an dem der Rest der Anwendung haengt, wird danach aus den Positionen neu abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand bekommt seine Positionen in der Migration aus der vorhandenen Verkettung. An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse, der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor. Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst verweist. Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen. --- README.md | 47 ++ package-lock.json | 7 + server/package.json | 1 + .../migration.sql | 52 ++ server/prisma/schema.prisma | 29 ++ server/src/lib/forum.ts | 11 +- server/src/routes/auth.routes.ts | 8 +- server/src/routes/characters.routes.ts | 62 +++ server/src/routes/imports.routes.ts | 26 +- server/src/services/backup.ts | 448 ++++++++++++++++++ server/src/services/characters.ts | 238 +++++++++- server/src/services/forum.ts | 28 +- server/test/backup.test.ts | 230 +++++++++ server/test/ketten.test.ts | 280 +++++++++++ web/src/components/ChainView.tsx | 391 +++++++++++++++ web/src/components/LinksModal.tsx | 160 +++++++ web/src/components/MemberList.tsx | 134 ++++++ web/src/lib/session.tsx | 12 +- web/src/lib/types.ts | 19 + web/src/pages/Import.tsx | 122 ++++- web/src/pages/PlayerDetail.tsx | 415 +++------------- web/src/styles.css | 109 +++++ 22 files changed, 2472 insertions(+), 357 deletions(-) create mode 100644 server/prisma/migrations/20250110000000_links_und_kette/migration.sql create mode 100644 server/src/services/backup.ts create mode 100644 server/test/backup.test.ts create mode 100644 server/test/ketten.test.ts create mode 100644 web/src/components/ChainView.tsx create mode 100644 web/src/components/LinksModal.tsx create mode 100644 web/src/components/MemberList.tsx diff --git a/README.md b/README.md index 9b7251e..1164584 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,33 @@ zur Wahl: | Entzogen | kostet ein Guthaben | | Im Rollenspiel gestorben | nur durch einen Administrator, mit Begründung | +## Ketten: ein Platz, viele Redesigns + +Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte: erster CC, Redesign, +noch ein Redesign. Auf der Spielerseite steht je Platz eine Spalte, **von unten nach oben** +gelesen - unten der erste CC, darüber seine Redesigns, die Pfeile zeigen in Richtung des +jüngsten Eintrags. + +Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später auf, weil sein Antrag in +einem anderen Board lag oder weil der Spieler es für den Beitritt zu einem Gruppen-CC benutzt +hat, dessen Antrag jemand anders geschrieben hat. Dann steht in der Kette schon „Redesign 3 → +Redesign 5", und Redesign 4 fehlt. + +**Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen.** Beim Ziehen erscheinen +zwischen den Einträgen Ablagestellen - auch mitten in einer Kette und in jeder anderen Kette +desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo er hingehört. +*Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in +diese Kette gehörte. + +Das Verschieben **ordnet nur ein**: Es entscheidet nichts, bucht nichts und ändert an keinem +Status etwas. Wer aktiv war, bleibt aktiv. + +### Verweise an einem CC + +An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Erstellers, ein Modell, +eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der +Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*. + ## Gruppen-CCs Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen @@ -271,6 +298,9 @@ CC kehrt zurück - gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *k angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite - es gibt keinen zweiten Buchungsweg mit eigenen Regeln. +Aus dem Abgleich kommt nebenbei die Adresse des Forumsprofils mit; auf der Spielerseite führt +der Forum-Name dann direkt dorthin. Die SteamID verlinkt entsprechend auf das GExtension-Profil. + Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler - und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der Ersteller nirgends, @@ -373,6 +403,23 @@ an. Bis dahin geht die Spur wenigstens nicht verloren. Auf der Übersicht tragen betroffene Spieler ein Kennzeichen, und über *Nur offene Problemfälle* lässt sich die Liste darauf eingrenzen. +## Umzug auf einen anderen Server + +Unter *Import* steht für Administratoren **Alles sichern und zurückholen**: der gesamte Bestand +als eine einzelne Tabelle - Spieler, Guthaben, CCs samt Ketten, frühere Konten, Mitgliedschaften +und Verweise. Gedacht für den Wechsel des Servers oder den Umstieg auf ein anderes Programm. + +Weil verschiedene Satzarten in einer Datei stehen, sagt die erste Spalte `satzart`, worum es in +der Zeile geht (`SPIELER`, `KONTO`, `GUTHABEN`, `CC`, `MITGLIED`, `VERWEIS`); die übrigen Spalten +bedeuten je nach Satzart etwas anderes. Der Spieler steht in jeder Zeile, damit jede Zeile für +sich verständlich bleibt und eine Umsortierung in der Tabelle den Bestand nicht zerreißt. + +Beim Einspielen wird **ergänzt, nicht überschrieben**: Was es schon gibt, bleibt unangetastet. +Erkannt wird ein Spieler an seiner SteamID, ersatzweise am Anzeigenamen; eine Buchung an +Spieler, Art, Menge, Anlass, Zeitpunkt und Notiz. Zweimal dieselbe Datei einzuspielen ändert +deshalb nichts. Zeilen, die sich nicht zuordnen lassen, werden mit Zeilennummer gemeldet, ohne +den Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten. + ## Rollen Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. diff --git a/package-lock.json b/package-lock.json index a6b2c72..afe933e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2584,6 +2584,12 @@ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", "license": "MIT" }, + "node_modules/csv-stringify": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5024,6 +5030,7 @@ "@node-rs/argon2": "^2.0.2", "@prisma/client": "^6.5.0", "csv-parse": "^5.6.0", + "csv-stringify": "^6.8.3", "fastify": "^5.2.1", "otplib": "^13.5.0", "prisma": "^6.5.0", diff --git a/server/package.json b/server/package.json index ffa41c5..838371c 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "@node-rs/argon2": "^2.0.2", "@prisma/client": "^6.5.0", "csv-parse": "^5.6.0", + "csv-stringify": "^6.8.3", "fastify": "^5.2.1", "otplib": "^13.5.0", "prisma": "^6.5.0", diff --git a/server/prisma/migrations/20250110000000_links_und_kette/migration.sql b/server/prisma/migrations/20250110000000_links_und_kette/migration.sql new file mode 100644 index 0000000..7e16c03 --- /dev/null +++ b/server/prisma/migrations/20250110000000_links_und_kette/migration.sql @@ -0,0 +1,52 @@ +-- Verweise an einem CC: Addon des Erstellers, Modell, Absprachen. +CREATE TABLE IF NOT EXISTS "CharacterLink" ( + "id" SERIAL NOT NULL, + "characterId" INTEGER NOT NULL, + "label" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdById" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "CharacterLink_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "CharacterLink_characterId_idx" ON "CharacterLink"("characterId"); + +DO $$ +BEGIN + ALTER TABLE "CharacterLink" + ADD CONSTRAINT "CharacterLink_characterId_fkey" + FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- Platz in der Redesign-Kette. Bisher ergab sich die Reihenfolge allein aus +-- der Verkettung; zum Umsortieren braucht es eine Zahl, die sich aendern +-- laesst, ohne die ganze Kette neu zu haengen. +ALTER TABLE "Character" ADD COLUMN IF NOT EXISTS "chainPosition" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS "Character_lineageId_chainPosition_idx" + ON "Character"("lineageId", "chainPosition"); + +-- Bestand: die vorhandene Verkettung in Positionen uebersetzen. Der Anfang +-- jeder Kette ist der CC ohne Vorgaenger, danach geht es Glied fuer Glied +-- weiter - in Zehnerschritten, damit spaeter etwas dazwischen passt. +WITH RECURSIVE kette AS ( + SELECT "id", "lineageId", 0 AS pos + FROM "Character" + WHERE "predecessorId" IS NULL + UNION ALL + SELECT c."id", c."lineageId", k.pos + 10 + FROM "Character" c + JOIN kette k ON c."predecessorId" = k."id" +) +UPDATE "Character" c +SET "chainPosition" = kette.pos +FROM kette +WHERE c."id" = kette."id"; + +-- Profil-Adressen aus dem Forum. Sie werden uebernommen statt gebaut: die +-- Profile liegen nicht unter dem Forums-Pfad, sondern eine Ebene darueber. +ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "authorProfileUrl" TEXT; +ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "forumProfileUrl" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 97f82cc..f183ae7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -120,6 +120,9 @@ model Player { steamId String? @unique forumName String? notes String? + /// Sein Forumsprofil. Kommt aus dem Abgleich und überlebt eine spätere + /// Namensänderung, weil die Nummer darin steht und nicht der Name. + forumProfileUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -232,12 +235,17 @@ model Character { /// Alle CCs einer Redesign-Kette teilen sich die lineageId. lineageId String @default(uuid()) + /// Platz in der Kette, aufsteigend vom ersten CC zum jüngsten Redesign. + /// Lücken sind erlaubt und nützlich: ein später gefundenes Redesign lässt + /// sich dazwischenschieben, ohne die übrigen anzufassen. + chainPosition Int @default(0) predecessorId Int? predecessor Character? @relation("CharacterLineage", fields: [predecessorId], references: [id], onDelete: NoAction, onUpdate: NoAction) successors Character[] @relation("CharacterLineage") ledgerEntries CreditLedger[] forumThreads ForumThread[] + links CharacterLink[] members CharacterMember[] /// Beitritte, für die dieser CC aufgegeben wurde. givenUpFor CharacterMember[] @relation("MemberPredecessor") @@ -252,6 +260,7 @@ model Character { @@index([playerId]) @@index([playerId, status]) @@index([lineageId]) + @@index([lineageId, chainPosition]) } model Import { @@ -354,6 +363,8 @@ model ForumThread { authorName String? /// Überlebt Namensänderungen im Forum, anders als der Anzeigename. authorForumId Int? + /// Das Profil des Erstellers, wie das Forum selbst darauf verweist. + authorProfileUrl String? /// Labels des Threads, kommagetrennt. Reiner Kontext für die Entscheidung. labels String? @@ -477,3 +488,21 @@ model ScheduledRun { lastNote String? updatedAt DateTime @updatedAt } + +/// Beliebige Verweise an einem CC: das Steam-Addon des Erstellers, ein +/// Modell, ein Absprache-Dokument. Der Name wird zum Link, damit in der Liste +/// steht, worum es geht, und nicht eine nackte Adresse. +model CharacterLink { + id Int @id @default(autoincrement()) + characterId Int + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + label String + url String + + createdById Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([characterId]) +} diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts index 5e71fa1..4459dac 100644 --- a/server/src/lib/forum.ts +++ b/server/src/lib/forum.ts @@ -18,6 +18,10 @@ export interface ForumThread { authorName: string | null; /// Stabiler als der Name: Forumsnamen ändern sich, die ID nicht. authorForumId: number | null; + /// Sein Profil, so wie das Forum selbst darauf verweist. Übernommen statt + /// zusammengebaut: die Profile liegen nicht unter dem Forums-Pfad, sondern + /// eine Ebene darüber, und das lässt sich nicht verlässlich erraten. + authorProfileUrl: string | null; /// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück. isMovedStub: boolean; /// Labels des Threads, etwa "Wichtig" oder "SCP-RP". @@ -39,7 +43,7 @@ const ROW_SPLIT = /
  • /; const TOPIC_LINK = /]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/; const USER_LINK = - /]*class="userLink"[^>]*>(?:]*><\/i>)?\s*(?:]*>)?\s*([^<]{1,80})/; + /]*class="userLink"[^>]*>(?:]*><\/i>)?\s*(?:]*>)?\s*([^<]{1,80})/; // Wer sein Konto gelöscht hat, steht ohne Profillink in der Zelle. Der Name // ist trotzdem da und bleibt die einzige Spur zum Spieler - ohne diesen @@ -83,7 +87,7 @@ export function parseBoard(html: string): ForumThread[] { seen.add(threadId); const user = USER_LINK.exec(block); - let authorName = user ? decode(user[2] ?? '') || null : null; + let authorName = user ? decode(user[3] ?? '') || null : null; if (authorName === null) { const cell = AUTHOR_CELL.exec(block); if (cell) authorName = decode((cell[1] ?? '').replace(/<[^>]*>/g, ' ')) || null; @@ -100,7 +104,8 @@ export function parseBoard(html: string): ForumThread[] { title: decode(topic[3] ?? ''), url: decode(topic[1] ?? ''), authorName, - authorForumId: user ? Number(user[1]) : null, + authorForumId: user ? Number(user[2]) : null, + authorProfileUrl: user ? decode(user[1] ?? '') || null : null, isMovedStub: MOVED_MARKER.test(block), labels, postedAt: postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt : null, diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index 580c907..8c9ba10 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -198,7 +198,13 @@ export async function authRoutes(app: FastifyInstance): Promise { app.get('/api/auth/me', async (request) => { if (!request.user) return { user: null }; - return { user: request.user }; + // Die Adressen der fremden Dienste kommen mit, damit die Oberfläche + // Profil-Links bauen kann, ohne sie ein zweites Mal zu kennen. + return { + user: request.user, + gextensionBaseUrl: env.GEXTENSION_BASE_URL, + forumBaseUrl: env.FORUM_BASE_URL, + }; }); // --- Eigener Account --- diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 35b1da7..d023f1c 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -19,6 +19,11 @@ import { deactivateMember, reactivateMember, redesignMembership, + addLink, + updateLink, + removeLink, + moveInChain, + detachFromChain, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -140,6 +145,63 @@ export async function characterRoutes(app: FastifyInstance): Promise { return reviveDeadCharacter(id, body, contextOf(request)); }); + // --- Verweise --- + + app.post('/api/characters/:id/links', { preHandler: requireEditor }, async (request, reply) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + label: z.string().trim().min(1).max(120), + url: z.string().trim().min(1).max(2000), + }), + request.body, + ); + reply.code(201); + return addLink(id, body, contextOf(request)); + }); + + app.patch('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + label: z.string().trim().min(1).max(120).optional(), + url: z.string().trim().min(1).max(2000).optional(), + }), + request.body, + ); + return updateLink(id, body, contextOf(request)); + }); + + app.delete('/api/characters/links/:id', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + await removeLink(id, contextOf(request)); + return { ok: true }; + }); + + // --- Ketten --- + + /** + * Schiebt einen CC in eine Kette, hinter ein bestimmtes Glied oder an den + * Anfang. Für das Nachtragen von Lücken, die sich erst später auftun. + */ + app.post('/api/characters/:id/move', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + lineageId: z.string().trim().min(1).max(64), + afterId: z.number().int().positive().nullable().optional(), + }), + request.body, + ); + return moveInChain(id, body, contextOf(request)); + }); + + /** Löst einen CC aus seiner Kette und stellt ihn allein. */ + app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + return detachFromChain(id, contextOf(request)); + }); + // --- Gruppen-CCs --- app.get('/api/characters/:id/members', { preHandler: requireAuth }, async (request) => { diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts index 3bd403c..e256402 100644 --- a/server/src/routes/imports.routes.ts +++ b/server/src/routes/imports.routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { requireEditor } from '../auth/guard.js'; +import { requireAdmin, requireEditor } from '../auth/guard.js'; import { badRequest } from '../errors.js'; import { applyMapping, @@ -14,6 +14,7 @@ import { dismissRows, pendingOverview, } from '../services/imports.js'; +import { exportAll, importAll } from '../services/backup.js'; import { contextOf, idParam, parseWith } from './helpers.js'; const mappingBody = z.object({ @@ -44,6 +45,29 @@ export async function importRoutes(app: FastifyInstance): Promise { }; }); + /** + * Der gesamte Bestand als eine Tabelle - für den Umzug auf einen anderen + * Server oder den Wechsel auf ein anderes Programm. + */ + app.get('/api/backup/export', { preHandler: requireAdmin }, async (_request, reply) => { + const inhalt = await exportAll(); + const datum = new Date().toISOString().slice(0, 10); + + reply.header('content-type', 'text/csv; charset=utf-8'); + reply.header('content-disposition', `attachment; filename="cc-manager-${datum}.csv"`); + // Das Byte-Order-Mark, damit Excel die Umlaute richtig liest. + return `\uFEFF${inhalt}`; + }); + + /** Spielt eine zuvor exportierte Tabelle ein und ergänzt, was fehlt. */ + app.post('/api/backup/import', { preHandler: requireAdmin }, async (request) => { + const datei = await request.file(); + if (!datei) throw badRequest('NO_FILE', 'Es wurde keine Datei übertragen'); + + const inhalt = (await datei.toBuffer()).toString('utf8'); + return importAll(inhalt, contextOf(request)); + }); + /** Wie viele Pakete insgesamt noch auf eine Buchung warten. */ app.get('/api/imports/pending/overview', { preHandler: requireEditor }, async () => { return pendingOverview(); diff --git a/server/src/services/backup.ts b/server/src/services/backup.ts new file mode 100644 index 0000000..fafe6c9 --- /dev/null +++ b/server/src/services/backup.ts @@ -0,0 +1,448 @@ +import { stringify } from 'csv-stringify/sync'; +import { parse } from 'csv-parse/sync'; +import { CharacterStatus, CreditKind, CreditSource, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { badRequest } from '../errors.js'; +import { normalizeSteamId } from '../lib/steamid.js'; +import { recordAudit, type ActionContext } from './audit.js'; + +/** + * Vollständiger Export und Wiedereinspielung des Bestands als eine CSV. + * + * Gedacht für den Umzug auf einen anderen Server oder den Wechsel auf ein + * anderes Programm: alles, was ein Spieler an Guthaben, CCs, Konten und + * Verweisen hat, steht in einer Datei, die sich auch in einer Tabelle öffnen + * lässt. + * + * Verschiedene Satzarten in einer Datei gehen nur mit einer Spalte, die sagt, + * worum es in der Zeile geht. Deshalb steht in "satzart", was folgt, und die + * übrigen Spalten bedeuten je nach Satzart etwas anderes. Das ist der Preis + * für eine einzelne Datei statt eines Archivs mit sechs. + * + * Der Spieler wird über seine SteamID erkannt, ersatzweise über den + * Anzeigenamen. Deshalb steht er in jeder Zeile, auch wenn das die Datei + * länger macht: so bleibt jede Zeile für sich verständlich, und eine + * Umsortierung in der Tabelle macht den Bestand nicht kaputt. + */ + +/// Die Reihenfolge der Satzarten beim Einspielen. Ein CC braucht seinen +/// Spieler, eine Mitgliedschaft ihren CC, ein Verweis ebenso. +const SATZARTEN = ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS'] as const; +type Satzart = (typeof SATZARTEN)[number]; + +const SPALTEN = [ + 'satzart', + 'spieler', + 'steam_id', + 'forum_name', + 'notizen', + 'cc_name', + 'cc_status', + 'cc_grund', + 'cc_notiz', + 'cc_lore', + 'kette', + 'kette_position', + 'art', + 'menge', + 'anlass', + 'zeitpunkt', + 'bezahlt_mit', + 'wert', + 'bezeichnung', + 'bemerkung', +] as const; + +type Zeile = Partial>; + +function datum(wert: Date | null | undefined): string { + return wert ? wert.toISOString() : ''; +} + +/** Alles, was der Bestand hergibt, als eine Tabelle. */ +export async function exportAll(): Promise { + const [spieler, aliasse, ledger, ccs, mitglieder, links] = await Promise.all([ + prisma.player.findMany({ orderBy: { id: 'asc' } }), + prisma.playerAlias.findMany({ orderBy: { id: 'asc' }, include: { player: true } }), + prisma.creditLedger.findMany({ orderBy: { id: 'asc' }, include: { player: true } }), + prisma.character.findMany({ + orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }], + include: { player: true }, + }), + prisma.characterMember.findMany({ + orderBy: { id: 'asc' }, + include: { player: true, character: { select: { name: true, playerId: true } } }, + }), + prisma.characterLink.findMany({ + orderBy: { id: 'asc' }, + include: { character: { select: { name: true, playerId: true } } }, + }), + ]); + + // Für CC-bezogene Zeilen wird der Besitzer gebraucht, nicht der Nutzer. + const spielerNach = new Map(spieler.map((p) => [p.id, p])); + const kennung = (playerId: number): { spieler: string; steam_id: string } => { + const p = spielerNach.get(playerId); + return { spieler: p?.displayName ?? `#${playerId}`, steam_id: p?.steamId ?? '' }; + }; + + const zeilen: Zeile[] = []; + + for (const p of spieler) { + zeilen.push({ + satzart: 'SPIELER', + spieler: p.displayName, + steam_id: p.steamId ?? '', + forum_name: p.forumName ?? '', + notizen: p.notes ?? '', + bezeichnung: p.isProblem ? 'PROBLEM' : '', + bemerkung: p.problemNote ?? '', + }); + } + + for (const a of aliasse) { + zeilen.push({ + satzart: 'KONTO', + spieler: a.player.displayName, + steam_id: a.player.steamId ?? '', + art: a.type, + wert: a.value, + bemerkung: a.note ?? '', + }); + } + + for (const l of ledger) { + zeilen.push({ + satzart: 'GUTHABEN', + spieler: l.player.displayName, + steam_id: l.player.steamId ?? '', + art: l.kind, + menge: String(l.amount), + anlass: l.source, + zeitpunkt: datum(l.occurredAt), + bemerkung: l.note ?? '', + }); + } + + for (const c of ccs) { + zeilen.push({ + satzart: 'CC', + ...kennung(c.playerId), + cc_name: c.name, + cc_status: c.status, + cc_grund: c.inactiveReason ?? '', + cc_notiz: c.inactiveNote ?? '', + cc_lore: c.loreText ?? '', + kette: c.lineageId, + kette_position: String(c.chainPosition), + zeitpunkt: datum(c.createdAt), + }); + } + + for (const m of mitglieder) { + zeilen.push({ + satzart: 'MITGLIED', + spieler: m.player.displayName, + steam_id: m.player.steamId ?? '', + cc_name: m.character.name, + bezeichnung: kennung(m.character.playerId).spieler, + bezahlt_mit: m.paidWith ?? '', + zeitpunkt: datum(m.joinedAt), + cc_grund: m.inactiveReason ?? '', + cc_notiz: m.inactiveNote ?? '', + art: m.leftAt ? 'BEENDET' : 'AKTIV', + wert: datum(m.leftAt), + bemerkung: m.note ?? '', + }); + } + + for (const l of links) { + zeilen.push({ + satzart: 'VERWEIS', + ...kennung(l.character.playerId), + cc_name: l.character.name, + bezeichnung: l.label, + wert: l.url, + }); + } + + return stringify(zeilen, { header: true, columns: [...SPALTEN], delimiter: ';' }); +} + +export interface ImportReport { + gelesen: number; + angelegt: Record; + uebersprungen: Record; + fehler: { zeile: number; grund: string }[]; +} + +function leer(): Record { + return Object.fromEntries(SATZARTEN.map((a) => [a, 0])); +} + +/** + * Spielt eine zuvor exportierte Tabelle ein. + * + * Bestehendes wird erkannt und übersprungen, nicht überschrieben: der + * häufigste Fall ist eine Datei, die zur Hälfte schon eingespielt ist. Was + * sich nicht zuordnen lässt, wird mit Zeilennummer gemeldet, statt den ganzen + * Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten. + */ +export async function importAll(inhalt: string, ctx: ActionContext): Promise { + let zeilen: Zeile[]; + try { + zeilen = parse(inhalt, { + columns: true, + skip_empty_lines: true, + trim: true, + delimiter: [';', ','], + bom: true, + }) as Zeile[]; + } catch (error) { + throw badRequest( + 'PARSE_FAILED', + `Die Datei liess sich nicht lesen: ${error instanceof Error ? error.message : 'unbekannt'}`, + ); + } + + if (zeilen.length === 0) throw badRequest('EMPTY', 'Die Datei enthält keine Zeilen'); + if (!('satzart' in zeilen[0]!)) { + throw badRequest( + 'NO_SATZART', + 'Der Datei fehlt die Spalte "satzart" - stammt sie aus dem Export?', + ); + } + + const report: ImportReport = { + gelesen: zeilen.length, + angelegt: leer(), + uebersprungen: leer(), + fehler: [], + }; + + // Satzart für Satzart, damit Verweise ihr Ziel schon vorfinden. + for (const satzart of SATZARTEN) { + for (const [index, zeile] of zeilen.entries()) { + if ((zeile.satzart ?? '').toUpperCase() !== satzart) continue; + + try { + const neu = await spieleZeileEin(satzart, zeile, ctx); + if (neu) report.angelegt[satzart] = (report.angelegt[satzart] ?? 0) + 1; + else report.uebersprungen[satzart] = (report.uebersprungen[satzart] ?? 0) + 1; + } catch (error) { + report.fehler.push({ + zeile: index + 2, // plus Kopfzeile, plus Zählung ab eins + grund: error instanceof Error ? error.message : 'Unbekannter Fehler', + }); + } + } + } + + await recordAudit(prisma, ctx, { + action: 'backup.import', + targetType: 'backup', + targetId: null, + details: { ...report } as unknown as Prisma.InputJsonValue, + }); + + return report; +} + +/** Findet den Spieler einer Zeile - über die SteamID, sonst über den Namen. */ +async function findeSpieler(zeile: Zeile): Promise { + const steamId = zeile.steam_id ? normalizeSteamId(zeile.steam_id) : null; + if (steamId) { + const treffer = await prisma.player.findUnique({ where: { steamId }, select: { id: true } }); + if (treffer) return treffer.id; + } + + const name = zeile.spieler?.trim(); + if (!name) return null; + + const treffer = await prisma.player.findFirst({ + where: { displayName: name }, + select: { id: true }, + }); + return treffer?.id ?? null; +} + +async function findeCC(playerId: number, name: string): Promise { + const treffer = await prisma.character.findFirst({ + where: { playerId, name }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + return treffer?.id ?? null; +} + +/** true, wenn etwas angelegt wurde; false, wenn es schon da war. */ +async function spieleZeileEin( + satzart: Satzart, + zeile: Zeile, + ctx: ActionContext, +): Promise { + switch (satzart) { + case 'SPIELER': { + const name = zeile.spieler?.trim(); + if (!name) throw new Error('Ohne Namen lässt sich kein Spieler anlegen'); + + const vorhanden = await findeSpieler(zeile); + if (vorhanden !== null) return false; + + const steamId = zeile.steam_id ? normalizeSteamId(zeile.steam_id) : null; + await prisma.player.create({ + data: { + displayName: name, + steamId, + forumName: zeile.forum_name?.trim() || null, + notes: zeile.notizen?.trim() || null, + isProblem: zeile.bezeichnung === 'PROBLEM', + problemNote: zeile.bemerkung?.trim() || null, + ...(zeile.bezeichnung === 'PROBLEM' ? { problemSince: new Date() } : {}), + }, + }); + return true; + } + + case 'KONTO': { + const playerId = await findeSpieler(zeile); + if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`); + + const type = zeile.art === 'FORUM_NAME' ? 'FORUM_NAME' : 'STEAM_ID'; + const roh = zeile.wert?.trim(); + if (!roh) throw new Error('Ohne Wert kein Konto'); + const value = type === 'STEAM_ID' ? (normalizeSteamId(roh) ?? roh) : roh; + + const vorhanden = await prisma.playerAlias.findUnique({ + where: { type_value: { type, value } }, + select: { id: true }, + }); + if (vorhanden) return false; + + await prisma.playerAlias.create({ + data: { playerId, type, value, note: zeile.bemerkung?.trim() || null }, + }); + return true; + } + + case 'GUTHABEN': { + const playerId = await findeSpieler(zeile); + if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`); + + const kind = zeile.art as CreditKind; + const source = zeile.anlass as CreditSource; + const amount = Number(zeile.menge); + if (!Number.isInteger(amount)) throw new Error(`Menge "${zeile.menge}" ist keine Zahl`); + + const occurredAt = zeile.zeitpunkt ? new Date(zeile.zeitpunkt) : new Date(); + const note = zeile.bemerkung?.trim() || null; + + // Ohne eigene Kennung bleibt der Abdruck: derselbe Spieler, dieselbe + // Bewegung zur selben Zeit ist dieselbe Buchung. + const vorhanden = await prisma.creditLedger.findFirst({ + where: { playerId, kind, source, amount, occurredAt, note }, + select: { id: true }, + }); + if (vorhanden) return false; + + await prisma.creditLedger.create({ + data: { playerId, kind, amount, source, occurredAt, note, createdById: ctx.actorId }, + }); + return true; + } + + case 'CC': { + const playerId = await findeSpieler(zeile); + if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`); + + const name = zeile.cc_name?.trim(); + if (!name) throw new Error('Ohne Namen kein CC'); + if ((await findeCC(playerId, name)) !== null) return false; + + const status = + zeile.cc_status === 'INACTIVE' ? CharacterStatus.INACTIVE : CharacterStatus.ACTIVE; + + const erstellt = await prisma.character.create({ + data: { + playerId, + name, + loreText: zeile.cc_lore?.trim() || null, + status, + inactiveReason: (zeile.cc_grund?.trim() || null) as never, + inactiveNote: zeile.cc_notiz?.trim() || null, + ...(zeile.kette?.trim() ? { lineageId: zeile.kette.trim() } : {}), + chainPosition: Number(zeile.kette_position) || 0, + ...(zeile.zeitpunkt ? { createdAt: new Date(zeile.zeitpunkt) } : {}), + createdById: ctx.actorId, + }, + }); + + // Der Besitzer ist immer auch Mitglied; eigene MITGLIED-Zeilen + // ergänzen die übrigen. + await prisma.characterMember.create({ + data: { characterId: erstellt.id, playerId, createdById: ctx.actorId }, + }); + return true; + } + + case 'MITGLIED': { + const playerId = await findeSpieler(zeile); + if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`); + + const ccName = zeile.cc_name?.trim(); + const besitzer = zeile.bezeichnung?.trim(); + if (!ccName || !besitzer) throw new Error('Mitgliedschaft ohne CC oder Besitzer'); + + const besitzerId = await findeSpieler({ spieler: besitzer }); + if (besitzerId === null) throw new Error(`Besitzer "${besitzer}" nicht gefunden`); + + const characterId = await findeCC(besitzerId, ccName); + if (characterId === null) throw new Error(`CC "${ccName}" nicht gefunden`); + + const vorhanden = await prisma.characterMember.findUnique({ + where: { characterId_playerId: { characterId, playerId } }, + select: { id: true }, + }); + if (vorhanden) return false; + + await prisma.characterMember.create({ + data: { + characterId, + playerId, + paidWith: (zeile.bezahlt_mit?.trim() || null) as never, + inactiveReason: (zeile.cc_grund?.trim() || null) as never, + inactiveNote: zeile.cc_notiz?.trim() || null, + leftAt: zeile.wert ? new Date(zeile.wert) : null, + ...(zeile.zeitpunkt ? { joinedAt: new Date(zeile.zeitpunkt) } : {}), + note: zeile.bemerkung?.trim() || null, + createdById: ctx.actorId, + }, + }); + return true; + } + + case 'VERWEIS': { + const playerId = await findeSpieler(zeile); + if (playerId === null) throw new Error(`Spieler "${zeile.spieler}" nicht gefunden`); + + const ccName = zeile.cc_name?.trim(); + const label = zeile.bezeichnung?.trim(); + const url = zeile.wert?.trim(); + if (!ccName || !label || !url) throw new Error('Verweis ohne CC, Namen oder Adresse'); + + const characterId = await findeCC(playerId, ccName); + if (characterId === null) throw new Error(`CC "${ccName}" nicht gefunden`); + + const vorhanden = await prisma.characterLink.findFirst({ + where: { characterId, url }, + select: { id: true }, + }); + if (vorhanden) return false; + + await prisma.characterLink.create({ + data: { characterId, label, url, createdById: ctx.actorId }, + }); + return true; + } + } +} diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 92d8f86..2df998c 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -195,6 +195,9 @@ export async function redesignCharacter( loreText: input.loreText ?? null, status: CharacterStatus.ACTIVE, lineageId: old.lineageId, + // Zehnerschritte, damit ein später gefundenes Zwischenglied noch + // dazwischen passt, ohne die ganze Kette anzufassen. + chainPosition: old.chainPosition + 10, predecessorId: old.id, createdById: ctx.actorId, members: { @@ -537,7 +540,7 @@ export async function listCharacters(playerId: number) { some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] }, }, }, - orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }], include: { predecessor: { select: { id: true, name: true } }, successors: { select: { id: true, name: true } }, @@ -546,6 +549,10 @@ export async function listCharacters(playerId: number) { select: { threadId: true, title: true, url: true }, orderBy: { threadId: 'desc' }, }, + links: { + select: { id: true, label: true, url: true }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }, members: { orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], include: { @@ -633,6 +640,235 @@ export async function reviveDeadCharacter( } +// --- Ketten --- + +/** + * Setzt die Verkettung einer Linie neu auf, nach der Reihenfolge der + * Positionen. + * + * Die Kette steht doppelt in den Daten: als Position und als Verweis auf den + * Vorgänger. Die Position lässt sich verschieben, ohne alles anzufassen; der + * Vorgänger-Verweis ist das, woran der Rest der Anwendung hängt. Nach jedem + * Umsortieren wird er deshalb aus den Positionen neu abgeleitet - dann können + * beide nicht auseinanderlaufen. + */ +async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Promise { + const glieder = await tx.character.findMany({ + where: { lineageId }, + orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], + select: { id: true, predecessorId: true }, + }); + + let vorheriges: number | null = null; + for (const glied of glieder) { + if (glied.predecessorId !== vorheriges) { + await tx.character.update({ + where: { id: glied.id }, + data: { predecessorId: vorheriges }, + }); + } + vorheriges = glied.id; + } +} + +/** + * Verschiebt einen CC innerhalb seiner Kette oder in eine andere. + * + * Gedacht für das Nachtragen von Lücken: ein Redesign, das erst Jahre später + * auftaucht, gehört mitten in eine bestehende Kette - und nicht ans Ende, + * bloß weil es zuletzt eingetragen wurde. Am Status ändert das Verschieben + * nichts; wer aktiv war, bleibt aktiv. + */ +export async function moveInChain( + characterId: number, + input: { lineageId: string; afterId?: number | null }, + ctx: ActionContext, +): Promise<{ lineageId: string }> { + const character = await loadCharacter(characterId); + + // Die Ziel-Linie muss dem Spieler gehören, sonst wanderten CCs zwischen + // Spielern - dafür gibt es hier keinen Weg und keinen Grund. + const zielGlieder = await prisma.character.findMany({ + where: { lineageId: input.lineageId }, + orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], + select: { id: true, playerId: true, chainPosition: true }, + }); + if (zielGlieder.length > 0 && zielGlieder.some((g) => g.playerId !== character.playerId)) { + throw conflict('FOREIGN_CHAIN', 'Diese Kette gehört einem anderen Spieler'); + } + + const vorgaenger = + input.afterId === undefined || input.afterId === null + ? null + : zielGlieder.find((g) => g.id === input.afterId); + if (input.afterId != null && !vorgaenger) { + throw badRequest('NOT_IN_CHAIN', 'Der genannte Vorgänger liegt nicht in dieser Kette'); + } + if (input.afterId === characterId) { + throw badRequest('SELF_REFERENCE', 'Ein CC kann nicht hinter sich selbst stehen'); + } + + const alteLinie = character.lineageId; + + await prisma.$transaction(async (tx) => { + // Erst Platz schaffen: alles hinter der Einfügestelle rückt auf. + const ab = vorgaenger ? vorgaenger.chainPosition + 1 : 0; + await tx.character.updateMany({ + where: { + lineageId: input.lineageId, + chainPosition: { gte: ab }, + id: { not: characterId }, + }, + data: { chainPosition: { increment: 10 } }, + }); + + await tx.character.update({ + where: { id: characterId }, + data: { lineageId: input.lineageId, chainPosition: ab }, + }); + + await verketteNeu(tx, input.lineageId); + if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie); + + await recordAudit(tx, ctx, { + action: 'character.moved_in_chain', + targetType: 'character', + targetId: characterId, + details: { + name: character.name, + fromLineage: alteLinie, + toLineage: input.lineageId, + afterId: input.afterId ?? null, + }, + }); + }); + + return { lineageId: input.lineageId }; +} + +/** + * Löst einen CC aus seiner Kette und stellt ihn allein - für den Fall, dass + * sich herausstellt, dass er gar nicht dorthin gehörte. + */ +export async function detachFromChain( + characterId: number, + ctx: ActionContext, +): Promise<{ lineageId: string }> { + const character = await loadCharacter(characterId); + const neueLinie = randomUUID(); + + await prisma.$transaction(async (tx) => { + await tx.character.update({ + where: { id: characterId }, + data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null }, + }); + + await verketteNeu(tx, character.lineageId); + + await recordAudit(tx, ctx, { + action: 'character.detached', + targetType: 'character', + targetId: characterId, + details: { name: character.name, fromLineage: character.lineageId }, + }); + }); + + return { lineageId: neueLinie }; +} + +// --- Verweise an einem CC --- + +/// Nur diese Schemata. Ein "javascript:"-Link in einer Oberfläche, die +/// mehrere Leute bedienen, wäre ein offenes Scheunentor. +const ERLAUBTE_SCHEMATA = ['http:', 'https:']; + +function pruefeUrl(roh: string): string { + const wert = roh.trim(); + let parsed: URL; + try { + parsed = new URL(wert); + } catch { + throw badRequest('INVALID_URL', 'Das ist keine gültige Adresse - bitte mit http:// oder https://'); + } + if (!ERLAUBTE_SCHEMATA.includes(parsed.protocol)) { + throw badRequest('INVALID_URL', 'Erlaubt sind nur Adressen mit http:// oder https://'); + } + return parsed.toString(); +} + +/** + * Hängt einen Verweis an einen CC: das Steam-Addon des Erstellers, ein + * Modell, eine Absprache. Der Name wird in der Liste zum Link, damit dort + * steht, worum es geht, statt einer nackten Adresse. + */ +export async function addLink( + characterId: number, + input: { label: string; url: string }, + ctx: ActionContext, +) { + const character = await loadCharacter(characterId); + const label = input.label.trim(); + if (!label) throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht'); + + const created = await prisma.characterLink.create({ + data: { characterId, label, url: pruefeUrl(input.url), createdById: ctx.actorId }, + }); + + await recordAudit(prisma, ctx, { + action: 'character.link_added', + targetType: 'character', + targetId: characterId, + details: { name: character.name, label, url: created.url }, + }); + + return created; +} + +export async function updateLink( + linkId: number, + input: { label?: string | undefined; url?: string | undefined }, + ctx: ActionContext, +) { + const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } }); + if (!vorhanden) throw notFound('Verweis'); + + const label = input.label?.trim(); + if (input.label !== undefined && !label) { + throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht'); + } + + const updated = await prisma.characterLink.update({ + where: { id: linkId }, + data: { + ...(label ? { label } : {}), + ...(input.url !== undefined ? { url: pruefeUrl(input.url) } : {}), + }, + }); + + await recordAudit(prisma, ctx, { + action: 'character.link_updated', + targetType: 'character', + targetId: vorhanden.characterId, + details: { label: updated.label, url: updated.url }, + }); + + return updated; +} + +export async function removeLink(linkId: number, ctx: ActionContext): Promise { + const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } }); + if (!vorhanden) throw notFound('Verweis'); + + await prisma.characterLink.delete({ where: { id: linkId } }); + + await recordAudit(prisma, ctx, { + action: 'character.link_removed', + targetType: 'character', + targetId: vorhanden.characterId, + details: { label: vorhanden.label, url: vorhanden.url }, + }); +} + // --- Gruppen-CCs --- /// Ein Platz in einem CC kostet ein CC-Guthaben oder ein Redesign - je nachdem, diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index f652c22..784216f 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -347,6 +347,7 @@ async function scanIntern(ctx: ActionContext): Promise { url: thread.url, authorName: thread.authorName, authorForumId: thread.authorForumId, + authorProfileUrl: thread.authorProfileUrl, labels, playerId, currentBoard: board, @@ -367,10 +368,13 @@ async function scanIntern(ctx: ActionContext): Promise { // nach, ohne dass ein vorhandener Wert überschrieben wird. const datumFehlt = existing.postedAt === null && thread.postedAt !== null; + const profilFehlt = existing.authorProfileUrl === null && thread.authorProfileUrl !== null; + const geaendert = verschoben || nachgetragen || datumFehlt || + profilFehlt || existing.title !== thread.title || existing.url !== thread.url || existing.authorName !== thread.authorName || @@ -388,6 +392,7 @@ async function scanIntern(ctx: ActionContext): Promise { authorForumId: thread.authorForumId, labels, ...(datumFehlt ? { postedAt: thread.postedAt } : {}), + ...(profilFehlt ? { authorProfileUrl: thread.authorProfileUrl } : {}), ...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}), ...(verschoben ? { @@ -584,7 +589,7 @@ export async function claimThreadsForPlayer(playerId: number, names: string[]): const kandidaten = await prisma.forumThread.findMany({ where: { playerId: null, authorName: { in: gesucht, mode: 'insensitive' } }, - select: { threadId: true }, + select: { threadId: true, authorProfileUrl: true }, }); if (kandidaten.length === 0) return 0; @@ -592,9 +597,29 @@ export async function claimThreadsForPlayer(playerId: number, names: string[]): where: { threadId: { in: kandidaten.map((t) => t.threadId) }, playerId: null }, data: { playerId }, }); + + await merkeForumProfil(playerId, kandidaten); return result.count; } +/** + * Hinterlegt das Forumsprofil am Spieler, sobald einer seiner Threads es + * mitbringt. Einmal gesetzt bleibt es: es zeigt auf eine Nummer, überlebt + * also eine Namensänderung im Forum. + */ +async function merkeForumProfil( + playerId: number, + threads: { authorProfileUrl: string | null }[], +): Promise { + const profil = threads.find((t) => t.authorProfileUrl)?.authorProfileUrl; + if (!profil) return; + + await prisma.player.updateMany({ + where: { id: playerId, forumProfileUrl: null }, + data: { forumProfileUrl: profil }, + }); +} + /** Threads, bei denen nach der Entscheidung das Board gewechselt hat. */ export async function listBoardChangeNotices() { return prisma.forumThread.findMany({ @@ -638,6 +663,7 @@ export async function assignPlayer( if (!player) throw notFound('Spieler'); await prisma.forumThread.update({ where: { threadId }, data: { playerId } }); + await merkeForumProfil(playerId, [thread]); await recordAudit(prisma, ctx, { action: 'forum.player_assigned', diff --git a/server/test/backup.test.ts b/server/test/backup.test.ts new file mode 100644 index 0000000..cf91149 --- /dev/null +++ b/server/test/backup.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Backup = typeof import('../src/services/backup.js'); +type Players = typeof import('../src/services/players.js'); +type Characters = typeof import('../src/services/characters.js'); +type Credits = typeof import('../src/services/credits.js'); +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let backup: Backup; +let players: Players; +let characters: Characters; +let credits: Credits; +let db: Db; +let ctx: { actorId: number; actorName: string }; + +before(async () => { + database = await startTestDatabase(54339); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + process.env.SCHEDULE_ENABLED = 'false'; + + db = await import('../src/db.js'); + backup = await import('../src/services/backup.js'); + players = await import('../src/services/players.js'); + characters = await import('../src/services/characters.js'); + credits = await import('../src/services/credits.js'); + + const user = await db.prisma.user.create({ + data: { username: 'umzug', passwordHash: 'x', role: 'ADMIN' }, + }); + ctx = { actorId: user.id, actorName: user.username }; +}); + +after(async () => { + await db.prisma.$disconnect(); + await database.stop(); +}); + +/** Räumt den Bestand ab, als wäre es eine frische Instanz. */ +async function leereInstanz() { + await db.prisma.characterLink.deleteMany(); + await db.prisma.characterMember.deleteMany(); + await db.prisma.creditLedger.deleteMany(); + await db.prisma.character.deleteMany(); + await db.prisma.playerAlias.deleteMany(); + await db.prisma.player.deleteMany(); +} + +/** Ein Bestand mit allem, was der Export kennen muss. */ +async function bestandAnlegen() { + const chef = await players.createPlayer( + { + displayName: 'Umzugs-Chef', + steamId: '76561198000030001', + forumName: 'UmzugsChef', + notes: 'Führt die Gruppe', + }, + ctx, + ); + const gast = await players.createPlayer( + { displayName: 'Umzugs-Gast', steamId: '76561198000030002' }, + ctx, + ); + const problemfall = await players.createPlayer( + { displayName: 'Umzugs-Problem', isProblem: true, problemNote: 'Kein Paket auffindbar' }, + ctx, + ); + + await players.addAlias(chef.id, 'STEAM_ID', '76561198000030099', 'Erstes Konto', ctx); + await credits.bookCcPackage(chef.id, ctx); + await credits.bookCcPackage(gast.id, ctx); + await credits.bookRedesignPackage(chef.id, ctx); + + const erster = await characters.createCharacter( + chef.id, + { name: 'Erster Wurf', loreText: 'Ein Anfang' }, + ctx, + ); + const zweiter = await characters.redesignCharacter( + erster.character.id, + { name: 'Zweiter Wurf', useKind: 'REDESIGN_PAID' }, + ctx, + ); + await characters.addMember(zweiter.character.id, gast.id, { useKind: 'CC' }, ctx); + await characters.addLink( + zweiter.character.id, + { label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=1' }, + ctx, + ); + + return { chef, gast, problemfall, erster, zweiter }; +} + +describe('Umzug auf eine andere Instanz', () => { + let csv = ''; + + it('schreibt jede Satzart in die Tabelle', async () => { + await bestandAnlegen(); + csv = await backup.exportAll(); + + for (const satzart of ['SPIELER', 'KONTO', 'GUTHABEN', 'CC', 'MITGLIED', 'VERWEIS']) { + assert.ok(csv.includes(`${satzart};`), `${satzart} fehlt im Export`); + } + assert.ok(csv.startsWith('satzart;'), 'die Kopfzeile nennt die Satzart zuerst'); + assert.ok(csv.includes('Ersteller-Addon'), 'Verweise gehören dazu'); + assert.ok(csv.includes('Kein Paket auffindbar'), 'Problemfälle auch'); + }); + + it('stellt den Bestand auf einer leeren Instanz wieder her', async () => { + await leereInstanz(); + assert.equal(await db.prisma.player.count(), 0); + + const bericht = await backup.importAll(csv, ctx); + assert.deepEqual(bericht.fehler, [], 'keine Zeile darf hängen bleiben'); + + assert.equal(await db.prisma.player.count(), 3); + assert.equal(await db.prisma.character.count(), 2); + assert.equal(await db.prisma.characterLink.count(), 1); + + const chef = await db.prisma.player.findFirstOrThrow({ + where: { displayName: 'Umzugs-Chef' }, + }); + assert.equal(chef.steamId, '76561198000030001'); + assert.equal(chef.forumName, 'UmzugsChef'); + + // Guthaben und Verbräuche stehen wieder wie zuvor. + const stand = await credits.getBalances(chef.id); + assert.equal(stand.CC, 0, 'ein Paket gekauft, einen CC daraus gebaut'); + assert.equal(stand.REDESIGN_PAID, 1, 'zwei Redesigns erhalten, eines verbraucht'); + }); + + it('hält die Redesign-Kette zusammen', async () => { + const chef = await db.prisma.player.findFirstOrThrow({ + where: { displayName: 'Umzugs-Chef' }, + }); + const kette = await db.prisma.character.findMany({ + where: { playerId: chef.id }, + orderBy: { chainPosition: 'asc' }, + }); + + assert.deepEqual(kette.map((c) => c.name), ['Erster Wurf', 'Zweiter Wurf']); + assert.equal( + kette[0]?.lineageId, + kette[1]?.lineageId, + 'beide gehören zur selben Kette', + ); + assert.equal(kette[0]?.status, 'INACTIVE'); + assert.equal(kette[1]?.status, 'ACTIVE'); + }); + + it('nimmt den Gruppen-CC samt seinem zweiten Nutzer mit', async () => { + const gast = await db.prisma.player.findFirstOrThrow({ + where: { displayName: 'Umzugs-Gast' }, + }); + const seine = await characters.listCharacters(gast.id); + assert.deepEqual( + seine.map((c) => c.name), + ['Zweiter Wurf'], + 'er ist Mitglied, nicht Besitzer', + ); + }); + + it('legt beim zweiten Einspielen nichts doppelt an', async () => { + const vorher = { + spieler: await db.prisma.player.count(), + ccs: await db.prisma.character.count(), + buchungen: await db.prisma.creditLedger.count(), + mitglieder: await db.prisma.characterMember.count(), + links: await db.prisma.characterLink.count(), + }; + + const bericht = await backup.importAll(csv, ctx); + assert.deepEqual(bericht.fehler, []); + assert.equal( + Object.values(bericht.angelegt).reduce((a, b) => a + b, 0), + 0, + 'alles war schon da', + ); + + assert.deepEqual( + { + spieler: await db.prisma.player.count(), + ccs: await db.prisma.character.count(), + buchungen: await db.prisma.creditLedger.count(), + mitglieder: await db.prisma.characterMember.count(), + links: await db.prisma.characterLink.count(), + }, + vorher, + 'ein zweiter Lauf darf den Bestand nicht verdoppeln', + ); + }); + + it('ergänzt einen Spieler, der auf der Zielinstanz fehlt', async () => { + await db.prisma.player.deleteMany({ where: { displayName: 'Umzugs-Problem' } }); + assert.equal(await db.prisma.player.count(), 2); + + const bericht = await backup.importAll(csv, ctx); + assert.equal(bericht.angelegt.SPIELER, 1, 'nur der fehlende kommt dazu'); + assert.equal(await db.prisma.player.count(), 3); + + const wieder = await db.prisma.player.findFirstOrThrow({ + where: { displayName: 'Umzugs-Problem' }, + }); + assert.equal(wieder.isProblem, true, 'auch die Markierung kommt mit'); + }); + + it('meldet krumme Zeilen, statt den Lauf abzubrechen', async () => { + const kaputt = [ + 'satzart;spieler;steam_id;cc_name;bezeichnung;wert', + 'VERWEIS;Gibt-Es-Nicht;;Kein CC;Irgendwas;https://example.org/', + 'SPIELER;Kommt-Trotzdem-Durch;76561198000030777;;;', + ].join('\n'); + + const bericht = await backup.importAll(kaputt, ctx); + assert.equal(bericht.fehler.length, 1); + assert.match(bericht.fehler[0]?.grund ?? '', /nicht gefunden/); + assert.equal(bericht.angelegt.SPIELER, 1, 'die gute Zeile läuft trotzdem durch'); + }); + + it('weist eine Datei ohne Satzart ab', async () => { + await assert.rejects( + () => backup.importAll('name;wert\nirgendwas;1', ctx), + (error: Error & { code?: string }) => error.code === 'NO_SATZART', + ); + }); +}); diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts new file mode 100644 index 0000000..27fdc29 --- /dev/null +++ b/server/test/ketten.test.ts @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Characters = typeof import('../src/services/characters.js'); +type Credits = typeof import('../src/services/credits.js'); +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let characters: Characters; +let credits: Credits; +let db: Db; +let ctx: { actorId: number; actorName: string }; + +before(async () => { + database = await startTestDatabase(54340); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + process.env.SCHEDULE_ENABLED = 'false'; + + db = await import('../src/db.js'); + characters = await import('../src/services/characters.js'); + credits = await import('../src/services/credits.js'); + + const user = await db.prisma.user.create({ + data: { username: 'ketten', passwordHash: 'x', role: 'ADMIN' }, + }); + ctx = { actorId: user.id, actorName: user.username }; +}); + +after(async () => { + await db.prisma.$disconnect(); + await database.stop(); +}); + +async function spielerMitPaketen(name: string, pakete = 1) { + const player = await db.prisma.player.create({ data: { displayName: name } }); + for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(player.id, ctx); + return player.id; +} + +/** Die Kette eines Spielers, wie sie angezeigt würde. */ +async function kette(playerId: number, lineageId: string) { + const glieder = await db.prisma.character.findMany({ + where: { playerId, lineageId }, + orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }], + select: { id: true, name: true, predecessorId: true, chainPosition: true }, + }); + return glieder; +} + +/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */ +function verkettungStimmt(glieder: { id: number; predecessorId: number | null }[]): void { + let erwartet: number | null = null; + for (const glied of glieder) { + assert.equal( + glied.predecessorId, + erwartet, + 'die Verkettung muss der Reihenfolge folgen, sonst laufen beide Sichten auseinander', + ); + erwartet = glied.id; + } +} + +describe('Lücken in einer Redesign-Kette schliessen', () => { + it('legt Redesigns hintereinander ab', async () => { + const spieler = await spielerMitPaketen('Kettenbauer', 1); + const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Redesign 2', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const glieder = await kette(spieler, zwei.character.lineageId); + assert.deepEqual(glieder.map((g) => g.name), ['CC 1', 'Redesign 2']); + verkettungStimmt(glieder); + }); + + it('schiebt ein später gefundenes Redesign mitten hinein', async () => { + const spieler = await spielerMitPaketen('Nachtraeger', 2); + const eins = await characters.createCharacter(spieler, { name: 'CC 1' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const drei = await characters.redesignCharacter( + eins.character.id, + { name: 'Redesign 3', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + // Redesign 2 taucht erst jetzt auf - als eigener CC, noch ohne Kette. + const zwei = await characters.createCharacter(spieler, { name: 'Redesign 2' }, ctx); + assert.notEqual(zwei.character.lineageId, drei.character.lineageId); + + await characters.moveInChain( + zwei.character.id, + { lineageId: drei.character.lineageId, afterId: eins.character.id }, + ctx, + ); + + const glieder = await kette(spieler, drei.character.lineageId); + assert.deepEqual( + glieder.map((g) => g.name), + ['CC 1', 'Redesign 2', 'Redesign 3'], + 'die Lücke ist geschlossen, ohne die übrigen anzufassen', + ); + verkettungStimmt(glieder); + }); + + it('setzt einen Eintrag auch an den Anfang', async () => { + const spieler = await spielerMitPaketen('Vorspann', 2); + const b = await characters.createCharacter(spieler, { name: 'B' }, ctx); + const a = await characters.createCharacter(spieler, { name: 'A' }, ctx); + + await characters.moveInChain( + a.character.id, + { lineageId: b.character.lineageId, afterId: null }, + ctx, + ); + + const glieder = await kette(spieler, b.character.lineageId); + assert.deepEqual(glieder.map((g) => g.name), ['A', 'B']); + verkettungStimmt(glieder); + assert.equal(glieder[0]?.predecessorId, null, 'der Erste hat keinen Vorgänger'); + }); + + it('holt einen Eintrag aus einer Kette in eine andere', async () => { + const spieler = await spielerMitPaketen('Umsortierer', 3); + const ersteKette = await characters.createCharacter(spieler, { name: 'Strang A' }, ctx); + const zweiteKette = await characters.createCharacter(spieler, { name: 'Strang B' }, ctx); + const wanderer = await characters.createCharacter(spieler, { name: 'Wanderer' }, ctx); + + await characters.moveInChain( + wanderer.character.id, + { lineageId: ersteKette.character.lineageId, afterId: ersteKette.character.id }, + ctx, + ); + assert.equal((await kette(spieler, ersteKette.character.lineageId)).length, 2); + + // Doch die andere Kette. + await characters.moveInChain( + wanderer.character.id, + { lineageId: zweiteKette.character.lineageId, afterId: zweiteKette.character.id }, + ctx, + ); + + const erste = await kette(spieler, ersteKette.character.lineageId); + const zweite = await kette(spieler, zweiteKette.character.lineageId); + assert.deepEqual(erste.map((g) => g.name), ['Strang A'], 'die alte Kette schliesst sich'); + assert.deepEqual(zweite.map((g) => g.name), ['Strang B', 'Wanderer']); + verkettungStimmt(erste); + verkettungStimmt(zweite); + }); + + it('löst einen Eintrag wieder heraus', async () => { + const spieler = await spielerMitPaketen('Loeser', 2); + const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx); + await credits.bookRedesignPackage(spieler, ctx); + const zwei = await characters.redesignCharacter( + eins.character.id, + { name: 'Geht wieder', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx); + assert.notEqual(lineageId, eins.character.lineageId); + + const alte = await kette(spieler, eins.character.lineageId); + assert.deepEqual(alte.map((g) => g.name), ['Bleibt']); + + const allein = await db.prisma.character.findUniqueOrThrow({ + where: { id: zwei.character.id }, + }); + assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger'); + }); + + it('lässt keinen CC in die Kette eines anderen Spielers', async () => { + const einer = await spielerMitPaketen('Einer', 1); + const anderer = await spielerMitPaketen('Anderer', 1); + const seiner = await characters.createCharacter(einer, { name: 'Seiner' }, ctx); + const fremder = await characters.createCharacter(anderer, { name: 'Fremder' }, ctx); + + await assert.rejects( + () => + characters.moveInChain( + fremder.character.id, + { lineageId: seiner.character.lineageId, afterId: seiner.character.id }, + ctx, + ), + (error: Error & { code?: string }) => error.code === 'FOREIGN_CHAIN', + ); + }); + + it('lässt einen CC nicht hinter sich selbst stellen', async () => { + const spieler = await spielerMitPaketen('Schlange', 1); + const cc = await characters.createCharacter(spieler, { name: 'Beisst sich' }, ctx); + + await assert.rejects( + () => + characters.moveInChain( + cc.character.id, + { lineageId: cc.character.lineageId, afterId: cc.character.id }, + ctx, + ), + (error: Error & { code?: string }) => error.code === 'SELF_REFERENCE', + ); + }); + + it('rührt den Status nicht an', async () => { + const spieler = await spielerMitPaketen('Unberuehrt', 2); + const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx); + const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx); + + await characters.moveInChain( + aktiv.character.id, + { lineageId: ziel.character.lineageId, afterId: ziel.character.id }, + ctx, + ); + + const danach = await db.prisma.character.findUniqueOrThrow({ + where: { id: aktiv.character.id }, + }); + assert.equal(danach.status, 'ACTIVE', 'verschieben ordnet nur ein, es entscheidet nichts'); + }); +}); + +describe('Verweise an einem CC', () => { + it('hängt einen Verweis an und gibt ihn mit aus', async () => { + const spieler = await spielerMitPaketen('Verweiser', 1); + const cc = await characters.createCharacter(spieler, { name: 'Mit Addon' }, ctx); + + await characters.addLink( + cc.character.id, + { label: 'Ersteller-Addon', url: 'https://steamcommunity.com/sharedfiles/?id=42' }, + ctx, + ); + + const liste = await characters.listCharacters(spieler); + const gefunden = liste.find((c) => c.id === cc.character.id); + assert.deepEqual( + gefunden?.links.map((l) => l.label), + ['Ersteller-Addon'], + ); + }); + + it('weist eine Adresse ab, die keine ist', async () => { + const spieler = await spielerMitPaketen('Vorsichtig', 1); + const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx); + + for (const url of ['javascript:alert(1)', 'nur-text', 'file:///etc/passwd']) { + await assert.rejects( + () => characters.addLink(cc.character.id, { label: 'Test', url }, ctx), + (error: Error & { code?: string }) => error.code === 'INVALID_URL', + `"${url}" hätte abgewiesen werden müssen`, + ); + } + }); + + it('lässt sich ändern und entfernen', async () => { + const spieler = await spielerMitPaketen('Pfleger', 1); + const cc = await characters.createCharacter(spieler, { name: 'Gepflegt' }, ctx); + const link = await characters.addLink( + cc.character.id, + { label: 'Alt', url: 'https://example.org/alt' }, + ctx, + ); + + const geaendert = await characters.updateLink( + link.id, + { label: 'Neu', url: 'https://example.org/neu' }, + ctx, + ); + assert.equal(geaendert.label, 'Neu'); + assert.equal(geaendert.url, 'https://example.org/neu'); + + await characters.removeLink(link.id, ctx); + assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx new file mode 100644 index 0000000..f24a71c --- /dev/null +++ b/web/src/components/ChainView.tsx @@ -0,0 +1,391 @@ +import { useState } from 'react'; +import MemberList from './MemberList'; +import { ApiError, api } from '../lib/api'; +import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format'; +import type { Character } from '../lib/types'; + +/** + * Die CCs eines Spielers als Ketten. + * + * Ein CC-Paket ist ein Platz, und jeder Platz hat seine eigene Geschichte: + * erster CC, Redesign, noch ein Redesign. Gelesen wird von unten nach oben, + * der älteste Eintrag unten - so wächst die Kette in die Richtung, in die + * auch die Zeit läuft. + * + * Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre später + * auf, weil sein Antrag woanders lag oder unter einem fremden Namen lief. + * Deshalb lässt sich jeder Eintrag mit der Maus an seinen Platz ziehen - + * auch mitten in eine Kette oder in eine andere. + */ +export type ChainAction = + | 'redesign' + | 'memberRedesign' + | 'addMember' + | 'deactivate' + | 'memberDeactivate' + | 'revert' + | 'reactivateFree' + | 'reactivatePaid' + | 'reviveDead' + | 'links'; + +interface Kette { + lineageId: string; + glieder: Character[]; +} + +function baueKetten(characters: Character[]): Kette[] { + const nach = new Map(); + for (const c of characters) { + const liste = nach.get(c.lineageId) ?? []; + liste.push(c); + nach.set(c.lineageId, liste); + } + + return [...nach.entries()] + .map(([lineageId, glieder]) => ({ + lineageId, + glieder: [...glieder].sort((a, b) => a.chainPosition - b.chainPosition), + })) + // Ketten mit einem aktiven CC zuerst - daran arbeitet man. + .sort((a, b) => { + const aktivA = a.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1; + const aktivB = b.glieder.some((c) => c.status === 'ACTIVE') ? 0 : 1; + if (aktivA !== aktivB) return aktivA - aktivB; + return (a.glieder[0]?.createdAt ?? '').localeCompare(b.glieder[0]?.createdAt ?? ''); + }); +} + +export default function ChainView({ + characters, + playerId, + canEdit, + admin, + onAction, + onMoved, + onError, +}: { + characters: Character[]; + playerId: number; + canEdit: boolean; + admin: boolean; + onAction: (action: ChainAction, character: Character) => void; + onMoved: () => void; + onError: (message: string) => void; +}) { + const [ziehe, setZiehe] = useState(null); + const [ueber, setUeber] = useState(null); + + const ketten = baueKetten(characters); + + if (ketten.length === 0) { + return ( +
    +

    CCs

    +

    Noch keine CCs. Über „Neuer CC" den ersten anlegen.

    +
    + ); + } + + /** Nach `afterId` einsortieren; null heisst an den Anfang. */ + const verschieben = async (lineageId: string, afterId: number | null) => { + if (!ziehe) return; + setUeber(null); + const bewegt = ziehe; + setZiehe(null); + + // Der Platz, auf den gezogen wurde, ist schon seiner. + if (bewegt.lineageId === lineageId) { + const kette = ketten.find((k) => k.lineageId === lineageId)?.glieder ?? []; + const index = kette.findIndex((c) => c.id === bewegt.id); + const zielIndex = afterId === null ? 0 : kette.findIndex((c) => c.id === afterId) + 1; + if (index === zielIndex || index === zielIndex - 1) return; + } + + try { + await api(`/api/characters/${bewegt.id}/move`, { + method: 'POST', + body: { lineageId, afterId }, + }); + onMoved(); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Verschieben fehlgeschlagen'); + } + }; + + const herausloesen = async (character: Character) => { + if (!window.confirm(`"${character.name}" aus seiner Kette lösen und allein stellen?`)) return; + try { + await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} }); + onMoved(); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen'); + } + }; + + const zone = (lineageId: string, afterId: number | null, key: string) => { + if (!canEdit || !ziehe) return null; + return ( +
    { + event.preventDefault(); + setUeber(key); + }} + onDragLeave={() => setUeber((v) => (v === key ? null : v))} + onDrop={(event) => { + event.preventDefault(); + void verschieben(lineageId, afterId); + }} + > + hierher +
    + ); + }; + + return ( +
    +
    +
    +

    CCs ({characters.length})

    +

    + Jede Spalte ist ein Platz, von unten nach oben gelesen: unten der erste CC, darüber + seine Redesigns. + {canEdit && ' Einträge lassen sich mit der Maus an ihren Platz ziehen - auch mitten in eine Kette oder in eine andere.'} +

    +
    +
    + +
    + {ketten.map((kette, index) => { + const aktiv = kette.glieder.find((c) => c.status === 'ACTIVE'); + // Von unten nach oben: der jüngste Eintrag steht oben. + const vonObenNachUnten = [...kette.glieder].reverse(); + + return ( +
    +
    + Platz {index + 1} + + {aktiv ? `aktiv: ${aktiv.name}` : 'kein aktiver CC'} + +
    + + {zone(kette.lineageId, kette.glieder[kette.glieder.length - 1]?.id ?? null, `${kette.lineageId}-oben`)} + + {vonObenNachUnten.map((character, i) => ( +
    + setZiehe(character)} + onDragEnd={() => { + setZiehe(null); + setUeber(null); + }} + onAction={onAction} + onDetach={() => void herausloesen(character)} + onChanged={onMoved} + onError={onError} + alleinInKette={kette.glieder.length === 1} + /> + + {i < vonObenNachUnten.length - 1 && ( + <> + + {zone( + kette.lineageId, + vonObenNachUnten[i + 1]?.id ?? null, + `${kette.lineageId}-${character.id}`, + )} + + )} +
    + ))} + + {zone(kette.lineageId, null, `${kette.lineageId}-unten`)} +
    + ); + })} +
    +
    + ); +} + +function ChainEntry({ + character, + playerId, + canEdit, + admin, + ziehend, + alleinInKette, + onDragStart, + onDragEnd, + onAction, + onDetach, + onChanged, + onError, +}: { + character: Character; + playerId: number; + canEdit: boolean; + admin: boolean; + ziehend: boolean; + alleinInKette: boolean; + onDragStart: () => void; + onDragEnd: () => void; + onAction: (action: ChainAction, character: Character) => void; + onDetach: () => void; + onChanged: () => void; + onError: (message: string) => void; +}) { + const aktiv = character.status === 'ACTIVE'; + const art = rueckkehrArt(character.inactiveReason); + const mitglieder = (character.members ?? []).filter((m) => !m.leftAt); + const gruppe = mitglieder.length > 1; + const eigeneMitgliedschaft = (character.members ?? []).find((m) => m.playerId === playerId); + const ruht = Boolean(eigeneMitgliedschaft?.leftAt && eigeneMitgliedschaft.inactiveReason); + const thread = character.forumThreads?.[0]; + + const klassen = ['chain-entry']; + if (!aktiv) klassen.push('is-inactive'); + if (ziehend) klassen.push('is-dragging'); + if (ruht) klassen.push('is-resting'); + + return ( +
    +
    + {canEdit && ( + + )} + {thread ? ( + + {character.name} + + ) : ( + {character.name} + )} + + {aktiv ? ( + gruppe && Gruppe · {mitglieder.length} + ) : ( + + {character.inactiveReason ? REASON_LABEL[character.inactiveReason] : 'Inaktiv'} + + )} + {ruht && dein Platz ruht} +
    + + {character.loreText &&

    {character.loreText}

    } + {character.inactiveNote &&

    {character.inactiveNote}

    } + + {(character.links?.length ?? 0) > 0 && ( +

    + {character.links?.map((link) => ( + + {link.label} + + ))} +

    + )} + + + +

    Seit {formatDate(character.createdAt)}

    + + {canEdit && ( +
    + {aktiv ? ( + <> + {gruppe ? ( + <> + + + + + ) : ( + + )} + + + + ) : art === 'admin' ? ( + admin ? ( + + ) : ( + Nur ein Administrator + ) + ) : art === 'gratis' ? ( + + ) : ( + + )} + + + {!alleinInKette && ( + + )} + {aktiv && ( + + )} +
    + )} + + {character.predecessorId !== null && !character.predecessor && ( +

    Vorgänger liegt in einer anderen Kette.

    + )} +
    + ); +} diff --git a/web/src/components/LinksModal.tsx b/web/src/components/LinksModal.tsx new file mode 100644 index 0000000..09f1573 --- /dev/null +++ b/web/src/components/LinksModal.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import Modal from './Modal'; +import Notice from './Notice'; +import { ApiError, api } from '../lib/api'; +import type { Character, CharacterLink } from '../lib/types'; + +/** + * Verweise an einem CC: das Addon des Erstellers, ein Modell, eine Absprache. + * Eingetragen wird ein Name und eine Adresse - in der Liste steht dann der + * Name als Link, damit man sieht, worum es geht, und nicht eine nackte + * Adresse lesen muss. + */ +export default function LinksModal({ + character, + canEdit, + onClose, + onChanged, +}: { + character: Character; + canEdit: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [links, setLinks] = useState(character.links ?? []); + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(''); + const [bearbeitet, setBearbeitet] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const melden = (err: unknown) => { + setError(err instanceof ApiError ? err.message : 'Hat nicht geklappt'); + }; + + const hinzufuegen = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + if (bearbeitet) { + const geaendert = await api(`/api/characters/links/${bearbeitet.id}`, { + method: 'PATCH', + body: { label: label.trim(), url: url.trim() }, + }); + setLinks((alt) => alt.map((l) => (l.id === geaendert.id ? geaendert : l))); + setBearbeitet(null); + } else { + const neu = await api(`/api/characters/${character.id}/links`, { + method: 'POST', + body: { label: label.trim(), url: url.trim() }, + }); + setLinks((alt) => [...alt, neu]); + } + setLabel(''); + setUrl(''); + onChanged(); + } catch (err) { + melden(err); + } finally { + setBusy(false); + } + }; + + const entfernen = async (link: CharacterLink) => { + if (!window.confirm(`Verweis "${link.label}" entfernen?`)) return; + try { + await api(`/api/characters/links/${link.id}`, { method: 'DELETE' }); + setLinks((alt) => alt.filter((l) => l.id !== link.id)); + onChanged(); + } catch (err) { + melden(err); + } + }; + + return ( + + {error && setError(null)}>{error}} + + {links.length === 0 ? ( +

    Noch keine Verweise.

    + ) : ( +
      + {links.map((link) => ( +
    • +
      + + {link.label} + +

      {link.url}

      +
      + {canEdit && ( +
      + + +
      + )} +
    • + ))} +
    + )} + + {canEdit && ( +
    + + setLabel(event.target.value)} + placeholder="z. B. Ersteller-Addon" + required + /> + + + setUrl(event.target.value)} + placeholder="https://steamcommunity.com/sharedfiles/…" + required + /> + +
    + {bearbeitet && ( + + )} + +
    +
    + )} +
    + ); +} diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx new file mode 100644 index 0000000..a197ed2 --- /dev/null +++ b/web/src/components/MemberList.tsx @@ -0,0 +1,134 @@ +import { Link } from 'react-router-dom'; +import { ApiError, api } from '../lib/api'; +import { REASON_LABEL, rueckkehrArt } from '../lib/format'; +import type { Character, CharacterMember } from '../lib/types'; + +/** + * Wer einen CC nutzt. Bei einem Gruppen-CC kann ein Einzelner ausfallen, + * ohne die übrigen zu berühren - dann steht er hier, bis er zurückkehrt. + */ +export default function MemberList({ + character, + currentPlayerId, + canEdit, + onChanged, + onError, +}: { + character: Character; + currentPlayerId: number; + canEdit: boolean; + onChanged: () => void; + onError: (message: string) => void; +}) { + const alle = character.members ?? []; + const aktiv = alle.filter((m) => !m.leftAt); + // Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht. + const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason); + const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason); + + if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null; + + const austragen = async (playerId: number, name: string) => { + if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return; + try { + await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' }); + onChanged(); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen'); + } + }; + + const zurueckholen = async (mitglied: CharacterMember) => { + const art = rueckkehrArt(mitglied.inactiveReason); + const name = mitglied.player.displayName; + + let reason: string | undefined; + if (art === 'admin') { + const eingabe = window.prompt( + `"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`, + ); + if (!eingabe?.trim()) return; + reason = eingabe.trim(); + } + + let useKind: string | undefined; + if (art === 'bezahlt') { + const kulanz = window.confirm( + `Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`, + ); + useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID'; + } + + try { + await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, { + method: 'POST', + body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) }, + }); + onChanged(); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen'); + } + }; + + return ( +
    + {aktiv.map((mitglied) => ( + + {mitglied.playerId === currentPlayerId ? ( + {mitglied.player.displayName} + ) : ( + {mitglied.player.displayName} + )} + {canEdit && aktiv.length > 1 && ( + + )} + + ))} + + {ruhend.map((mitglied) => ( + + {mitglied.player.displayName} + + {mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'} + + {canEdit && ( + + )} + + ))} + + {ehemalig.length > 0 && ( + + früher:{' '} + {ehemalig + .map((m) => + m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName, + ) + .join(', ')} + + )} +
    + ); +} diff --git a/web/src/lib/session.tsx b/web/src/lib/session.tsx index f004ee3..e997e96 100644 --- a/web/src/lib/session.tsx +++ b/web/src/lib/session.tsx @@ -6,6 +6,8 @@ import type { Role, SessionUser } from './types'; interface SessionState { user: SessionUser | null; loading: boolean; + /// Die Adressen der fremden Dienste, für Profil-Links. + gextensionBaseUrl: string | null; setUser: (user: SessionUser | null) => void; refresh: () => Promise; logout: () => Promise; @@ -19,11 +21,15 @@ const SessionContext = createContext(null); export function SessionProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [gextensionBaseUrl, setGextension] = useState(null); const refresh = useCallback(async () => { try { - const result = await api<{ user: SessionUser | null }>('/api/auth/me'); + const result = await api<{ user: SessionUser | null; gextensionBaseUrl?: string }>( + '/api/auth/me', + ); setUser(result.user); + setGextension(result.gextensionBaseUrl ?? null); } catch { setUser(null); } finally { @@ -49,8 +55,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { ); const value = useMemo( - () => ({ user, loading, setUser, refresh, logout, can }), - [user, loading, refresh, logout, can], + () => ({ user, loading, gextensionBaseUrl, setUser, refresh, logout, can }), + [user, loading, gextensionBaseUrl, refresh, logout, can], ); return {children}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 7c239f8..d96cde7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -43,11 +43,19 @@ export interface PlayerSummary { balances: Balances; } +export interface CharacterLink { + id: number; + label: string; + url: string; +} + export interface Player { id: number; displayName: string; steamId: string | null; forumName: string | null; + /// Sein Forumsprofil, aus dem Abgleich übernommen. + forumProfileUrl: string | null; notes: string | null; /// Ein Fall, der sich gerade nicht klären lässt. Reine Merkhilfe. isProblem: boolean; @@ -131,10 +139,13 @@ export interface Character { inactiveReason: InactiveReason | null; inactiveNote: string | null; lineageId: string; + /// Platz in der Redesign-Kette, aufsteigend. + chainPosition: number; predecessorId: number | null; predecessor: CharacterRef | null; successors: CharacterRef[]; forumThreads?: ThreadRef[]; + links?: CharacterLink[]; members?: CharacterMember[]; createdAt: string; } @@ -365,3 +376,11 @@ export interface ScrapeState { summary: ScrapeSummary | null; error: string | null; } + +/// Ergebnis einer eingespielten Sicherung. +export interface BackupReport { + gelesen: number; + angelegt: Record; + uebersprungen: Record; + fehler: { zeile: number; grund: string }[]; +} diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx index 9f372bd..1aa2696 100644 --- a/web/src/pages/Import.tsx +++ b/web/src/pages/Import.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from 'react'; import Notice from '../components/Notice'; import { ApiError, api, uploadFile } from '../lib/api'; import { PLAN_LABEL, formatDate } from '../lib/format'; -import type { ImportPlan, ImportRecord } from '../lib/types'; +import { useSession } from '../lib/session'; +import type { BackupReport, ImportPlan, ImportRecord } from '../lib/types'; interface UploadResult { importId: number; @@ -53,6 +54,8 @@ function guessMapping(columns: string[]): MappingForm { } export default function Import() { + const { can } = useSession(); + const admin = can('ADMIN'); const [upload, setUpload] = useState(null); const [mapping, setMapping] = useState(EMPTY_MAPPING); const [preview, setPreview] = useState<{ @@ -253,6 +256,8 @@ export default function Import() { )} + {!upload && admin && } + {upload && !preview && (

    Spalten zuordnen

    @@ -462,3 +467,118 @@ function MappingSelect({ ); } + +/** + * Vollständige Sicherung: alles in eine Tabelle, und zurück. + * + * Gedacht für den Umzug auf einen anderen Server oder den Wechsel auf ein + * anderes Programm - nicht für den täglichen Gebrauch. Deshalb steht es + * unten und bleibt Administratoren vorbehalten. + */ +function BackupBox({ + onError, + onFlash, +}: { + onError: (message: string) => void; + onFlash: (message: string) => void; +}) { + const [busy, setBusy] = useState(false); + const [bericht, setBericht] = useState(null); + + const einspielen = async (file: File) => { + if ( + !window.confirm( + `"${file.name}" einspielen?\n\nWas es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`, + ) + ) { + return; + } + + setBusy(true); + setBericht(null); + try { + const ergebnis = await uploadFile('/api/backup/import', file); + setBericht(ergebnis); + + const angelegt = Object.values(ergebnis.angelegt).reduce((a, b) => a + b, 0); + onFlash( + angelegt > 0 + ? `${angelegt} Einträge ergänzt.` + : 'Nichts zu ergänzen - der Bestand war vollständig.', + ); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Einspielen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( +
    +

    Alles sichern und zurückholen

    + + Der gesamte Bestand als eine Tabelle: Spieler, Guthaben, CCs, frühere Konten, + Mitgliedschaften und Verweise. Für den Umzug auf einen anderen Server oder den Wechsel auf + ein anderes Programm. Beim Einspielen wird ergänzt, was fehlt - vorhandene Einträge + bleiben, wie sie sind. + + +
    + + Alles herunterladen + + +
    + + {bericht && ( +
    + + + + + + + + + + {Object.keys(bericht.angelegt).map((satzart) => ( + + + + + + ))} + +
    SatzartErgänztWar schon da
    {satzart}{bericht.angelegt[satzart] ?? 0}{bericht.uebersprungen[satzart] ?? 0}
    + + {bericht.fehler.length > 0 && ( + + {bericht.fehler.length} Zeilen liessen sich nicht zuordnen: +
      + {bericht.fehler.slice(0, 10).map((f) => ( +
    • + Zeile {f.zeile}: {f.grund} +
    • + ))} +
    + {bericht.fehler.length > 10 &&

    … und {bericht.fehler.length - 10} weitere.

    } +
    + )} +
    + )} +
    + ); +} diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 0a9348d..f6246bb 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'; import BalanceCards from '../components/BalanceCards'; import UnmatchedBox from '../components/UnmatchedBox'; import AliasBox from '../components/AliasBox'; +import ChainView from '../components/ChainView'; +import LinksModal from '../components/LinksModal'; import PendingRowsBox from '../components/PendingRowsBox'; import ThreadsBox from '../components/ThreadsBox'; import Modal from '../components/Modal'; @@ -14,14 +16,12 @@ import { SOURCE_LABEL, formatAmount, formatDate, - rueckkehrArt, } from '../lib/format'; import RunProgress from '../components/RunProgress'; import { useScrape } from '../lib/useScrape'; import { useSession } from '../lib/session'; import type { Character, - CharacterMember, Player, PlayerSummary, CreditKind, @@ -31,32 +31,7 @@ import type { type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD'; -/// Ein CC mit mehr als einem aktiven Nutzer. Dort gibt es zwei Ebenen: den CC -/// als Ganzes und den Anteil eines Einzelnen. -function istGruppe(character: Character): boolean { - return (character.members ?? []).filter((m) => !m.leftAt).length > 1; -} -/** - * Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt - * dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei. - */ -function CharacterName({ character }: { character: Character }) { - const thread = character.forumThreads?.[0]; - if (!thread) return {character.name}; - - return ( - - {character.name} - - ); -} type DialogState = | { type: 'none' } @@ -73,12 +48,13 @@ type DialogState = | { type: 'reviveDead'; character: Character } | { type: 'problem' } | { type: 'memberRedesign'; character: Character } - | { type: 'memberDeactivate'; character: Character }; + | { type: 'memberDeactivate'; character: Character } + | { type: 'links'; character: Character }; export default function PlayerDetail() { const { id } = useParams<{ id: string }>(); const playerId = Number(id); - const { can } = useSession(); + const { can, gextensionBaseUrl } = useSession(); const navigate = useNavigate(); const [detail, setDetail] = useState(null); @@ -143,8 +119,6 @@ export default function PlayerDetail() { const editor = can('EDITOR'); const admin = can('ADMIN'); - const active = detail.characters.filter((c) => c.status === 'ACTIVE'); - const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); const playerName = detail.player.displayName; @@ -157,8 +131,39 @@ export default function PlayerDetail() {

    {detail.player.displayName}

    - {detail.player.steamId ?? 'keine SteamID'} - {detail.player.forumName && ` · Forum: ${detail.player.forumName}`} + {detail.player.steamId ? ( + gextensionBaseUrl ? ( + + {detail.player.steamId} + + ) : ( + detail.player.steamId + ) + ) : ( + 'keine SteamID' + )} + {detail.player.forumName && ( + <> + {' · Forum: '} + {detail.player.forumProfileUrl ? ( + + {detail.player.forumName} + + ) : ( + detail.player.forumName + )} + + )}

    {detail.player.notes &&

    {detail.player.notes}

    } @@ -297,193 +302,28 @@ export default function PlayerDetail() {
    )} -
    -

    Aktive CCs ({active.length})

    - {active.length === 0 ? ( -

    Keine aktiven CCs.

    - ) : ( -
      - {active.map((character) => ( -
    • -
      - - {character.members && character.members.filter((m) => !m.leftAt).length > 1 && ( - - Gruppen-CC · {character.members.filter((m) => !m.leftAt).length} - - )} - {character.predecessor && ( - · Nachfolger von {character.predecessor.name} - )} - {character.loreText &&

      {character.loreText}

      } - void load()} - onError={setError} - /> - Seit {formatDate(character.createdAt)} -
      - {editor && ( -
      - {istGruppe(character) ? ( - <> - - - - - ) : ( - - )} - - - -
      - )} -
    • - ))} -
    - )} -
    - -
    -

    Inaktive CCs ({inactive.length})

    - {inactive.length === 0 ? ( -

    Keine inaktiven CCs.

    - ) : ( -
      - {inactive.map((character) => { - const art = rueckkehrArt(character.inactiveReason); - const free = art === 'gratis'; - return ( -
    • -
      - - - {character.inactiveReason - ? REASON_LABEL[character.inactiveReason] - : 'Unbekannt'} - - {character.successors.length > 0 && ( - - {' '} - · Ersetzt durch {character.successors.map((s) => s.name).join(', ')} - - )} - {character.inactiveNote && ( -

      {character.inactiveNote}

      - )} -

      - {art === 'admin' - ? 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator.' - : free - ? 'Kommt gratis zurück - kostet kein Guthaben.' - : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'} -

      -
      - {editor && ( -
      - {art === 'admin' ? ( - admin ? ( - - ) : ( - Nur ein Administrator - ) - ) : free ? ( - - ) : ( - - )} -
      - )} -
    • - ); - })} -
    - )} -
    + { + if (action === 'reactivateFree') { + void run( + () => + api(`/api/characters/${character.id}/reactivate-free`, { + method: 'POST', + body: {}, + }), + `"${character.name}" ist wieder aktiv - kostenlos.`, + ); + return; + } + setDialog({ type: action, character }); + }} + onMoved={() => void load()} + onError={setError} + /> )} + {dialog.type === 'links' && ( + setDialog({ type: 'none' })} + onChanged={() => void load()} + /> + )} + {dialog.type === 'memberRedesign' && ( void; - onError: (message: string) => void; -}) { - const alle = character.members ?? []; - const aktiv = alle.filter((m) => !m.leftAt); - // Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht. - const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason); - const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason); - - if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null; - - const austragen = async (playerId: number, name: string) => { - if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return; - try { - await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' }); - onChanged(); - } catch (err) { - onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen'); - } - }; - - const zurueckholen = async (mitglied: CharacterMember) => { - const art = rueckkehrArt(mitglied.inactiveReason); - const name = mitglied.player.displayName; - - let reason: string | undefined; - if (art === 'admin') { - const eingabe = window.prompt( - `"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`, - ); - if (!eingabe?.trim()) return; - reason = eingabe.trim(); - } - - let useKind: string | undefined; - if (art === 'bezahlt') { - const kulanz = window.confirm( - `Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`, - ); - useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID'; - } - - try { - await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, { - method: 'POST', - body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) }, - }); - onChanged(); - } catch (err) { - onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen'); - } - }; - - return ( -
    - {aktiv.map((mitglied) => ( - - {mitglied.playerId === currentPlayerId ? ( - {mitglied.player.displayName} - ) : ( - {mitglied.player.displayName} - )} - {canEdit && aktiv.length > 1 && ( - - )} - - ))} - - {ruhend.map((mitglied) => ( - - {mitglied.player.displayName} - - {mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'} - - {canEdit && ( - - )} - - ))} - - {ehemalig.length > 0 && ( - - früher:{' '} - {ehemalig - .map((m) => - m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName, - ) - .join(', ')} - - )} -
    - ); -} - function AddMemberModal({ character, busy, diff --git a/web/src/styles.css b/web/src/styles.css index 7fb3e12..0dcc8f6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1103,3 +1103,112 @@ textarea { opacity: 0.75; border-style: dashed; } + +/* --- Ketten: ein Platz je Spalte, gelesen von unten nach oben --- */ + +.chains { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); + gap: 1rem; + align-items: start; +} + +.chain { + display: flex; + flex-direction: column; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; +} + +.chain-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.6rem; +} + +.chain-entry { + background: var(--bg-raised); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 0.6rem 0.75rem; + cursor: grab; +} + +.chain-entry:active { + cursor: grabbing; +} + +.chain-entry.is-inactive { + opacity: 0.8; + border-style: dashed; +} + +.chain-entry.is-dragging { + opacity: 0.4; +} + +.chain-entry.is-resting { + border-color: var(--negative); +} + +.chain-entry-head { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.chain-grip { + color: var(--text-muted); + cursor: grab; + letter-spacing: -0.1em; +} + +/* Der Pfeil zeigt zum jüngeren Eintrag, also nach oben. */ +.chain-arrow { + text-align: center; + color: var(--text-muted); + line-height: 1; + padding: 0.2rem 0; +} + +/* Erscheint nur, solange etwas gezogen wird. */ +.chain-drop { + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + padding: 0.35rem; + margin: 0.2rem 0; + text-align: center; + font-size: 0.78rem; + color: var(--text-muted); +} + +.chain-drop.is-over { + border-color: var(--accent); + border-style: solid; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.chain-actions { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.chain-links { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin: 0.4rem 0 0; +} + +.chain-links a.badge { + color: var(--accent); + border-color: var(--accent); +}