Erstzugang schuetzen, Kettenstatus richtigstellen, CCs nachtraeglich aendern

Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch
Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und
loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein
Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes
Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true
hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den
Namen kennt nur er, die Migration nicht.

Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt",
obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger,
der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger
hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht
dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch.

Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines
CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt -
sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst
eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign
bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben
wird nichts.

Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite
offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den
Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht,
ohne im Hintergrund dauernd zu fragen.
This commit is contained in:
2026-09-21 14:36:26 +02:00
parent c82391e73f
commit 2527f6a240
19 changed files with 941 additions and 34 deletions
+234 -13
View File
@@ -3,6 +3,7 @@ import {
CharacterStatus,
CreditKind,
CreditSource,
ForumClassification,
InactiveReason,
type Character,
type Prisma,
@@ -417,15 +418,24 @@ export async function reactivatePaid(
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Ändert die Angaben eines CCs: Name, Beschreibung und den Antrag, aus dem
* er hervorging.
*
* Der Antrag wird über seine Thread-Nummer oder die volle Adresse angegeben;
* ein leeres Feld löst die bestehende Verknüpfung. Kennt der Abgleich den
* Thread noch nicht, wird er vorgemerkt - sonst stünde er beim nächsten Lauf
* als offener Antrag da, obwohl er längst eingetragen ist.
*/
export async function updateCharacter(
characterId: number,
input: { name?: string; loreText?: string | null },
input: { name?: string; loreText?: string | null; threadRef?: string | null },
ctx: ActionContext,
): Promise<Character> {
await loadCharacter(characterId);
const vorher = await loadCharacter(characterId);
return prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
const updated = await prisma.$transaction(async (tx) => {
const geaendert = await tx.character.update({
where: { id: characterId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
@@ -437,11 +447,94 @@ export async function updateCharacter(
action: 'character.update',
targetType: 'character',
targetId: characterId,
details: { name: updated.name },
details: { name: geaendert.name, vorher: vorher.name },
});
return updated;
return geaendert;
});
if (input.threadRef !== undefined) {
await setCharacterThread(characterId, input.threadRef, ctx);
}
return updated;
}
/**
* Hängt einen Forums-Antrag an einen CC oder löst ihn wieder.
*
* Angenommen wird die Thread-Nummer, der Pfad oder die volle Adresse - beim
* Eintippen von Hand landet mal das eine, mal das andere im Feld.
*/
export async function setCharacterThread(
characterId: number,
ref: string | null,
ctx: ActionContext,
): Promise<void> {
const character = await loadCharacter(characterId);
const { parseThreadId } = await import('../lib/forum.js');
const { linkThreadToCharacter } = await import('./forum.js');
const bisher = await prisma.forumThread.findFirst({
where: { linkedCharacterId: characterId },
select: { threadId: true },
});
const eingabe = ref?.trim() ?? '';
const threadId = eingabe.length === 0 ? null : parseThreadId(eingabe);
if (eingabe.length > 0 && threadId === null) {
throw badRequest(
'INVALID_THREAD',
'Das ist keine Thread-Nummer und keine Forums-Adresse',
);
}
if (bisher?.threadId === threadId) return;
// Die alte Verknüpfung fällt weg. Der Thread bleibt als entschieden
// vermerkt: er wurde ja bearbeitet, nur hängt er jetzt an keinem CC mehr.
if (bisher) {
await prisma.forumThread.update({
where: { threadId: bisher.threadId },
data: { linkedCharacterId: null },
});
}
if (threadId === null) {
await recordAudit(prisma, ctx, {
action: 'character.thread_unlinked',
targetType: 'character',
targetId: characterId,
details: { name: character.name, threadId: bisher?.threadId ?? null },
});
return;
}
const vorhanden = await prisma.forumThread.findUnique({ where: { threadId } });
if (vorhanden) {
await prisma.forumThread.update({
where: { threadId },
data: {
linkedCharacterId: characterId,
needsClassification: false,
classifiedById: ctx.actorId,
classifiedAt: vorhanden.classifiedAt ?? new Date(),
playerId: vorhanden.playerId ?? character.playerId,
},
});
await recordAudit(prisma, ctx, {
action: 'character.thread_linked',
targetType: 'character',
targetId: characterId,
details: { name: character.name, threadId },
});
return;
}
// Unbekannter Thread: über denselben Weg vormerken wie bei einem von Hand
// eingetragenen Antrag, damit er nicht als offen wieder auftaucht.
await linkThreadToCharacter(threadId, characterId, ForumClassification.NEW_CC, ctx);
}
/**
@@ -637,6 +730,96 @@ export async function reviveDeadCharacter(
}
/**
* Stellt nachträglich um, woraus ein Redesign bezahlt wurde.
*
* Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich
* manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der
* beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben,
* sondern wie jede Korrektur im Kontobuch: die alte Zeile wird
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas
* geändert wurde, und wann.
*/
export async function changeRedesignKind(
characterId: number,
useKind: RedesignKind,
ctx: ActionContext,
): Promise<CharacterResult> {
const character = await loadCharacter(characterId);
if (!REDESIGN_KINDS.includes(useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
}
// Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht
// mehr; offen ist immer höchstens eine.
const buchung = await prisma.creditLedger.findFirst({
where: {
characterId,
source: CreditSource.CONSUME,
kind: { in: [...REDESIGN_KINDS] },
reversedBy: null,
},
orderBy: { id: 'desc' },
});
if (!buchung) {
throw conflict(
'NO_REDESIGN_BOOKING',
'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen',
);
}
if (buchung.kind === useKind) {
throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht');
}
const grund =
useKind === CreditKind.REDESIGN_KULANZ
? 'Umgestellt auf Kulanz'
: 'Umgestellt auf bezahltes Redesign';
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId: buchung.playerId,
kind: buchung.kind,
amount: -buchung.amount,
source: CreditSource.REVERSAL,
groupId: buchung.groupId,
characterId,
reversalOfId: buchung.id,
note: `${grund}: ${character.name}`,
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
await tx.creditLedger.create({
data: {
playerId: buchung.playerId,
kind: useKind,
amount: buchung.amount,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId,
note: buchung.note ?? `Redesign: ${character.name}`,
createdById: ctx.actorId,
// Bezahlt wurde damals, nicht heute.
occurredAt: buchung.occurredAt,
},
});
await recordAudit(tx, ctx, {
action: 'character.redesign_kind_changed',
targetType: 'character',
targetId: characterId,
details: { name: character.name, from: buchung.kind, to: useKind },
});
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
// --- Ketten ---
/**
@@ -653,16 +836,38 @@ async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Pro
const glieder = await tx.character.findMany({
where: { lineageId },
orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
select: { id: true, predecessorId: true },
select: { id: true, predecessorId: true, status: true, inactiveReason: true },
});
let vorheriges: number | null = null;
for (const glied of glieder) {
for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1;
const daten: Prisma.CharacterUpdateInput = {};
if (glied.predecessorId !== vorheriges) {
await tx.character.update({
where: { id: glied.id },
data: { predecessorId: vorheriges },
});
daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } };
}
// Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
// Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein
// und auch nicht "geputzt": es ist redesignt, Punkt.
if (!letztes && glied.inactiveReason !== InactiveReason.REWORKED) {
daten.status = CharacterStatus.INACTIVE;
daten.inactiveReason = InactiveReason.REWORKED;
daten.inactiveNote = null;
}
// Umgekehrt: Wer ans Ende rückt, ist von niemandem mehr ersetzt worden.
// Die Markierung wäre dann schlicht falsch - sie stammt von einem
// Nachfolger, der inzwischen woanders steht.
if (letztes && glied.inactiveReason === InactiveReason.REWORKED) {
daten.status = CharacterStatus.ACTIVE;
daten.inactiveReason = null;
daten.inactiveNote = null;
}
if (Object.keys(daten).length > 0) {
await tx.character.update({ where: { id: glied.id }, data: daten });
}
vorheriges = glied.id;
}
@@ -727,6 +932,11 @@ export async function moveInChain(
await verketteNeu(tx, input.lineageId);
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie);
// Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv
// machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie
// jeder andere.
await assertSlotsReichen(tx, character.playerId, 'Dieses Verschieben');
await recordAudit(tx, ctx, {
action: 'character.moved_in_chain',
targetType: 'character',
@@ -755,12 +965,23 @@ export async function detachFromChain(
const neueLinie = randomUUID();
await prisma.$transaction(async (tx) => {
// Allein in einer neuen Linie ist er zugleich Anfang und Ende. Eine
// Redesign-Markierung stammte von einem Nachfolger, der jetzt woanders
// steht - sie wäre nur noch falsch.
await tx.character.update({
where: { id: characterId },
data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null },
data: {
lineageId: neueLinie,
chainPosition: 0,
predecessorId: null,
...(character.inactiveReason === InactiveReason.REWORKED
? { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }
: {}),
},
});
await verketteNeu(tx, character.lineageId);
await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen');
await recordAudit(tx, ctx, {
action: 'character.detached',