diff --git a/.env.example b/.env.example index 74b14a2..a55c519 100644 --- a/.env.example +++ b/.env.example @@ -30,14 +30,9 @@ 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 +# Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter +# Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum +# ab welcher Stunde. Hier steht dazu nichts mehr. # 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 1164584..3207913 100644 --- a/README.md +++ b/README.md @@ -350,27 +350,31 @@ das Signal zu unzuverlässig. ### Was von allein läuft -Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss: +Beide Abgleiche laufen nach Zeitplan, ohne dass jemand einen Knopf drücken muss. Eingestellt +wird das unter *Verwaltung → Zeitplan*, je Lauf einzeln: -| Lauf | Wann | +| Einstellung | Bedeutung | | --- | --- | -| **Käufe bei GExtension** | alle zwei Stunden | -| dieselben, aber ruhende Konten | alle sechs Stunden | -| **Forums-Anträge** | einmal nachts, gegen 4 Uhr | +| **läuft automatisch** | Aus heißt: nur noch von Hand, über den Knopf auf der jeweiligen Seite. | +| **Abstand** | In Minuten, mindestens 15 und höchstens eine Woche. Daneben steht, was das in Stunden bedeutet. | +| **Ruhende Konten** | Nur beim Käufe-Abgleich: der seltenere Takt für Spieler, bei denen lange nichts gekauft wurde. | +| **Ruhend ab** | Nach wie vielen Tagen ohne Kauf jemand als ruhend gilt. | +| **Frühestens ab** | Nur beim Forumslauf: eine Stunde, vor der nicht gelaufen wird. Mit einem täglichen Abstand liegt der Lauf damit verlässlich nachts. | -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. +Ab Werk: Käufe alle zwei Stunden, ruhende Konten alle sechs, ruhend nach drei Jahren; das Forum +einmal täglich, frühestens ab vier Uhr. + +Der seltenere Takt für ruhende Konten spart den Großteil der Anfragen: Wer seit Jahren nichts +kauft, fängt selten von einer Stunde auf die andere damit an, und es sind mit der Zeit die +meisten Spieler. 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. +Darunter steht, wann zuletzt gelaufen wurde und was dabei herauskam. Änderungen greifen sofort, +ohne Neustart; der Stand der Läufe steht in der Datenbank, ein Neustart stößt also nicht alles +erneut an. ### Wie lange ein Abgleich dauert diff --git a/compose.yaml b/compose.yaml index 1c691b8..f6303c8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,10 +17,9 @@ services: 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. + # Forumslauf nach UTC und damit im Sommer zwei Stunden daneben. Der + # Takt selbst steht in der Anwendung unter Verwaltung -> Zeitplan. 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/20250111000000_zeitplan_einstellbar/migration.sql b/server/prisma/migrations/20250111000000_zeitplan_einstellbar/migration.sql new file mode 100644 index 0000000..e8b1bc9 --- /dev/null +++ b/server/prisma/migrations/20250111000000_zeitplan_einstellbar/migration.sql @@ -0,0 +1,22 @@ +-- Der Zeitplan wird einstellbar. Bisher standen Takt und Schalter in der +-- Umgebung und liessen sich nur mit einem Neustart aendern. +CREATE TABLE IF NOT EXISTS "ScheduleSetting" ( + "name" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "intervalMinutes" INTEGER NOT NULL DEFAULT 120, + "idleIntervalMinutes" INTEGER, + "idleAfterDays" INTEGER, + "onlyAfterHour" INTEGER, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ScheduleSetting_pkey" PRIMARY KEY ("name") +); + +-- Die bisherigen Werte als Ausgangspunkt: Kaeufe alle zwei Stunden, ruhende +-- Konten alle sechs, ruhend ab drei Jahren; das Forum einmal taeglich, +-- fruehestens ab vier Uhr. +INSERT INTO "ScheduleSetting" + ("name", "enabled", "intervalMinutes", "idleIntervalMinutes", "idleAfterDays", "onlyAfterHour") +VALUES + ('gextension', true, 120, 360, 1095, NULL), + ('forum', true, 1440, NULL, NULL, 4) +ON CONFLICT ("name") DO NOTHING; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f183ae7..cc3226b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -506,3 +506,26 @@ model CharacterLink { @@index([characterId]) } + +/// Was der Zeitplan tun soll. Steht in der Datenbank statt in der Umgebung, +/// damit sich der Takt ändern lässt, ohne den Dienst neu zu starten. +model ScheduleSetting { + /// "gextension" oder "forum". + name String @id + enabled Boolean @default(true) + + /// Abstand zwischen zwei Läufen, in Minuten. + intervalMinutes Int @default(120) + + /// Nur beim Käufe-Abgleich: der seltenere Takt für Konten, bei denen lange + /// nichts gekauft wurde, und ab wann ein Konto als ruhend gilt. + idleIntervalMinutes Int? + idleAfterDays Int? + + /// Nur beim Forumslauf: frühestens ab dieser Stunde. Damit lässt sich ein + /// täglicher Lauf in die Nacht legen, statt ihn irgendwann am Tag + /// loszuschicken. + onlyAfterHour Int? + + updatedAt DateTime @updatedAt +} diff --git a/server/src/env.ts b/server/src/env.ts index 98dd334..fa2c52c 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -35,15 +35,6 @@ 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/routes/admin.routes.ts b/server/src/routes/admin.routes.ts index b4f7441..67dbf9a 100644 --- a/server/src/routes/admin.routes.ts +++ b/server/src/routes/admin.routes.ts @@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise { // --- Audit-Log --- - /** Stand der wiederkehrenden Abgleiche. */ + /** Einstellungen und 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(), - }; + const { listRuns, listSettings } = await import('../services/schedule.js'); + const [settings, runs] = await Promise.all([listSettings(), listRuns()]); + return { settings, runs }; + }); + + app.patch('/api/admin/schedule/:name', async (request) => { + const { updateSetting, LAEUFE } = await import('../services/schedule.js'); + + const params = parseWith( + z.object({ name: z.enum(['gextension', 'forum']) }), + request.params, + ); + const body = parseWith( + z.object({ + enabled: z.boolean().optional(), + intervalMinutes: z.number().int().optional(), + idleIntervalMinutes: z.number().int().nullable().optional(), + idleAfterDays: z.number().int().nullable().optional(), + onlyAfterHour: z.number().int().nullable().optional(), + }), + request.body, + ); + + if (!LAEUFE.includes(params.name)) throw notFound('Lauf'); + return updateSetting(params.name, body, contextOf(request)); }); app.get('/api/admin/audit', async (request) => { diff --git a/server/src/services/schedule.ts b/server/src/services/schedule.ts index 74e7563..879dd71 100644 --- a/server/src/services/schedule.ts +++ b/server/src/services/schedule.ts @@ -1,9 +1,9 @@ import { CreditKind } from '@prisma/client'; import { prisma } from '../db.js'; -import { env } from '../env.js'; +import { badRequest } from '../errors.js'; import { knownSteamIds, scrapePlayers } from './scraper.js'; import { scanForum } from './forum.js'; -import type { ActionContext } from './audit.js'; +import { recordAudit, type ActionContext } from './audit.js'; /** * Wiederkehrende Läufe. @@ -14,24 +14,52 @@ import type { ActionContext } from './audit.js'; * 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. + * Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt + * sich beides im Betrieb ändern, ohne den Dienst neu zu starten. Der Stand + * der Läufe steht ebenfalls dort, damit ein Neustart nicht alles erneut + * anstösst. */ /// Wie oft überhaupt nachgesehen wird, ob etwas fällig ist. -const TICK_MS = 5 * 60 * 1000; +const TICK_MS = 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; +const MINUTE_MS = 60 * 1000; +const TAG_MS = 24 * 60 * MINUTE_MS; + +/// Die beiden Läufe, die es gibt. +export const LAEUFE = ['gextension', 'forum'] as const; +export type LaufName = (typeof LAEUFE)[number]; /// Unter diesem Namen läuft, was der Zeitplan anstößt. const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' }; +/// Fällt ein Eintrag, greifen diese Werte - dieselben, mit denen der +/// Zeitplan ursprünglich fest verdrahtet war. +const VORGABEN: Record = { + gextension: { + enabled: true, + intervalMinutes: 120, + idleIntervalMinutes: 360, + idleAfterDays: 1095, + onlyAfterHour: null, + }, + forum: { + enabled: true, + intervalMinutes: 1440, + idleIntervalMinutes: null, + idleAfterDays: null, + onlyAfterHour: 4, + }, +}; + +export interface ScheduleSettingInput { + enabled: boolean; + intervalMinutes: number; + idleIntervalMinutes: number | null; + idleAfterDays: number | null; + onlyAfterHour: number | null; +} + export interface RunState { name: string; lastRunAt: Date | null; @@ -39,6 +67,91 @@ export interface RunState { lastNote: string | null; } +export async function getSetting(name: LaufName): Promise { + const eintrag = await prisma.scheduleSetting.findUnique({ where: { name } }); + if (!eintrag) return VORGABEN[name]; + + return { + enabled: eintrag.enabled, + intervalMinutes: eintrag.intervalMinutes, + idleIntervalMinutes: eintrag.idleIntervalMinutes, + idleAfterDays: eintrag.idleAfterDays, + onlyAfterHour: eintrag.onlyAfterHour, + }; +} + +export async function listSettings(): Promise<(ScheduleSettingInput & { name: LaufName })[]> { + return Promise.all( + LAEUFE.map(async (name) => ({ name, ...(await getSetting(name)) })), + ); +} + +/** + * Ändert die Einstellungen eines Laufs. + * + * Die Grenzen sind bewusst eng: unter einer Viertelstunde wäre der Abgleich + * eine Belästigung für einen fremden Server, über einer Woche liefe er + * praktisch nicht mehr. + */ +export async function updateSetting( + name: LaufName, + input: Partial, + ctx: ActionContext, +): Promise { + const vorher = await getSetting(name); + + const pruefe = (wert: number | null | undefined, feld: string): number | null | undefined => { + if (wert === undefined || wert === null) return wert; + if (!Number.isInteger(wert) || wert < 15 || wert > 10080) { + throw badRequest( + 'INVALID_INTERVAL', + `${feld}: zwischen 15 Minuten und einer Woche (10080 Minuten)`, + ); + } + return wert; + }; + + const intervalMinutes = pruefe(input.intervalMinutes, 'Abstand') ?? vorher.intervalMinutes; + const idleIntervalMinutes = + input.idleIntervalMinutes === undefined + ? vorher.idleIntervalMinutes + : (pruefe(input.idleIntervalMinutes, 'Abstand für ruhende Konten') ?? null); + + if (input.idleAfterDays !== undefined && input.idleAfterDays !== null) { + if (!Number.isInteger(input.idleAfterDays) || input.idleAfterDays < 1) { + throw badRequest('INVALID_DAYS', 'Ruhend ab: mindestens ein Tag'); + } + } + if (input.onlyAfterHour !== undefined && input.onlyAfterHour !== null) { + if (!Number.isInteger(input.onlyAfterHour) || input.onlyAfterHour < 0 || input.onlyAfterHour > 23) { + throw badRequest('INVALID_HOUR', 'Frühestens ab: eine Stunde zwischen 0 und 23'); + } + } + + const daten = { + enabled: input.enabled ?? vorher.enabled, + intervalMinutes, + idleIntervalMinutes, + idleAfterDays: input.idleAfterDays === undefined ? vorher.idleAfterDays : input.idleAfterDays, + onlyAfterHour: input.onlyAfterHour === undefined ? vorher.onlyAfterHour : input.onlyAfterHour, + }; + + await prisma.scheduleSetting.upsert({ + where: { name }, + create: { name, ...daten }, + update: daten, + }); + + await recordAudit(prisma, ctx, { + action: 'schedule.updated', + targetType: 'schedule', + targetId: null, + details: { name, ...daten }, + }); + + return daten; +} + async function letzterLauf(name: string): Promise { const eintrag = await prisma.scheduledRun.findUnique({ where: { name } }); return eintrag?.lastRunAt ?? null; @@ -66,13 +179,21 @@ export async function listRuns(): Promise { /** * 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. + * Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen + * anderen reicht der seltenere - 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 { +export async function faelligeSpieler( + jetzt = new Date(), + einstellung?: ScheduleSettingInput, +): Promise { + const regel = einstellung ?? (await getSetting('gextension')); + const aktiverTakt = regel.intervalMinutes * MINUTE_MS; + const ruhenderTakt = (regel.idleIntervalMinutes ?? regel.intervalMinutes) * MINUTE_MS; + const ruhendAb = (regel.idleAfterDays ?? 0) * TAG_MS; + const [spieler, letzteKaeufe] = await Promise.all([ prisma.player.findMany({ where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] }, @@ -91,8 +212,8 @@ export async function faelligeSpieler(jetzt = new Date()): Promise { .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; + ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb); + const abstand = ruhend ? ruhenderTakt : aktiverTakt; if (p.gextensionSyncedAt === null) return true; return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand; @@ -100,18 +221,45 @@ export async function faelligeSpieler(jetzt = new Date()): Promise { .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; +/** + * Ob ein Lauf jetzt ansteht. + * + * Ohne Stundenfenster genügt der Abstand zum letzten Lauf. Mit Fenster wird + * zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein + * täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem + * Neustart weiter in den Tag wandern zu lassen. + */ +export function istFaellig( + letzter: Date | null, + jetzt: Date, + regel: Pick, +): boolean { + if (letzter !== null) { + const vergangen = jetzt.getTime() - letzter.getTime(); + if (vergangen < regel.intervalMinutes * MINUTE_MS) return false; + } - const heuteUm = new Date(jetzt); - heuteUm.setHours(stunde, 0, 0, 0); + if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true; + if (jetzt.getHours() < regel.onlyAfterHour) return false; - return letzter === null || letzter < heuteUm; + // Innerhalb desselben Tages nicht zweimal, wenn der Takt tageweise läuft. + if (letzter !== null && regel.intervalMinutes >= 1440) { + const heuteAb = new Date(jetzt); + heuteAb.setHours(regel.onlyAfterHour, 0, 0, 0); + if (letzter >= heuteAb) return false; + } + + return true; } -async function kaeufePruefen(log: Logger): Promise { - const faellig = await faelligeSpieler(); +interface Logger { + info: (msg: string) => void; + warn: (msg: string) => void; + debug: (msg: string) => void; +} + +async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise { + const faellig = await faelligeSpieler(new Date(), regel); if (faellig.length === 0) { log.debug('Zeitplan: keine Spieler fällig'); return; @@ -144,22 +292,11 @@ async function forumPruefen(log: Logger): Promise { 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 => { @@ -169,16 +306,16 @@ export function startSchedule(log: Logger): () => void { try { const jetzt = new Date(); - const forumZuletzt = await letzterLauf('forum'); - if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) { + const forumRegel = await getSetting('forum'); + if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) { 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); + const kaufRegel = await getSetting('gextension'); + if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) { + await kaeufePruefen(log, kaufRegel); + } } catch (error) { const grund = error instanceof Error ? error.message : 'Unbekannter Fehler'; log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`); @@ -195,9 +332,7 @@ export function startSchedule(log: Logger): () => void { 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`, - ); + log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan'); return () => { clearTimeout(start); diff --git a/server/test/backup.test.ts b/server/test/backup.test.ts index cf91149..3f715e9 100644 --- a/server/test/backup.test.ts +++ b/server/test/backup.test.ts @@ -21,7 +21,6 @@ before(async () => { process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; - process.env.SCHEDULE_ENABLED = 'false'; db = await import('../src/db.js'); backup = await import('../src/services/backup.js'); diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index 27fdc29..b7fc69e 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -17,7 +17,6 @@ before(async () => { process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; - process.env.SCHEDULE_ENABLED = 'false'; db = await import('../src/db.js'); characters = await import('../src/services/characters.js'); diff --git a/server/test/zeitplan.test.ts b/server/test/zeitplan.test.ts index 5dd0f5e..487af75 100644 --- a/server/test/zeitplan.test.ts +++ b/server/test/zeitplan.test.ts @@ -21,8 +21,6 @@ before(async () => { 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'); @@ -139,37 +137,110 @@ describe('Wer wann auf neue Pakete geprüft wird', () => { }); }); -describe('Der nächtliche Forumslauf', () => { - const nacht = 4; +describe('Wann ein Lauf ansteht', () => { + const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 }; + const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null }; - 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('wartet den eingestellten Abstand ab', () => { + const jetzt = new Date('2026-09-21T12:00:00'); + const vorEinerStunde = new Date('2026-09-21T11:00:00'); + const vorDreiStunden = new Date('2026-09-21T09:00:00'); + + assert.equal(schedule.istFaellig(vorEinerStunde, jetzt, allezweiStunden), false); + assert.equal(schedule.istFaellig(vorDreiStunden, jetzt, allezweiStunden), true); }); - 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 beim ersten Mal sofort', () => { + assert.equal(schedule.istFaellig(null, new Date('2026-09-21T12:00:00'), allezweiStunden), true); + }); + + it('hält sich beim Forum an die Stunde', () => { + const frueh = new Date('2026-09-21T02:30:00'); + assert.equal(schedule.istFaellig(null, frueh, taeglichNachts), false, 'vor vier Uhr nicht'); + + const nachVier = new Date('2026-09-21T04:10:00'); + assert.equal(schedule.istFaellig(null, nachVier, taeglichNachts), 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); + assert.equal(schedule.istFaellig(morgens, abends, taeglichNachts), 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); + assert.equal(schedule.istFaellig(vorgestern, mittags, taeglichNachts), true); }); - it('stößt nach einem Neustart nicht erneut an', () => { + it('stösst 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); + assert.equal(schedule.istFaellig(heuteFrueh, nachStart, taeglichNachts), false); + }); + + it('ignoriert die Stunde, wenn der Takt kürzer als ein Tag ist', () => { + // Ein sechsstündlicher Lauf ab acht Uhr soll auch um vierzehn Uhr laufen. + const regel = { intervalMinutes: 360, onlyAfterHour: 8 }; + const mittags = new Date('2026-09-21T14:00:00'); + const morgens = new Date('2026-09-21T08:00:00'); + assert.equal(schedule.istFaellig(morgens, mittags, regel), true); + }); +}); + +describe('Einstellungen des Zeitplans', () => { + it('liefert Vorgaben, solange nichts eingestellt wurde', async () => { + const kaeufe = await schedule.getSetting('gextension'); + assert.equal(kaeufe.intervalMinutes, 120); + assert.equal(kaeufe.idleIntervalMinutes, 360); + assert.equal(kaeufe.idleAfterDays, 1095); + + const forum = await schedule.getSetting('forum'); + assert.equal(forum.intervalMinutes, 1440); + assert.equal(forum.onlyAfterHour, 4); + }); + + it('nimmt eine Änderung an und wendet sie sofort an', async () => { + const ctx = { actorId: null, actorName: 'Test' }; + await schedule.updateSetting('gextension', { intervalMinutes: 45, enabled: false }, ctx); + + const danach = await schedule.getSetting('gextension'); + assert.equal(danach.intervalMinutes, 45); + assert.equal(danach.enabled, false); + assert.equal(danach.idleIntervalMinutes, 360, 'unberührte Felder bleiben'); + + await schedule.updateSetting('gextension', { intervalMinutes: 120, enabled: true }, ctx); + }); + + it('weist unsinnige Abstände ab', async () => { + const ctx = { actorId: null, actorName: 'Test' }; + + for (const minuten of [0, 5, 20000]) { + await assert.rejects( + () => schedule.updateSetting('forum', { intervalMinutes: minuten }, ctx), + (error: Error & { code?: string }) => error.code === 'INVALID_INTERVAL', + `${minuten} Minuten hätten abgewiesen werden müssen`, + ); + } + + await assert.rejects( + () => schedule.updateSetting('forum', { onlyAfterHour: 25 }, ctx), + (error: Error & { code?: string }) => error.code === 'INVALID_HOUR', + ); + }); + + it('richtet sich beim Fälligsein nach der Einstellung', async () => { + const ctx = { actorId: null, actorName: 'Test' }; + await schedule.updateSetting('gextension', { intervalMinutes: 30 }, ctx); + + const regel = await schedule.getSetting('gextension'); + const jetzt = new Date('2026-09-21T12:00:00'); + assert.equal(schedule.istFaellig(new Date('2026-09-21T11:45:00'), jetzt, regel), false); + assert.equal(schedule.istFaellig(new Date('2026-09-21T11:15:00'), jetzt, regel), true); + + await schedule.updateSetting('gextension', { intervalMinutes: 120 }, ctx); }); }); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d96cde7..b52d73b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -352,9 +352,21 @@ export interface ScheduleRun { lastNote: string | null; } -export interface ScheduleState { +export type ScheduleName = 'gextension' | 'forum'; + +export interface ScheduleSetting { + name: ScheduleName; enabled: boolean; - forumHour: number; + intervalMinutes: number; + /// Nur beim Käufe-Abgleich. + idleIntervalMinutes: number | null; + idleAfterDays: number | null; + /// Nur beim Forumslauf. + onlyAfterHour: number | null; +} + +export interface ScheduleState { + settings: ScheduleSetting[]; runs: ScheduleRun[]; } diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index 03fcc7a..285f81c 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -10,6 +10,7 @@ import type { ProductMapping, ProductTarget, Role, + ScheduleSetting, ScheduleState, } from '../lib/types'; @@ -74,7 +75,7 @@ export default function Admin() { {tab === 'users' && } {tab === 'mappings' && } {tab === 'audit' && } - {tab === 'schedule' && } + {tab === 'schedule' && } ); } @@ -522,80 +523,266 @@ 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. + * Zeitplan: was von allein laufen soll und wie oft. + * + * Die Abstände stehen in Minuten, weil sich damit alles ausdrücken lässt - + * daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnen + * muss. Beide Abgleiche greifen auf fremde Server zu; ein sehr kurzer Takt + * ist deshalb keine gute Idee und unter einer Viertelstunde nicht möglich. */ -function ScheduleTab({ onError }: { onError: (message: string) => void }) { +function ScheduleTab({ + onError, + onDone, +}: { + onError: (message: string) => void; + onDone: (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'); - } - })(); + const laden = useCallback(async () => { + try { + setStand(await api('/api/admin/schedule')); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } }, [onError]); + useEffect(() => { + void laden(); + }, [laden]); + const LABEL: Record = { gextension: 'Käufe bei GExtension', forum: 'Forums-Anträge', letzter_fehler: 'Zuletzt gescheitert', }; - return ( -
-

