Nacherfassung an alten Kettengliedern, Filter fuer die Spielerliste
Beim Durcharbeiten von Spieler A stellt sich heraus, dass er schon vor drei
Redesigns bei einem Gruppen-CC von Spieler B dabei war. Das ging bisher nicht:
"Einem inaktiven CC kann niemand beitreten" - richtig fuer den laufenden
Betrieb, falsch fuers Nacherfassen. Dort geht es nicht um jetzt, sondern um
eine Geschichte, die laengst gelaufen ist.
An einem stillgelegten CC wird deshalb nachgetragen statt beigetreten: mit
Zeitraum, und die Guthaben-Angabe ist freigestellt, weil die Buchung im Konto
stehen kann. Wer weiter unten in derselben Kette schon steht, kommt auch an
einem laufenden CC ohne neue Buchung dazu - sein Platz wurde einmal bezahlt,
und ein Redesign tauscht ihn aus. Fuer alle anderen bleibt die Angabe Pflicht.
Damit vier Kettenglieder nicht vier Klicks bedeuten, traegt ein Aufruf einen
Spieler ab einem Glied in die ganze weitere Kette ein; gebucht wird einmal.
Ein Test spielt den ganzen Fall durch: B fertig, A nachgetragen, C steigt in
der Mitte ein und wieder aus, D kommt erst am Ende dazu - und am Schluss sieht
jeder seine eigene Geschichte.
Leere Import-Vorschauen blieben in der Liste stehen, sobald alle Zeilen beim
Spieler gebucht waren. Bestaetigen liess sich so eine Huelle nicht ("nichts zu
buchen"), sie war nur im Weg. Jetzt gilt sie als erledigt - sie ist es ja.
Auch der Bestand wird beim naechsten Aufruf der Liste abgeraeumt.
Und die Spielerliste laesst sich eingrenzen: nur mit aktivem CC, nur mit
ausschliesslich stillgelegten, nur Beteiligte eines Gruppen-CCs, nur ohne
jeden CC, nur Problemfaelle. Sortiert wird nach Namen, nach der Zahl der
CC-Eintraege oder nach Anlage.
This commit is contained in:
@@ -126,6 +126,21 @@ hat, kann nicht über ein Redesign beitreten - dafür braucht es ein CC-Paket.
|
|||||||
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
|
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
|
||||||
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
|
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
|
||||||
|
|
||||||
|
### Nachtragen, was längst passiert ist
|
||||||
|
|
||||||
|
Beim Nacherfassen kommt heraus, dass ein Spieler schon vor drei Redesigns bei einem fremden
|
||||||
|
Gruppen-CC dabei war. Dafür gilt: **An einem stillgelegten CC wird nachgetragen, nicht
|
||||||
|
beigetreten.** Der Knopf heißt dort *Nutzer nachtragen*, und die Guthaben-Angabe ist
|
||||||
|
freigestellt - die Buchung kann längst im Konto stehen. Ein Zeitraum lässt sich mitgeben.
|
||||||
|
|
||||||
|
Wer weiter unten in derselben Kette schon steht, kommt auch an einem **laufenden** CC ohne neue
|
||||||
|
Buchung dazu: Sein Platz wurde einmal bezahlt, und ein Redesign tauscht ihn aus, statt einen
|
||||||
|
zweiten zu verlangen. Genau das tut ein Redesign im laufenden Betrieb auch - es nimmt die Gruppe
|
||||||
|
mit. Für alle anderen bleibt die Angabe Pflicht.
|
||||||
|
|
||||||
|
Damit das nicht vier Klicks für vier Kettenglieder bedeutet, trägt ein Aufruf den Spieler ab
|
||||||
|
einem Glied in die **ganze weitere Kette** ein. Gebucht wird dabei genau einmal, beim Einstieg.
|
||||||
|
|
||||||
### Einzelner Nutzer oder ganze Gruppe
|
### Einzelner Nutzer oder ganze Gruppe
|
||||||
|
|
||||||
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist:
|
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und die Knöpfe sagen, welche gemeint ist:
|
||||||
@@ -403,6 +418,16 @@ Der Lauf geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab
|
|||||||
was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht
|
was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht
|
||||||
hat.
|
hat.
|
||||||
|
|
||||||
|
## Die Spielerliste eingrenzen
|
||||||
|
|
||||||
|
Über der Tabelle stehen zwei Auswahlfelder. Eingegrenzt wird auf Spieler mit aktivem CC, auf
|
||||||
|
solche mit ausschließlich stillgelegten, auf die Beteiligten eines Gruppen-CCs, auf Spieler ganz
|
||||||
|
ohne CC oder auf die offenen Problemfälle. Sortiert wird nach Namen, nach der Zahl der
|
||||||
|
CC-Einträge oder nach dem Zeitpunkt der Anlage.
|
||||||
|
|
||||||
|
Maßgeblich ist überall die **Mitgliedschaft**, nicht wer den CC angelegt hat: Wer einem
|
||||||
|
Gruppen-CC angehört, zählt dazu, auch ohne ihn erstellt zu haben.
|
||||||
|
|
||||||
## Problemfälle
|
## Problemfälle
|
||||||
|
|
||||||
Nicht jeder Fall lässt sich beim Eintragen sauber klären: ein CC-Antrag, zu dem sich kein Paket
|
Nicht jeder Fall lässt sich beim Eintragen sauber klären: ein CC-Antrag, zu dem sich kein Paket
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
moveInChain,
|
moveInChain,
|
||||||
detachFromChain,
|
detachFromChain,
|
||||||
changeRedesignKind,
|
changeRedesignKind,
|
||||||
|
addMemberToChain,
|
||||||
} 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';
|
||||||
@@ -232,10 +233,16 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
playerId: z.number().int().positive(),
|
playerId: z.number().int().positive(),
|
||||||
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
/// Fehlt nur bei einer Nacherfassung an einem inaktiven CC.
|
||||||
|
useKind: z
|
||||||
|
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
|
||||||
|
.optional(),
|
||||||
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
|
/// Der eigene CC, den der Spieler dafür aufgibt. Pflicht, sobald der
|
||||||
/// Platz über ein Redesign bezahlt wird.
|
/// Platz über ein Redesign bezahlt wird.
|
||||||
predecessorId: z.number().int().positive().optional(),
|
predecessorId: z.number().int().positive().optional(),
|
||||||
|
/// Zeitraum beim Nachtragen.
|
||||||
|
joinedAt: z.coerce.date().optional(),
|
||||||
|
leftAt: z.coerce.date().optional(),
|
||||||
note: optionalNote,
|
note: optionalNote,
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
@@ -243,7 +250,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return addMember(
|
return addMember(
|
||||||
id,
|
id,
|
||||||
body.playerId,
|
body.playerId,
|
||||||
{ useKind: body.useKind, predecessorId: body.predecessorId, note: body.note },
|
{
|
||||||
|
useKind: body.useKind,
|
||||||
|
predecessorId: body.predecessorId,
|
||||||
|
joinedAt: body.joinedAt,
|
||||||
|
leftAt: body.leftAt,
|
||||||
|
note: body.note,
|
||||||
|
},
|
||||||
contextOf(request),
|
contextOf(request),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -351,6 +364,39 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trägt einen Spieler ab diesem Glied in die ganze weitere Kette ein -
|
||||||
|
* für die Nacherfassung, wenn jemand schon vor mehreren Redesigns dabei
|
||||||
|
* war. Gebucht wird einmal, beim Einstieg.
|
||||||
|
*/
|
||||||
|
app.post('/api/characters/:id/members/chain', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
playerId: z.number().int().positive(),
|
||||||
|
useKind: z
|
||||||
|
.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ])
|
||||||
|
.optional(),
|
||||||
|
predecessorId: z.number().int().positive().optional(),
|
||||||
|
joinedAt: z.coerce.date().optional(),
|
||||||
|
note: optionalNote,
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return addMemberToChain(
|
||||||
|
id,
|
||||||
|
body.playerId,
|
||||||
|
{
|
||||||
|
useKind: body.useKind,
|
||||||
|
predecessorId: body.predecessorId,
|
||||||
|
joinedAt: body.joinedAt,
|
||||||
|
note: body.note,
|
||||||
|
},
|
||||||
|
contextOf(request),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
|
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
|
||||||
const params = parseWith(
|
const params = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -32,11 +32,18 @@ export async function playerRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
q: z.string().trim().max(120).default(''),
|
q: z.string().trim().max(120).default(''),
|
||||||
take: z.coerce.number().int().min(1).max(200).default(50),
|
take: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
skip: z.coerce.number().int().min(0).default(0),
|
skip: z.coerce.number().int().min(0).default(0),
|
||||||
problems: z.enum(['true', 'false']).default('false'),
|
filter: z
|
||||||
|
.enum(['all', 'problems', 'group', 'active', 'onlyInactive', 'none'])
|
||||||
|
.default('all'),
|
||||||
|
sort: z.enum(['name', 'characters', 'newest']).default('name'),
|
||||||
|
/// Alte Schreibweise, damit gespeicherte Adressen weiter greifen.
|
||||||
|
problems: z.enum(['true', 'false']).optional(),
|
||||||
}),
|
}),
|
||||||
request.query,
|
request.query,
|
||||||
);
|
);
|
||||||
return searchPlayers(query.q, query.take, query.skip, query.problems === 'true');
|
|
||||||
|
const filter = query.problems === 'true' ? 'problems' : query.filter;
|
||||||
|
return searchPlayers(query.q, query.take, query.skip, filter, query.sort);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => {
|
||||||
|
|||||||
@@ -1106,11 +1106,11 @@ const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.RE
|
|||||||
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
|
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
|
||||||
*/
|
*/
|
||||||
async function ladeAufgegebenenCC(
|
async function ladeAufgegebenenCC(
|
||||||
input: { useKind: JoinKind; predecessorId?: number | undefined },
|
input: { useKind?: JoinKind | undefined; predecessorId?: number | undefined },
|
||||||
playerId: number,
|
playerId: number,
|
||||||
characterId: number,
|
characterId: number,
|
||||||
): Promise<Character | null> {
|
): Promise<Character | null> {
|
||||||
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
|
if (input.useKind === undefined || !JOIN_REDESIGN_KINDS.includes(input.useKind)) {
|
||||||
if (input.predecessorId !== undefined) {
|
if (input.predecessorId !== undefined) {
|
||||||
throw badRequest(
|
throw badRequest(
|
||||||
'NO_PREDECESSOR_NEEDED',
|
'NO_PREDECESSOR_NEEDED',
|
||||||
@@ -1149,6 +1149,27 @@ async function ladeAufgegebenenCC(
|
|||||||
return vorgaenger;
|
return vorgaenger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob der Spieler in einem früheren Glied derselben Kette geführt wird.
|
||||||
|
*
|
||||||
|
* Für die Nacherfassung entscheidend: Wer bei einem CC dabei war, der später
|
||||||
|
* redesignt wurde, ist beim Nachfolger dabei, ohne ein zweites Mal zu zahlen.
|
||||||
|
* Sein Platz wurde einmal gebucht und ist derselbe geblieben.
|
||||||
|
*/
|
||||||
|
async function warSchonInDerKette(character: Character, playerId: number): Promise<boolean> {
|
||||||
|
const treffer = await prisma.characterMember.findFirst({
|
||||||
|
where: {
|
||||||
|
playerId,
|
||||||
|
character: {
|
||||||
|
lineageId: character.lineageId,
|
||||||
|
chainPosition: { lt: character.chainPosition },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return treffer !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
|
* 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
|
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
|
||||||
@@ -1157,17 +1178,42 @@ async function ladeAufgegebenenCC(
|
|||||||
export async function addMember(
|
export async function addMember(
|
||||||
characterId: number,
|
characterId: number,
|
||||||
playerId: number,
|
playerId: number,
|
||||||
input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
|
input: {
|
||||||
|
/// Fehlt bei einer reinen Nacherfassung an einem inaktiven CC: dort wird
|
||||||
|
/// eingetragen, was war, und die Buchung steht längst im Konto.
|
||||||
|
useKind?: JoinKind | undefined;
|
||||||
|
predecessorId?: number | undefined;
|
||||||
|
note?: string | undefined;
|
||||||
|
/// Zeitraum, wenn nachgetragen wird statt jetzt beizutreten.
|
||||||
|
joinedAt?: Date | undefined;
|
||||||
|
leftAt?: Date | undefined;
|
||||||
|
},
|
||||||
ctx: ActionContext,
|
ctx: ActionContext,
|
||||||
): Promise<CharacterResult & { memberCount: number }> {
|
): Promise<CharacterResult & { memberCount: number }> {
|
||||||
const character = await loadCharacter(characterId);
|
const character = await loadCharacter(characterId);
|
||||||
|
const historisch = character.status !== CharacterStatus.ACTIVE;
|
||||||
|
|
||||||
if (character.status !== CharacterStatus.ACTIVE) {
|
// Wer weiter unten in derselben Kette schon dabei war, setzt hier nur
|
||||||
throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten');
|
// fort: sein Platz wurde damals bezahlt, und ein Redesign tauscht einen
|
||||||
|
// Platz aus, statt einen zweiten zu verlangen. Genau das tut ein Redesign
|
||||||
|
// im laufenden Betrieb auch - es nimmt die Gruppe mit.
|
||||||
|
const schonInDerKette = await warSchonInDerKette(character, playerId);
|
||||||
|
|
||||||
|
// Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem
|
||||||
|
// inaktiven wird nachgetragen, was einmal war - dort ist die Angabe
|
||||||
|
// freigestellt, weil die Buchung längst im Konto stehen kann.
|
||||||
|
if (!historisch && !schonInDerKette && input.useKind === undefined) {
|
||||||
|
throw badRequest(
|
||||||
|
'NEED_KIND',
|
||||||
|
'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!JOIN_KINDS.includes(input.useKind)) {
|
if (input.useKind !== undefined && !JOIN_KINDS.includes(input.useKind)) {
|
||||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
||||||
}
|
}
|
||||||
|
if (input.leftAt && input.joinedAt && input.leftAt < input.joinedAt) {
|
||||||
|
throw badRequest('BAD_RANGE', 'Das Ende liegt vor dem Anfang');
|
||||||
|
}
|
||||||
|
|
||||||
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||||
if (!player) throw notFound('Spieler');
|
if (!player) throw notFound('Spieler');
|
||||||
@@ -1204,9 +1250,9 @@ export async function addMember(
|
|||||||
await tx.characterMember.update({
|
await tx.characterMember.update({
|
||||||
where: { id: vorhanden.id },
|
where: { id: vorhanden.id },
|
||||||
data: {
|
data: {
|
||||||
leftAt: null,
|
leftAt: input.leftAt ?? null,
|
||||||
joinedAt: new Date(),
|
joinedAt: input.joinedAt ?? new Date(),
|
||||||
paidWith: input.useKind,
|
paidWith: input.useKind ?? null,
|
||||||
predecessorId: vorgaenger?.id ?? null,
|
predecessorId: vorgaenger?.id ?? null,
|
||||||
note: input.note ?? null,
|
note: input.note ?? null,
|
||||||
createdById: ctx.actorId,
|
createdById: ctx.actorId,
|
||||||
@@ -1217,14 +1263,17 @@ export async function addMember(
|
|||||||
data: {
|
data: {
|
||||||
characterId,
|
characterId,
|
||||||
playerId,
|
playerId,
|
||||||
paidWith: input.useKind,
|
paidWith: input.useKind ?? null,
|
||||||
predecessorId: vorgaenger?.id ?? null,
|
predecessorId: vorgaenger?.id ?? null,
|
||||||
note: input.note ?? null,
|
note: input.note ?? null,
|
||||||
createdById: ctx.actorId,
|
createdById: ctx.actorId,
|
||||||
|
...(input.joinedAt ? { joinedAt: input.joinedAt } : {}),
|
||||||
|
...(input.leftAt ? { leftAt: input.leftAt } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.useKind !== undefined) {
|
||||||
await tx.creditLedger.create({
|
await tx.creditLedger.create({
|
||||||
data: {
|
data: {
|
||||||
playerId,
|
playerId,
|
||||||
@@ -1239,8 +1288,10 @@ export async function addMember(
|
|||||||
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
|
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
|
||||||
: `Beitritt zu "${character.name}"`),
|
: `Beitritt zu "${character.name}"`),
|
||||||
createdById: ctx.actorId,
|
createdById: ctx.actorId,
|
||||||
|
...(input.joinedAt ? { occurredAt: input.joinedAt } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
|
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
|
||||||
|
|
||||||
@@ -1251,7 +1302,8 @@ export async function addMember(
|
|||||||
details: {
|
details: {
|
||||||
playerId,
|
playerId,
|
||||||
name: character.name,
|
name: character.name,
|
||||||
useKind: input.useKind,
|
useKind: input.useKind ?? null,
|
||||||
|
nachgetragen: historisch,
|
||||||
predecessorId: vorgaenger?.id ?? null,
|
predecessorId: vorgaenger?.id ?? null,
|
||||||
predecessorName: vorgaenger?.name ?? null,
|
predecessorName: vorgaenger?.name ?? null,
|
||||||
},
|
},
|
||||||
@@ -1543,6 +1595,74 @@ export async function reactivateMember(
|
|||||||
return { memberCount };
|
return { memberCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trägt einen Spieler ab einem Kettenglied in die gesamte weitere Kette ein.
|
||||||
|
*
|
||||||
|
* Für die Nacherfassung: Stellt sich heraus, dass jemand schon beim dritten
|
||||||
|
* von sechs Gliedern dabei war, wäre es Unfug, ihn viermal einzeln
|
||||||
|
* einzutragen. Gebucht wird genau einmal - beim Einstieg -, denn ein
|
||||||
|
* Redesign tauscht seinen Platz aus, statt einen weiteren zu verlangen.
|
||||||
|
*
|
||||||
|
* Wer schon irgendwo in der Kette steht, wird dort nicht angerührt.
|
||||||
|
*/
|
||||||
|
export async function addMemberToChain(
|
||||||
|
characterId: number,
|
||||||
|
playerId: number,
|
||||||
|
input: {
|
||||||
|
useKind?: JoinKind | undefined;
|
||||||
|
predecessorId?: number | undefined;
|
||||||
|
note?: string | undefined;
|
||||||
|
joinedAt?: Date | undefined;
|
||||||
|
},
|
||||||
|
ctx: ActionContext,
|
||||||
|
): Promise<{ added: number; memberCount: number }> {
|
||||||
|
const einstieg = await loadCharacter(characterId);
|
||||||
|
|
||||||
|
const kette = await prisma.character.findMany({
|
||||||
|
where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition } },
|
||||||
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const vorhandene = new Set(
|
||||||
|
(
|
||||||
|
await prisma.characterMember.findMany({
|
||||||
|
where: { playerId, characterId: { in: kette.map((c) => c.id) } },
|
||||||
|
select: { characterId: true },
|
||||||
|
})
|
||||||
|
).map((m) => m.characterId),
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const [index, glied] of kette.entries()) {
|
||||||
|
if (vorhandene.has(glied.id)) continue;
|
||||||
|
|
||||||
|
// Nur der Einstieg kostet. Alles Weitere ist dieselbe Mitgliedschaft,
|
||||||
|
// durch die Kette fortgeschrieben.
|
||||||
|
await addMember(
|
||||||
|
glied.id,
|
||||||
|
playerId,
|
||||||
|
{
|
||||||
|
...(index === 0 ? { useKind: input.useKind } : {}),
|
||||||
|
...(index === 0 && input.predecessorId !== undefined
|
||||||
|
? { predecessorId: input.predecessorId }
|
||||||
|
: {}),
|
||||||
|
...(input.note !== undefined ? { note: input.note } : {}),
|
||||||
|
...(input.joinedAt !== undefined ? { joinedAt: input.joinedAt } : {}),
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const letztes = kette[kette.length - 1];
|
||||||
|
const memberCount = letztes
|
||||||
|
? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null } })
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { added, memberCount };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
|
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
|
||||||
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
|
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
|
|||||||
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
|
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const ergebnis = await prisma.$transaction(async (tx) => {
|
||||||
let booked = 0;
|
let booked = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const erledigt: number[] = [];
|
const erledigt: number[] = [];
|
||||||
@@ -677,6 +677,10 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
|
|||||||
|
|
||||||
return { booked, skipped };
|
return { booked, skipped };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bleibt von einer Vorschau nichts übrig, ist sie erledigt.
|
||||||
|
await schliesseLeere();
|
||||||
|
return ergebnis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -704,6 +708,7 @@ export async function dismissRows(rowIds: number[], ctx: ActionContext) {
|
|||||||
details: { count: result.count, rowIds },
|
details: { count: result.count, rowIds },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await schliesseLeere();
|
||||||
return { dismissed: result.count };
|
return { dismissed: result.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +735,33 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst Importe ab, an denen nichts mehr zu tun ist.
|
||||||
|
*
|
||||||
|
* Wer die Zeilen eines Spielers direkt auf dessen Seite bucht, lässt eine
|
||||||
|
* leere Vorschau zurück. Sie stünde weiter in der Liste, liesse sich nicht
|
||||||
|
* bestätigen ("nichts zu buchen") und wäre nur im Weg. Also gilt sie als
|
||||||
|
* erledigt - sie ist es ja.
|
||||||
|
*/
|
||||||
|
async function schliesseLeere(): Promise<number> {
|
||||||
|
const leer = await prisma.import.findMany({
|
||||||
|
where: {
|
||||||
|
status: ImportStatus.PENDING,
|
||||||
|
rows: { none: { plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] } } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (leer.length === 0) return 0;
|
||||||
|
|
||||||
|
const result = await prisma.import.updateMany({
|
||||||
|
where: { id: { in: leer.map((i) => i.id) } },
|
||||||
|
data: { status: ImportStatus.COMMITTED, committedAt: new Date() },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listImports(take = 50) {
|
export async function listImports(take = 50) {
|
||||||
|
await schliesseLeere();
|
||||||
return prisma.import.findMany({
|
return prisma.import.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take,
|
take,
|
||||||
|
|||||||
@@ -17,19 +17,68 @@ export interface PlayerInput {
|
|||||||
problemNote?: string | null;
|
problemNote?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Womit die Liste eingegrenzt wird.
|
||||||
|
export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none';
|
||||||
|
/// Wonach sie sortiert wird.
|
||||||
|
export type PlayerSort = 'name' | 'characters' | 'newest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spieler, die an einem CC mit mehr als einem Nutzer beteiligt sind.
|
||||||
|
*
|
||||||
|
* Das lässt sich nicht in einer Bedingung ausdrücken, weil dabei ein CC mit
|
||||||
|
* sich selbst verglichen würde: erst stehen die Gruppen-CCs fest, dann ihre
|
||||||
|
* Nutzer.
|
||||||
|
*/
|
||||||
|
async function spielerMitGruppenCC(): Promise<number[]> {
|
||||||
|
const gruppen = await prisma.characterMember.groupBy({
|
||||||
|
by: ['characterId'],
|
||||||
|
where: { leftAt: null },
|
||||||
|
having: { characterId: { _count: { gt: 1 } } },
|
||||||
|
});
|
||||||
|
if (gruppen.length === 0) return [];
|
||||||
|
|
||||||
|
const beteiligte = await prisma.characterMember.findMany({
|
||||||
|
where: { characterId: { in: gruppen.map((g) => g.characterId) }, leftAt: null },
|
||||||
|
select: { playerId: true },
|
||||||
|
});
|
||||||
|
return [...new Set(beteiligte.map((b) => b.playerId))];
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchPlayers(
|
export async function searchPlayers(
|
||||||
query: string,
|
query: string,
|
||||||
take = 50,
|
take = 50,
|
||||||
skip = 0,
|
skip = 0,
|
||||||
onlyProblems = false,
|
filter: PlayerFilter = 'all',
|
||||||
|
sort: PlayerSort = 'name',
|
||||||
) {
|
) {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
|
// Wer STEAM_0:1:... eintippt, soll den Spieler finden, auch wenn intern die
|
||||||
// 64-Bit-Form gespeichert ist.
|
// 64-Bit-Form gespeichert ist.
|
||||||
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
|
const asSteamId = looksLikeSteamId(trimmed) ? normalizeSteamId(trimmed) : null;
|
||||||
|
|
||||||
|
// Massgeblich ist die Mitgliedschaft, nicht wer den CC angelegt hat: wer
|
||||||
|
// einem Gruppen-CC angehört, hat dort einen Platz, auch ohne ihn erstellt
|
||||||
|
// zu haben.
|
||||||
|
const eingrenzung: Prisma.PlayerWhereInput =
|
||||||
|
filter === 'problems'
|
||||||
|
? { isProblem: true }
|
||||||
|
: filter === 'group'
|
||||||
|
? { id: { in: await spielerMitGruppenCC() } }
|
||||||
|
: filter === 'active'
|
||||||
|
? { memberships: { some: { leftAt: null, character: { status: 'ACTIVE' } } } }
|
||||||
|
: filter === 'onlyInactive'
|
||||||
|
? {
|
||||||
|
AND: [
|
||||||
|
{ memberships: { some: {} } },
|
||||||
|
{ memberships: { none: { leftAt: null, character: { status: 'ACTIVE' } } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: filter === 'none'
|
||||||
|
? { memberships: { none: {} } }
|
||||||
|
: {};
|
||||||
|
|
||||||
const where: Prisma.PlayerWhereInput = {
|
const where: Prisma.PlayerWhereInput = {
|
||||||
...(onlyProblems ? { isProblem: true } : {}),
|
...eingrenzung,
|
||||||
...(trimmed.length === 0
|
...(trimmed.length === 0
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
@@ -41,10 +90,17 @@ export async function searchPlayers(
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const orderBy: Prisma.PlayerOrderByWithRelationInput[] =
|
||||||
|
sort === 'characters'
|
||||||
|
? [{ characters: { _count: 'desc' } }, { displayName: 'asc' }]
|
||||||
|
: sort === 'newest'
|
||||||
|
? [{ createdAt: 'desc' }]
|
||||||
|
: [{ displayName: 'asc' }];
|
||||||
|
|
||||||
const [players, total] = await Promise.all([
|
const [players, total] = await Promise.all([
|
||||||
prisma.player.findMany({
|
prisma.player.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { displayName: 'asc' },
|
orderBy,
|
||||||
take,
|
take,
|
||||||
skip,
|
skip,
|
||||||
include: { _count: { select: { characters: true } } },
|
include: { _count: { select: { characters: true } } },
|
||||||
|
|||||||
@@ -277,14 +277,14 @@ describe('Problemfälle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('listet sie auf Wunsch für sich', async () => {
|
it('listet sie auf Wunsch für sich', async () => {
|
||||||
const offen = await players.searchPlayers('', 100, 0, true);
|
const offen = await players.searchPlayers('', 100, 0, 'problems');
|
||||||
assert.ok(offen.total >= 3);
|
assert.ok(offen.total >= 3);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
offen.items.every((p) => p.isProblem),
|
offen.items.every((p) => p.isProblem),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const alle = await players.searchPlayers('', 100, 0, false);
|
const alle = await players.searchPlayers('', 100, 0, 'all');
|
||||||
assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei');
|
assert.ok(alle.total > offen.total, 'ohne Filter sind auch die unauffälligen dabei');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -415,3 +415,73 @@ describe('Ersteller ohne Spieler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Spielerliste eingrenzen und sortieren', () => {
|
||||||
|
before(async () => {
|
||||||
|
// Ein Gruppen-CC, ein stillgelegter CC und ein Spieler ohne alles.
|
||||||
|
const wirt = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Wirt', steamId: '76561198000040001' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const gast = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Gast', steamId: '76561198000040002' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
for (const id of [wirt.id, gast.id]) {
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gruppe = await characters.createCharacter(wirt.id, { name: 'Filter-Runde' }, ctx);
|
||||||
|
await characters.addMember(gruppe.character.id, gast.id, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
const einsam = await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Einsam', steamId: '76561198000040003' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
await db.prisma.creditLedger.create({
|
||||||
|
data: { playerId: einsam.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
|
||||||
|
});
|
||||||
|
const stillgelegt = await characters.createCharacter(einsam.id, { name: 'Ruht' }, ctx);
|
||||||
|
await characters.deactivateCharacter(stillgelegt.character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
|
await players.createPlayer(
|
||||||
|
{ displayName: 'Filter-Leer', steamId: '76561198000040004' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const namen = async (filter: Parameters<typeof players.searchPlayers>[3]) =>
|
||||||
|
(await players.searchPlayers('Filter-', 100, 0, filter)).items.map((p) => p.displayName).sort();
|
||||||
|
|
||||||
|
it('findet die Beteiligten eines Gruppen-CCs', async () => {
|
||||||
|
assert.deepEqual(await namen('group'), ['Filter-Gast', 'Filter-Wirt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer einen aktiven CC hat', async () => {
|
||||||
|
assert.deepEqual(await namen('active'), ['Filter-Gast', 'Filter-Wirt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer nur noch stillgelegte CCs hat', async () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
await namen('onlyInactive'),
|
||||||
|
['Filter-Einsam'],
|
||||||
|
'wer gar keinen CC hat, gehört nicht dazu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findet, wer gar keinen CC hat', async () => {
|
||||||
|
assert.deepEqual(await namen('none'), ['Filter-Leer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert nach der Zahl der CC-Einträge', async () => {
|
||||||
|
const liste = await players.searchPlayers('Filter-', 100, 0, 'all', 'characters');
|
||||||
|
const zahlen = liste.items.map((p) => p.characterCount);
|
||||||
|
assert.deepEqual(
|
||||||
|
[...zahlen].sort((a, b) => b - a),
|
||||||
|
zahlen,
|
||||||
|
'die längste Liste steht oben',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -258,16 +258,33 @@ describe('Gruppen-CCs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lässt niemanden einem inaktiven CC beitreten', async () => {
|
it('lässt an einem inaktiven CC nachtragen, was damals war', async () => {
|
||||||
|
// Beitreten kann man einem stillgelegten CC nicht - eintragen, wer
|
||||||
|
// damals dabei war, dagegen schon. Beim Nacherfassen geht es nicht um
|
||||||
|
// jetzt, sondern um eine Geschichte, die längst gelaufen ist.
|
||||||
const gruender = await spielerMitPaket('Inaktiv-Chef');
|
const gruender = await spielerMitPaket('Inaktiv-Chef');
|
||||||
const spaet = await spielerMitPaket('Zu spät');
|
const damals = await spielerMitPaket('War damals dabei');
|
||||||
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
|
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
|
||||||
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
|
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
|
||||||
|
|
||||||
await assert.rejects(
|
await characters.addMember(
|
||||||
() => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx),
|
character.id,
|
||||||
(error: Error & { code?: string }) => error.code === 'NOT_ACTIVE',
|
damals,
|
||||||
|
{ useKind: 'CC', joinedAt: new Date('2023-05-01'), leftAt: new Date('2023-09-01') },
|
||||||
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const eintrag = await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { characterId: character.id, playerId: damals },
|
||||||
|
});
|
||||||
|
assert.equal(eintrag.joinedAt.toISOString().slice(0, 10), '2023-05-01');
|
||||||
|
assert.ok(eintrag.leftAt, 'mit Ende, denn vorbei ist es');
|
||||||
|
|
||||||
|
// Ein stillgelegter CC belegt keinen Platz - auch nicht rückwirkend.
|
||||||
|
const belegt = (await characters.listCharacters(damals)).filter(
|
||||||
|
(c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === damals && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(belegt, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,56 @@ describe('Einzelne Zeilen beim Spieler buchen', () => {
|
|||||||
await imports.discardImport(zweiterImport, ctx);
|
await imports.discardImport(zweiterImport, ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lässt keine leere Vorschau in der Liste zurück', async () => {
|
||||||
|
// Genau der Fall aus dem Betrieb: alle Zeilen beim Spieler gebucht, die
|
||||||
|
// Vorschau bleibt als leere Hülle stehen und lässt sich nicht bestätigen.
|
||||||
|
const importId = await vorschau('LEER');
|
||||||
|
|
||||||
|
const offen = [
|
||||||
|
...(await imports.listPendingRowsFor(eule)),
|
||||||
|
...(await imports.listPendingRowsFor(specht)),
|
||||||
|
].filter((row) => row.importId === importId);
|
||||||
|
assert.equal(offen.length, 3, 'alle drei Zeilen dieses Imports');
|
||||||
|
|
||||||
|
await imports.bookRows(offen.map((row) => row.id), ctx);
|
||||||
|
|
||||||
|
const danach = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
|
||||||
|
assert.equal(danach.status, 'COMMITTED', 'nichts mehr zu tun heisst erledigt');
|
||||||
|
|
||||||
|
const liste = await imports.listImports();
|
||||||
|
assert.equal(
|
||||||
|
liste.some((i) => i.id === importId && i.status === 'PENDING'),
|
||||||
|
false,
|
||||||
|
'und damit aus der Liste offener Importe verschwunden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('räumt auch Vorschauen ab, die schon leer herumlagen', async () => {
|
||||||
|
const importId = await vorschau('ALTLAST');
|
||||||
|
const offen = [
|
||||||
|
...(await imports.listPendingRowsFor(eule)),
|
||||||
|
...(await imports.listPendingRowsFor(specht)),
|
||||||
|
].filter((row) => row.importId === importId);
|
||||||
|
|
||||||
|
// Von Hand leergeräumt, ohne den Import anzufassen - so sieht der
|
||||||
|
// Bestand aus, der vor dieser Korrektur entstanden ist.
|
||||||
|
await db.prisma.importRow.updateMany({
|
||||||
|
where: { id: { in: offen.map((row) => row.id) } },
|
||||||
|
data: { plan: 'SKIP_DUPLICATE' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
||||||
|
'PENDING',
|
||||||
|
);
|
||||||
|
|
||||||
|
await imports.listImports();
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(await db.prisma.import.findUniqueOrThrow({ where: { id: importId } })).status,
|
||||||
|
'COMMITTED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('weist eine leere Auswahl ab', async () => {
|
it('weist eine leere Auswahl ab', async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => imports.bookRows([], ctx),
|
() => imports.bookRows([], ctx),
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
|
type Credits = typeof import('../src/services/credits.js');
|
||||||
|
type Db = typeof import('../src/db.js');
|
||||||
|
|
||||||
|
let database: TestDatabase;
|
||||||
|
let characters: Characters;
|
||||||
|
let credits: Credits;
|
||||||
|
let db: Db;
|
||||||
|
let ctx: { actorId: number; actorName: string };
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
database = await startTestDatabase(54341);
|
||||||
|
process.env.DATABASE_URL = database.url;
|
||||||
|
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
db = await import('../src/db.js');
|
||||||
|
characters = await import('../src/services/characters.js');
|
||||||
|
credits = await import('../src/services/credits.js');
|
||||||
|
|
||||||
|
const user = await db.prisma.user.create({
|
||||||
|
data: { username: 'nacherfasser', passwordHash: 'x', role: 'ADMIN' },
|
||||||
|
});
|
||||||
|
ctx = { actorId: user.id, actorName: user.username };
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await database.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function spieler(name: string, pakete = 2) {
|
||||||
|
const p = await db.prisma.player.create({ data: { displayName: name } });
|
||||||
|
for (let i = 0; i < pakete; i += 1) await credits.bookCcPackage(p.id, ctx);
|
||||||
|
return p.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die aktiven Mitglieder eines CCs, nach Namen. */
|
||||||
|
async function mitglieder(characterId: number): Promise<string[]> {
|
||||||
|
const liste = await db.prisma.characterMember.findMany({
|
||||||
|
where: { characterId, leftAt: null },
|
||||||
|
include: { player: { select: { displayName: true } } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return liste.map((m) => m.player.displayName).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Fall aus dem Betrieb: Spieler B ist längst eingetragen, und erst beim
|
||||||
|
* Durcharbeiten von Spieler A stellt sich heraus, dass die beiden einen
|
||||||
|
* Gruppen-CC teilten - über mehrere Redesigns hinweg, mit wechselnder
|
||||||
|
* Besetzung.
|
||||||
|
*/
|
||||||
|
describe('Nachträglich aufräumen, was längst passiert ist', () => {
|
||||||
|
let b = 0;
|
||||||
|
let a = 0;
|
||||||
|
let c = 0;
|
||||||
|
let d = 0;
|
||||||
|
let alpha = 0;
|
||||||
|
let beta = 0;
|
||||||
|
let gamma = 0;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
b = await spieler('Spieler B');
|
||||||
|
a = await spieler('Spieler A');
|
||||||
|
c = await spieler('Spieler C');
|
||||||
|
d = await spieler('Spieler D');
|
||||||
|
|
||||||
|
// B wurde zuerst durchgearbeitet: ein CC, zweimal redesignt.
|
||||||
|
const ersterCC = await characters.createCharacter(b, { name: 'Alpha' }, ctx);
|
||||||
|
alpha = ersterCC.character.id;
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(b, ctx);
|
||||||
|
const zweiter = await characters.redesignCharacter(
|
||||||
|
alpha,
|
||||||
|
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
beta = zweiter.character.id;
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(b, ctx);
|
||||||
|
const dritter = await characters.redesignCharacter(
|
||||||
|
beta,
|
||||||
|
{ name: 'Gamma', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
gamma = dritter.character.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trägt A rückwirkend in ein längst redesigntes Glied ein', async () => {
|
||||||
|
// Alpha ist inaktiv - im laufenden Betrieb tritt dort niemand mehr bei.
|
||||||
|
// Beim Nachtragen geht es aber nicht um jetzt, sondern um damals.
|
||||||
|
const alt = await db.prisma.character.findUniqueOrThrow({ where: { id: alpha } });
|
||||||
|
assert.equal(alt.status, 'INACTIVE');
|
||||||
|
|
||||||
|
await characters.addMember(
|
||||||
|
alpha,
|
||||||
|
a,
|
||||||
|
{ useKind: 'CC', joinedAt: new Date('2024-03-01') },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(await mitglieder(alpha), ['Spieler A', 'Spieler B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht ihn durch die Kette, ohne ein zweites Mal zu buchen', async () => {
|
||||||
|
const vorher = await credits.getBalances(a);
|
||||||
|
|
||||||
|
const ergebnis = await characters.addMemberToChain(beta, a, {}, ctx);
|
||||||
|
assert.equal(ergebnis.added, 2, 'Beta und Gamma kommen dazu');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await credits.getBalances(a),
|
||||||
|
vorher,
|
||||||
|
'sein Platz wurde einmal bezahlt und ist derselbe geblieben',
|
||||||
|
);
|
||||||
|
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('belegt dabei genau einen Platz', async () => {
|
||||||
|
const belegt = (await characters.listCharacters(a)).filter(
|
||||||
|
(cc) => cc.status === 'ACTIVE' && cc.members.some((m) => m.playerId === a && !m.leftAt),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
belegt.map((cc) => cc.name),
|
||||||
|
['Gamma'],
|
||||||
|
'die früheren Glieder sind inaktiv und belegen nichts',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt C mittendrin ein- und wieder aussteigen', async () => {
|
||||||
|
// C war nur bei Beta dabei - davor und danach nicht.
|
||||||
|
await characters.addMember(beta, c, { useKind: 'CC', joinedAt: new Date('2024-06-01') }, ctx);
|
||||||
|
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B', 'Spieler C']);
|
||||||
|
|
||||||
|
await characters.removeMember(beta, c, 'Ausgestiegen vor dem nächsten Redesign', ctx);
|
||||||
|
assert.deepEqual(await mitglieder(beta), ['Spieler A', 'Spieler B']);
|
||||||
|
|
||||||
|
// Bei Gamma steht er gar nicht erst.
|
||||||
|
assert.equal((await mitglieder(gamma)).includes('Spieler C'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt D erst beim letzten Glied auf', async () => {
|
||||||
|
await characters.addMember(gamma, d, { useKind: 'CC' }, ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(await mitglieder(gamma), ['Spieler A', 'Spieler B', 'Spieler D']);
|
||||||
|
assert.equal((await mitglieder(alpha)).includes('Spieler D'), false, 'damals war er nicht da');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt jedem Beteiligten seine eigene Geschichte', async () => {
|
||||||
|
const seins = async (playerId: number) =>
|
||||||
|
(await characters.listCharacters(playerId)).map((cc) => cc.name).sort();
|
||||||
|
|
||||||
|
assert.deepEqual(await seins(a), ['Alpha', 'Beta', 'Gamma'], 'A war von Anfang an dabei');
|
||||||
|
assert.deepEqual(await seins(b), ['Alpha', 'Beta', 'Gamma']);
|
||||||
|
assert.deepEqual(await seins(c), ['Beta'], 'C nur in der Mitte');
|
||||||
|
assert.deepEqual(await seins(d), ['Gamma'], 'D nur am Ende');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt an einem laufenden CC weiterhin eine Guthaben-Angabe', async () => {
|
||||||
|
const fremder = await spieler('Fremder', 1);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => characters.addMember(gamma, fremder, {}, ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
|
||||||
|
'wer neu dazukommt, zahlt - die Ausnahme gilt nur der eigenen Kette',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen verdrehten Zeitraum ab', async () => {
|
||||||
|
const spaeter = await spieler('Verdreht', 1);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
characters.addMember(
|
||||||
|
alpha,
|
||||||
|
spaeter,
|
||||||
|
{ joinedAt: new Date('2025-01-01'), leftAt: new Date('2024-01-01') },
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'BAD_RANGE',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -471,7 +471,20 @@ function ChainEntry({
|
|||||||
Deaktivieren
|
Deaktivieren
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : art === 'admin' ? (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* An einem längst ersetzten CC geht es nicht ums Beitreten,
|
||||||
|
sondern ums Nachtragen: wer damals dabei war. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny"
|
||||||
|
title="Wer damals dabei war, lässt sich hier nachtragen"
|
||||||
|
onClick={() => onAction('addMember', character)}
|
||||||
|
>
|
||||||
|
Nutzer nachtragen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{art === 'admin' ? (
|
||||||
admin ? (
|
admin ? (
|
||||||
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reviveDead', character)}>
|
<button type="button" className="btn btn-tiny btn-primary" onClick={() => onAction('reviveDead', character)}>
|
||||||
Zurückholen
|
Zurückholen
|
||||||
@@ -488,6 +501,8 @@ function ChainEntry({
|
|||||||
Bezahlt reaktivieren
|
Bezahlt reaktivieren
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
|
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
|
|||||||
@@ -399,3 +399,8 @@ export interface BackupReport {
|
|||||||
uebersprungen: Record<string, number>;
|
uebersprungen: Record<string, number>;
|
||||||
fehler: { zeile: number; grund: string }[];
|
fehler: { zeile: number; grund: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Womit die Spielerliste eingegrenzt wird.
|
||||||
|
export type PlayerFilter = 'all' | 'problems' | 'group' | 'active' | 'onlyInactive' | 'none';
|
||||||
|
/// Wonach sie sortiert wird.
|
||||||
|
export type PlayerSort = 'name' | 'characters' | 'newest';
|
||||||
|
|||||||
+49
-19
@@ -9,6 +9,8 @@ import { useScrape } from '../lib/useScrape';
|
|||||||
import { useSession } from '../lib/session';
|
import { useSession } from '../lib/session';
|
||||||
import type {
|
import type {
|
||||||
PendingOverview,
|
PendingOverview,
|
||||||
|
PlayerFilter,
|
||||||
|
PlayerSort,
|
||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
UnmatchedOverview,
|
UnmatchedOverview,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
@@ -44,14 +46,24 @@ export default function Dashboard() {
|
|||||||
players: [],
|
players: [],
|
||||||
});
|
});
|
||||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
/// Nur die Fälle zeigen, bei denen noch etwas offen ist.
|
const [filter, setFilter] = useState<PlayerFilter>('all');
|
||||||
const [nurProbleme, setNurProbleme] = useState(false);
|
const [sort, setSort] = useState<PlayerSort>('name');
|
||||||
|
|
||||||
const load = useCallback(async (search: string, probleme: boolean, signal?: AbortSignal) => {
|
const load = useCallback(
|
||||||
|
async (
|
||||||
|
search: string,
|
||||||
|
eingrenzung: PlayerFilter,
|
||||||
|
sortierung: PlayerSort,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ q: search, take: '100' });
|
const params = new URLSearchParams({
|
||||||
if (probleme) params.set('problems', 'true');
|
q: search,
|
||||||
|
take: '100',
|
||||||
|
filter: eingrenzung,
|
||||||
|
sort: sortierung,
|
||||||
|
});
|
||||||
const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {});
|
const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {});
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -61,7 +73,9 @@ export default function Dashboard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const loadFlagged = useCallback(async () => {
|
const loadFlagged = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,8 +95,8 @@ export default function Dashboard() {
|
|||||||
}, [loadFlagged]);
|
}, [loadFlagged]);
|
||||||
|
|
||||||
const nachlesen = useCallback(async () => {
|
const nachlesen = useCallback(async () => {
|
||||||
await Promise.all([load(query, nurProbleme), loadFlagged()]);
|
await Promise.all([load(query, filter, sort), loadFlagged()]);
|
||||||
}, [load, loadFlagged, query, nurProbleme]);
|
}, [load, loadFlagged, query, filter, sort]);
|
||||||
|
|
||||||
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
|
// Andere tragen ebenfalls ein; beim Zurückkehren an den Tab den Stand holen.
|
||||||
useRefreshOnFocus(nachlesen);
|
useRefreshOnFocus(nachlesen);
|
||||||
@@ -101,12 +115,12 @@ export default function Dashboard() {
|
|||||||
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
|
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => void load(query, nurProbleme, controller.signal), 250);
|
const timer = setTimeout(() => void load(query, filter, sort, controller.signal), 250);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [query, nurProbleme, load]);
|
}, [query, filter, sort, load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -142,14 +156,30 @@ export default function Dashboard() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="checkbox" style={{ marginBottom: '1rem' }}>
|
<div className="list-tools">
|
||||||
<input
|
<select
|
||||||
type="checkbox"
|
value={filter}
|
||||||
checked={nurProbleme}
|
onChange={(event) => setFilter(event.target.value as PlayerFilter)}
|
||||||
onChange={(event) => setNurProbleme(event.target.checked)}
|
aria-label="Eingrenzen"
|
||||||
/>
|
>
|
||||||
<span>Nur offene Problemfälle</span>
|
<option value="all">Alle Spieler</option>
|
||||||
</label>
|
<option value="active">Nur mit aktivem CC</option>
|
||||||
|
<option value="onlyInactive">Nur mit ausschliesslich inaktiven CCs</option>
|
||||||
|
<option value="group">Nur mit Gruppen-CC</option>
|
||||||
|
<option value="none">Nur ohne jeden CC</option>
|
||||||
|
<option value="problems">Nur offene Problemfälle</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(event) => setSort(event.target.value as PlayerSort)}
|
||||||
|
aria-label="Sortierung"
|
||||||
|
>
|
||||||
|
<option value="name">Nach Namen</option>
|
||||||
|
<option value="characters">Meiste CC-Einträge zuerst</option>
|
||||||
|
<option value="newest">Zuletzt angelegt zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <Notice kind="error">{error}</Notice>}
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
|
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
|
||||||
@@ -283,7 +313,7 @@ export default function Dashboard() {
|
|||||||
onCreated={() => {
|
onCreated={() => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
if (vorgabe !== null) setParams({}, { replace: true });
|
if (vorgabe !== null) setParams({}, { replace: true });
|
||||||
void load(query, nurProbleme);
|
void load(query, filter, sort);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user