Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
import { z } from "zod";
|
|
|
|
/**
|
|
* Zentrale Umgebungsvalidierung.
|
|
*
|
|
* Fehlt oder taugt eine Variable nicht, bricht der Start hier ab — mit einer
|
|
* Meldung, die sagt welche. Das ist deutlich angenehmer als ein Dienst, der
|
|
* anläuft und beim ersten Datei-Upload merkt, dass MinIO nie konfiguriert war.
|
|
*
|
|
* Beim Container-Build ist keine echte Umgebung vorhanden; dort setzt das
|
|
* Dockerfile SKIP_ENV_VALIDATION=1.
|
|
*/
|
|
|
|
/**
|
|
* Akzeptiert "true"/"1"/"ja" als wahr — Compose liefert alles als Text.
|
|
* Der Standardwert wird innerhalb der Umwandlung gesetzt, nicht per .default():
|
|
* .default() erwartet den Eingabetyp (string), nicht das Ergebnis (boolean).
|
|
*/
|
|
const boolAusText = (standard: boolean) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((wert) => {
|
|
if (wert === undefined || wert.trim() === "") return standard;
|
|
return ["true", "1", "ja", "yes", "on"].includes(wert.trim().toLowerCase());
|
|
});
|
|
|
|
const zahl = (standard: number) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((wert) => (wert === undefined || wert === "" ? standard : Number(wert)))
|
|
.pipe(z.number().int().positive());
|
|
|
|
const schema = z.object({
|
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
TZ: z.string().default("Europe/Berlin"),
|
|
APP_URL: z.string().url().default("http://localhost:3000"),
|
|
|
|
// Datenbank
|
|
DATABASE_URL: z.string().url(),
|
|
DIRECT_DATABASE_URL: z.string().url().optional(),
|
|
|
|
// Redis / Warteschlangen
|
|
REDIS_URL: z.string().url(),
|
|
WORKER_CONCURRENCY: zahl(5),
|
|
|
|
// Objektspeicher
|
|
S3_ENDPOINT: z.string().min(1),
|
|
S3_PORT: zahl(9000),
|
|
S3_USE_SSL: boolAusText(false),
|
|
S3_ACCESS_KEY: z.string().min(1),
|
|
S3_SECRET_KEY: z.string().min(1),
|
|
S3_REGION: z.string().default("eu-central-1"),
|
|
S3_BUCKET: z.string().min(1).default("florian"),
|
|
S3_PUBLIC_URL: z.string().url().optional(),
|
|
S3_URL_TTL: zahl(900),
|
|
|
|
// PDF-Erzeugung
|
|
GOTENBERG_URL: z.string().url().optional(),
|
|
GOTENBERG_TIMEOUT: zahl(30),
|
|
|
|
// Sitzungen
|
|
AUTH_SECRET: z.string().min(32, "AUTH_SECRET muss mindestens 32 Zeichen haben"),
|
|
AUTH_TRUST_HOST: boolAusText(true),
|
|
AUTH_SESSION_MAX_AGE: zahl(28_800),
|
|
|
|
// OIDC gegen authentik
|
|
AUTH_OIDC_ENABLED: boolAusText(false),
|
|
AUTH_OIDC_ISSUER: z.string().optional(),
|
|
AUTH_OIDC_CLIENT_ID: z.string().optional(),
|
|
AUTH_OIDC_CLIENT_SECRET: z.string().optional(),
|
|
AUTH_OIDC_NAME: z.string().default("authentik"),
|
|
AUTH_OIDC_GROUP_CLAIM: z.string().default("groups"),
|
|
AUTH_OIDC_GROUP_MAPPING: z.string().default(""),
|
|
AUTH_OIDC_AUTO_PROVISION: boolAusText(true),
|
|
AUTH_OIDC_STANDARD_ORGANISATION: z.string().optional(),
|
|
|
|
// Lokaler Login
|
|
AUTH_LOCAL_ENABLED: boolAusText(true),
|
|
AUTH_BCRYPT_ROUNDS: zahl(12),
|
|
|
|
// Kiosk
|
|
KIOSK_ENABLED: boolAusText(true),
|
|
KIOSK_DEVICE_SECRET: z.string().min(8),
|
|
KIOSK_PIN_LAENGE: zahl(6),
|
|
KIOSK_MAX_FEHLVERSUCHE: zahl(5),
|
|
KIOSK_SPERRDAUER: zahl(300),
|
|
KIOSK_IDLE_TIMEOUT: zahl(120),
|
|
|
|
// Integrationen (alle optional und abschaltbar)
|
|
MAIL_ENABLED: boolAusText(false),
|
|
MAIL_HOST: z.string().optional(),
|
|
MAIL_PORT: zahl(587),
|
|
MAIL_SECURE: boolAusText(false),
|
|
MAIL_USER: z.string().optional(),
|
|
MAIL_PASSWORD: z.string().optional(),
|
|
MAIL_FROM: z.string().optional(),
|
|
|
|
TELEGRAM_ENABLED: boolAusText(false),
|
|
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
|
TELEGRAM_CHAT_ID: z.string().optional(),
|
|
|
|
LLM_ENABLED: boolAusText(false),
|
|
LLM_BASE_URL: z.string().optional(),
|
|
LLM_API_KEY: z.string().optional(),
|
|
LLM_MODELL: z.string().optional(),
|
|
|
|
// Wartung und Prüfung (Phase 2)
|
|
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
|
|
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
|
|
/** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */
|
|
WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"),
|
|
/** Wie weit im Voraus Termine angelegt werden. */
|
|
WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180),
|
|
|
|
// Aufbewahrung
|
|
AUFBEWAHRUNG_JAHRE: zahl(10),
|
|
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
|
|
PERSON_ANONYMISIERUNG_TAGE: zahl(365),
|
|
});
|
|
|
|
export type Umgebung = z.infer<typeof schema>;
|
|
|
|
/** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */
|
|
const platzhalter: Record<string, string> = {
|
|
DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public",
|
|
REDIS_URL: "redis://localhost:6379",
|
|
S3_ENDPOINT: "localhost",
|
|
S3_ACCESS_KEY: "build",
|
|
S3_SECRET_KEY: "build",
|
|
AUTH_SECRET: "build_time_platzhalter_mindestens_32_zeichen",
|
|
KIOSK_DEVICE_SECRET: "build_time_platzhalter",
|
|
};
|
|
|
|
function lesen(): Umgebung {
|
|
const roh =
|
|
process.env.SKIP_ENV_VALIDATION === "1" || process.env.NODE_ENV === "test"
|
|
? { ...platzhalter, ...process.env }
|
|
: process.env;
|
|
|
|
const ergebnis = schema.safeParse(roh);
|
|
|
|
if (!ergebnis.success) {
|
|
const zeilen = ergebnis.error.issues.map(
|
|
(problem) => ` - ${problem.path.join(".")}: ${problem.message}`,
|
|
);
|
|
throw new Error(
|
|
[
|
|
"Die Umgebungskonfiguration ist unvollständig oder fehlerhaft:",
|
|
...zeilen,
|
|
"",
|
|
"Vorlage mit Erklärungen zu jeder Variable: .env.example",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
return ergebnis.data;
|
|
}
|
|
|
|
export const env: Umgebung = lesen();
|
|
|
|
/** true, wenn die Anmeldung über authentik vollständig konfiguriert ist. */
|
|
export const oidcVerfuegbar =
|
|
env.AUTH_OIDC_ENABLED &&
|
|
Boolean(env.AUTH_OIDC_ISSUER) &&
|
|
Boolean(env.AUTH_OIDC_CLIENT_ID) &&
|
|
Boolean(env.AUTH_OIDC_CLIENT_SECRET);
|
|
|
|
/**
|
|
* Zuordnung authentik-Gruppe zu FLORIAN-Rolle aus AUTH_OIDC_GROUP_MAPPING.
|
|
* Format: "gruppe:ROLLE,gruppe2:ROLLE2".
|
|
*/
|
|
export function oidcGruppenZuordnung(): Map<string, string> {
|
|
const karte = new Map<string, string>();
|
|
for (const eintrag of env.AUTH_OIDC_GROUP_MAPPING.split(",")) {
|
|
const [gruppe, rolle] = eintrag.split(":").map((teil) => teil?.trim());
|
|
if (gruppe && rolle) karte.set(gruppe.toLowerCase(), rolle.toUpperCase());
|
|
}
|
|
return karte;
|
|
}
|