From dbde5b7d11e90557d412bfef602974c4f1ac461d Mon Sep 17 00:00:00 2001
From: Harlekin
Date: Mon, 21 Sep 2026 12:35:36 +0200
Subject: [PATCH] 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.
---
.env.example | 9 +
README.md | 44 +++-
compose.yaml | 5 +
.../migration.sql | 38 ++++
server/prisma/schema.prisma | 23 ++
server/src/env.ts | 9 +
server/src/index.ts | 8 +
server/src/lib/forum.ts | 11 +
server/src/routes/admin.routes.ts | 11 +
server/src/routes/imports.routes.ts | 6 +
server/src/services/characters.ts | 67 ++++++
server/src/services/credits.ts | 5 +-
server/src/services/forum.ts | 7 +
server/src/services/imports.ts | 56 +++++
server/src/services/schedule.ts | 206 ++++++++++++++++++
server/test/fixtures/board.html | 4 +
server/test/forum.test.ts | 10 +
server/test/gruppen.test.ts | 93 ++++++++
server/test/zeitplan.test.ts | 168 ++++++++++++++
web/src/components/ThreadsBox.tsx | 12 +-
web/src/lib/types.ts | 25 +++
web/src/pages/Admin.tsx | 98 ++++++++-
web/src/pages/Dashboard.tsx | 49 ++++-
web/src/pages/Forum.tsx | 5 +-
web/src/pages/PlayerDetail.tsx | 11 +-
web/src/styles.css | 16 +-
26 files changed, 974 insertions(+), 22 deletions(-)
create mode 100644 server/prisma/migrations/20250108000000_kaufdatum_antragsdatum_zeitplan/migration.sql
create mode 100644 server/src/services/schedule.ts
create mode 100644 server/test/zeitplan.test.ts
diff --git a/.env.example b/.env.example
index de14fcf..74b14a2 100644
--- a/.env.example
+++ b/.env.example
@@ -30,6 +30,15 @@ BOOTSTRAP_ADMIN_PASSWORD=
# (Burning Board) zugeschnitten.
# FORUM_BASE_URL=https://senseigaming.de/forum
+# Wiederkehrende Abgleiche. Die Käufe werden alle zwei Stunden geprüft, bei
+# Spielern ohne Kauf in den letzten drei Jahren alle sechs; das Forum einmal
+# nachts. Gebucht wird dabei nichts von allein - neue Pakete landen in der
+# Import-Vorschau, neue Anträge in der Liste.
+# SCHEDULE_ENABLED=true
+
+# Stunde des nächtlichen Forumslaufs (Ortszeit des Servers, Standard: 4).
+# FORUM_SCAN_HOUR=4
+
# Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen).
# AUDIT_RETENTION_DAYS=365
diff --git a/README.md b/README.md
index 4dc656d..418ec49 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,22 @@ Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln
eigenen CC: **der Platz kostet ein eigenes Guthaben** - entweder aus dem CC-Paket oder über ein
Redesign, je nachdem, worüber der Spieler hinzukommt.
+### CC-Pakete sind Plätze
+
+Ein CC-Paket ist ein **Platz für einen gleichzeitig aktiven CC**. Zwei Pakete sind zwei Plätze,
+und so weiter. Ein Redesign **tauscht** einen CC aus, es schafft keinen zusätzlichen Platz: fünf
+Millionen Redesigns auf ein Paket bleiben ein gleichzeitig aktiver CC.
+
+Geprüft wird das an einer Stelle, für alle Wege zugleich - erschaffen, redesignen, reaktivieren,
+zurückholen, einem Gruppen-CC beitreten. Jeder aktive CC braucht eine eigene Buchung im CC-Topf.
+Wer einen Platz belegen will, der nicht gedeckt ist, bekommt eine Absage mit dem Hinweis, welcher
+CC dafür weichen müsste.
+
+Ein **überzogenes Konto** bleibt davon unberührt: Einen CC einzutragen, bevor das Paket verbucht
+ist, geht weiter - die Buchung ist ja da, nur das Guthaben fehlt noch, und das Minus steht
+sichtbar auf der Spielerseite. Der Unterschied ist, ob ein Platz gebucht wurde oder aus dem
+Nichts entsteht.
+
Über *Nutzer aufnehmen* auf der Spielerseite kommt jemand dazu; das Guthaben wird von seinem
Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird über das ✕ an seinem
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
@@ -61,8 +77,8 @@ Welcher Weg hineinführt, macht einen Unterschied:
| **CC-Paket** | Ein Platz kommt hinzu. Bestehende CCs des Spielers bleiben unberührt. |
| **Redesign** (bezahlt oder Kulanz) | Der Platz **ersetzt** einen eigenen CC, genau wie jedes andere Redesign. Welcher aufgegeben wird, muss angegeben werden. |
-Ein Redesign lässt nie einen CC entstehen, es tauscht einen aus - das gilt auch beim Beitritt zu
-einer Gruppe. Der aufgegebene CC muss dem Spieler gehören, **aktiv sein muss er nicht**: auch ein
+Das ist dieselbe Regel wie oben, nur an einem Gruppen-CC: über ein Redesign entsteht kein
+zusätzlicher Platz. Der aufgegebene CC muss dem Spieler gehören, **aktiv sein muss er nicht**: auch ein
längst stillgelegter CC lässt sich redesignen. Ist er noch aktiv, wird er dabei inaktiv gesetzt
(Grund *Redesignt*); war er es schon nicht mehr, bleibt er, wie er ist. Wer gar keinen eigenen CC
hat, kann nicht über ein Redesign beitreten - dafür braucht es ein CC-Paket.
@@ -275,6 +291,30 @@ Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein and
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
das Signal zu unzuverlässig.
+### Was von allein läuft
+
+Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss:
+
+| Lauf | Wann |
+| --- | --- |
+| **Käufe bei GExtension** | alle zwei Stunden |
+| dieselben, aber ruhende Konten | alle sechs Stunden |
+| **Forums-Anträge** | einmal nachts, gegen 4 Uhr |
+
+Als *ruhend* gilt, wer in den letzten drei Jahren nichts gekauft hat. Das sind mit der Zeit die
+meisten Spieler, und wer seit Jahren nichts kauft, fängt selten von einer Stunde auf die andere
+damit an - der seltenere Takt spart deshalb den Großteil der Anfragen, ohne dass etwas liegen
+bleibt. Maßgeblich ist das Kaufdatum aus dem Shop, nicht der Zeitpunkt der Eingabe hier.
+
+**Gebucht wird dabei nichts von allein.** Neue Pakete landen in der Import-Vorschau, neue Anträge
+in der Liste - entschieden wird weiterhin von Hand. Auf der Übersicht steht, wie viele Pakete auf
+eine Buchung warten und bei wem.
+
+Unter *Verwaltung → Zeitplan* steht, wann zuletzt gelaufen wurde und was dabei herauskam. Der
+Zeitplan lässt sich über `SCHEDULE_ENABLED=false` abschalten, die Stunde des Forumslaufs über
+`FORUM_SCAN_HOUR` verschieben. Der Stand steht in der Datenbank, nicht im Prozess: ein Neustart
+stößt deshalb nicht alles erneut an.
+
### Wie lange ein Abgleich dauert
Das Archiv allein umfasst über zwanzig Seiten. Zwischen den Abrufen liegt gut eine Sekunde, ein
diff --git a/compose.yaml b/compose.yaml
index 7628ad7..1c691b8 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -16,6 +16,11 @@ services:
GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de}
FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum}
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
+ # Die Zeitzone bestimmt, wann "nachts um vier" ist - ohne sie liefe der
+ # Forumslauf nach UTC und damit im Sommer zwei Stunden daneben.
+ TZ: ${TZ:-Europe/Berlin}
+ SCHEDULE_ENABLED: ${SCHEDULE_ENABLED:-true}
+ FORUM_SCAN_HOUR: ${FORUM_SCAN_HOUR:-4}
expose:
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
- "3000"
diff --git a/server/prisma/migrations/20250108000000_kaufdatum_antragsdatum_zeitplan/migration.sql b/server/prisma/migrations/20250108000000_kaufdatum_antragsdatum_zeitplan/migration.sql
new file mode 100644
index 0000000..b004afe
--- /dev/null
+++ b/server/prisma/migrations/20250108000000_kaufdatum_antragsdatum_zeitplan/migration.sql
@@ -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")
+);
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 9e7933a..b9b4f1b 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -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
+}
diff --git a/server/src/env.ts b/server/src/env.ts
index fa2c52c..98dd334 100644
--- a/server/src/env.ts
+++ b/server/src/env.ts
@@ -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),
diff --git a/server/src/index.ts b/server/src/index.ts
index 973d63e..daabc94 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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 {
}, 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 => {
app.log.info(`${signal} empfangen, fahre herunter`);
clearInterval(cleanup);
+ stopSchedule();
await app.close();
await prisma.$disconnect();
process.exit(0);
diff --git a/server/src/lib/forum.ts b/server/src/lib/forum.ts
index 54faeb1..5e71fa1 100644
--- a/server/src/lib/forum.ts
+++ b/server/src/lib/forum.ts
@@ -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 = /
diff --git a/server/test/forum.test.ts b/server/test/forum.test.ts
index a95d208..c8368c5 100644
--- a/server/test/forum.test.ts
+++ b/server/test/forum.test.ts
@@ -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
diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts
index 2e152f5..9f2dd73 100644
--- a/server/test/gruppen.test.ts
+++ b/server/test/gruppen.test.ts
@@ -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',
+ );
+ });
+});
diff --git a/server/test/zeitplan.test.ts b/server/test/zeitplan.test.ts
new file mode 100644
index 0000000..83e49d7
--- /dev/null
+++ b/server/test/zeitplan.test.ts
@@ -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);
+ });
+});
diff --git a/web/src/components/ThreadsBox.tsx b/web/src/components/ThreadsBox.tsx
index fbe29fb..491538b 100644
--- a/web/src/components/ThreadsBox.tsx
+++ b/web/src/components/ThreadsBox.tsx
@@ -1,5 +1,4 @@
import { useState } from 'react';
-import Notice from './Notice';
import { ClassifyModal } from './ThreadModals';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import type { ForumThread } from '../lib/types';
@@ -38,14 +37,6 @@ export default function ThreadsBox({
) : (
<>
- {offen.length > 0 && (
-
- {offen.length} {offen.length === 1 ? 'Antrag wartet' : 'Anträge warten'} auf eine
- Entscheidung. Was hier entschieden wird, steht danach in der Anträge-Liste nicht
- mehr offen.
-
- )}
-
{threads.map((thread) => (
)}
- von {thread.authorName ?? 'unbekannt'}
+ {thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '}
+ {thread.authorName ?? 'unbekannt'}
{thread.labels && ` · ${thread.labels}`}
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
{thread.classifiedAt && ` · entschieden ${formatDate(thread.classifiedAt)}`}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 5cbfd76..85e5165 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -144,6 +144,8 @@ export interface LedgerEntry {
characterId: number | null;
character: CharacterRef | null;
createdAt: string;
+ /// Wann es tatsächlich passierte - beim Import das Kaufdatum aus dem Shop.
+ occurredAt: string;
createdBy: { username: string } | null;
reversedBy: { id: number } | null;
reversalOfId: number | null;
@@ -259,6 +261,8 @@ export interface ForumThread {
playerId: number | null;
player: { id: number; displayName: string } | null;
currentBoard: ForumBoard;
+ /// Wann der Antrag im Forum gestellt wurde.
+ postedAt: string | null;
firstSeenAt: string;
lastSeenAt: string;
lastBoardChangeAt: string | null;
@@ -322,3 +326,24 @@ export interface PendingRow {
externalId: string | null;
import: { id: number; filename: string; createdAt: string };
}
+
+/// Stand der wiederkehrenden Abgleiche.
+export interface ScheduleRun {
+ name: string;
+ lastRunAt: string | null;
+ lastStatus: string | null;
+ lastNote: string | null;
+}
+
+export interface ScheduleState {
+ enabled: boolean;
+ forumHour: number;
+ runs: ScheduleRun[];
+}
+
+/// Pakete, die noch in einer offenen Import-Vorschau liegen.
+export interface PendingOverview {
+ total: number;
+ withoutPlayer: number;
+ players: { playerId: number; displayName: string; count: number }[];
+}
diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx
index 53ccb59..03fcc7a 100644
--- a/web/src/pages/Admin.tsx
+++ b/web/src/pages/Admin.tsx
@@ -4,9 +4,16 @@ import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import { useSession } from '../lib/session';
-import type { AdminUser, AuditEntry, ProductMapping, ProductTarget, Role } from '../lib/types';
+import type {
+ AdminUser,
+ AuditEntry,
+ ProductMapping,
+ ProductTarget,
+ Role,
+ ScheduleState,
+} from '../lib/types';
-type Tab = 'users' | 'mappings' | 'audit';
+type Tab = 'users' | 'mappings' | 'audit' | 'schedule';
const TARGET_LABEL: Record = {
CC_PACKAGE: 'CC-Paket',
@@ -52,6 +59,13 @@ export default function Admin() {
>
Audit-Log
+
{error && setError(null)}>{error}}
@@ -60,6 +74,7 @@ export default function Admin() {
{tab === 'users' && }
{tab === 'mappings' && }
{tab === 'audit' && }
+ {tab === 'schedule' && }
);
}
@@ -505,3 +520,82 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
);
}
+
+/**
+ * Was die wiederkehrenden Abgleiche zuletzt getan haben. Sie laufen ohne
+ * Zutun, deshalb muss nachlesbar sein, ob sie überhaupt laufen - ein still
+ * stehender Zeitplan fällt sonst erst auf, wenn Wochen fehlen.
+ */
+function ScheduleTab({ onError }: { onError: (message: string) => void }) {
+ const [stand, setStand] = useState(null);
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ setStand(await api('/api/admin/schedule'));
+ } catch (err) {
+ onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
+ }
+ })();
+ }, [onError]);
+
+ const LABEL: Record = {
+ gextension: 'Käufe bei GExtension',
+ forum: 'Forums-Anträge',
+ letzter_fehler: 'Zuletzt gescheitert',
+ };
+
+ return (
+
+
Zeitplan
+
+ {stand === null ? (
+
Lade …
+ ) : (
+ <>
+ {stand.enabled ? (
+
+ Die Käufe werden alle zwei Stunden geprüft, bei Spielern ohne Kauf in den letzten
+ drei Jahren alle sechs. Das Forum wird täglich gegen {stand.forumHour} Uhr gelesen.
+ Gebucht wird dabei nichts von allein: neue Pakete landen in der Import-Vorschau,
+ neue Anträge in der Liste.
+
+ ) : (
+
+ Der Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false).
+ Beide Abgleiche laufen nur, wenn sie von Hand angestoßen werden.
+
+ )}
+
+ {stand.runs.length === 0 ? (
+