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 = /
  • ([\s\S]{0,400}?)<\/li>/; // Ein zurückgelassener Verweis ist kein eigener Thread. const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*\s*]*\sdate="([^"]+)"/; + // Labels hängen als eigene Links über dem Titel, nicht als span. const LABEL = /]*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, }); } diff --git a/server/src/routes/admin.routes.ts b/server/src/routes/admin.routes.ts index 3ffbe3d..b4f7441 100644 --- a/server/src/routes/admin.routes.ts +++ b/server/src/routes/admin.routes.ts @@ -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 { // --- 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({ diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts index e56dc4a..3bd403c 100644 --- a/server/src/routes/imports.routes.ts +++ b/server/src/routes/imports.routes.ts @@ -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 { }; }); + /** 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() }; }); diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index b74dfd4..15937af 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -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 { + 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 { 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', diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 22837fb..32cf0c3 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -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: { diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts index ca7c6a4..53c65f5 100644 --- a/server/src/services/forum.ts +++ b/server/src/services/forum.ts @@ -333,6 +333,7 @@ export async function scanForum(ctx: ActionContext): Promise { 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 { // 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 { authorName: thread.authorName, authorForumId: thread.authorForumId, labels, + ...(datumFehlt ? { postedAt: thread.postedAt } : {}), ...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}), ...(verschoben ? { diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index c3f6178..c3883f6 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -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(); + 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, }), }); diff --git a/server/src/services/schedule.ts b/server/src/services/schedule.ts new file mode 100644 index 0000000..74e7563 --- /dev/null +++ b/server/src/services/schedule.ts @@ -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 { + const eintrag = await prisma.scheduledRun.findUnique({ where: { name } }); + return eintrag?.lastRunAt ?? null; +} + +async function laufNotieren(name: string, status: string, note: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 => { + 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); + }; +} diff --git a/server/test/fixtures/board.html b/server/test/fixtures/board.html index 53cd200..ff2a489 100644 --- a/server/test/fixtures/board.html +++ b/server/test/fixtures/board.html @@ -17,6 +17,10 @@
  • CC Antrag | Testfigur +
      +
    • 4. März 2025 um 19:22
    • +
    • 1. September 2025
    • +
  • 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. - - )} -