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