- 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]>
350 lines
8.7 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|