feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)

Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:58:30 +02:00
co-authored by Claude Opus 5
parent 19b0751c16
commit d416f3869a
38 changed files with 1014 additions and 60 deletions
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { prisma } from "@/lib/db";
/**
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
*
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
*
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
*/
const SCHLUESSEL = "system";
export const einstellungenSchema = z.object({
// --- Adresse und Agent ---
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
agentAutoUpdate: z.boolean().default(false),
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
steuersatz: z.number().min(0).max(100).default(19),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
// --- Sicherheit ---
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
anmeldeversuche: z.number().int().min(3).max(50).default(5),
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
// --- Firma und Darstellung ---
firmaName: z.string().max(120).default(""),
firmaAnschrift: z.string().max(400).default(""),
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
zeitzone: z.string().min(1).default("Europe/Berlin"),
});
export type Einstellungen = z.infer<typeof einstellungenSchema>;
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
/**
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
*/
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
export async function einstellungen(): Promise<Einstellungen> {
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
return werte;
}
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: werte },
update: { wert: werte },
});
zwischenspeicher = null;
}
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
export function zwischenspeicherLeeren(): void {
zwischenspeicher = null;
}
/**
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
*/
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
const { zahlungszielTage } = await einstellungen();
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
}
/** systemd-Angabe für den Melderhythmus des Agents. */
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
switch (rhythmus) {
case "stuendlich":
return "hourly";
case "zwoelfstuendlich":
return "0/12:00:00";
default:
return "daily";
}
}
/**
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
*/
export function domaeneErlaubt(email: string, domaenen: string): boolean {
const liste = domaenen
.split(",")
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (liste.length === 0) return true;
const domaene = email.toLowerCase().split("@")[1] ?? "";
return liste.includes(domaene);
}