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.
# 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
+17 -13
View File
@@ -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
+2 -3
View File
@@ -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"
@@ -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])
}
/// 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.
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),
+26 -7
View File
@@ -267,14 +267,33 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
// --- 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) => {
+183 -48
View File
@@ -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<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 {
name: string;
lastRunAt: Date | null;
@@ -39,6 +67,91 @@ export interface RunState {
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> {
const eintrag = await prisma.scheduledRun.findUnique({ where: { name } });
return eintrag?.lastRunAt ?? null;
@@ -66,13 +179,21 @@ export async function listRuns(): Promise<RunState[]> {
/**
* 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<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([
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<number[]> {
.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<number[]> {
.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<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);
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<void> {
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<void> {
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<void> {
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<void> => {
@@ -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);
-1
View File
@@ -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');
-1
View File
@@ -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');
+86 -15
View File
@@ -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);
});
});
+14 -2
View File
@@ -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[];
}
+214 -27
View File
@@ -10,6 +10,7 @@ import type {
ProductMapping,
ProductTarget,
Role,
ScheduleSetting,
ScheduleState,
} from '../lib/types';
@@ -74,7 +75,7 @@ export default function Admin() {
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
{tab === 'audit' && <AuditTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} />}
{tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
</div>
);
}
@@ -522,51 +523,75 @@ 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<ScheduleState | null>(null);
useEffect(() => {
void (async () => {
const laden = useCallback(async () => {
try {
setStand(await api<ScheduleState>('/api/admin/schedule'));
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
})();
}, [onError]);
useEffect(() => {
void laden();
}, [laden]);
const LABEL: Record<string, string> = {
gextension: 'Käufe bei GExtension',
forum: 'Forums-Anträge',
letzter_fehler: 'Zuletzt gescheitert',
};
if (stand === null) {
return (
<section className="block">
<h2>Zeitplan</h2>
{stand === null ? (
<p className="muted">Lade …</p>
) : (
<>
{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>
)}
</section>
);
}
return (
<>
<section className="block">
<h2>Zeitplan</h2>
<Notice kind="info">
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.
</Notice>
{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>
) : (
@@ -594,8 +619,170 @@ function ScheduleTab({ onError }: { onError: (message: string) => void }) {
</table>
</div>
)}
</>
)}
</section>
</>
);
}
/** 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 (
<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>
<label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
<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);
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;
}