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:
+3
-8
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+185
-50
@@ -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
|
||||||
const heuteUm = new Date(jetzt);
|
* zusätzlich verlangt, dass die Stunde erreicht ist - so lässt sich ein
|
||||||
heuteUm.setHours(stunde, 0, 0, 0);
|
* täglicher Lauf verlässlich in die Nacht legen, statt ihn mit jedem
|
||||||
|
* Neustart weiter in den Tag wandern zu lassen.
|
||||||
return letzter === null || letzter < heuteUm;
|
*/
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function kaeufePruefen(log: Logger): Promise<void> {
|
if (regel.onlyAfterHour === null || regel.onlyAfterHour === undefined) return true;
|
||||||
const faellig = await faelligeSpieler();
|
if (jetzt.getHours() < regel.onlyAfterHour) return false;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+214
-27
@@ -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,51 +523,75 @@ 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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (stand === null) {
|
||||||
return (
|
return (
|
||||||
<section className="block">
|
<section className="block">
|
||||||
<h2>Zeitplan</h2>
|
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
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 ? (
|
{stand.runs.length === 0 ? (
|
||||||
<p className="muted">Noch kein Lauf verzeichnet.</p>
|
<p className="muted">Noch kein Lauf verzeichnet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -594,8 +619,170 @@ function ScheduleTab({ onError }: { onError: (message: string) => void }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user