Zeitplan

- - {stand === null ? ( + if (stand === null) { + return ( +
+

Zeitplan

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

Noch kein Lauf verzeichnet.

- ) : ( -
- - - - - - + return ( + <> +
+

Zeitplan

+ + Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue + Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören - + ein knapper Takt bringt wenig und belastet sie ohne Not. + + + {stand.settings.map((setting) => ( + { + onDone(text); + void laden(); + }} + onError={onError} + /> + ))} +
+ +
+

Letzte Läufe

+ {stand.runs.length === 0 ? ( +

Noch kein Lauf verzeichnet.

+ ) : ( +
+
LaufZuletztErgebnis
+ + + + + + + + + {stand.runs.map((run) => ( + + + + - - - {stand.runs.map((run) => ( - - - - - - ))} - -
LaufZuletztErgebnis
{LABEL[run.name] ?? run.name}{formatDate(run.lastRunAt)} + {run.lastStatus === 'FAILED' && Fehler: } + {run.lastNote ?? '-'} +
{LABEL[run.name] ?? run.name}{formatDate(run.lastRunAt)} - {run.lastStatus === 'FAILED' && Fehler: } - {run.lastNote ?? '-'} -
+ ))} + + +
+ )} +
+ + ); +} + +/** Minuten in eine Angabe, die sich lesen lässt. */ +function inStunden(minuten: number): string { + if (minuten < 60) return `${minuten} Minuten`; + if (minuten % 1440 === 0) { + const tage = minuten / 1440; + return tage === 1 ? 'einmal täglich' : `alle ${tage} Tage`; + } + const stunden = minuten / 60; + const text = Number.isInteger(stunden) ? String(stunden) : stunden.toFixed(1).replace('.', ','); + return `${text} Stunden`; +} + +function ScheduleForm({ + setting, + titel, + onSaved, + onError, +}: { + setting: ScheduleSetting; + titel: string; + onSaved: (message: string) => void; + onError: (message: string) => void; +}) { + const [enabled, setEnabled] = useState(setting.enabled); + const [interval, setInterval] = useState(String(setting.intervalMinutes)); + const [idleInterval, setIdleInterval] = useState( + setting.idleIntervalMinutes === null ? '' : String(setting.idleIntervalMinutes), + ); + const [idleDays, setIdleDays] = useState( + setting.idleAfterDays === null ? '' : String(setting.idleAfterDays), + ); + const [hour, setHour] = useState( + setting.onlyAfterHour === null ? '' : String(setting.onlyAfterHour), + ); + const [busy, setBusy] = useState(false); + + const istKaeufe = setting.name === 'gextension'; + + const speichern = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + try { + await api(`/api/admin/schedule/${setting.name}`, { + method: 'PATCH', + body: { + enabled, + intervalMinutes: Number(interval), + ...(istKaeufe + ? { + idleIntervalMinutes: idleInterval ? Number(idleInterval) : null, + idleAfterDays: idleDays ? Number(idleDays) : null, + } + : { onlyAfterHour: hour === '' ? null : Number(hour) }), + }, + }); + onSaved(`Zeitplan für „${titel}" gespeichert.`); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Speichern fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

{titel}

+ +
+ +
+
+ + setInterval(event.target.value)} + disabled={!enabled} + required + /> + + {Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'} + +
+ + {istKaeufe ? ( + <> +
+ + setIdleInterval(event.target.value)} + disabled={!enabled} + placeholder="leer = derselbe Abstand" + /> + + {idleInterval && Number(idleInterval) >= 15 + ? `= ${inStunden(Number(idleInterval))}` + : 'wie oben'} +
- )} - - )} - + +
+ + setIdleDays(event.target.value)} + disabled={!enabled} + placeholder="leer = alle gleich" + /> + + {idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'} + +
+ + ) : ( +
+ + setHour(event.target.value)} + disabled={!enabled} + placeholder="leer = jederzeit" + /> + + {hour === '' + ? 'sobald der Abstand um ist' + : `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`} + +
+ )} +
+ +
+ +
+
); } diff --git a/web/src/styles.css b/web/src/styles.css index 0dcc8f6..9ab5ea3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1212,3 +1212,37 @@ textarea { color: var(--accent); border-color: var(--accent); } + +/* --- Zeitplan --- */ + +.schedule-form { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.85rem 1rem; + margin-top: 0.85rem; +} + +.schedule-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.schedule-head h3 { + margin: 0; +} + +.schedule-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.75rem 1rem; + margin-top: 0.6rem; +} + +/* Die Umrechnung steht direkt unter dem Feld, nicht daneben. */ +.schedule-fields span { + display: block; + margin-top: 0.2rem; +}