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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 = /<li class="tabularListRow">/;
|
||||
const TOPIC_LINK =
|
||||
/<a\s+href="([^"]*thread\/(\d+)-[^"]*)"[^>]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/;
|
||||
const USER_LINK =
|
||||
/<a\s+href="[^"]*user\/(\d+)-[^"]*"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\s*([^<]{1,80})/;
|
||||
/<a\s+href="([^"]*user\/(\d+)-[^"]*)"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\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,
|
||||
|
||||
@@ -198,7 +198,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
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 ---
|
||||
|
||||
@@ -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<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -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<Record<(typeof SPALTEN)[number], string>>;
|
||||
|
||||
function datum(wert: Date | null | undefined): string {
|
||||
return wert ? wert.toISOString() : '';
|
||||
}
|
||||
|
||||
/** Alles, was der Bestand hergibt, als eine Tabelle. */
|
||||
export async function exportAll(): Promise<string> {
|
||||
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<string, number>;
|
||||
uebersprungen: Record<string, number>;
|
||||
fehler: { zeile: number; grund: string }[];
|
||||
}
|
||||
|
||||
function leer(): Record<string, number> {
|
||||
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<ImportReport> {
|
||||
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<number | null> {
|
||||
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<number | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -347,6 +347,7 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
||||
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<ForumScanSummary> {
|
||||
// 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<ForumScanSummary> {
|
||||
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<void> {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user