Zeitplan in der Oberflaeche einstellbar

Takt und Schalter der beiden Abgleiche standen in der Umgebung und liessen
sich nur mit einem Neustart aendern. Jetzt stehen sie unter Verwaltung ->
Zeitplan, je Lauf einzeln: ob er automatisch laeuft, in welchem Abstand, beim
Kaeufe-Abgleich zusaetzlich der seltenere Takt fuer ruhende Konten und ab
wann ein Konto als ruhend gilt, beim Forumslauf die Stunde, vor der nicht
gelaufen wird.

Die Abstaende stehen in Minuten - damit laesst sich alles ausdruecken -, und
daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnet.
Unter einer Viertelstunde geht nicht: beide Abgleiche greifen auf Server zu,
die der Community gehoeren.

Aenderungen greifen sofort, ohne Neustart. Die Vorgaben sind die bisherigen
Werte, sie stehen in der Migration.

SCHEDULE_ENABLED und FORUM_SCAN_HOUR entfallen damit.
This commit is contained in:
2026-09-21 13:25:56 +02:00
parent db37d3388a
commit 6094e4eaef
14 changed files with 658 additions and 168 deletions
+3 -8
View File
@@ -30,14 +30,9 @@ BOOTSTRAP_ADMIN_PASSWORD=
# (Burning Board) zugeschnitten. # (Burning Board) zugeschnitten.
# FORUM_BASE_URL=https://senseigaming.de/forum # FORUM_BASE_URL=https://senseigaming.de/forum
# Wiederkehrende Abgleiche. Die Käufe werden alle zwei Stunden geprüft, bei # Die wiederkehrenden Abgleiche werden in der Anwendung eingestellt, unter
# Spielern ohne Kauf in den letzten drei Jahren alle sechs; das Forum einmal # Verwaltung -> Zeitplan: ob sie laufen, in welchem Abstand, und beim Forum
# nachts. Gebucht wird dabei nichts von allein - neue Pakete landen in der # ab welcher Stunde. Hier steht dazu nichts mehr.
# 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). # Aufbewahrung der Audit-Einträge in Tagen (Standard: 365, 0 = nie löschen).
# AUDIT_RETENTION_DAYS=365 # AUDIT_RETENTION_DAYS=365
+17 -13
View File
@@ -350,27 +350,31 @@ das Signal zu unzuverlässig.
### Was von allein läuft ### 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 | | **läuft automatisch** | Aus heißt: nur noch von Hand, über den Knopf auf der jeweiligen Seite. |
| dieselben, aber ruhende Konten | alle sechs Stunden | | **Abstand** | In Minuten, mindestens 15 und höchstens eine Woche. Daneben steht, was das in Stunden bedeutet. |
| **Forums-Anträge** | einmal nachts, gegen 4 Uhr | | **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 Ab Werk: Käufe alle zwei Stunden, ruhende Konten alle sechs, ruhend nach drei Jahren; das Forum
meisten Spieler, und wer seit Jahren nichts kauft, fängt selten von einer Stunde auf die andere einmal täglich, frühestens ab vier Uhr.
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. 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 **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 in der Liste - entschieden wird weiterhin von Hand. Auf der Übersicht steht, wie viele Pakete auf
eine Buchung warten und bei wem. eine Buchung warten und bei wem.
Unter *Verwaltung → Zeitplan* steht, wann zuletzt gelaufen wurde und was dabei herauskam. Der Darunter steht, wann zuletzt gelaufen wurde und was dabei herauskam. Änderungen greifen sofort,
Zeitplan lässt sich über `SCHEDULE_ENABLED=false` abschalten, die Stunde des Forumslaufs über ohne Neustart; der Stand der Läufe steht in der Datenbank, ein Neustart stößt also nicht alles
`FORUM_SCAN_HOUR` verschieben. Der Stand steht in der Datenbank, nicht im Prozess: ein Neustart erneut an.
stößt deshalb nicht alles erneut an.
### Wie lange ein Abgleich dauert ### Wie lange ein Abgleich dauert
+2 -3
View File
@@ -17,10 +17,9 @@ services:
FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum} FORUM_BASE_URL: ${FORUM_BASE_URL:-https://senseigaming.de/forum}
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
# Die Zeitzone bestimmt, wann "nachts um vier" ist - ohne sie liefe der # 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} TZ: ${TZ:-Europe/Berlin}
SCHEDULE_ENABLED: ${SCHEDULE_ENABLED:-true}
FORUM_SCAN_HOUR: ${FORUM_SCAN_HOUR:-4}
expose: expose:
# Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS. # Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS.
- "3000" - "3000"
@@ -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;
+23
View File
@@ -506,3 +506,26 @@ model CharacterLink {
@@index([characterId]) @@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
}
-9
View File
@@ -35,15 +35,6 @@ const schema = z.object({
// WoltLab (Burning Board) zugeschnitten. // WoltLab (Burning Board) zugeschnitten.
FORUM_BASE_URL: z.string().url().default('https://senseigaming.de/forum'), 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. // Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
+26 -7
View File
@@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
// --- Audit-Log --- // --- Audit-Log ---
/** Stand der wiederkehrenden Abgleiche. */ /** Einstellungen und Stand der wiederkehrenden Abgleiche. */
app.get('/api/admin/schedule', async () => { app.get('/api/admin/schedule', async () => {
const { listRuns } = await import('../services/schedule.js'); const { listRuns, listSettings } = await import('../services/schedule.js');
return { const [settings, runs] = await Promise.all([listSettings(), listRuns()]);
enabled: env.SCHEDULE_ENABLED, return { settings, runs };
forumHour: env.FORUM_SCAN_HOUR, });
runs: await listRuns(),
}; 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) => { app.get('/api/admin/audit', async (request) => {
+183 -48
View File
@@ -1,9 +1,9 @@
import { CreditKind } from '@prisma/client'; import { CreditKind } from '@prisma/client';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { env } from '../env.js'; import { badRequest } from '../errors.js';
import { knownSteamIds, scrapePlayers } from './scraper.js'; import { knownSteamIds, scrapePlayers } from './scraper.js';
import { scanForum } from './forum.js'; import { scanForum } from './forum.js';
import type { ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
/** /**
* Wiederkehrende Läufe. * Wiederkehrende Läufe.
@@ -14,24 +14,52 @@ import type { ActionContext } from './audit.js';
* bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend * bearbeitet zu werden - und weil ein voller Durchlauf gut zwei Dutzend
* Seiten liest. * Seiten liest.
* *
* Der Zeitplan steht hier, der Stand in der Datenbank. Nach einem Neustart * Takt und Schalter stehen in der Datenbank, nicht in der Umgebung: so lässt
* wird deshalb nicht alles erneut angestoßen. * 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. /// 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 MINUTE_MS = 60 * 1000;
const AKTIVE_PRUEFUNG_MS = 2 * 60 * 60 * 1000; const TAG_MS = 24 * 60 * MINUTE_MS;
/// Wessen letzter Kauf lange zurückliegt, seltener - das spart die meisten
/// Anfragen, denn das sind mit der Zeit die meisten Spieler. /// Die beiden Läufe, die es gibt.
const RUHENDE_PRUEFUNG_MS = 6 * 60 * 60 * 1000; export const LAEUFE = ['gextension', 'forum'] as const;
/// Ab wann jemand als ruhend gilt. export type LaufName = (typeof LAEUFE)[number];
const RUHEND_AB_MS = 3 * 365 * 24 * 60 * 60 * 1000;
/// Unter diesem Namen läuft, was der Zeitplan anstößt. /// Unter diesem Namen läuft, was der Zeitplan anstößt.
const SYSTEM_CONTEXT: ActionContext = { actorId: null, actorName: 'Zeitplan' }; 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<LaufName, ScheduleSettingInput> = {
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 { export interface RunState {
name: string; name: string;
lastRunAt: Date | null; lastRunAt: Date | null;
@@ -39,6 +67,91 @@ export interface RunState {
lastNote: string | null; lastNote: string | null;
} }
export async function getSetting(name: LaufName): Promise<ScheduleSettingInput> {
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<ScheduleSettingInput>,
ctx: ActionContext,
): Promise<ScheduleSettingInput> {
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<Date | null> { async function letzterLauf(name: string): Promise<Date | null> {
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } }); const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
return eintrag?.lastRunAt ?? null; return eintrag?.lastRunAt ?? null;
@@ -66,13 +179,21 @@ export async function listRuns(): Promise<RunState[]> {
/** /**
* Die Spieler, die jetzt an der Reihe sind. * Die Spieler, die jetzt an der Reihe sind.
* *
* Wer in den letzten drei Jahren etwas gekauft hat, wird alle zwei Stunden * Wer in letzter Zeit gekauft hat, wird im normalen Takt geprüft. Bei allen
* geprüft. Bei allen anderen reichen sechs - wer seit Jahren nichts kauft, * anderen reicht der seltenere - wer seit Jahren nichts kauft, fängt selten
* fängt selten von einer Stunde auf die andere damit an, und es sind mit * von einer Stunde auf die andere damit an, und es sind mit Abstand die
* Abstand die meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt * meisten. Maßgeblich ist der Kaufzeitpunkt, nicht der Zeitpunkt der Eingabe
* der Eingabe hier. * hier.
*/ */
export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> { export async function faelligeSpieler(
jetzt = new Date(),
einstellung?: ScheduleSettingInput,
): Promise<number[]> {
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([ const [spieler, letzteKaeufe] = await Promise.all([
prisma.player.findMany({ prisma.player.findMany({
where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] }, where: { OR: [{ steamId: { not: null } }, { aliases: { some: { type: 'STEAM_ID' } } }] },
@@ -91,8 +212,8 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.filter((p) => { .filter((p) => {
const letzterKauf = kaufNach.get(p.id) ?? null; const letzterKauf = kaufNach.get(p.id) ?? null;
const ruhend = const ruhend =
letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > RUHEND_AB_MS; ruhendAb > 0 && (letzterKauf === null || jetzt.getTime() - letzterKauf.getTime() > ruhendAb);
const abstand = ruhend ? RUHENDE_PRUEFUNG_MS : AKTIVE_PRUEFUNG_MS; const abstand = ruhend ? ruhenderTakt : aktiverTakt;
if (p.gextensionSyncedAt === null) return true; if (p.gextensionSyncedAt === null) return true;
return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand; return jetzt.getTime() - p.gextensionSyncedAt.getTime() >= abstand;
@@ -100,18 +221,45 @@ export async function faelligeSpieler(jetzt = new Date()): Promise<number[]> {
.map((p) => p.id); .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 { * Ob ein Lauf jetzt ansteht.
if (jetzt.getHours() < stunde) return false; *
* 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<ScheduleSettingInput, 'intervalMinutes' | 'onlyAfterHour'>,
): boolean {
if (letzter !== null) {
const vergangen = jetzt.getTime() - letzter.getTime();
if (vergangen < regel.intervalMinutes * MINUTE_MS) return false;
}
const heuteUm = new Date(jetzt); if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
heuteUm.setHours(stunde, 0, 0, 0); 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<void> { interface Logger {
const faellig = await faelligeSpieler(); info: (msg: string) => void;
warn: (msg: string) => void;
debug: (msg: string) => void;
}
async function kaeufePruefen(log: Logger, regel: ScheduleSettingInput): Promise<void> {
const faellig = await faelligeSpieler(new Date(), regel);
if (faellig.length === 0) { if (faellig.length === 0) {
log.debug('Zeitplan: keine Spieler fällig'); log.debug('Zeitplan: keine Spieler fällig');
return; return;
@@ -144,22 +292,11 @@ async function forumPruefen(log: Logger): Promise<void> {
await laufNotieren('forum', ergebnis.failedBoards.length > 0 ? 'FAILED' : 'OK', note); 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 * Startet den Zeitplan. Es läuft immer nur ein Abgleich - stockt einer, wird
* der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen. * der nächste Termin übersprungen statt zwei Läufe übereinanderzulegen.
*/ */
export function startSchedule(log: Logger): () => void { export function startSchedule(log: Logger): () => void {
if (!env.SCHEDULE_ENABLED) {
log.info('Zeitplan ist abgeschaltet (SCHEDULE_ENABLED=false)');
return () => {};
}
let laeuft = false; let laeuft = false;
const tick = async (): Promise<void> => { const tick = async (): Promise<void> => {
@@ -169,16 +306,16 @@ export function startSchedule(log: Logger): () => void {
try { try {
const jetzt = new Date(); const jetzt = new Date();
const forumZuletzt = await letzterLauf('forum'); const forumRegel = await getSetting('forum');
if (forumLaufFaellig(forumZuletzt, jetzt, env.FORUM_SCAN_HOUR)) { if (forumRegel.enabled && istFaellig(await letzterLauf('forum'), jetzt, forumRegel)) {
await forumPruefen(log); await forumPruefen(log);
return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt. return; // Der Forumslauf dauert; die Käufe kommen beim nächsten Takt.
} }
const kaeufeZuletzt = await letzterLauf('gextension'); const kaufRegel = await getSetting('gextension');
const abstandReicht = if (kaufRegel.enabled && istFaellig(await letzterLauf('gextension'), jetzt, kaufRegel)) {
kaeufeZuletzt === null || jetzt.getTime() - kaeufeZuletzt.getTime() >= AKTIVE_PRUEFUNG_MS; await kaeufePruefen(log, kaufRegel);
if (abstandReicht) await kaeufePruefen(log); }
} catch (error) { } catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler'; const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`); log.warn(`Zeitplan: Lauf fehlgeschlagen - ${grund}`);
@@ -195,9 +332,7 @@ export function startSchedule(log: Logger): () => void {
start.unref(); start.unref();
timer.unref(); timer.unref();
log.info( log.info('Zeitplan läuft; Takt und Schalter stehen unter Verwaltung → Zeitplan');
`Zeitplan läuft: Käufe alle 2 Stunden (ruhende Konten alle 6), Forum täglich um ${env.FORUM_SCAN_HOUR} Uhr`,
);
return () => { return () => {
clearTimeout(start); clearTimeout(start);
-1
View File
@@ -21,7 +21,6 @@ before(async () => {
process.env.DATABASE_URL = database.url; process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js'); db = await import('../src/db.js');
backup = await import('../src/services/backup.js'); backup = await import('../src/services/backup.js');
-1
View File
@@ -17,7 +17,6 @@ before(async () => {
process.env.DATABASE_URL = database.url; process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.SCHEDULE_ENABLED = 'false';
db = await import('../src/db.js'); db = await import('../src/db.js');
characters = await import('../src/services/characters.js'); characters = await import('../src/services/characters.js');
+86 -15
View File
@@ -21,8 +21,6 @@ before(async () => {
process.env.DATABASE_URL = database.url; process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test'; 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'); db = await import('../src/db.js');
schedule = await import('../src/services/schedule.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', () => { describe('Wann ein Lauf ansteht', () => {
const nacht = 4; const taeglichNachts = { intervalMinutes: 1440, onlyAfterHour: 4 };
const allezweiStunden = { intervalMinutes: 120, onlyAfterHour: null };
it('läuft nicht, bevor die Stunde erreicht ist', () => { it('wartet den eingestellten Abstand ab', () => {
const frueh = new Date('2026-09-21T02:30:00'); const jetzt = new Date('2026-09-21T12:00:00');
assert.equal(schedule.forumLaufFaellig(null, frueh, nacht), false); 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', () => { it('läuft beim ersten Mal sofort', () => {
const morgens = new Date('2026-09-21T04:10:00'); assert.equal(schedule.istFaellig(null, new Date('2026-09-21T12:00:00'), allezweiStunden), true);
const gestern = new Date('2026-09-20T04:05:00'); });
assert.equal(schedule.forumLaufFaellig(gestern, morgens, nacht), 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', () => { it('läuft an einem Tag nur einmal', () => {
const morgens = new Date('2026-09-21T04:10:00'); const morgens = new Date('2026-09-21T04:10:00');
const abends = new Date('2026-09-21T22:00: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', () => { it('holt einen ausgefallenen Lauf am selben Tag nach', () => {
// Der Dienst stand nachts still und kommt erst mittags hoch. // Der Dienst stand nachts still und kommt erst mittags hoch.
const mittags = new Date('2026-09-21T12:00:00'); const mittags = new Date('2026-09-21T12:00:00');
const vorgestern = new Date('2026-09-19T04: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 nachStart = new Date('2026-09-21T06:00:00');
const heuteFrueh = new Date('2026-09-21T04:02: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);
}); });
}); });
+14 -2
View File
@@ -352,9 +352,21 @@ export interface ScheduleRun {
lastNote: string | null; lastNote: string | null;
} }
export interface ScheduleState { export type ScheduleName = 'gextension' | 'forum';
export interface ScheduleSetting {
name: ScheduleName;
enabled: boolean; 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[]; runs: ScheduleRun[];
} }
+248 -61
View File
@@ -10,6 +10,7 @@ import type {
ProductMapping, ProductMapping,
ProductTarget, ProductTarget,
Role, Role,
ScheduleSetting,
ScheduleState, ScheduleState,
} from '../lib/types'; } from '../lib/types';
@@ -74,7 +75,7 @@ export default function Admin() {
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />} {tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />} {tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
{tab === 'audit' && <AuditTab onError={setError} />} {tab === 'audit' && <AuditTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} />} {tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
</div> </div>
); );
} }
@@ -522,80 +523,266 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
} }
/** /**
* Was die wiederkehrenden Abgleiche zuletzt getan haben. Sie laufen ohne * Zeitplan: was von allein laufen soll und wie oft.
* Zutun, deshalb muss nachlesbar sein, ob sie überhaupt laufen - ein still *
* stehender Zeitplan fällt sonst erst auf, wenn Wochen fehlen. * 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<ScheduleState | null>(null); const [stand, setStand] = useState<ScheduleState | null>(null);
useEffect(() => { const laden = useCallback(async () => {
void (async () => { try {
try { setStand(await api<ScheduleState>('/api/admin/schedule'));
setStand(await api<ScheduleState>('/api/admin/schedule')); } catch (err) {
} catch (err) { onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); }
}
})();
}, [onError]); }, [onError]);
useEffect(() => {
void laden();
}, [laden]);
const LABEL: Record<string, string> = { const LABEL: Record<string, string> = {
gextension: 'Käufe bei GExtension', gextension: 'Käufe bei GExtension',
forum: 'Forums-Anträge', forum: 'Forums-Anträge',
letzter_fehler: 'Zuletzt gescheitert', letzter_fehler: 'Zuletzt gescheitert',
}; };
return ( if (stand === null) {
<section className="block"> return (
<h2>Zeitplan</h2> <section className="block">
<h2>Zeitplan</h2>
{stand === null ? (
<p className="muted">Lade …</p> <p className="muted">Lade …</p>
) : ( </section>
<> );
{stand.enabled ? ( }
<Notice kind="info">
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.
</Notice>
) : (
<Notice kind="warn">
Der Zeitplan ist abgeschaltet (<code className="mono">SCHEDULE_ENABLED=false</code>).
Beide Abgleiche laufen nur, wenn sie von Hand angestoßen werden.
</Notice>
)}
{stand.runs.length === 0 ? ( return (
<p className="muted">Noch kein Lauf verzeichnet.</p> <>
) : ( <section className="block">
<div className="table-wrap"> <h2>Zeitplan</h2>
<table className="table"> <Notice kind="info">
<thead> Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue
<tr> Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören -
<th>Lauf</th> ein knapper Takt bringt wenig und belastet sie ohne Not.
<th>Zuletzt</th> </Notice>
<th>Ergebnis</th>
{stand.settings.map((setting) => (
<ScheduleForm
key={setting.name}
setting={setting}
titel={LABEL[setting.name] ?? setting.name}
onSaved={(text) => {
onDone(text);
void laden();
}}
onError={onError}
/>
))}
</section>
<section className="block">
<h2>Letzte Läufe</h2>
{stand.runs.length === 0 ? (
<p className="muted">Noch kein Lauf verzeichnet.</p>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Lauf</th>
<th>Zuletzt</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{stand.runs.map((run) => (
<tr key={run.name}>
<td>{LABEL[run.name] ?? run.name}</td>
<td className="muted small">{formatDate(run.lastRunAt)}</td>
<td className="muted small">
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
{run.lastNote ?? '-'}
</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{stand.runs.map((run) => ( </table>
<tr key={run.name}> </div>
<td>{LABEL[run.name] ?? run.name}</td> )}
<td className="muted small">{formatDate(run.lastRunAt)}</td> </section>
<td className="muted small"> </>
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>} );
{run.lastNote ?? '-'} }
</td>
</tr> /** Minuten in eine Angabe, die sich lesen lässt. */
))} function inStunden(minuten: number): string {
</tbody> if (minuten < 60) return `${minuten} Minuten`;
</table> 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 (
<form onSubmit={speichern} className="schedule-form">
<div className="schedule-head">
<h3>{titel}</h3>
<label className="checkbox">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>{enabled ? 'läuft automatisch' : 'nur von Hand'}</span>
</label>
</div>
<div className="schedule-fields">
<div>
<label htmlFor={`${setting.name}-interval`}>Abstand (Minuten)</label>
<input
id={`${setting.name}-interval`}
type="number"
min={15}
max={10080}
value={interval}
onChange={(event) => setInterval(event.target.value)}
disabled={!enabled}
required
/>
<span className="muted small">
{Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
</span>
</div>
{istKaeufe ? (
<>
<div>
<label htmlFor={`${setting.name}-idle`}>Ruhende Konten (Minuten)</label>
<input
id={`${setting.name}-idle`}
type="number"
min={15}
max={10080}
value={idleInterval}
onChange={(event) => setIdleInterval(event.target.value)}
disabled={!enabled}
placeholder="leer = derselbe Abstand"
/>
<span className="muted small">
{idleInterval && Number(idleInterval) >= 15
? `= ${inStunden(Number(idleInterval))}`
: 'wie oben'}
</span>
</div> </div>
)}
</> <div>
)} <label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
</section> <input
id={`${setting.name}-days`}
type="number"
min={1}
value={idleDays}
onChange={(event) => setIdleDays(event.target.value)}
disabled={!enabled}
placeholder="leer = alle gleich"
/>
<span className="muted small">
{idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
</span>
</div>
</>
) : (
<div>
<label htmlFor={`${setting.name}-hour`}>Frühestens ab (Uhr)</label>
<input
id={`${setting.name}-hour`}
type="number"
min={0}
max={23}
value={hour}
onChange={(event) => setHour(event.target.value)}
disabled={!enabled}
placeholder="leer = jederzeit"
/>
<span className="muted small">
{hour === ''
? 'sobald der Abstand um ist'
: `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
</span>
</div>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Speichern'}
</button>
</div>
</form>
); );
} }
+34
View File
@@ -1212,3 +1212,37 @@ textarea {
color: var(--accent); color: var(--accent);
border-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;
}