Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die erste Seite - danach steht der Umfang fest, und der Balken zeigt einen ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer Seite mit. Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten fuenfzig zu zeigen und den Rest zu verschweigen. Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf, waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt den Spieler mit bereits eingetragenem Forum-Namen an. Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei Seiten zu wechseln: - Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen. - Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen. Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt, auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren. Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide Seiten sie brauchen.
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import type { Prisma } from '@prisma/client';
|
|
import { prisma } from '../db.js';
|
|
import { conflict, notFound } from '../errors.js';
|
|
import { getBalances, getBalancesForMany, listLedger, type Balances } from './credits.js';
|
|
import { listCharacters } from './characters.js';
|
|
import { recordAudit, type ActionContext } from './audit.js';
|
|
import { AliasType } from '@prisma/client';
|
|
import { looksLikeSteamId, normalizeSteamId } from '../lib/steamid.js';
|
|
|
|
export interface PlayerInput {
|
|
displayName: string;
|
|
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,
|
|
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 = {
|
|
...(onlyProblems ? { isProblem: true } : {}),
|
|
...(trimmed.length === 0
|
|
? {}
|
|
: {
|
|
OR: [
|
|
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
|
{ steamId: { contains: asSteamId ?? trimmed, mode: 'insensitive' } },
|
|
{ forumName: { contains: trimmed, mode: 'insensitive' } },
|
|
],
|
|
}),
|
|
};
|
|
|
|
const [players, total] = await Promise.all([
|
|
prisma.player.findMany({
|
|
where,
|
|
orderBy: { displayName: 'asc' },
|
|
take,
|
|
skip,
|
|
include: { _count: { select: { characters: true } } },
|
|
}),
|
|
prisma.player.count({ where }),
|
|
]);
|
|
|
|
const balances = await getBalancesForMany(players.map((p) => p.id));
|
|
const activeCounts = await prisma.character.groupBy({
|
|
by: ['playerId'],
|
|
where: { playerId: { in: players.map((p) => p.id) }, status: 'ACTIVE' },
|
|
_count: { _all: true },
|
|
});
|
|
const activeByPlayer = new Map(activeCounts.map((c) => [c.playerId, c._count._all]));
|
|
|
|
return {
|
|
total,
|
|
items: players.map((player) => ({
|
|
id: player.id,
|
|
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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getPlayerDetail(playerId: number) {
|
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
// Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an
|
|
// einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln.
|
|
const { listThreads } = await import('./forum.js');
|
|
const { listPendingRowsFor } = await import('./imports.js');
|
|
|
|
const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] =
|
|
await Promise.all([
|
|
getBalances(playerId),
|
|
listCharacters(playerId),
|
|
listLedger({ playerId, take: 200 }),
|
|
prisma.unmatchedPackage.findMany({
|
|
where: { playerId, resolved: false },
|
|
orderBy: { scrapedAt: 'desc' },
|
|
include: { player: { select: { id: true, displayName: true } } },
|
|
}),
|
|
prisma.playerAlias.findMany({
|
|
where: { playerId },
|
|
orderBy: [{ type: 'asc' }, { value: 'asc' }],
|
|
}),
|
|
// Ältester zuerst: Anträge werden der Reihe nach abgearbeitet.
|
|
listThreads({ playerId, order: 'oldest' }),
|
|
listPendingRowsFor(playerId),
|
|
]);
|
|
|
|
return {
|
|
player,
|
|
balances,
|
|
characters,
|
|
ledger: ledger.items,
|
|
ledgerTotal: ledger.total,
|
|
unmatched,
|
|
aliases,
|
|
threads,
|
|
pendingRows,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
|
* Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die
|
|
* andere die 64-Bit-Zahl.
|
|
*/
|
|
function canonicalSteamId(input: string | null | undefined): string | null {
|
|
const value = input?.trim();
|
|
if (!value) return null;
|
|
|
|
const normalized = normalizeSteamId(value);
|
|
if (!normalized) {
|
|
throw conflict(
|
|
'INVALID_STEAMID',
|
|
`"${value}" sieht nach keiner SteamID aus. Erlaubt sind STEAM_0:1:..., [U:1:...] oder die 17-stellige Zahl.`,
|
|
);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
async function assertSteamIdFree(steamId: string, exceptId?: number): Promise<void> {
|
|
const existing = await prisma.player.findUnique({ where: { steamId }, select: { id: true } });
|
|
if (existing && existing.id !== exceptId) {
|
|
throw conflict('STEAMID_TAKEN', 'Diese SteamID ist bereits einem Spieler zugeordnet');
|
|
}
|
|
}
|
|
|
|
export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
|
|
const steamId = canonicalSteamId(input.steamId);
|
|
if (steamId) await assertSteamIdFree(steamId);
|
|
|
|
const created = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.player.create({
|
|
data: {
|
|
displayName: input.displayName.trim(),
|
|
steamId,
|
|
forumName: input.forumName?.trim() || null,
|
|
notes: input.notes?.trim() || null,
|
|
...(input.isProblem
|
|
? {
|
|
isProblem: true,
|
|
problemNote: input.problemNote?.trim() || null,
|
|
problemSince: new Date(),
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'player.create',
|
|
targetType: 'player',
|
|
targetId: created.id,
|
|
details: {
|
|
displayName: created.displayName,
|
|
steamId: created.steamId,
|
|
isProblem: created.isProblem,
|
|
},
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
// Wartende Forums-Anträge unter diesem Namen gleich mit übernehmen, sonst
|
|
// blieben sie bis zum nächsten vollen Abgleich in der Warteliste liegen.
|
|
if (created.forumName) {
|
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
|
await claimThreadsForPlayer(created.id, [created.forumName]);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
|
|
const existing = await prisma.player.findUnique({ where: { id: playerId } });
|
|
if (!existing) throw notFound('Spieler');
|
|
|
|
const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
|
|
if (steamId) await assertSteamIdFree(steamId, playerId);
|
|
|
|
const updated = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.player.update({
|
|
where: { id: playerId },
|
|
data: {
|
|
...(input.displayName !== undefined ? { displayName: input.displayName.trim() } : {}),
|
|
...(steamId !== undefined ? { steamId } : {}),
|
|
...(input.forumName !== undefined ? { forumName: input.forumName?.trim() || null } : {}),
|
|
...(input.notes !== undefined ? { notes: input.notes?.trim() || null } : {}),
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'player.update',
|
|
targetType: 'player',
|
|
targetId: playerId,
|
|
details: { displayName: updated.displayName, steamId: updated.steamId },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
if (updated.forumName && updated.forumName !== existing.forumName) {
|
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
|
await claimThreadsForPlayer(playerId, [updated.forumName]);
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
|
|
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
|
|
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
|
|
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
|
*/
|
|
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
|
const existing = await prisma.player.findUnique({
|
|
where: { id: playerId },
|
|
include: { _count: { select: { characters: true, ledger: true } } },
|
|
});
|
|
if (!existing) throw notFound('Spieler');
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
// Rohzeilen der Importe verlieren ihren Personenbezug.
|
|
await tx.importRow.updateMany({
|
|
where: {
|
|
OR: [
|
|
{ resolvedPlayerId: playerId },
|
|
...(existing.steamId ? [{ playerKey: existing.steamId }] : []),
|
|
],
|
|
},
|
|
data: {
|
|
playerKey: null,
|
|
playerName: null,
|
|
resolvedPlayerId: null,
|
|
raw: {},
|
|
},
|
|
});
|
|
|
|
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
|
await tx.auditLog.updateMany({
|
|
where: { targetType: 'player', targetId: String(playerId) },
|
|
data: { details: { redacted: true } },
|
|
});
|
|
|
|
await tx.player.delete({ where: { id: playerId } });
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'player.delete',
|
|
targetType: 'player',
|
|
targetId: playerId,
|
|
// Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen,
|
|
// nicht die gelöschten Daten konservieren.
|
|
details: {
|
|
removedCharacters: existing._count.characters,
|
|
removedLedgerRows: existing._count.ledger,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 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,
|
|
rawValue: string,
|
|
note: string | undefined,
|
|
ctx: ActionContext,
|
|
) {
|
|
const player = await prisma.player.findUnique({ where: { id: playerId } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
let value = rawValue.trim();
|
|
if (type === AliasType.STEAM_ID) {
|
|
const normalized = normalizeSteamId(value);
|
|
if (!normalized) {
|
|
throw conflict('INVALID_STEAMID', `"${value}" sieht nach keiner SteamID aus`);
|
|
}
|
|
value = normalized;
|
|
|
|
if (value === player.steamId) {
|
|
throw conflict('IS_PRIMARY', 'Das ist bereits die hinterlegte SteamID dieses Spielers');
|
|
}
|
|
} else if (value.toLowerCase() === player.forumName?.toLowerCase()) {
|
|
throw conflict('IS_PRIMARY', 'Das ist bereits der hinterlegte Forum-Name dieses Spielers');
|
|
}
|
|
|
|
if (value.length === 0) throw conflict('EMPTY', 'Der Wert darf nicht leer sein');
|
|
|
|
const vorhanden = await prisma.playerAlias.findUnique({
|
|
where: { type_value: { type, value } },
|
|
include: { player: { select: { displayName: true } } },
|
|
});
|
|
if (vorhanden) {
|
|
throw conflict(
|
|
'ALIAS_TAKEN',
|
|
vorhanden.playerId === playerId
|
|
? 'Dieses Konto ist hier schon eingetragen'
|
|
: `Dieses Konto gehört bereits zu "${vorhanden.player.displayName}"`,
|
|
);
|
|
}
|
|
|
|
const created = await prisma.playerAlias.create({
|
|
data: { playerId, type, value, note: note?.trim() || null },
|
|
});
|
|
|
|
await recordAudit(prisma, ctx, {
|
|
action: 'player.alias_added',
|
|
targetType: 'player',
|
|
targetId: playerId,
|
|
details: { type, value, note: note ?? null },
|
|
});
|
|
|
|
if (type === AliasType.FORUM_NAME) {
|
|
const { claimThreadsForPlayer } = await import('./forum.js');
|
|
await claimThreadsForPlayer(playerId, [value]);
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
export async function removeAlias(aliasId: number, ctx: ActionContext): Promise<void> {
|
|
const alias = await prisma.playerAlias.findUnique({ where: { id: aliasId } });
|
|
if (!alias) throw notFound('Eintrag');
|
|
|
|
await prisma.playerAlias.delete({ where: { id: aliasId } });
|
|
|
|
await recordAudit(prisma, ctx, {
|
|
action: 'player.alias_removed',
|
|
targetType: 'player',
|
|
targetId: alias.playerId,
|
|
details: { type: alias.type, value: alias.value },
|
|
});
|
|
}
|