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 ? (
+