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:
@@ -30,6 +30,14 @@ BOOTSTRAP_ADMIN_PASSWORD=
|
|||||||
# (Burning Board) zugeschnitten.
|
# (Burning Board) zugeschnitten.
|
||||||
# FORUM_BASE_URL=https://senseigaming.de/forum
|
# FORUM_BASE_URL=https://senseigaming.de/forum
|
||||||
|
|
||||||
|
# Der Zugang aus der Erstinstallation ist geschützt: weder Rolle noch Zustand
|
||||||
|
# lassen sich ändern, sein Passwort nicht von aussen zurücksetzen, und löschen
|
||||||
|
# lässt er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg in die
|
||||||
|
# Anwendung offen, wenn sich jemand die Rechte entzieht. Sein eigenes Passwort
|
||||||
|
# ändert er weiterhin selbst unter "Konto". Auf true gesetzt gilt er wie jeder
|
||||||
|
# andere Zugang.
|
||||||
|
# BOOTSTRAP_ADMIN_EDITABLE=false
|
||||||
|
|
||||||
# Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter
|
# Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter
|
||||||
# Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum
|
# Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum
|
||||||
# ab welcher Stunde. Hier steht dazu nichts mehr.
|
# ab welcher Stunde. Hier steht dazu nichts mehr.
|
||||||
|
|||||||
@@ -61,8 +61,16 @@ desselben Spielers. Ein nachgetragener CC wird also schlicht dorthin gezogen, wo
|
|||||||
*Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in
|
*Herauslösen* stellt einen Eintrag wieder allein, wenn sich herausstellt, dass er gar nicht in
|
||||||
diese Kette gehörte.
|
diese Kette gehörte.
|
||||||
|
|
||||||
Das Verschieben **ordnet nur ein**: Es entscheidet nichts, bucht nichts und ändert an keinem
|
Eines folgt daraus von selbst: **Wer einen Nachfolger hat, wurde durch ihn ersetzt.** Anders ist
|
||||||
Status etwas. Wer aktiv war, bleibt aktiv.
|
eine Kette nicht zu lesen. Deshalb trägt jedes Glied außer dem obersten den Vermerk *Redesignt*,
|
||||||
|
und wer ans Ende rückt, verliert ihn wieder - er stammte ja von einem Nachfolger, der jetzt
|
||||||
|
woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadurch wieder aktiv,
|
||||||
|
muss er gedeckt sein wie jeder andere.
|
||||||
|
|
||||||
|
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen -
|
||||||
|
die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, ob ein
|
||||||
|
Redesign aus einem gekauften Paket oder aus Kulanz kam; umgebucht wird über eine Gegenbuchung,
|
||||||
|
in der Historie bleibt beides sichtbar.
|
||||||
|
|
||||||
Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich
|
Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich
|
||||||
der Spieler herausredesignt hat. Er war darin, und das bleibt sichtbar; verschieben oder
|
der Spieler herausredesignt hat. Er war darin, und das bleibt sichtbar; verschieben oder
|
||||||
@@ -433,6 +441,13 @@ den Lauf abzubrechen - eine krumme Zeile darf nicht neunhundert gute aufhalten.
|
|||||||
|
|
||||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||||
|
|
||||||
|
Der Zugang aus der **Erstinstallation** ist geschützt: Weder seine Rolle noch sein Zustand
|
||||||
|
lassen sich ändern, sein Passwort nicht von außen zurücksetzen, und löschen lässt er sich nicht -
|
||||||
|
auch nicht durch sich selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand aus
|
||||||
|
Versehen die Rechte entzieht oder ein Konto abhandenkommt. Sein eigenes Passwort ändert er
|
||||||
|
weiterhin selbst, unter *Konto*. Wer das nicht will, setzt `BOOTSTRAP_ADMIN_EDITABLE=true`; dann
|
||||||
|
gilt er wie jeder andere Zugang.
|
||||||
|
|
||||||
- **Viewer** - darf nur lesen.
|
- **Viewer** - darf nur lesen.
|
||||||
- **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
- **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||||
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
# Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen.
|
||||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
# Dieser Zugang bleibt unantastbar, solange das hier nicht "true" ist.
|
||||||
|
BOOTSTRAP_ADMIN_EDITABLE: ${BOOTSTRAP_ADMIN_EDITABLE:-false}
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: "true"
|
||||||
# Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
# Nur nötig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll.
|
||||||
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Der Zugang aus der Erstinstallation laesst sich schuetzen, damit sich
|
||||||
|
-- niemand - auch er selbst nicht - den letzten Weg in die Anwendung verbaut.
|
||||||
|
-- Welcher Zugang das ist, traegt der Start nach: er kennt den Namen aus
|
||||||
|
-- BOOTSTRAP_ADMIN_USER, die Migration nicht.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "isProtected" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -88,6 +88,13 @@ model User {
|
|||||||
totpEnabled Boolean @default(false)
|
totpEnabled Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
mustChangePassword Boolean @default(false)
|
mustChangePassword Boolean @default(false)
|
||||||
|
|
||||||
|
/// Der Zugang aus der Erstinstallation. Solange er geschützt ist, lassen
|
||||||
|
/// sich weder seine Rolle noch sein Zustand ändern, sein Passwort nicht von
|
||||||
|
/// aussen zurücksetzen und er selbst nicht löschen - auch nicht durch ihn
|
||||||
|
/// selbst. Damit bleibt ein Weg in die Anwendung offen, wenn sich jemand
|
||||||
|
/// aus Versehen oder mit Absicht die Rechte entzieht.
|
||||||
|
isProtected Boolean @default(false)
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function bootstrap(log: {
|
|||||||
passwordHash: await hashPassword(password),
|
passwordHash: await hashPassword(password),
|
||||||
role: 'ADMIN',
|
role: 'ADMIN',
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
|
isProtected: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,6 +57,14 @@ export async function bootstrap(log: {
|
|||||||
log.warn('─'.repeat(60));
|
log.warn('─'.repeat(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bestehende Installationen kennen die Markierung noch nicht. Welcher
|
||||||
|
// Zugang der erste war, weiss nur der Start: der Name steht in der
|
||||||
|
// Umgebung, nicht in der Datenbank.
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { username: env.BOOTSTRAP_ADMIN_USER, isProtected: false },
|
||||||
|
data: { isProtected: true },
|
||||||
|
});
|
||||||
|
|
||||||
const mappingCount = await prisma.productMapping.count();
|
const mappingCount = await prisma.productMapping.count();
|
||||||
if (mappingCount === 0) {
|
if (mappingCount === 0) {
|
||||||
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS });
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ const schema = z.object({
|
|||||||
// WoltLab (Burning Board) zugeschnitten.
|
// WoltLab (Burning Board) zugeschnitten.
|
||||||
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
|
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
|
||||||
|
|
||||||
|
// Der Zugang aus der Erstinstallation ist ab Werk geschützt: weder Rolle
|
||||||
|
// noch Zustand lassen sich ändern, sein Passwort nicht von aussen
|
||||||
|
// zurücksetzen, und löschen lässt er sich nicht - auch nicht durch sich
|
||||||
|
// selbst. So bleibt ein Weg in die Anwendung offen, wenn sich jemand aus
|
||||||
|
// Versehen die Rechte entzieht. Auf "true" gesetzt, gilt er wie jeder
|
||||||
|
// andere Zugang.
|
||||||
|
BOOTSTRAP_ADMIN_EDITABLE: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
|
||||||
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||||
|
|||||||
@@ -17,10 +17,33 @@ const userSelect = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
|
isProtected: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
} satisfies Prisma.UserSelect;
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält den Zugang aus der Erstinstallation aus dem Spiel.
|
||||||
|
*
|
||||||
|
* An ihm lässt sich nichts ändern, was ihn aussperren könnte: keine Rolle,
|
||||||
|
* kein Zustand, kein Passwort von aussen, keine Löschung - und zwar auch
|
||||||
|
* dann nicht, wenn er es selbst versucht. Damit bleibt ein Weg zurück offen,
|
||||||
|
* wenn sich jemand die Rechte entzieht oder ein Konto abhanden kommt. Sein
|
||||||
|
* eigenes Passwort ändert er weiterhin selbst, über den Bereich "Konto".
|
||||||
|
*
|
||||||
|
* Wer das nicht will, setzt BOOTSTRAP_ADMIN_EDITABLE auf true; dann gilt er
|
||||||
|
* wie jeder andere Zugang.
|
||||||
|
*/
|
||||||
|
function assertNichtGeschuetzt(target: { isProtected: boolean; username: string }): void {
|
||||||
|
if (!target.isProtected || env.BOOTSTRAP_ADMIN_EDITABLE) return;
|
||||||
|
|
||||||
|
throw conflict(
|
||||||
|
'PROTECTED_ACCOUNT',
|
||||||
|
`"${target.username}" ist der Zugang aus der Erstinstallation und bleibt unangetastet. ` +
|
||||||
|
'Wer das ändern will, setzt BOOTSTRAP_ADMIN_EDITABLE=true in der Umgebung.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
|
// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht.
|
||||||
async function assertNotLastAdmin(userId: number): Promise<void> {
|
async function assertNotLastAdmin(userId: number): Promise<void> {
|
||||||
const target = await prisma.user.findUnique({ where: { id: userId } });
|
const target = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -42,7 +65,16 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
|
orderBy: [{ isActive: 'desc' }, { username: 'asc' }],
|
||||||
select: userSelect,
|
select: userSelect,
|
||||||
});
|
});
|
||||||
return { items };
|
|
||||||
|
// Ob der Schutz gerade greift, entscheidet die Umgebung. Die Oberfläche
|
||||||
|
// bekommt das Ergebnis, nicht die Regel - sie soll nicht nachbauen
|
||||||
|
// müssen, was hier schon entschieden ist.
|
||||||
|
return {
|
||||||
|
items: items.map((user) => ({
|
||||||
|
...user,
|
||||||
|
isLocked: user.isProtected && !env.BOOTSTRAP_ADMIN_EDITABLE,
|
||||||
|
})),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/users', async (request, reply) => {
|
app.post('/api/admin/users', async (request, reply) => {
|
||||||
@@ -102,6 +134,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
|
|
||||||
const losesAdmin =
|
const losesAdmin =
|
||||||
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
|
(body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false;
|
||||||
@@ -138,6 +171,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -166,6 +200,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
if (!target) throw notFound('Nutzer');
|
if (!target) throw notFound('Nutzer');
|
||||||
|
assertNichtGeschuetzt(target);
|
||||||
await assertNotLastAdmin(id);
|
await assertNotLastAdmin(id);
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
removeLink,
|
removeLink,
|
||||||
moveInChain,
|
moveInChain,
|
||||||
detachFromChain,
|
detachFromChain,
|
||||||
|
changeRedesignKind,
|
||||||
} from '../services/characters.js';
|
} from '../services/characters.js';
|
||||||
import { parseThreadId } from '../lib/forum.js';
|
import { parseThreadId } from '../lib/forum.js';
|
||||||
import { linkThreadToCharacter } from '../services/forum.js';
|
import { linkThreadToCharacter } from '../services/forum.js';
|
||||||
@@ -74,6 +75,8 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
z.object({
|
z.object({
|
||||||
name: z.string().trim().min(1).max(160).optional(),
|
name: z.string().trim().min(1).max(160).optional(),
|
||||||
loreText: z.string().trim().max(5000).nullable().optional(),
|
loreText: z.string().trim().max(5000).nullable().optional(),
|
||||||
|
/// Thread-Nummer oder volle Adresse; leer löst die Verknüpfung.
|
||||||
|
threadRef: z.string().trim().max(500).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
@@ -178,6 +181,21 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachträglich umstellen, woraus ein Redesign bezahlt wurde. Umgebucht
|
||||||
|
* wird im Kontobuch, nicht überschrieben.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/redesign-kind', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return changeRedesignKind(id, body.useKind, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
// --- Ketten ---
|
// --- Ketten ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CharacterStatus,
|
CharacterStatus,
|
||||||
CreditKind,
|
CreditKind,
|
||||||
CreditSource,
|
CreditSource,
|
||||||
|
ForumClassification,
|
||||||
InactiveReason,
|
InactiveReason,
|
||||||
type Character,
|
type Character,
|
||||||
type Prisma,
|
type Prisma,
|
||||||
@@ -417,15 +418,24 @@ export async function reactivatePaid(
|
|||||||
return { character, balances, overdrawn: isOverdrawn(balances) };
|
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(
|
export async function updateCharacter(
|
||||||
characterId: number,
|
characterId: number,
|
||||||
input: { name?: string; loreText?: string | null },
|
input: { name?: string; loreText?: string | null; threadRef?: string | null },
|
||||||
ctx: ActionContext,
|
ctx: ActionContext,
|
||||||
): Promise<Character> {
|
): Promise<Character> {
|
||||||
await loadCharacter(characterId);
|
const vorher = await loadCharacter(characterId);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const updated = await tx.character.update({
|
const geaendert = await tx.character.update({
|
||||||
where: { id: characterId },
|
where: { id: characterId },
|
||||||
data: {
|
data: {
|
||||||
...(input.name !== undefined ? { name: input.name } : {}),
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
@@ -437,11 +447,94 @@ export async function updateCharacter(
|
|||||||
action: 'character.update',
|
action: 'character.update',
|
||||||
targetType: 'character',
|
targetType: 'character',
|
||||||
targetId: characterId,
|
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 ---
|
// --- Ketten ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -653,16 +836,38 @@ async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Pro
|
|||||||
const glieder = await tx.character.findMany({
|
const glieder = await tx.character.findMany({
|
||||||
where: { lineageId },
|
where: { lineageId },
|
||||||
orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
|
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;
|
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) {
|
if (glied.predecessorId !== vorheriges) {
|
||||||
await tx.character.update({
|
daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } };
|
||||||
where: { id: glied.id },
|
}
|
||||||
data: { predecessorId: 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;
|
vorheriges = glied.id;
|
||||||
}
|
}
|
||||||
@@ -727,6 +932,11 @@ export async function moveInChain(
|
|||||||
await verketteNeu(tx, input.lineageId);
|
await verketteNeu(tx, input.lineageId);
|
||||||
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie);
|
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, {
|
await recordAudit(tx, ctx, {
|
||||||
action: 'character.moved_in_chain',
|
action: 'character.moved_in_chain',
|
||||||
targetType: 'character',
|
targetType: 'character',
|
||||||
@@ -755,12 +965,23 @@ export async function detachFromChain(
|
|||||||
const neueLinie = randomUUID();
|
const neueLinie = randomUUID();
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
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({
|
await tx.character.update({
|
||||||
where: { id: characterId },
|
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 verketteNeu(tx, character.lineageId);
|
||||||
|
await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen');
|
||||||
|
|
||||||
await recordAudit(tx, ctx, {
|
await recordAudit(tx, ctx, {
|
||||||
action: 'character.detached',
|
action: 'character.detached',
|
||||||
|
|||||||
@@ -219,6 +219,115 @@ describe('Rollen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Der Zugang aus der Erstinstallation', () => {
|
||||||
|
/** Legt einen geschützten Admin an, wie es der erste Start tut. */
|
||||||
|
async function erstzugang() {
|
||||||
|
return db.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: 'erstzugang',
|
||||||
|
passwordHash: 'x',
|
||||||
|
role: 'ADMIN',
|
||||||
|
isProtected: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lässt sich seine Rolle nicht nehmen', async () => {
|
||||||
|
const geschuetzt = await erstzugang();
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'VIEWER' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
assert.equal(response.json().error, 'PROTECTED_ACCOUNT');
|
||||||
|
|
||||||
|
const unveraendert = await db.prisma.user.findUniqueOrThrow({ where: { id: geschuetzt.id } });
|
||||||
|
assert.equal(unveraendert.role, 'ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich nicht sperren', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bekommt sein Passwort nicht von aussen zurückgesetzt', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}/reset-password`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { newPassword: 'EinLangesPasswort1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich nicht löschen', async () => {
|
||||||
|
const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } });
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/admin/users/${geschuetzt.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
assert.ok(await db.prisma.user.findUnique({ where: { id: geschuetzt.id } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist in der Liste als geschützt ausgewiesen', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/users',
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintrag = response
|
||||||
|
.json()
|
||||||
|
.items.find((u: { username: string }) => u.username === 'erstzugang');
|
||||||
|
assert.equal(eintrag.isLocked, true, 'die Oberfläche soll die Knöpfe gar nicht erst zeigen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt gewöhnliche Konten unangetastet', async () => {
|
||||||
|
const credentials = await login('chef');
|
||||||
|
const normal = await db.prisma.user.findFirstOrThrow({ where: { username: 'gast' } });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${normal.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'EDITOR' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200, 'der Schutz gilt nur für den Erstzugang');
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/users/${normal.id}`,
|
||||||
|
headers: authHeaders(credentials),
|
||||||
|
payload: { role: 'VIEWER' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Erstpasswort', () => {
|
describe('Erstpasswort', () => {
|
||||||
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
it('sperrt schreibende Aktionen bis zum Wechsel', async () => {
|
||||||
const credentials = await login('neu');
|
const credentials = await login('neu');
|
||||||
|
|||||||
+224
-7
@@ -152,15 +152,23 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
|
|||||||
verkettungStimmt(zweite);
|
verkettungStimmt(zweite);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('löst einen Eintrag wieder heraus', async () => {
|
it('holt einen falsch einsortierten Eintrag wieder heraus', async () => {
|
||||||
|
// Zwei eigenständige CCs, einer davon versehentlich in die Kette des
|
||||||
|
// anderen gezogen. Beide sind aus einem eigenen Paket bezahlt.
|
||||||
const spieler = await spielerMitPaketen('Loeser', 2);
|
const spieler = await spielerMitPaketen('Loeser', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Bleibt' }, ctx);
|
||||||
await credits.bookRedesignPackage(spieler, ctx);
|
const zwei = await characters.createCharacter(spieler, { name: 'Gehoert woanders hin' }, ctx);
|
||||||
const zwei = await characters.redesignCharacter(
|
|
||||||
eins.character.id,
|
await characters.moveInChain(
|
||||||
{ name: 'Geht wieder', useKind: 'REDESIGN_PAID' },
|
zwei.character.id,
|
||||||
|
{ lineageId: eins.character.lineageId, afterId: eins.character.id },
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
// In der Kette gilt: wer einen Nachfolger hat, wurde ersetzt.
|
||||||
|
const eingereiht = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: eins.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(eingereiht.inactiveReason, 'REWORKED');
|
||||||
|
|
||||||
const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx);
|
const { lineageId } = await characters.detachFromChain(zwei.character.id, ctx);
|
||||||
assert.notEqual(lineageId, eins.character.lineageId);
|
assert.notEqual(lineageId, eins.character.lineageId);
|
||||||
@@ -168,12 +176,67 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
|
|||||||
const alte = await kette(spieler, eins.character.lineageId);
|
const alte = await kette(spieler, eins.character.lineageId);
|
||||||
assert.deepEqual(alte.map((g) => g.name), ['Bleibt']);
|
assert.deepEqual(alte.map((g) => g.name), ['Bleibt']);
|
||||||
|
|
||||||
|
// Und ohne Nachfolger ist "redesignt" hinfällig.
|
||||||
|
const zurueck = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: eins.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(zurueck.status, 'ACTIVE');
|
||||||
|
assert.equal(zurueck.inactiveReason, null);
|
||||||
|
|
||||||
const allein = await db.prisma.character.findUniqueOrThrow({
|
const allein = await db.prisma.character.findUniqueOrThrow({
|
||||||
where: { id: zwei.character.id },
|
where: { id: zwei.character.id },
|
||||||
});
|
});
|
||||||
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
|
assert.equal(allein.predecessorId, null, 'allein heisst ohne Vorgänger');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lässt ein Redesign nicht ohne eigenen Platz allein stehen', async () => {
|
||||||
|
// Ein Redesign hat keine eigene CC-Buchung - es tauscht einen Platz aus.
|
||||||
|
// Allein gestellt braucht es einen, und der muss gedeckt sein.
|
||||||
|
const spieler = await spielerMitPaketen('Knapp', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'Original' }, ctx);
|
||||||
|
await credits.bookRedesignPackage(spieler, ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'Neue Fassung', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.detachFromChain(zwei.character.id, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT',
|
||||||
|
'sonst stünden zwei aktive CCs auf einem Paket',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt die Redesign-Markierung ab, wenn der Nachfolger wegzieht', async () => {
|
||||||
|
// Genau der Fall aus dem Betrieb: das Redesign gehörte in eine andere
|
||||||
|
// Kette. Was zurückbleibt, ist von niemandem mehr ersetzt worden.
|
||||||
|
const spieler = await spielerMitPaketen('Umsortiert', 2);
|
||||||
|
const platzA = await characters.createCharacter(spieler, { name: 'Platz A' }, ctx);
|
||||||
|
const platzB = await characters.createCharacter(spieler, { name: 'Platz B' }, ctx);
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(spieler, ctx);
|
||||||
|
const redesign = await characters.redesignCharacter(
|
||||||
|
platzB.character.id,
|
||||||
|
{ name: 'Redesign von B', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
// B ist jetzt redesignt. Nun stellt sich heraus: das Redesign gehört zu A.
|
||||||
|
await characters.moveInChain(
|
||||||
|
redesign.character.id,
|
||||||
|
{ lineageId: platzA.character.lineageId, afterId: platzA.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const b = await db.prisma.character.findUniqueOrThrow({ where: { id: platzB.character.id } });
|
||||||
|
assert.equal(b.status, 'ACTIVE', 'ohne Nachfolger ist B wieder der aktuelle CC');
|
||||||
|
assert.equal(b.inactiveReason, null);
|
||||||
|
|
||||||
|
const a = await db.prisma.character.findUniqueOrThrow({ where: { id: platzA.character.id } });
|
||||||
|
assert.equal(a.inactiveReason, 'REWORKED', 'A hat jetzt einen Nachfolger');
|
||||||
|
});
|
||||||
|
|
||||||
it('lässt keinen CC in die Kette eines anderen Spielers', async () => {
|
it('lässt keinen CC in die Kette eines anderen Spielers', async () => {
|
||||||
const einer = await spielerMitPaketen('Einer', 1);
|
const einer = await spielerMitPaketen('Einer', 1);
|
||||||
const anderer = await spielerMitPaketen('Anderer', 1);
|
const anderer = await spielerMitPaketen('Anderer', 1);
|
||||||
@@ -206,7 +269,7 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rührt den Status nicht an', async () => {
|
it('macht aus einem eingereihten CC einen redesignten', async () => {
|
||||||
const spieler = await spielerMitPaketen('Unberuehrt', 2);
|
const spieler = await spielerMitPaketen('Unberuehrt', 2);
|
||||||
const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx);
|
const aktiv = await characters.createCharacter(spieler, { name: 'Aktiv' }, ctx);
|
||||||
const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx);
|
const ziel = await characters.createCharacter(spieler, { name: 'Ziel' }, ctx);
|
||||||
@@ -220,7 +283,10 @@ describe('Lücken in einer Redesign-Kette schliessen', () => {
|
|||||||
const danach = await db.prisma.character.findUniqueOrThrow({
|
const danach = await db.prisma.character.findUniqueOrThrow({
|
||||||
where: { id: aktiv.character.id },
|
where: { id: aktiv.character.id },
|
||||||
});
|
});
|
||||||
assert.equal(danach.status, 'ACTIVE', 'verschieben ordnet nur ein, es entscheidet nichts');
|
assert.equal(danach.status, 'ACTIVE', 'am Ende der Kette bleibt er der aktuelle');
|
||||||
|
|
||||||
|
const davor = await db.prisma.character.findUniqueOrThrow({ where: { id: ziel.character.id } });
|
||||||
|
assert.equal(davor.inactiveReason, 'REWORKED', 'wer einen Nachfolger bekommt, ist ersetzt');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,3 +343,154 @@ describe('Verweise an einem CC', () => {
|
|||||||
assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0);
|
assert.equal(await db.prisma.characterLink.count({ where: { characterId: cc.character.id } }), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Redesign nachträglich umbuchen', () => {
|
||||||
|
it('stellt von bezahlt auf Kulanz um, ohne die Geschichte zu verbiegen', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Umbucher', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'Erste Fassung' }, ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'Zweite Fassung', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
|
||||||
|
|
||||||
|
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx);
|
||||||
|
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_KULANZ', ctx);
|
||||||
|
|
||||||
|
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
|
||||||
|
assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht');
|
||||||
|
|
||||||
|
// Korrigiert wird durch Gegenbuchung, nicht durch Überschreiben.
|
||||||
|
const zeilen = await db.prisma.creditLedger.findMany({
|
||||||
|
where: { characterId: zwei.character.id },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
assert.equal(zeilen.length, 3, 'Verbrauch, Storno, neuer Verbrauch');
|
||||||
|
assert.equal(zeilen[1]?.source, 'REVERSAL');
|
||||||
|
assert.equal(zeilen[2]?.kind, 'REDESIGN_KULANZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('und wieder zurück', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Hin und her', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
||||||
|
await credits.bookKulanz(spieler, 1, 'Erstmal Kulanz', ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'B', useKind: 'REDESIGN_KULANZ' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx);
|
||||||
|
assert.equal(danach.balances.REDESIGN_KULANZ, 1);
|
||||||
|
assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist dieselbe Art ab', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Unveraendert', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'X' }, ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'Y', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen CC ohne Redesign-Buchung ab', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Ohne Redesign', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.changeRedesignKind(eins.character.id, 'REDESIGN_KULANZ', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NO_REDESIGN_BOOKING',
|
||||||
|
'ein CC aus dem Paket ist kein Redesign',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Angaben eines CCs nachtragen', () => {
|
||||||
|
it('ändert Namen und Beschreibung', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Korrigierer', 1);
|
||||||
|
const cc = await characters.createCharacter(spieler, { name: 'Tipfehler' }, ctx);
|
||||||
|
|
||||||
|
await characters.updateCharacter(
|
||||||
|
cc.character.id,
|
||||||
|
{ name: 'Tippfehler behoben', loreText: 'Jetzt mit Beschreibung' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: cc.character.id } });
|
||||||
|
assert.equal(danach.name, 'Tippfehler behoben');
|
||||||
|
assert.equal(danach.loreText, 'Jetzt mit Beschreibung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hängt einen Antrag an, den der Abgleich schon kennt', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Verknuepfer', 1);
|
||||||
|
const cc = await characters.createCharacter(spieler, { name: 'Mit Antrag' }, ctx);
|
||||||
|
|
||||||
|
await db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId: 7771,
|
||||||
|
title: 'Sein Antrag',
|
||||||
|
url: 'https://forum.example.org/index.php?thread/7771-sein-antrag/',
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await characters.updateCharacter(
|
||||||
|
cc.character.id,
|
||||||
|
{ threadRef: 'https://forum.example.org/index.php?thread/7771-sein-antrag/' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7771 } });
|
||||||
|
assert.equal(thread.linkedCharacterId, cc.character.id);
|
||||||
|
assert.equal(thread.needsClassification, false, 'er ist damit erledigt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merkt einen unbekannten Antrag vor', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Vormerker', 1);
|
||||||
|
const cc = await characters.createCharacter(spieler, { name: 'Noch ungelesen' }, ctx);
|
||||||
|
|
||||||
|
// Nur die Nummer, und der Abgleich kennt den Thread nicht.
|
||||||
|
await characters.updateCharacter(cc.character.id, { threadRef: '8888' }, ctx);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 8888 } });
|
||||||
|
assert.equal(thread.linkedCharacterId, cc.character.id);
|
||||||
|
assert.equal(
|
||||||
|
thread.needsClassification,
|
||||||
|
false,
|
||||||
|
'sonst stünde er beim nächsten Lauf als offener Antrag da',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('löst eine Verknüpfung wieder', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Loeser2', 1);
|
||||||
|
const cc = await characters.createCharacter(spieler, { name: 'Doch nicht' }, ctx);
|
||||||
|
await characters.updateCharacter(cc.character.id, { threadRef: '9999' }, ctx);
|
||||||
|
|
||||||
|
await characters.updateCharacter(cc.character.id, { threadRef: null }, ctx);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9999 } });
|
||||||
|
assert.equal(thread.linkedCharacterId, null);
|
||||||
|
|
||||||
|
const liste = await characters.listCharacters(spieler);
|
||||||
|
const eintrag = liste.find((c) => c.id === cc.character.id);
|
||||||
|
assert.deepEqual(eintrag?.forumThreads, [], 'am CC hängt kein Antrag mehr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine Eingabe ab, die keine Thread-Angabe ist', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Vertipper', 1);
|
||||||
|
const cc = await characters.createCharacter(spieler, { name: 'Sauber' }, ctx);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.updateCharacter(cc.character.id, { threadRef: 'keine ahnung' }, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'INVALID_THREAD',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export type ChainAction =
|
|||||||
| 'reactivateFree'
|
| 'reactivateFree'
|
||||||
| 'reactivatePaid'
|
| 'reactivatePaid'
|
||||||
| 'reviveDead'
|
| 'reviveDead'
|
||||||
| 'links';
|
| 'links'
|
||||||
|
| 'redesignKind'
|
||||||
|
| 'edit';
|
||||||
|
|
||||||
interface Kette {
|
interface Kette {
|
||||||
lineageId: string;
|
lineageId: string;
|
||||||
@@ -487,6 +489,19 @@ function ChainEntry({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
{character.predecessorId !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
title="Bezahltes Redesign oder Kulanz? Lässt sich nachträglich umbuchen."
|
||||||
|
onClick={() => onAction('redesignKind', character)}
|
||||||
|
>
|
||||||
|
Bezahlart
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
|
||||||
Verweise
|
Verweise
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ export interface AdminUser {
|
|||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/// Der Zugang aus der Erstinstallation, solange er geschützt ist: an ihm
|
||||||
|
/// lässt sich nichts ändern, auch nicht durch ihn selbst.
|
||||||
|
isLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductMapping {
|
export interface ProductMapping {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt neu, sobald der Tab wieder in den Vordergrund kommt.
|
||||||
|
*
|
||||||
|
* An dieser Anwendung arbeiten mehrere Leute gleichzeitig. Wer eine Seite
|
||||||
|
* offen hat, während ein anderer etwas einträgt, sieht sonst einen Stand von
|
||||||
|
* vor einer halben Stunde - und merkt es nicht. Beim Zurückkehren an den Tab
|
||||||
|
* zu laden trifft genau den Moment, in dem jemand wieder hinsieht, ohne im
|
||||||
|
* Hintergrund dauernd Anfragen zu schicken.
|
||||||
|
*/
|
||||||
|
export function useRefreshOnFocus(reload: () => void | Promise<void>): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const beiRueckkehr = () => {
|
||||||
|
if (document.visibilityState === 'visible') void reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', beiRueckkehr);
|
||||||
|
window.addEventListener('focus', beiRueckkehr);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', beiRueckkehr);
|
||||||
|
window.removeEventListener('focus', beiRueckkehr);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
}
|
||||||
+18
-2
@@ -159,12 +159,20 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
<td>
|
<td>
|
||||||
<strong>{row.username}</strong>
|
<strong>{row.username}</strong>
|
||||||
{row.id === me?.id && <span className="badge">du</span>}
|
{row.id === me?.id && <span className="badge">du</span>}
|
||||||
|
{row.isLocked && (
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
title="Zugang aus der Erstinstallation - geschützt, damit immer ein Weg zurück bleibt"
|
||||||
|
>
|
||||||
|
geschützt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
value={row.role}
|
value={row.role}
|
||||||
disabled={row.id === me?.id}
|
disabled={row.id === me?.id || row.isLocked === true}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
void act(
|
void act(
|
||||||
() =>
|
() =>
|
||||||
@@ -185,6 +193,13 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
|
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
|
||||||
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
|
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
|
{row.isLocked ? (
|
||||||
|
<span className="muted small">
|
||||||
|
{row.id === me?.id
|
||||||
|
? 'Passwort änderst du unter Konto'
|
||||||
|
: 'Erstzugang - unantastbar'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-tiny btn-ghost"
|
className="btn btn-tiny btn-ghost"
|
||||||
@@ -192,7 +207,8 @@ function UsersTab({ onError, onDone }: TabProps) {
|
|||||||
>
|
>
|
||||||
Passwort
|
Passwort
|
||||||
</button>
|
</button>
|
||||||
{row.id !== me?.id && (
|
)}
|
||||||
|
{row.id !== me?.id && !row.isLocked && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Modal from '../components/Modal';
|
|||||||
import Notice from '../components/Notice';
|
import Notice from '../components/Notice';
|
||||||
import { ApiError, api } from '../lib/api';
|
import { ApiError, api } from '../lib/api';
|
||||||
import RunProgress from '../components/RunProgress';
|
import RunProgress from '../components/RunProgress';
|
||||||
|
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
|
||||||
import { useScrape } from '../lib/useScrape';
|
import { useScrape } from '../lib/useScrape';
|
||||||
import { useSession } from '../lib/session';
|
import { useSession } from '../lib/session';
|
||||||
import type {
|
import type {
|
||||||
@@ -83,6 +84,9 @@ export default function Dashboard() {
|
|||||||
await Promise.all([load(query, nurProbleme), loadFlagged()]);
|
await Promise.all([load(query, nurProbleme), loadFlagged()]);
|
||||||
}, [load, loadFlagged, query, nurProbleme]);
|
}, [load, loadFlagged, query, nurProbleme]);
|
||||||
|
|
||||||
|
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
|
||||||
|
useRefreshOnFocus(nachlesen);
|
||||||
|
|
||||||
const scrape = useScrape({
|
const scrape = useScrape({
|
||||||
onDone: (bericht) => setSyncResult(bericht),
|
onDone: (bericht) => setSyncResult(bericht),
|
||||||
onError: setError,
|
onError: setError,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import RunProgress from '../components/RunProgress';
|
|||||||
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
|
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
|
||||||
import { ApiError, api } from '../lib/api';
|
import { ApiError, api } from '../lib/api';
|
||||||
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
||||||
|
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
|
||||||
import { useSession } from '../lib/session';
|
import { useSession } from '../lib/session';
|
||||||
import type {
|
import type {
|
||||||
ForumScanSummary,
|
ForumScanSummary,
|
||||||
@@ -108,6 +109,8 @@ export default function Forum() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useRefreshOnFocus(load);
|
||||||
|
|
||||||
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
|
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(
|
const timer = window.setTimeout(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
import RunProgress from '../components/RunProgress';
|
import RunProgress from '../components/RunProgress';
|
||||||
|
import { useRefreshOnFocus } from '../lib/useRefreshOnFocus';
|
||||||
import { useScrape } from '../lib/useScrape';
|
import { useScrape } from '../lib/useScrape';
|
||||||
import { useSession } from '../lib/session';
|
import { useSession } from '../lib/session';
|
||||||
import type {
|
import type {
|
||||||
@@ -49,7 +50,9 @@ type DialogState =
|
|||||||
| { type: 'problem' }
|
| { type: 'problem' }
|
||||||
| { type: 'memberRedesign'; character: Character }
|
| { type: 'memberRedesign'; character: Character }
|
||||||
| { type: 'memberDeactivate'; character: Character }
|
| { type: 'memberDeactivate'; character: Character }
|
||||||
| { type: 'links'; character: Character };
|
| { type: 'links'; character: Character }
|
||||||
|
| { type: 'redesignKind'; character: Character }
|
||||||
|
| { type: 'edit'; character: Character };
|
||||||
|
|
||||||
export default function PlayerDetail() {
|
export default function PlayerDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -79,6 +82,9 @@ export default function PlayerDetail() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
|
||||||
|
useRefreshOnFocus(load);
|
||||||
|
|
||||||
const run = async (action: () => Promise<unknown>, message: string) => {
|
const run = async (action: () => Promise<unknown>, message: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -457,6 +463,40 @@ export default function PlayerDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'edit' && (
|
||||||
|
<EditCharacterModal
|
||||||
|
character={dialog.character}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}`, { method: 'PATCH', body: values }),
|
||||||
|
`"${values.name}" gespeichert.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog.type === 'redesignKind' && (
|
||||||
|
<RedesignKindModal
|
||||||
|
character={dialog.character}
|
||||||
|
balances={detail.balances}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(useKind) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${dialog.character.id}/redesign-kind`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { useKind },
|
||||||
|
}),
|
||||||
|
`Bezahlart von "${dialog.character.name}" umgebucht.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{dialog.type === 'links' && (
|
{dialog.type === 'links' && (
|
||||||
<LinksModal
|
<LinksModal
|
||||||
character={dialog.character}
|
character={dialog.character}
|
||||||
@@ -1656,3 +1696,146 @@ function ProblemModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt um, woraus ein Redesign bezahlt wurde.
|
||||||
|
*
|
||||||
|
* Umgebucht wird im Kontobuch: die alte Zeile wird gegengebucht, die neue
|
||||||
|
* kommt dazu. Überschrieben wird nichts - in der Historie bleibt sichtbar,
|
||||||
|
* dass hier etwas geändert wurde.
|
||||||
|
*/
|
||||||
|
function RedesignKindModal({
|
||||||
|
character,
|
||||||
|
balances,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
balances: { REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (useKind: CreditKind) => void;
|
||||||
|
}) {
|
||||||
|
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_KULANZ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
|
||||||
|
<Notice kind="info">
|
||||||
|
Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich manchmal erst
|
||||||
|
später heraus. Umgebucht wird über eine Gegenbuchung - in der Historie bleibt beides
|
||||||
|
sichtbar.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
||||||
|
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
||||||
|
] as const
|
||||||
|
).map(([wert, label, stand]) => (
|
||||||
|
<label key={wert} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="redesignKind"
|
||||||
|
checked={useKind === wert}
|
||||||
|
onChange={() => setUseKind(wert)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSubmit(useKind)}
|
||||||
|
>
|
||||||
|
{busy ? 'Buche um …' : 'Umbuchen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ändert die Angaben eines CCs. Der Antrag wird über seine Nummer oder die
|
||||||
|
* volle Adresse angegeben - beim Eintippen landet mal das eine, mal das
|
||||||
|
* andere im Feld, also nimmt das Feld beides.
|
||||||
|
*/
|
||||||
|
function EditCharacterModal({
|
||||||
|
character,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: { name: string; loreText: string | null; threadRef: string | null }) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(character.name);
|
||||||
|
const [lore, setLore] = useState(character.loreText ?? '');
|
||||||
|
const [thread, setThread] = useState(character.forumThreads?.[0]?.url ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`"${character.name}" bearbeiten`} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
loreText: lore.trim() || null,
|
||||||
|
threadRef: thread.trim() || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="edit-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="edit-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="edit-thread">Forums-Antrag</label>
|
||||||
|
<input
|
||||||
|
id="edit-thread"
|
||||||
|
value={thread}
|
||||||
|
onChange={(event) => setThread(event.target.value)}
|
||||||
|
placeholder="Adresse oder Thread-Nummer, leer = kein Antrag"
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Der Name des CCs führt in der Liste dorthin. Ein unbekannter Thread wird vorgemerkt,
|
||||||
|
damit er beim nächsten Abgleich nicht als offener Antrag auftaucht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="edit-lore">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-lore"
|
||||||
|
rows={4}
|
||||||
|
value={lore}
|
||||||
|
onChange={(event) => setLore(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user