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]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -0,0 +1,349 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user