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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user