import { Logger } from '@nestjs/common'; import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types'; /** * Where a value is read from: the object, its field path (for messages) and * the list the issues go to. Helpers never throw - an invalid or missing key * gives the fallback, and an invalid one that is present is reported. */ export interface Scope { readonly raw: Readonly>; readonly path: string; readonly issues: RuleConfigIssue[]; } const NO_VALUES: Readonly> = Object.freeze({}); export function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null; } export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope { return { raw: asRecord(json) ?? NO_VALUES, path: '', issues }; } export function fieldPath(scope: Scope, key: string): string { return scope.path === '' ? key : `${scope.path}.${key}`; } export function report(scope: Scope, key: string, message: string): void { scope.issues.push({ field: fieldPath(scope, key), message }); } /** * A key counts as given when it is present - an explicit null too, which the * readers then report as invalid instead of silently using the default. */ export function has(scope: Scope, key: string): boolean { if (!Object.hasOwn(scope.raw, key)) { return false; } return scope.raw[key] !== undefined; } /** An explicit null: on an optional key it clears the value. */ export function isCleared(scope: Scope, key: string): boolean { return Object.hasOwn(scope.raw, key) && scope.raw[key] === null; } /** The nested object under `key`; a non-object is reported and read as empty. */ export function childScope(scope: Scope, key: string): Scope { const path = fieldPath(scope, key); if (!has(scope, key)) { return { raw: NO_VALUES, path, issues: scope.issues }; } const nested = asRecord(scope.raw[key]); if (nested === null) { report(scope, key, 'Bitte die Angaben als Gruppe senden.'); return { raw: NO_VALUES, path, issues: scope.issues }; } return { raw: nested, path, issues: scope.issues }; } /** Runs `read`; if it reported anything, `fallback()` replaces its result. */ export function orFallback( scope: Scope, read: () => T, fallback: () => T, ): T { const before = scope.issues.length; const value = read(); return scope.issues.length > before ? fallback() : value; } const numberFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 3, }); export function formatDe(value: number): string { return numberFormat.format(value); } export function intIn( scope: Scope, key: string, fallback: number, min: number, max: number, ): number { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if ( typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ) { return value; } report( scope, key, `Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`, ); return fallback; } /** Like intIn, but an absent key stays undefined. */ export function optionalIntIn( scope: Scope, key: string, min: number, max: number, ): number | undefined { if (!has(scope, key) || isCleared(scope, key)) { return undefined; } const value = intIn(scope, key, Number.NaN, min, max); return Number.isNaN(value) ? undefined : value; } export interface NumberOptions { /** The lower limit itself is not allowed (min < x). */ exclusiveMin?: boolean; } export function numberIn( scope: Scope, key: string, fallback: number, min: number, max: number, options: NumberOptions = {}, ): number { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if ( typeof value === 'number' && Number.isFinite(value) && (options.exclusiveMin ? value > min : value >= min) && value <= max ) { return value; } const lower = options.exclusiveMin ? `größer als ${formatDe(min)}` : `mindestens ${formatDe(min)}`; report( scope, key, Number.isFinite(max) ? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.` : `Bitte eine Zahl ${lower} angeben.`, ); return fallback; } /** Like numberIn, but an absent key stays undefined. */ export function optionalNumberIn( scope: Scope, key: string, min: number, max: number, options: NumberOptions = {}, ): number | undefined { if (!has(scope, key) || isCleared(scope, key)) { return undefined; } const value = numberIn(scope, key, Number.NaN, min, max, options); return Number.isNaN(value) ? undefined : value; } export function bool(scope: Scope, key: string, fallback: boolean): boolean { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if (typeof value === 'boolean') { return value; } report(scope, key, 'Bitte Ja oder Nein wählen.'); return fallback; } export function stringIn( scope: Scope, key: string, fallback: string, minLength: number, maxLength: number, pattern?: RegExp, ): string { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if ( typeof value === 'string' && value.length >= minLength && value.length <= maxLength && (pattern === undefined || pattern.test(value)) ) { return value; } report( scope, key, `Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`, ); return fallback; } const MONTH_DAY = /^(\d{2})-(\d{2})$/; /** 'MM-DD' that exists in a leap year (02-29 is allowed). */ export function isMonthDay(value: unknown): value is string { if (typeof value !== 'string') { return false; } const match = MONTH_DAY.exec(value); if (!match) { return false; } const month = Number(match[1]); const day = Number(match[2]); const date = new Date(Date.UTC(2024, month - 1, day)); return date.getUTCMonth() === month - 1 && date.getUTCDate() === day; } export function monthDay(scope: Scope, key: string, fallback: string): string { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if (isMonthDay(value)) { return value; } report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.'); return fallback; } export function months( scope: Scope, key: string, fallback: readonly number[], ): number[] { if (!has(scope, key)) { return [...fallback]; } const value = scope.raw[key]; if ( Array.isArray(value) && value.every( (month): month is number => typeof month === 'number' && Number.isInteger(month) && month >= 1 && month <= 12, ) && new Set(value).size === value.length ) { return [...value]; } report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.'); return [...fallback]; } export function enumValue( scope: Scope, key: string, fallback: T, allowed: readonly T[], ): T { if (!has(scope, key)) { return fallback; } const value = scope.raw[key]; if ( typeof value === 'string' && (allowed as readonly string[]).includes(value) ) { return value as T; } report(scope, key, 'Bitte einen gültigen Wert wählen.'); return fallback; } export function enumList( scope: Scope, key: string, fallback: readonly T[], allowed: readonly T[], ): T[] { if (!has(scope, key)) { return [...fallback]; } const value = scope.raw[key]; if ( Array.isArray(value) && value.every( (entry): entry is T => typeof entry === 'string' && (allowed as readonly string[]).includes(entry), ) && new Set(value).size === value.length ) { return [...value]; } report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.'); return [...fallback]; } const logger = new Logger('RuleConfig'); /** * Builds the parser of one rule from its reader. The reader turns the scope * into a complete config and reports every invalid key; `parse` logs those * (trigger and field, never the value) and returns the config, `validate` * returns them. */ export function makeParser( trigger: string, defaults: T, read: (scope: Scope) => T, ): RuleConfigParser { return { defaults, parse(json) { const issues: RuleConfigIssue[] = []; const value = read(rootScope(json, issues)); for (const issue of issues) { logger.warn( `Invalid setting ${trigger}.${issue.field}; using the default`, ); } return value; }, validate(json) { const issues: RuleConfigIssue[] = []; read(rootScope(json, issues)); return issues; }, }; }