Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch

Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign
tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt
galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein
redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend
sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket.

Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer
Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist
die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung
im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung
ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der
Spielerseite.

Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei
Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal
nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der
Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der
Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit
ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann
zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung
warten und bei wem.

Ausserdem:

- Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den
  Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen.
- Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten.
- Die Hinweisbox ueber den Antraegen beim Spieler faellt weg.
- Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem
  Tabellenlayout gefallen, daher die versetzte Trennlinie.
- Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier"
  nach UTC und im Sommer zwei Stunden daneben.
This commit is contained in:
2026-09-21 12:35:36 +02:00
parent 293d882cb4
commit dbde5b7d11
26 changed files with 974 additions and 22 deletions
@@ -0,0 +1,38 @@
-- Wann die Bewegung tatsaechlich stattfand. Beim Import ist das der Kauf im
-- Shop, nicht der Zeitpunkt, an dem jemand hier auf "Buchen" geklickt hat.
ALTER TABLE "CreditLedger" ADD COLUMN IF NOT EXISTS "occurredAt" TIMESTAMP(3);
-- Bestand: erst einmal der Eintragungszeitpunkt, ...
UPDATE "CreditLedger" SET "occurredAt" = "createdAt" WHERE "occurredAt" IS NULL;
-- ... und fuer bereits gebuchte Importe das echte Kaufdatum nachtragen. Der
-- Weg fuehrt ueber die Bestellung: sie verbindet die Buchung mit der Zeile,
-- in der das Kaufdatum steht.
UPDATE "CreditLedger" cl
SET "occurredAt" = r."purchasedAt"
FROM "ImportedOrder" o
JOIN "ImportRow" r
ON r."externalId" = o."externalId"
AND r."importId" = o."importId"
WHERE cl."groupId" = o."groupId"
AND r."purchasedAt" IS NOT NULL;
ALTER TABLE "CreditLedger" ALTER COLUMN "occurredAt" SET NOT NULL;
ALTER TABLE "CreditLedger" ALTER COLUMN "occurredAt" SET DEFAULT CURRENT_TIMESTAMP;
CREATE INDEX IF NOT EXISTS "CreditLedger_occurredAt_idx" ON "CreditLedger"("occurredAt");
-- Wann ein Antrag gestellt wurde, steht in der Boarduebersicht. Bestehende
-- Threads bekommen den Wert beim naechsten Abgleich nachgetragen.
ALTER TABLE "ForumThread" ADD COLUMN IF NOT EXISTS "postedAt" TIMESTAMP(3);
-- Stand der wiederkehrenden Laeufe. Im Code steht der Zeitplan, hier steht,
-- wann zuletzt gelaufen wurde - sonst stiesse jeder Neustart alles neu an.
CREATE TABLE IF NOT EXISTS "ScheduledRun" (
"name" TEXT NOT NULL,
"lastRunAt" TIMESTAMP(3),
"lastStatus" TEXT,
"lastNote" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ScheduledRun_pkey" PRIMARY KEY ("name")
);
+23
View File
@@ -206,10 +206,17 @@ model CreditLedger {
createdBy User? @relation("LedgerCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
/// Wann die Bewegung tatsächlich stattfand - beim Import das Kaufdatum aus
/// dem Shop, sonst der Zeitpunkt der Eingabe. Danach richtet sich die
/// Anzeige: wann jemand gekauft hat, ist die Frage, nicht wann es jemand
/// hier eingetippt hat.
occurredAt DateTime @default(now())
@@index([playerId])
@@index([playerId, kind])
@@index([groupId])
@@index([createdAt])
@@index([occurredAt])
}
model Character {
@@ -352,6 +359,9 @@ model ForumThread {
player Player? @relation(fields: [playerId], references: [id], onDelete: SetNull)
currentBoard ForumBoard
/// Wann der Antrag im Forum gestellt wurde. Anders als firstSeenAt sagt das
/// etwas über den Antrag aus und nicht darüber, wann wir ihn bemerkt haben.
postedAt DateTime?
firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now())
lastBoardChangeAt DateTime?
@@ -438,3 +448,16 @@ model CharacterMember {
@@index([playerId])
@@index([characterId])
}
/// Wann ein wiederkehrender Lauf zuletzt lief. Der Zeitplan steht im Code,
/// der Stand in der Datenbank - sonst würde jeder Neustart des Dienstes alles
/// erneut anstoßen, und bei mehreren Neustarts am Tag liefe der Abgleich
/// häufiger als gewollt.
model ScheduledRun {
name String @id
lastRunAt DateTime?
/// OK oder FAILED, mit einer kurzen Notiz für die Fehlersuche.
lastStatus String?
lastNote String?
updatedAt DateTime @updatedAt
}
+9
View File
@@ -35,6 +35,15 @@ const schema = z.object({
// WoltLab (Burning Board) zugeschnitten.
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'),
// Wiederkehrende Abgleiche: Käufe alle zwei Stunden (ruhende Konten alle
// sechs), das Forum einmal nachts. Abschaltbar, etwa für eine zweite
// Instanz, die nicht mitlaufen soll.
SCHEDULE_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
FORUM_SCAN_HOUR: z.coerce.number().int().min(0).max(23).default(4),
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
+8
View File
@@ -2,6 +2,7 @@ import { buildApp } from './app.js';
import { bootstrap } from './bootstrap.js';
import { cleanupExpiredSessions } from './auth/session.js';
import { cleanupOldAuditEntries } from './services/audit.js';
import { startSchedule } from './services/schedule.js';
import { prisma } from './db.js';
import { env } from './env.js';
@@ -37,9 +38,16 @@ async function main(): Promise<void> {
}, CLEANUP_INTERVAL_MS);
cleanup.unref();
const stopSchedule = startSchedule({
info: (msg) => app.log.info(msg),
warn: (msg) => app.log.warn(msg),
debug: (msg) => app.log.debug(msg),
});
const shutdown = async (signal: string): Promise<void> => {
app.log.info(`${signal} empfangen, fahre herunter`);
clearInterval(cleanup);
stopSchedule();
await app.close();
await prisma.$disconnect();
process.exit(0);
+11
View File
@@ -22,6 +22,8 @@ export interface ForumThread {
isMovedStub: boolean;
/// Labels des Threads, etwa "Wichtig" oder "SCP-RP".
labels: string[];
/// Wann der Antrag gestellt wurde, laut Forum.
postedAt: Date | null;
}
/// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs,
@@ -47,6 +49,11 @@ const AUTHOR_CELL = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
// Ein zurückgelassener Verweis ist kein eigener Thread.
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
// Der Zeitpunkt des ersten Beitrags - das Datum des Antrags. Die Spalte
// daneben trägt den letzten Beitrag, der ist hier ohne Belang.
const POSTED_AT =
/<li class="messageGroupTime">\s*<woltlab-core-date-time[^>]*\sdate="([^"]+)"/;
// Labels hängen als eigene Links über dem Titel, nicht als span.
const LABEL = /<a[^>]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g;
@@ -85,6 +92,9 @@ export function parseBoard(html: string): ForumThread[] {
LABEL.lastIndex = 0;
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
const zeit = POSTED_AT.exec(block);
const postedAt = zeit ? new Date(zeit[1] as string) : null;
threads.push({
threadId,
title: decode(topic[3] ?? ''),
@@ -93,6 +103,7 @@ export function parseBoard(html: string): ForumThread[] {
authorForumId: user ? Number(user[1]) : null,
isMovedStub: MOVED_MARKER.test(block),
labels,
postedAt: postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt : null,
});
}
+11
View File
@@ -6,6 +6,7 @@ import { requireAdmin } from '../auth/guard.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { hashPassword, validatePasswordStrength } from '../auth/password.js';
import { destroyAllSessionsFor } from '../auth/session.js';
import { env } from '../env.js';
import { recordAudit } from '../services/audit.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -266,6 +267,16 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
// --- Audit-Log ---
/** Stand der wiederkehrenden Abgleiche. */
app.get('/api/admin/schedule', async () => {
const { listRuns } = await import('../services/schedule.js');
return {
enabled: env.SCHEDULE_ENABLED,
forumHour: env.FORUM_SCAN_HOUR,
runs: await listRuns(),
};
});
app.get('/api/admin/audit', async (request) => {
const query = parseWith(
z.object({
+6
View File
@@ -12,6 +12,7 @@ import {
parseUpload,
bookRows,
dismissRows,
pendingOverview,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -43,6 +44,11 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
};
});
/** Wie viele Pakete insgesamt noch auf eine Buchung warten. */
app.get('/api/imports/pending/overview', { preHandler: requireEditor }, async () => {
return pendingOverview();
});
app.get('/api/imports', { preHandler: requireEditor }, async () => {
return { items: await listImports() };
});
+67
View File
@@ -5,6 +5,7 @@ import {
CreditSource,
InactiveReason,
type Character,
type Prisma,
} from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
@@ -37,6 +38,60 @@ function isOverdrawn(balances: Balances): boolean {
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
}
/**
* Die Slot-Regel.
*
* Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei
* Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns
* dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen
* zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC.
*
* Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC
* braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen
* einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt
* noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der
* einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht
* wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts.
*
* Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen,
* redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten.
*/
async function assertSlotsReichen(
tx: Prisma.TransactionClient,
playerId: number,
was: string,
): Promise<void> {
const [eintraege, belegt] = await Promise.all([
tx.creditLedger.findMany({
where: { playerId, kind: CreditKind.CC },
select: { amount: true, source: true, reversalOf: { select: { source: true } } },
}),
tx.characterMember.count({
where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } },
}),
]);
// Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art
// der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener
// Platz von einem zurückgenommenen Paket unterscheiden.
let gebuchtePlaetze = 0;
for (const eintrag of eintraege) {
const zweck =
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount;
}
if (belegt > gebuchtePlaetze) {
throw conflict(
'NO_FREE_SLOT',
`${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` +
'Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht ' +
'einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - ' +
'oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.',
);
}
}
async function loadCharacter(id: number): Promise<Character> {
const character = await prisma.character.findUnique({ where: { id } });
if (!character) throw notFound('CC');
@@ -80,6 +135,8 @@ export async function createCharacter(
},
});
await assertSlotsReichen(tx, playerId, 'Ein neuer CC');
await recordAudit(tx, ctx, {
action: 'character.create',
targetType: 'character',
@@ -163,6 +220,8 @@ export async function redesignCharacter(
},
});
await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign');
await recordAudit(tx, ctx, {
action: 'character.redesign',
targetType: 'character',
@@ -267,6 +326,8 @@ export async function reactivateFree(
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
});
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.reactivate_free',
targetType: 'character',
@@ -332,6 +393,8 @@ export async function reactivatePaid(
},
});
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.reactivate_paid',
targetType: 'character',
@@ -535,6 +598,8 @@ export async function reviveDeadCharacter(
});
}
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.revive_dead',
targetType: 'character',
@@ -711,6 +776,8 @@ export async function addMember(
},
});
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
await recordAudit(tx, ctx, {
action: 'character.member_added',
targetType: 'character',
+4 -1
View File
@@ -68,6 +68,8 @@ export interface PackageRowParams {
note?: string | null;
importId?: number | null;
actorId: number | null;
/// Wann gekauft wurde. Ohne Angabe gilt der Zeitpunkt der Eingabe.
occurredAt?: Date | null;
}
/**
@@ -85,6 +87,7 @@ export function buildPackageRows(
note: params.note ?? null,
importId: params.importId ?? null,
createdById: params.actorId,
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
};
if (target === 'REDESIGN_PACKAGE') {
@@ -309,7 +312,7 @@ export async function listLedger(filter: LedgerFilter) {
const [items, total] = await Promise.all([
prisma.creditLedger.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }],
take: filter.take ?? 100,
skip: filter.skip ?? 0,
include: {
+7
View File
@@ -333,6 +333,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
labels,
playerId,
currentBoard: board,
postedAt: thread.postedAt,
});
historie.push({ threadId: thread.threadId, board });
summary.newThreads += 1;
@@ -345,9 +346,14 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
const nachgetragen = existing.playerId === null && playerId !== null;
// Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier
// nach, ohne dass ein vorhandener Wert überschrieben wird.
const datumFehlt = existing.postedAt === null && thread.postedAt !== null;
const geaendert =
verschoben ||
nachgetragen ||
datumFehlt ||
existing.title !== thread.title ||
existing.url !== thread.url ||
existing.authorName !== thread.authorName ||
@@ -364,6 +370,7 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels,
...(datumFehlt ? { postedAt: thread.postedAt } : {}),
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
...(verschoben
? {
+56
View File
@@ -437,6 +437,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
note: row.productName ? `Import: ${row.productName}` : `Import #${record.id}`,
importId: record.id,
actorId: ctx.actorId,
occurredAt: row.purchasedAt,
}),
});
@@ -510,6 +511,60 @@ export async function listPendingRowsFor(playerId: number) {
});
}
/**
* Wie viele Pakete insgesamt noch auf eine Buchung warten, und bei wem.
*
* Die Abgleiche laufen nach Zeitplan und legen Vorschauen an, ohne dass
* jemand danach gesehen hat. Ohne diese Übersicht sammelten sie sich
* unbemerkt an.
*/
export async function pendingOverview() {
const offen = await prisma.importRow.groupBy({
by: ['resolvedPlayerId'],
where: {
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
_count: { _all: true },
});
// ImportRow führt den Spieler nur als Zahl - die Zeile entsteht, bevor
// feststeht, ob es den Spieler überhaupt gibt. Die Namen kommen deshalb
// in einem zweiten Zug dazu.
const ids = offen.map((g) => g.resolvedPlayerId).filter((id): id is number => id !== null);
const namen = new Map(
(
await prisma.player.findMany({
where: { id: { in: ids } },
select: { id: true, displayName: true },
})
).map((p) => [p.id, p.displayName]),
);
const jeSpieler = new Map<number, { playerId: number; displayName: string; count: number }>();
let ohneSpieler = 0;
let gesamt = 0;
for (const gruppe of offen) {
gesamt += gruppe._count._all;
if (gruppe.resolvedPlayerId === null) {
ohneSpieler += gruppe._count._all;
continue;
}
jeSpieler.set(gruppe.resolvedPlayerId, {
playerId: gruppe.resolvedPlayerId,
displayName: namen.get(gruppe.resolvedPlayerId) ?? 'Unbekannt',
count: gruppe._count._all,
});
}
return {
total: gesamt,
withoutPlayer: ohneSpieler,
players: [...jeSpieler.values()].sort((a, b) => b.count - a.count),
};
}
/**
* Bucht einzelne Zeilen sofort, ohne auf die Bestätigung des ganzen Imports
* zu warten. Gebuchte Zeilen werden als erledigt vermerkt - und zwar überall,
@@ -574,6 +629,7 @@ export async function bookRows(rowIds: number[], ctx: ActionContext) {
note: row.productName ? `Import: ${row.productName}` : `Import #${row.importId}`,
importId: row.importId,
actorId: ctx.actorId,
occurredAt: row.purchasedAt,
}),
});
+206
View File
@@ -0,0 +1,206 @@
import { CreditKind } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { knownSteamIds, scrapePlayers } from './scraper.js';
import { scanForum } from './forum.js';
import type { ActionContext } from './audit.js';
/**
* Wiederkehrende Läufe.
*
* Beide Abgleiche greifen auf fremde Server zu, deshalb laufen sie selten und
* gestaffelt: die Käufe mehrmals täglich, weil dort ständig etwas dazukommt;
* das Forum einmal nachts, weil ein Antrag keine Stunde alt sein muss, um
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
* Seiten liest.
*
* Der Zeitplan steht hier, der Stand in der Datenbank. Nach einem Neustart
* wird deshalb nicht alles erneut angestoßen.
*/
/// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist.
const TICK_MS = 5 * 60 * 1000;
/// Wer in letzter Zeit gekauft hat, wird häufiger geprüft.
const AKTIVE_PRUEFUNG_MS = 2 * 60 * 60 * 1000;
/// Wessen letzter Kauf lange zurückliegt, seltener - das spart die meisten
/// Anfragen, denn das sind mit der Zeit die meisten Spieler.
const RUHENDE_PRUEFUNG_MS = 6 * 60 * 60 * 1000;
/// Ab wann jemand als ruhend gilt.
const RUHEND_AB_MS = 3 * 365 * 24 * 60 * 60 * 1000;
/// Unter diesem Namen läuft, was der Zeitplan anstößt.
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' };
export interface RunState {
name: string;
lastRunAt: Date | null;
lastStatus: string | null;
lastNote: string | null;
}
async function letzterLauf(name: string): Promise<Date | null> {
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
return eintrag?.lastRunAt ?? null;
}
async function laufNotieren(name: string, status: string, note: string): Promise<void> {
const daten = { lastRunAt: new Date(), lastStatus: status, lastNote: note.slice(0, 500) };
await prisma.scheduledRun.upsert({
where: { name },
create: { name, ...daten },
update: daten,
});
}
export async function listRuns(): Promise<RunState[]> {
const eintraege = await prisma.scheduledRun.findMany({ orderBy: { name: 'asc' } });
return eintraege.map((e) => ({
name: e.name,
lastRunAt: e.lastRunAt,
lastStatus: e.lastStatus,
lastNote: e.lastNote,
}));
}
/**
* Die Spieler, die jetzt an der Reihe sind.
*
* Wer in den letzten drei Jahren etwas gekauft hat, wird alle zwei Stunden
* geprüft. Bei allen anderen reichen sechs - wer seit Jahren nichts kauft,
* fängt selten von einer Stunde auf die andere damit an, und es sind mit
* Abstand die meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt
* der Eingabe hier.
*/
export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
const [spieler, letzteKaeufe] = await Promise.all([
prisma.player.findMany({
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
select: { id: true, gextensionSyncedAt: true },
}),
prisma.creditLedger.groupBy({
by: ['playerId'],
where: { kind: CreditKind.CC, amount: { gt: 0 } },
_max: { occurredAt: true },
}),
]);
const kaufNach = new Map(letzteKaeufe.map((k) => [k.playerId, k._max.occurredAt]));
return spieler
.filter((p) => {
const letzterKauf = kaufNach.get(p.id) ?? null;
const ruhend =
letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > RUHEND_AB_MS;
const abstand = ruhend ? RUHENDE_PRUEFUNG_MS : AKTIVE_PRUEFUNG_MS;
if (p.gextensionSyncedAt === null) return true;
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
})
.map((p) => p.id);
}
/** Ob der nächtliche Lauf für heute noch aussteht. */
export function forumLaufFaellig(letzter: Date | null, jetzt: Date, stunde: number): boolean {
if (jetzt.getHours() < stunde) return false;
const heuteUm = new Date(jetzt);
heuteUm.setHours(stunde, 0, 0, 0);
return letzter === null || letzter < heuteUm;
}
async function kaeufePruefen(log: Logger): Promise<void> {
const faellig = await faelligeSpieler();
if (faellig.length === 0) {
log.debug('Zeitplan: keine Spieler fällig');
return;
}
const alle = await knownSteamIds();
const ziele = alle.filter((eintrag) => faellig.includes(eintrag.playerId));
if (ziele.length === 0) return;
log.info(`Zeitplan: prüfe ${ziele.length} Konten auf neue Pakete`);
const ergebnis = await scrapePlayers(ziele, SYSTEM_CONTEXT);
const note =
`${ergebnis.scannedPlayers} Konten, ${ergebnis.newRows} neue Zeilen, ` +
`${ergebnis.unclear} unklar, ${ergebnis.failedPlayers.length} Fehler`;
await laufNotieren('gextension', 'OK', note);
if (ergebnis.newRows > 0) {
log.info(`Zeitplan: ${ergebnis.newRows} neue Pakete liegen zur Bestätigung bereit`);
}
}
async function forumPruefen(log: Logger): Promise<void> {
log.info('Zeitplan: lese das Forum');
const ergebnis = await scanForum(SYSTEM_CONTEXT);
const note =
`${ergebnis.scannedPages} Seiten, ${ergebnis.newThreads} neu, ` +
`${ergebnis.unassignedThreads} ohne Spieler, ${ergebnis.failedBoards.length} Board-Fehler`;
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note);
}
interface Logger {
info: (msg: string) => void;
warn: (msg: string) => void;
debug: (msg: string) => void;
}
/**
* Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
*/
export function startSchedule(log: Logger): () => void {
if (!env.SCHEDULE_ENABLED) {
log.info('Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false)');
return () => {};
}
let laeuft = false;
const tick = async (): Promise<void> => {
if (laeuft) return;
laeuft = true;
try {
const jetzt = new Date();
const forumZuletzt = await letzterLauf('forum');
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) {
await forumPruefen(log);
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
}
const kaeufeZuletzt = await letzterLauf('gextension');
const abstandReicht =
kaeufeZuletzt === null || jetzt.getTime() - kaeufeZuletzt.getTime() >= AKTIVE_PRUEFUNG_MS;
if (abstandReicht) await kaeufePruefen(log);
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
await laufNotieren('letzter_fehler', 'FAILED', grund).catch(() => {});
} finally {
laeuft = false;
}
};
// Der erste Takt kommt verzögert, damit der Start nicht mit einem Abgleich
// zusammenfällt - beim Hochfahren ist genug anderes zu tun.
const start = setTimeout(() => void tick(), 60_000);
const timer = setInterval(() => void tick(), TICK_MS);
start.unref();
timer.unref();
log.info(
`Zeitplan läuft: Käufe alle 2 Stunden (ruhende Konten alle 6), Forum täglich um ${env.FORUM_SCAN_HOUR} Uhr`,
);
return () => {
clearTimeout(start);
clearInterval(timer);
};
}
+4
View File
@@ -17,6 +17,10 @@
</li>
<li class="columnSubject">
<a href="https://forum.example.org/index.php?thread/7001-cc-antrag-testfigur/" class="messageGroupLink wbbTopicLink" data-object-id="7001">CC Antrag | Testfigur</a>
<ul class="inlineList dotSeparated small messageGroupInfo">
<li class="messageGroupTime"><woltlab-core-date-time date="2025-03-04T18:22:05+00:00">4. März 2025 um 19:22</woltlab-core-date-time></li>
<li class="messageGroupLastPostTime"><woltlab-core-date-time date="2025-09-01T08:00:00+00:00">1. September 2025</woltlab-core-date-time></li>
</ul>
</li>
</ul>
</li>
+10
View File
@@ -20,6 +20,16 @@ describe('Board-Übersicht lesen', () => {
);
});
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
const erster = threads.find((t) => t.threadId === 7001);
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
});
it('kommt ohne Zeitangabe klar', () => {
const ohneZeit = threads.find((t) => t.threadId === 7002);
assert.equal(ohneZeit?.postedAt, null);
});
it('liest den Ersteller auch ohne Profil-Link', () => {
// Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne
// Rueckfall haette der Thread keinen Autor und liesse sich niemandem
+93
View File
@@ -347,3 +347,96 @@ describe('Tote und entzogene CCs', () => {
assert.equal(result.character.status, 'ACTIVE');
});
});
describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => {
it('lässt einen redesignten CC nicht neben seinem Nachfolger zurückkehren', async () => {
const spieler = await spielerMitPaket('Schlaufuchs');
const a = await characters.createCharacter(spieler, { name: 'CC A' }, ctx);
await characters.redesignCharacter(
a.character.id,
{ name: 'CC B', useKind: 'REDESIGN_PAID' },
ctx,
);
// Ein weiteres Redesign-Paket kauft keinen Platz, nur einen Tausch.
await credits.bookRedesignPackage(spieler, ctx);
await assert.rejects(
() => characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx),
(error: Error & { code?: string }) => error.code === 'NO_FREE_SLOT',
'sonst stünden zwei aktive CCs auf einem einzigen Paket',
);
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
assert.equal(aktive.length, 1);
});
it('lässt ihn zurückkehren, sobald der Nachfolger weg ist', async () => {
const spieler = await spielerMitPaket('Geduldig');
const a = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
const b = await characters.redesignCharacter(
a.character.id,
{ name: 'Zweiter', useKind: 'REDESIGN_PAID' },
ctx,
);
await credits.bookRedesignPackage(spieler, ctx);
await characters.deactivateCharacter(b.character.id, 'OTHER', ctx, 'Platz gemacht');
const zurueck = await characters.reactivatePaid(a.character.id, 'REDESIGN_PAID', ctx);
assert.equal(zurueck.character.status, 'ACTIVE');
});
it('lässt ihn mit einem zweiten CC-Paket auch neben dem Nachfolger zurückkehren', async () => {
const spieler = await spielerMitPaket('Grossverdiener');
const a = await characters.createCharacter(spieler, { name: 'Alpha' }, ctx);
await characters.redesignCharacter(
a.character.id,
{ name: 'Beta', useKind: 'REDESIGN_PAID' },
ctx,
);
// Das zweite Paket ist ein zweiter Platz - damit dürfen beide laufen.
await credits.bookCcPackage(spieler, ctx);
const zurueck = await characters.reactivatePaid(a.character.id, 'CC', ctx);
assert.equal(zurueck.character.status, 'ACTIVE');
const aktive = (await characters.listCharacters(spieler)).filter((c) => c.status === 'ACTIVE');
assert.equal(aktive.length, 2, 'zwei Pakete, zwei Plätze');
});
it('lässt ein überzogenes Konto weiterhin zu', async () => {
// Ein CC vor dem Paket einzutragen bleibt möglich; das Minus steht
// sichtbar da. Gebucht ist der Platz trotzdem - das ist der Unterschied
// zu einem CC, der ohne jede Buchung aktiv wird.
const spieler = (await db.prisma.player.create({ data: { displayName: 'Vorab' } })).id;
const ergebnis = await characters.createCharacter(spieler, { name: 'Vorab-CC' }, ctx);
assert.equal(ergebnis.balances.CC, -1);
assert.equal(ergebnis.overdrawn, true);
assert.equal(ergebnis.character.status, 'ACTIVE');
});
it('hält die Regel auch bei einem Gruppen-CC ein', async () => {
const gruender = await spielerMitPaket('Wirtin');
const gast = await spielerMitPaket('Gast mit Plan');
const { character } = await characters.createCharacter(gruender, { name: 'Die Runde' }, ctx);
// Der Gast hat einen eigenen CC und gibt ihn für den Platz auf.
const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx);
await credits.bookRedesignPackage(gast, ctx);
await characters.addMember(
character.id,
gast,
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
ctx,
);
const aktive = (await characters.listCharacters(gast)).filter((c) => c.status === 'ACTIVE');
assert.deepEqual(
aktive.map((c) => c.name),
['Die Runde'],
'ein Platz, ein aktiver CC - der eigene ist dafür weg',
);
});
});
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Schedule = typeof import('../src/services/schedule.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let schedule: Schedule;
let db: Db;
const JETZT = new Date('2026-09-21T12:00:00Z');
const TAG = 24 * 60 * 60 * 1000;
before(async () => {
database = await startTestDatabase(54338);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
// Der Zeitplan selbst darf im Test nicht anlaufen, nur seine Logik zählt.
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js');
schedule = await import('../src/services/schedule.js');
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
/**
* Ein Spieler mit Steam-Konto, letztem Kauf vor `kaufVorTagen` und letztem
* Abgleich vor `abgleichVorStunden`.
*/
async function spieler(
name: string,
steamId: string,
kaufVorTagen: number | null,
abgleichVorStunden: number | null,
) {
const angelegt = await db.prisma.player.create({
data: {
displayName: name,
steamId,
gextensionSyncedAt:
abgleichVorStunden === null
? null
: new Date(JETZT.getTime() - abgleichVorStunden * 60 * 60 * 1000),
},
});
if (kaufVorTagen !== null) {
await db.prisma.creditLedger.create({
data: {
playerId: angelegt.id,
kind: 'CC',
amount: 1,
source: 'CC_PACKAGE',
occurredAt: new Date(JETZT.getTime() - kaufVorTagen * TAG),
},
});
}
return angelegt.id;
}
describe('Wer wann auf neue Pakete geprüft wird', () => {
let frisch = 0;
let frischGeradeGeprueft = 0;
let ruhend = 0;
let ruhendLangeHer = 0;
let nochNieGeprueft = 0;
before(async () => {
// Kauf vor einem Monat, Abgleich vor drei Stunden: fällig (Takt zwei Stunden).
frisch = await spieler('Frisch', '76561198000020001', 30, 3);
// Ebenso frisch, aber gerade eben geprüft: noch nicht wieder dran.
frischGeradeGeprueft = await spieler('Eben geprüft', '76561198000020002', 30, 1);
// Letzter Kauf vor vier Jahren, Abgleich vor drei Stunden: der Takt ist
// hier sechs Stunden, also noch nicht dran.
ruhend = await spieler('Ruhend', '76561198000020003', 4 * 365, 3);
// Derselbe Fall, aber der Abgleich liegt sieben Stunden zurück: fällig.
ruhendLangeHer = await spieler('Ruhend und überfällig', '76561198000020004', 4 * 365, 7);
// Nie abgeglichen: immer fällig, egal wie lange der Kauf her ist.
nochNieGeprueft = await spieler('Neu hier', '76561198000020005', null, null);
});
it('nimmt einen Spieler mit frischem Kauf nach zwei Stunden wieder dran', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(frisch));
});
it('lässt ihn in Ruhe, solange die zwei Stunden nicht um sind', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(faellig.includes(frischGeradeGeprueft), false);
});
it('prüft ein ruhendes Konto nur alle sechs Stunden', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(
faellig.includes(ruhend),
false,
'drei Stunden reichen bei einem Konto ohne Kauf seit Jahren nicht',
);
assert.ok(faellig.includes(ruhendLangeHer), 'nach sieben Stunden aber schon');
});
it('nimmt einen nie geprüften Spieler sofort dran', async () => {
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(nochNieGeprueft));
});
it('lässt Spieler ohne Steam-Konto ganz aus', async () => {
const ohne = await db.prisma.player.create({ data: { displayName: 'Ohne Konto' } });
const faellig = await schedule.faelligeSpieler(JETZT);
assert.equal(
faellig.includes(ohne.id),
false,
'ohne Kennung gibt es bei GExtension nichts abzurufen',
);
});
it('zählt ein früheres Konto mit', async () => {
const umzuegler = await db.prisma.player.create({
data: { displayName: 'Nur Alias', gextensionSyncedAt: new Date(JETZT.getTime() - TAG) },
});
await db.prisma.playerAlias.create({
data: { playerId: umzuegler.id, type: 'STEAM_ID', value: '76561198000020099' },
});
const faellig = await schedule.faelligeSpieler(JETZT);
assert.ok(faellig.includes(umzuegler.id), 'seine Kaeufe liegen unter der alten Kennung');
});
});
describe('Der nächtliche Forumslauf', () => {
const nacht = 4;
it('läuft nicht, bevor die Stunde erreicht ist', () => {
const frueh = new Date('2026-09-21T02:30:00');
assert.equal(schedule.forumLaufFaellig(null, frueh, nacht), false);
});
it('läuft, sobald die Stunde erreicht ist und heute noch nichts lief', () => {
const morgens = new Date('2026-09-21T04:10:00');
const gestern = new Date('2026-09-20T04:05:00');
assert.equal(schedule.forumLaufFaellig(gestern, morgens, nacht), true);
});
it('läuft an einem Tag nur einmal', () => {
const morgens = new Date('2026-09-21T04:10:00');
const abends = new Date('2026-09-21T22:00:00');
assert.equal(schedule.forumLaufFaellig(morgens, abends, nacht), false);
});
it('holt einen ausgefallenen Lauf am selben Tag nach', () => {
// Der Dienst stand nachts still und kommt erst mittags hoch.
const mittags = new Date('2026-09-21T12:00:00');
const vorgestern = new Date('2026-09-19T04:00:00');
assert.equal(schedule.forumLaufFaellig(vorgestern, mittags, nacht), true);
});
it('stößt nach einem Neustart nicht erneut an', () => {
const nachStart = new Date('2026-09-21T06:00:00');
const heuteFrueh = new Date('2026-09-21T04:02:00');
assert.equal(schedule.forumLaufFaellig(heuteFrueh, nachStart, nacht), false);
});
});