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); +}