Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle
Vier Punkte aus dem Betrieb. Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet. Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt hat. Jetzt werden alle Kennungen des Spielers abgefragt. Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben. Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden, welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt am Beitritt und bleibt nachvollziehbar. Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben - koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen. Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim Test.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- Spieler, deren Lage sich gerade nicht klaeren laesst, werden trotzdem
|
||||
-- angelegt und als Problemfall markiert.
|
||||
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "isProblem" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemNote" TEXT;
|
||||
ALTER TABLE "Player" ADD COLUMN IF NOT EXISTS "problemSince" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Player_isProblem_idx" ON "Player"("isProblem");
|
||||
|
||||
-- Wer ueber ein Redesign in einen Gruppen-CC kommt, gibt dafuer einen eigenen
|
||||
-- CC auf. Welcher das war, haengt am Beitritt.
|
||||
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "predecessorId" INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "CharacterMember_predecessorId_idx" ON "CharacterMember"("predecessorId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CharacterMember"
|
||||
ADD CONSTRAINT "CharacterMember_predecessorId_fkey"
|
||||
FOREIGN KEY ("predecessorId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -123,6 +123,14 @@ model Player {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Ein Fall, der sich gerade nicht sauber klären lässt: ein Antrag ohne
|
||||
/// auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
|
||||
/// Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht
|
||||
/// verloren geht - geklärt wird, wenn es darauf ankommt.
|
||||
isProblem Boolean @default(false)
|
||||
problemNote String?
|
||||
problemSince DateTime?
|
||||
|
||||
/// Von GExtension gemeldeter Verbrauchszähler. Reine Anzeige zum Abgleich,
|
||||
/// wird nie automatisch mit dem Ledger verrechnet.
|
||||
gextensionUsage Int?
|
||||
@@ -137,6 +145,7 @@ model Player {
|
||||
memberships CharacterMember[]
|
||||
|
||||
@@index([displayName])
|
||||
@@index([isProblem])
|
||||
}
|
||||
|
||||
/// Pakete, deren Titel nach CC oder Redesign klingt, sich aber keiner
|
||||
@@ -223,6 +232,8 @@ model Character {
|
||||
ledgerEntries CreditLedger[]
|
||||
forumThreads ForumThread[]
|
||||
members CharacterMember[]
|
||||
/// Beitritte, für die dieser CC aufgegeben wurde.
|
||||
givenUpFor CharacterMember[] @relation("MemberPredecessor")
|
||||
|
||||
createdById Int?
|
||||
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
@@ -414,6 +425,12 @@ model CharacterMember {
|
||||
paidWith CreditKind?
|
||||
note String?
|
||||
|
||||
/// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur
|
||||
/// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe
|
||||
/// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
||||
predecessorId Int?
|
||||
predecessor Character? @relation("MemberPredecessor", fields: [predecessorId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdById Int?
|
||||
|
||||
/// Ein Spieler kann demselben CC nicht zweimal angehören.
|
||||
|
||||
@@ -148,11 +148,19 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
z.object({
|
||||
playerId: z.number().int().positive(),
|
||||
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
||||
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
|
||||
/// Platz über ein Redesign bezahlt wird.
|
||||
predecessorId: z.number().int().positive().optional(),
|
||||
note: optionalNote,
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
return addMember(id, body.playerId, { useKind: body.useKind, note: body.note }, contextOf(request));
|
||||
return addMember(
|
||||
id,
|
||||
body.playerId,
|
||||
{ useKind: body.useKind, predecessorId: body.predecessorId, note: body.note },
|
||||
contextOf(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createPlayer,
|
||||
deletePlayer,
|
||||
getPlayerDetail,
|
||||
markProblem,
|
||||
removeAlias,
|
||||
searchPlayers,
|
||||
updatePlayer,
|
||||
@@ -20,6 +21,8 @@ const playerBody = z.object({
|
||||
steamId: z.string().trim().max(64).nullish(),
|
||||
forumName: z.string().trim().max(120).nullish(),
|
||||
notes: z.string().trim().max(2000).nullish(),
|
||||
isProblem: z.boolean().optional(),
|
||||
problemNote: z.string().trim().max(1000).nullish(),
|
||||
});
|
||||
|
||||
export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -29,10 +32,11 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
q: z.string().trim().max(120).default(''),
|
||||
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||
skip: z.coerce.number().int().min(0).default(0),
|
||||
problems: z.enum(['true', 'false']).default('false'),
|
||||
}),
|
||||
request.query,
|
||||
);
|
||||
return searchPlayers(query.q, query.take, query.skip);
|
||||
return searchPlayers(query.q, query.take, query.skip, query.problems === 'true');
|
||||
});
|
||||
|
||||
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
||||
@@ -62,10 +66,28 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(playerBody.partial(), request.body);
|
||||
// Die Problem-Markierung läuft über die eigene Route, damit ein Klären
|
||||
// oder Markieren als solches im Protokoll steht und nicht als "geändert".
|
||||
const body = parseWith(playerBody.omit({ isProblem: true, problemNote: true }).partial(), request.body);
|
||||
return updatePlayer(id, body, contextOf(request));
|
||||
});
|
||||
|
||||
/**
|
||||
* Einen Spieler als Problemfall markieren oder die Markierung aufheben.
|
||||
* Reine Merkhilfe - an Guthaben und CCs ändert sich dadurch nichts.
|
||||
*/
|
||||
app.post('/api/players/:id/problem', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
isProblem: z.boolean(),
|
||||
note: z.string().trim().max(1000).nullish(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
return markProblem(id, body, contextOf(request));
|
||||
});
|
||||
|
||||
// Frühere Steam- und Forumskonten eines Spielers.
|
||||
app.post('/api/players/:id/aliases', { preHandler: requireEditor }, async (request, reply) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { notFound } from '../errors.js';
|
||||
import { prisma } from '../db.js';
|
||||
import {
|
||||
knownSteamIds,
|
||||
steamIdsOf,
|
||||
listUnmatched,
|
||||
resolveUnmatched,
|
||||
scrapePlayers,
|
||||
@@ -32,20 +33,26 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||
return scrapePlayers(targets, contextOf(request));
|
||||
});
|
||||
|
||||
/** Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus. */
|
||||
/**
|
||||
* Einzelnes Profil abgleichen, etwa direkt von der Spielerseite aus.
|
||||
* Abgefragt werden alle Konten des Spielers, nicht nur das hinterlegte -
|
||||
* sonst fehlte das Guthaben, das unter einem früheren Konto gekauft wurde.
|
||||
*/
|
||||
app.post('/api/scrape/player/:id', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
|
||||
const player = await prisma.player.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, steamId: true },
|
||||
});
|
||||
const player = await prisma.player.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
if (!player.steamId) {
|
||||
|
||||
const steamIds = await steamIdsOf(id);
|
||||
if (steamIds.length === 0) {
|
||||
throw notFound('SteamID des Spielers');
|
||||
}
|
||||
|
||||
return scrapePlayers([{ playerId: player.id, steamId: player.steamId }], contextOf(request));
|
||||
return scrapePlayers(
|
||||
steamIds.map((steamId) => ({ playerId: id, steamId })),
|
||||
contextOf(request),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/scrape/unmatched', { preHandler: requireAuth }, async (request) => {
|
||||
|
||||
@@ -563,6 +563,61 @@ export async function reviveDeadCharacter(
|
||||
const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
||||
export type JoinKind = (typeof JOIN_KINDS)[number];
|
||||
|
||||
/// Beitritte, die auf einem Redesign beruhen und deshalb einen CC kosten.
|
||||
const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ];
|
||||
|
||||
/**
|
||||
* Prüft den CC, den ein Spieler für seinen Platz aufgibt.
|
||||
*
|
||||
* Über ein Redesign gibt es keinen zusätzlichen CC, sondern einen anderen:
|
||||
* ohne einen eigenen CC als Grundlage gibt es nichts zu ersetzen. Aktiv muss
|
||||
* dieser nicht sein - auch ein längst inaktiver CC lässt sich redesignen.
|
||||
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
|
||||
*/
|
||||
async function ladeAufgegebenenCC(
|
||||
input: { useKind: JoinKind; predecessorId?: number | undefined },
|
||||
playerId: number,
|
||||
characterId: number,
|
||||
): Promise<Character | null> {
|
||||
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
|
||||
if (input.predecessorId !== undefined) {
|
||||
throw badRequest(
|
||||
'NO_PREDECESSOR_NEEDED',
|
||||
'Ein Platz aus dem CC-Guthaben ersetzt keinen bestehenden CC',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.predecessorId === undefined) {
|
||||
throw badRequest(
|
||||
'NEED_PREDECESSOR',
|
||||
'Ein Beitritt über ein Redesign ersetzt einen eigenen CC - bitte angeben, welchen',
|
||||
);
|
||||
}
|
||||
if (input.predecessorId === characterId) {
|
||||
throw conflict('SAME_CHARACTER', 'Ein CC kann nicht für sich selbst aufgegeben werden');
|
||||
}
|
||||
|
||||
const vorgaenger = await prisma.character.findUnique({
|
||||
where: { id: input.predecessorId },
|
||||
include: { members: { where: { playerId, leftAt: null }, select: { id: true } } },
|
||||
});
|
||||
if (!vorgaenger) throw notFound('Aufgegebener CC');
|
||||
|
||||
// Aufgeben kann nur, wem der CC gehört. Bei einem aktiven CC zählt die
|
||||
// Mitgliedschaft, bei einem inaktiven der Eintrag als Inhaber - dort ist
|
||||
// die Mitgliedschaft beim Ausscheiden beendet worden.
|
||||
const gehoertIhm =
|
||||
vorgaenger.members.length > 0 ||
|
||||
(vorgaenger.status !== CharacterStatus.ACTIVE && vorgaenger.playerId === playerId);
|
||||
if (!gehoertIhm) {
|
||||
throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht');
|
||||
}
|
||||
|
||||
return vorgaenger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
|
||||
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
|
||||
@@ -571,7 +626,7 @@ export type JoinKind = (typeof JOIN_KINDS)[number];
|
||||
export async function addMember(
|
||||
characterId: number,
|
||||
playerId: number,
|
||||
input: { useKind: JoinKind; note?: string | undefined },
|
||||
input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
|
||||
ctx: ActionContext,
|
||||
): Promise<CharacterResult & { memberCount: number }> {
|
||||
const character = await loadCharacter(characterId);
|
||||
@@ -593,7 +648,25 @@ export async function addMember(
|
||||
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
|
||||
}
|
||||
|
||||
// Ein Redesign ersetzt einen CC, es kommt nie einer dazu. Wer über ein
|
||||
// Redesign beitritt, gibt dafür einen eigenen CC auf - genau wie bei jedem
|
||||
// anderen Redesign. Nur ein CC-Guthaben kauft einen zusätzlichen Platz.
|
||||
const vorgaenger = await ladeAufgegebenenCC(input, playerId, characterId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) {
|
||||
await tx.character.update({
|
||||
where: { id: vorgaenger.id },
|
||||
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
||||
});
|
||||
// Der Spieler nutzt den aufgegebenen CC nicht mehr; bei einem Gruppen-CC
|
||||
// laufen die übrigen Mitglieder dort unberührt weiter.
|
||||
await tx.characterMember.updateMany({
|
||||
where: { characterId: vorgaenger.id, playerId, leftAt: null },
|
||||
data: { leftAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
if (vorhanden) {
|
||||
// Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die
|
||||
// Zugehörigkeit nicht doppelt in der Liste steht.
|
||||
@@ -603,6 +676,7 @@ export async function addMember(
|
||||
leftAt: null,
|
||||
joinedAt: new Date(),
|
||||
paidWith: input.useKind,
|
||||
predecessorId: vorgaenger?.id ?? null,
|
||||
note: input.note ?? null,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
@@ -613,6 +687,7 @@ export async function addMember(
|
||||
characterId,
|
||||
playerId,
|
||||
paidWith: input.useKind,
|
||||
predecessorId: vorgaenger?.id ?? null,
|
||||
note: input.note ?? null,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
@@ -627,7 +702,11 @@ export async function addMember(
|
||||
source: CreditSource.CONSUME,
|
||||
groupId: randomUUID(),
|
||||
characterId,
|
||||
note: input.note ?? `Beitritt zu "${character.name}"`,
|
||||
note:
|
||||
input.note ??
|
||||
(vorgaenger
|
||||
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
|
||||
: `Beitritt zu "${character.name}"`),
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
@@ -636,7 +715,13 @@ export async function addMember(
|
||||
action: 'character.member_added',
|
||||
targetType: 'character',
|
||||
targetId: characterId,
|
||||
details: { playerId, name: character.name, useKind: input.useKind },
|
||||
details: {
|
||||
playerId,
|
||||
name: character.name,
|
||||
useKind: input.useKind,
|
||||
predecessorId: vorgaenger?.id ?? null,
|
||||
predecessorName: vorgaenger?.name ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,16 +12,25 @@ export interface PlayerInput {
|
||||
steamId?: string | null;
|
||||
forumName?: string | null;
|
||||
notes?: string | null;
|
||||
/// Anlegen, obwohl etwas nicht stimmt - siehe markProblem().
|
||||
isProblem?: boolean;
|
||||
problemNote?: string | null;
|
||||
}
|
||||
|
||||
export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||
export async function searchPlayers(
|
||||
query: string,
|
||||
take = 50,
|
||||
skip = 0,
|
||||
onlyProblems = false,
|
||||
) {
|
||||
const trimmed = query.trim();
|
||||
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
|
||||
// 64-Bit-Form gespeichert ist.
|
||||
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
|
||||
|
||||
const where: Prisma.PlayerWhereInput =
|
||||
trimmed.length === 0
|
||||
const where: Prisma.PlayerWhereInput = {
|
||||
...(onlyProblems ? { isProblem: true } : {}),
|
||||
...(trimmed.length === 0
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
@@ -29,7 +38,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||
{ steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } },
|
||||
{ forumName: { contains: trimmed, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const [players, total] = await Promise.all([
|
||||
prisma.player.findMany({
|
||||
@@ -57,6 +67,8 @@ export async function searchPlayers(query: string, take = 50, skip = 0) {
|
||||
displayName: player.displayName,
|
||||
steamId: player.steamId,
|
||||
forumName: player.forumName,
|
||||
isProblem: player.isProblem,
|
||||
problemNote: player.problemNote,
|
||||
characterCount: player._count.characters,
|
||||
activeCharacterCount: activeByPlayer.get(player.id) ?? 0,
|
||||
balances: balances.get(player.id) as Balances,
|
||||
@@ -131,6 +143,13 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||
steamId,
|
||||
forumName: input.forumName?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
...(input.isProblem
|
||||
? {
|
||||
isProblem: true,
|
||||
problemNote: input.problemNote?.trim() || null,
|
||||
problemSince: new Date(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -138,7 +157,11 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
||||
action: 'player.create',
|
||||
targetType: 'player',
|
||||
targetId: created.id,
|
||||
details: { displayName: created.displayName, steamId: created.steamId },
|
||||
details: {
|
||||
displayName: created.displayName,
|
||||
steamId: created.steamId,
|
||||
isProblem: created.isProblem,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
@@ -247,6 +270,48 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
* Trägt ein früheres Konto nach. Steam-Kennungen werden dabei auf dieselbe
|
||||
* Form gebracht wie die Hauptangabe, sonst greift der Abgleich nicht.
|
||||
*/
|
||||
/**
|
||||
* Markiert einen Spieler als Problemfall - oder hebt die Markierung auf.
|
||||
*
|
||||
* Gedacht für alles, was sich im Moment nicht sauber klären lässt: ein Antrag
|
||||
* ohne auffindbares Paket, ein fehlender GExtension-Eintrag, widersprüchliche
|
||||
* Angaben. Der Spieler wird trotzdem angelegt, damit die Spur nicht verloren
|
||||
* geht. Geklärt wird, wenn es darauf ankommt - kommt der Spieler nie wieder,
|
||||
* kommt es nie darauf an.
|
||||
*
|
||||
* Die Markierung hat bewusst **keine** Wirkung auf Guthaben oder CCs. Sie ist
|
||||
* eine Notiz mit Griff, kein Sperrvermerk.
|
||||
*/
|
||||
export async function markProblem(
|
||||
playerId: number,
|
||||
input: { isProblem: boolean; note?: string | null },
|
||||
ctx: ActionContext,
|
||||
) {
|
||||
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
||||
if (!existing) throw notFound('Spieler');
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
where: { id: playerId },
|
||||
data: input.isProblem
|
||||
? {
|
||||
isProblem: true,
|
||||
problemNote: input.note?.trim() || null,
|
||||
// Ein bereits offener Fall behält seinen Anfang.
|
||||
problemSince: existing.problemSince ?? new Date(),
|
||||
}
|
||||
: { isProblem: false, problemNote: null, problemSince: null },
|
||||
});
|
||||
|
||||
await recordAudit(prisma, ctx, {
|
||||
action: input.isProblem ? 'player.problem_marked' : 'player.problem_cleared',
|
||||
targetType: 'player',
|
||||
targetId: playerId,
|
||||
details: { displayName: updated.displayName, note: input.note?.trim() || null },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function addAlias(
|
||||
playerId: number,
|
||||
type: AliasType,
|
||||
|
||||
@@ -43,6 +43,28 @@ export async function knownSteamIds(): Promise<{ playerId: number; steamId: stri
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Steam-Kennungen eines einzelnen Spielers: die hinterlegte und alle
|
||||
* früheren Konten. Wer den Account gewechselt hat, hat seine älteren Käufe
|
||||
* unter der alten Kennung stehen - ohne die bliebe das Guthaben unsichtbar.
|
||||
*/
|
||||
export async function steamIdsOf(playerId: number): Promise<string[]> {
|
||||
const [player, aliases] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { id: playerId }, select: { steamId: true } }),
|
||||
prisma.playerAlias.findMany({
|
||||
where: { playerId, type: 'STEAM_ID' },
|
||||
select: { value: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const alle = [
|
||||
...(player?.steamId ? [player.steamId] : []),
|
||||
...aliases.map((alias) => alias.value),
|
||||
];
|
||||
return [...new Set(alle)];
|
||||
}
|
||||
|
||||
export interface ScrapeSummary {
|
||||
importId: number | null;
|
||||
scannedPlayers: number;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||
type Forum = typeof import('../src/services/forum.js');
|
||||
type Players = typeof import('../src/services/players.js');
|
||||
type Characters = typeof import('../src/services/characters.js');
|
||||
type Scraper = typeof import('../src/services/scraper.js');
|
||||
type Db = typeof import('../src/db.js');
|
||||
|
||||
let database: TestDatabase;
|
||||
let forum: Forum;
|
||||
let players: Players;
|
||||
let characters: Characters;
|
||||
let scraper: Scraper;
|
||||
let db: Db;
|
||||
let ctx: { actorId: number; actorName: string };
|
||||
|
||||
@@ -24,6 +26,7 @@ before(async () => {
|
||||
forum = await import('../src/services/forum.js');
|
||||
players = await import('../src/services/players.js');
|
||||
characters = await import('../src/services/characters.js');
|
||||
scraper = await import('../src/services/scraper.js');
|
||||
|
||||
const user = await db.prisma.user.create({
|
||||
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
|
||||
@@ -196,3 +199,115 @@ describe('Antrag am CC', () => {
|
||||
assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Problemfälle', () => {
|
||||
it('legt einen Spieler an, obwohl etwas offen ist', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{
|
||||
displayName: 'Fall ohne Paket',
|
||||
isProblem: true,
|
||||
problemNote: 'CC-Antrag da, aber kein Paket auffindbar',
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(spieler.isProblem, true);
|
||||
assert.equal(spieler.problemNote, 'CC-Antrag da, aber kein Paket auffindbar');
|
||||
assert.ok(spieler.problemSince, 'wann es aufgefallen ist, gehört dazu');
|
||||
});
|
||||
|
||||
it('markiert auch im Nachhinein', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{ displayName: 'Erst später auffällig', steamId: '76561198000011111' },
|
||||
ctx,
|
||||
);
|
||||
assert.equal(spieler.isProblem, false);
|
||||
|
||||
const markiert = await players.markProblem(
|
||||
spieler.id,
|
||||
{ isProblem: true, note: 'Kein GExtension-Eintrag' },
|
||||
ctx,
|
||||
);
|
||||
assert.equal(markiert.isProblem, true);
|
||||
assert.equal(markiert.problemNote, 'Kein GExtension-Eintrag');
|
||||
});
|
||||
|
||||
it('behält den Anfang, wenn die Notiz nachgeschärft wird', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{ displayName: 'Langer Fall', isProblem: true, problemNote: 'unklar' },
|
||||
ctx,
|
||||
);
|
||||
const anfang = spieler.problemSince;
|
||||
|
||||
const nachgeschaerft = await players.markProblem(
|
||||
spieler.id,
|
||||
{ isProblem: true, note: 'Paket liegt unter altem Konto' },
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(nachgeschaerft.problemSince, anfang, 'der Fall ist derselbe, nur klarer');
|
||||
});
|
||||
|
||||
it('räumt die Markierung restlos ab', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{ displayName: 'Geklärt', isProblem: true, problemNote: 'war ein Tippfehler' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
const geklaert = await players.markProblem(spieler.id, { isProblem: false }, ctx);
|
||||
assert.equal(geklaert.isProblem, false);
|
||||
assert.equal(geklaert.problemNote, null);
|
||||
assert.equal(geklaert.problemSince, null);
|
||||
});
|
||||
|
||||
it('ändert an Guthaben und CCs nichts', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{ displayName: 'Trotzdem berechtigt', steamId: '76561198000012222' },
|
||||
ctx,
|
||||
);
|
||||
await db.prisma.creditLedger.create({
|
||||
data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||
});
|
||||
const { character } = await characters.createCharacter(spieler.id, { name: 'Bleibt' }, ctx);
|
||||
|
||||
await players.markProblem(spieler.id, { isProblem: true, note: 'Herkunft unklar' }, ctx);
|
||||
|
||||
const detail = await players.getPlayerDetail(spieler.id);
|
||||
assert.equal(detail.balances.CC, 0, 'das Paket ist verbraucht, nicht verschwunden');
|
||||
assert.equal(detail.characters.find((c) => c.id === character.id)?.status, 'ACTIVE');
|
||||
});
|
||||
|
||||
it('listet sie auf Wunsch für sich', async () => {
|
||||
const offen = await players.searchPlayers('', 100, 0, true);
|
||||
assert.ok(offen.total >= 3);
|
||||
assert.equal(
|
||||
offen.items.every((p) => p.isProblem),
|
||||
true,
|
||||
);
|
||||
|
||||
const alle = await players.searchPlayers('', 100, 0, false);
|
||||
assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Konten beim Abgleich', () => {
|
||||
it('sammelt alle Steam-Kennungen eines Spielers, nicht nur die hinterlegte', async () => {
|
||||
const spieler = await players.createPlayer(
|
||||
{ displayName: 'Kontowechsler', steamId: '76561198000013333' },
|
||||
ctx,
|
||||
);
|
||||
await players.addAlias(spieler.id, 'STEAM_ID', '76561198000014444', 'Erstes Konto', ctx);
|
||||
await players.addAlias(spieler.id, 'STEAM_ID', 'STEAM_0:1:19867635', 'Ganz altes Konto', ctx);
|
||||
|
||||
const kennungen = await scraper.steamIdsOf(spieler.id);
|
||||
assert.deepEqual(
|
||||
kennungen.sort(),
|
||||
['76561198000013333', '76561198000014444', '76561198000000999'].sort(),
|
||||
'auch in der Legacy-Schreibweise nachgetragene Konten gehören dazu',
|
||||
);
|
||||
});
|
||||
|
||||
it('kommt mit einem Spieler ohne jede Kennung klar', async () => {
|
||||
const spieler = await players.createPlayer({ displayName: 'Nur ein Name' }, ctx);
|
||||
assert.deepEqual(await scraper.steamIdsOf(spieler.id), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,22 +69,113 @@ describe('Gruppen-CCs', () => {
|
||||
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
|
||||
});
|
||||
|
||||
it('lässt den Beitritt auch über ein Redesign zu', async () => {
|
||||
it('lässt den Beitritt auch über ein Redesign zu, kostet dann aber den eigenen CC', async () => {
|
||||
const gruender = await spielerMitPaket('Chef');
|
||||
const zweiter = await spielerMitPaket('Nachzügler');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
|
||||
|
||||
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
|
||||
await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
|
||||
const eigener = await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
|
||||
const result = await characters.addMember(
|
||||
character.id,
|
||||
zweiter,
|
||||
{ useKind: 'REDESIGN_PAID' },
|
||||
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
|
||||
assert.equal(result.memberCount, 2);
|
||||
|
||||
// Ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
||||
const aufgegeben = await db.prisma.character.findUniqueOrThrow({
|
||||
where: { id: eigener.character.id },
|
||||
});
|
||||
assert.equal(aufgegeben.status, 'INACTIVE');
|
||||
assert.equal(aufgegeben.inactiveReason, 'REWORKED');
|
||||
|
||||
const eigene = await characters.listCharacters(zweiter);
|
||||
assert.deepEqual(
|
||||
eigene.filter((c) => c.status === 'ACTIVE').map((c) => c.name),
|
||||
['Redesign-Bande'],
|
||||
'der alte CC ist weg, der Gruppen-CC an seiner Stelle',
|
||||
);
|
||||
|
||||
const beitritt = await db.prisma.characterMember.findFirstOrThrow({
|
||||
where: { characterId: character.id, playerId: zweiter },
|
||||
});
|
||||
assert.equal(beitritt.predecessorId, eigener.character.id, 'die Kette bleibt nachvollziehbar');
|
||||
});
|
||||
|
||||
it('verlangt für einen Beitritt über ein Redesign einen eigenen CC', async () => {
|
||||
const gruender = await spielerMitPaket('Chefin');
|
||||
const zweiter = await spielerMitPaket('Ohne CC');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossene Runde' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.addMember(character.id, zweiter, { useKind: 'REDESIGN_PAID' }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'NEED_PREDECESSOR',
|
||||
'ohne Vorgänger gäbe es nichts zu ersetzen',
|
||||
);
|
||||
});
|
||||
|
||||
it('nimmt als Vorgänger auch einen längst inaktiven CC an', async () => {
|
||||
const gruender = await spielerMitPaket('Anführer');
|
||||
const zweiter = await spielerMitPaket('Rückkehrer');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Zweite Chance' }, ctx);
|
||||
|
||||
const alt = await characters.createCharacter(zweiter, { name: 'Alter CC' }, ctx);
|
||||
await characters.deactivateCharacter(alt.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||
|
||||
const result = await characters.addMember(
|
||||
character.id,
|
||||
zweiter,
|
||||
{ useKind: 'REDESIGN_PAID', predecessorId: alt.character.id },
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.memberCount, 2);
|
||||
|
||||
// Was schon inaktiv war, wird nicht ein zweites Mal aufgegeben.
|
||||
const unveraendert = await db.prisma.character.findUniqueOrThrow({
|
||||
where: { id: alt.character.id },
|
||||
});
|
||||
assert.equal(unveraendert.inactiveReason, 'PLAYER_INACTIVITY');
|
||||
});
|
||||
|
||||
it('lässt niemanden einen fremden CC aufgeben', async () => {
|
||||
const gruender = await spielerMitPaket('Wirt');
|
||||
const fremder = await spielerMitPaket('Fremder');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
|
||||
const fremdesCC = await characters.createCharacter(gruender, { name: 'Nicht deiner' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
characters.addMember(
|
||||
character.id,
|
||||
fremder,
|
||||
{ useKind: 'REDESIGN_PAID', predecessorId: fremdesCC.character.id },
|
||||
ctx,
|
||||
),
|
||||
(error: Error & { code?: string }) => error.code === 'NOT_OWN_CHARACTER',
|
||||
);
|
||||
});
|
||||
|
||||
it('verlangt für einen Platz aus dem CC-Guthaben keinen Vorgänger', async () => {
|
||||
const gruender = await spielerMitPaket('Gastgeber');
|
||||
const zweiter = await spielerMitPaket('Gast');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Offene Tür' }, ctx);
|
||||
const eigener = await characters.createCharacter(zweiter, { name: 'Bleibt bestehen' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
characters.addMember(
|
||||
character.id,
|
||||
zweiter,
|
||||
{ useKind: 'CC', predecessorId: eigener.character.id },
|
||||
ctx,
|
||||
),
|
||||
(error: Error & { code?: string }) => error.code === 'NO_PREDECESSOR_NEEDED',
|
||||
'ein CC-Paket kauft einen Platz, es ersetzt nichts',
|
||||
);
|
||||
});
|
||||
|
||||
it('nimmt denselben Spieler nicht zweimal auf', async () => {
|
||||
|
||||
Reference in New Issue
Block a user