Files
ApiCore/ApiCore-Backend/src/automation/rule-config/parse-helpers.ts
T
Nick SzaunigandClaude Opus 5.5 d99a697739 feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:42:32 +02:00

350 lines
8.7 KiB
TypeScript

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<Record<string, unknown>>;
readonly path: string;
readonly issues: RuleConfigIssue[];
}
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<T>(
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<T extends string>(
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<T extends string>(
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<T>(
trigger: string,
defaults: T,
read: (scope: Scope) => T,
): RuleConfigParser<T> {
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;
},
};
}