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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

+2
View File
@@ -53,6 +53,7 @@ import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
@@ -106,6 +107,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
WebhooksModule,
DocumentsModule,
AutomationModule,
SuggestionsModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PermissionResolverService } from './permission-resolver.service';
/**
* PermissionResolverService for the job and worker context (BackupService
* clears its cache after a restore, which no job does). Not
* PermissionsModule: its PermissionCatalogSyncService would rewrite the
* permission catalog on every job or worker start. Global like
* PermissionsModule, so modules that inject the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
export class JobPermissionsModule {}
@@ -1,4 +1,4 @@
import { Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { AutomationService } from './automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
createMany: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
task: { findFirst: jest.Mock };
$queryRaw: jest.Mock;
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
createMany: jest.fn().mockResolvedValue({ count: 0 }),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
task: { findFirst: jest.fn().mockResolvedValue(null) },
$queryRaw: jest.fn().mockResolvedValue([]),
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
expect(tasks.createInTx).not.toHaveBeenCalled();
});
it('triggers when feed status is kritisch, case-insensitive', async () => {
it('triggers when feed status is CRITICAL', async () => {
tx.automationRule.findMany.mockResolvedValue([feedRule]);
await service.evaluateInspectionIn(asTx(), {
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
colonyId: 'colony-1',
queenSeen: true,
eggsPresent: true,
feedStatus: 'Kritisch',
feedStatus: 'CRITICAL',
} as never);
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
await service.evaluateInspectionIn(asTx(), {
...queenlessInspection,
feedStatus: 'kritisch',
feedStatus: 'CRITICAL',
} as never);
expect(tasks.createInTx).not.toHaveBeenCalled();
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
});
describe('update', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
// The rule row is locked first, then read, so "before" is the committed
// state at the moment of the write.
beforeEach(() => {
tx.$queryRaw.mockResolvedValue([{ locked: 1 }]);
});
it('locks the rule row and reads before inside the transaction', async () => {
const calls: string[] = [];
tx.$queryRaw.mockImplementation(() => {
calls.push('lock');
return Promise.resolve([{ locked: 1 }]);
});
tx.automationRule.findUnique.mockImplementation(() => {
calls.push('read');
return Promise.resolve(varroaRule);
});
tx.automationRule.update.mockImplementation(() => {
calls.push('write');
return Promise.resolve({ ...varroaRule, active: false });
});
auditLog.record.mockImplementation(() => {
calls.push('audit');
return Promise.resolve();
});
await service.update('rule-3', { active: false }, 'user-1');
expect(calls).toEqual(['lock', 'read', 'write', 'audit']);
const [[lockQuery]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(lockQuery.sql).toContain('FROM "AutomationRule"');
expect(lockQuery.sql).toContain('FOR UPDATE');
expect(lockQuery.values).toEqual(['rule-3']);
// Nothing is read or written outside the transaction.
expect(prisma.automationRule.findUnique).not.toHaveBeenCalled();
expect(prisma.automationRule.update).not.toHaveBeenCalled();
});
it('answers 404 when the rule does not exist', async () => {
tx.$queryRaw.mockResolvedValue([]);
await expect(
service.update('missing', { active: false }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.automationRule.update).not.toHaveBeenCalled();
});
it('records an audit log entry with before/after data in the transaction', async () => {
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue({
...varroaRule,
active: false,
});
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
entityType: 'AutomationRule',
entityId: 'rule-3',
action: 'UPDATE',
beforeData: varroaRule,
}),
tx,
);
});
it('clears the config on null so the rule falls back to its defaults', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue({
...varroaRule,
config: null,
});
await service.update('rule-3', { config: null as never }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
// A Json column is cleared with DbNull - a plain null fails the write.
@@ -952,16 +1002,60 @@ describe('AutomationService', () => {
});
it('leaves the config untouched when it is omitted', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue(varroaRule);
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue(varroaRule);
await service.update('rule-3', { active: true }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toBeUndefined();
});
it('rejects an invalid known value with the field and writes nothing', async () => {
tx.automationRule.findUnique.mockResolvedValue({
...varroaRule,
id: 'rule-9',
triggerType: 'SEASON_AUTOPILOT',
config: {},
});
const result = service.update(
'rule-9',
{ config: { queenMaxAgeYears: 0, somethingElse: true } },
'user-1',
);
await expect(result).rejects.toBeInstanceOf(BadRequestException);
await expect(result).rejects.toMatchObject({
response: {
message: 'Die Einstellung ist ungültig.',
issues: [{ field: 'queenMaxAgeYears' }],
},
});
expect(tx.automationRule.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('keeps unknown keys of a valid config as sent', async () => {
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue(varroaRule);
await service.update(
'rule-3',
{ config: { thresholdMitesPerDay: 7, somethingElse: true } },
'user-1',
);
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toEqual({
thresholdMitesPerDay: 7,
somethingElse: true,
});
});
});
describe('generateNumber', () => {
@@ -1,5 +1,11 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
@@ -21,6 +27,7 @@ import {
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
import { varroaThresholdOf } from './varroa-threshold';
type Tx = Prisma.TransactionClient;
@@ -56,84 +63,6 @@ function isUniqueViolation(error: unknown): boolean {
);
}
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
QUEENLESS_NO_EGGS: {
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/**
* Optional per-rule follow-up interval (config.dueInDays). There is no
* built-in default: how soon a queenless colony or a high mite fall needs a
@@ -223,33 +152,58 @@ export class AutomationService {
return rule;
}
/**
* The row is locked before it is read, so "before" (and the audit entry)
* is the state this write replaces. A config with an invalid known value
* is refused with the fields named; unknown keys pass unchanged.
*/
async update(
id: string,
dto: UpdateAutomationRuleDto,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
where: { id },
data: {
...dto,
// null resets the rule to its defaults; a Json column is cleared
// with DbNull, a plain null would fail the write.
config:
dto.config === null
? Prisma.DbNull
: (dto.config as Prisma.InputJsonValue | undefined),
},
return this.prisma.$transaction(async (tx) => {
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
const before = locked
? await tx.automationRule.findUnique({ where: { id } })
: null;
if (!before) {
throw new NotFoundException('Automation rule not found');
}
if (typeof dto.config === 'object' && dto.config !== null) {
const issues = validateRuleConfig(before.triggerType, dto.config);
if (issues.length > 0) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues,
});
}
}
const rule = await tx.automationRule.update({
where: { id },
data: {
...dto,
// null resets the rule to its defaults; a Json column is cleared
// with DbNull, a plain null would fail the write.
config:
dto.config === null
? Prisma.DbNull
: (dto.config as Prisma.InputJsonValue | undefined),
},
});
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
},
tx,
);
return rule;
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
// -------------------------------------------------------------------------
@@ -287,7 +241,7 @@ export class AutomationService {
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
if (feedRule && inspection.feedStatus === 'CRITICAL') {
await this.triggerActionIn(
tx,
feedRule,
@@ -0,0 +1,40 @@
import { bool, intIn, makeParser } from './parse-helpers';
import type { ChainConfig } from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
export function chainParser(trigger: string, defaults: ChainConfig) {
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
dueInDays: intIn(
scope,
'dueInDays',
defaults.dueInDays,
0,
MAX_DUE_IN_DAYS,
),
notify: bool(scope, 'notify', defaults.notify),
}));
}
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
dueInDays: 7,
notify: false,
};
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
'WITHDRAWAL_ENDED',
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
(scope) => ({
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
}),
);
/** The cleanup rule has no setting; it is only switched on or off. */
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
'COLONY_ENDED_CLEANUP',
{},
() => ({}),
);
@@ -0,0 +1,72 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
export interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
/** Texts of the rules that create a task; the new rules have none yet. */
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
SWARM_MOOD_HIGH:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: '',
BOTTLING_LOT_NUMBER_FORMAT: '',
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
PRODUCTION_BATCH_NUMBER_FORMAT: '',
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
INSPECTION_INTERVAL: '',
QUEEN_CELLS_SWARM_CHECK: '',
QUEEN_ACCEPTANCE_CHECK: '',
SPLIT_SCHEDULE: '',
COLONY_ENDED_CLEANUP: '',
WITHDRAWAL_ENDED: '',
HONEY_WATER_CONTENT: '',
SEASON_AUTOPILOT: '',
PHENOLOGY: '',
};
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
// the minimum per kind (config.minGrams) lives on the rule - and without
// one there is nothing to compare, hence off by default.
// - Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
'FEEDING_FOLLOW_UP',
]);
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
return {
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
string,
unknown
>,
actionTemplate: ACTION_TEMPLATES[trigger],
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
};
}
/** The rule rows created on first use; the config is the parser's default. */
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
Object.fromEntries(
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
(trigger) => [trigger, defaultsOf(trigger)],
),
) as Record<AutomationTriggerType, RuleDefaults>;
@@ -0,0 +1,8 @@
export * from './rule-config.types';
export {
RULE_CONFIG_PARSERS,
ruleConfigOf,
validateRuleConfig,
} from './rule-config.registry';
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
@@ -0,0 +1,44 @@
import type { SeasonSegmentKey } from '../../season/season-types';
import { childScope, intIn, makeParser } from './parse-helpers';
import type { InspectionIntervalConfig } from './rule-config.types';
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
const MIN_INTERVAL_DAYS = 7;
const MAX_INTERVAL_DAYS = 35;
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
const MAX_SWARM_INTERVAL_DAYS = 9;
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
},
};
const SEGMENTS = Object.keys(
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
) as IntervalSegment[];
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
'INSPECTION_INTERVAL',
DEFAULT_INSPECTION_INTERVAL_CONFIG,
(scope) => {
const days = childScope(scope, 'intervalDays');
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
const intervalDays = { ...defaults };
for (const segment of SEGMENTS) {
intervalDays[segment] = intIn(
days,
segment,
defaults[segment],
MIN_INTERVAL_DAYS,
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
);
}
return { intervalDays };
},
);
@@ -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;
},
};
}
@@ -0,0 +1,158 @@
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
} from '../../season/season-types';
import {
bool,
childScope,
enumList,
enumValue,
has,
isCleared,
makeParser,
monthDay,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { PhenologyConfig } from './rule-config.types';
/** Upper limit of a growing-degree-day threshold; far above any phase. */
const MAX_GTS_THRESHOLD = 5000;
const MAX_GAP_SHARE = 0.5;
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
useGts: true,
gtsThresholds: { SALWEIDE: 200 },
calibrateFromReports: true,
calibratePhases: ['KIRSCHE', 'APFEL'],
calendarFallback: {
SALWEIDE: '03-20',
KIRSCHE: '04-15',
APFEL: '04-28',
HOLUNDER: '05-30',
LINDE: '06-20',
TRACHTENDE: '08-01',
HERBST: '09-15',
WINTER: '11-15',
},
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
maxGapShare: 0.1,
};
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
// Without the group the defaults apply; with it, only the phases given
// have a threshold - leaving one out removes it.
if (!has(scope, 'gtsThresholds')) {
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
}
const thresholds = childScope(scope, 'gtsThresholds');
const result: PhenologyConfig['gtsThresholds'] = {};
for (const phase of PHENOLOGY_PHASES) {
if (isCleared(thresholds, phase)) {
continue;
}
const value = numberIn(
thresholds,
phase,
Number.NaN,
0,
MAX_GTS_THRESHOLD,
{ exclusiveMin: true },
);
if (!Number.isNaN(value)) {
result[phase] = value;
}
}
return result;
}
/**
* The calendar dates of the phases must not run backwards in phase order.
* One violation sends the whole map back to the defaults, so it stays
* consistent; the issue names a key the caller actually sent.
*/
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
const calendar = childScope(scope, 'calendarFallback');
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
const before = scope.issues.length;
const result = { ...defaults };
for (const phase of PHENOLOGY_PHASES) {
result[phase] = monthDay(calendar, phase, defaults[phase]);
}
if (scope.issues.length > before) {
return { ...defaults };
}
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
const previous = PHENOLOGY_PHASES[index - 1];
const phase = PHENOLOGY_PHASES[index];
if (result[phase] < result[previous]) {
const culprit = has(calendar, phase) ? phase : previous;
report(
calendar,
culprit,
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
);
return { ...defaults };
}
}
return result;
}
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
const season = childScope(scope, 'swarmSeason');
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
const from: PhenologyPhaseKey = enumValue(
season,
'from',
defaults.from,
PHENOLOGY_PHASES,
);
const until: PhenologyPhaseKey = enumValue(
season,
'until',
defaults.until,
PHENOLOGY_PHASES,
);
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
report(
season,
has(season, 'from') ? 'from' : 'until',
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
);
return { ...defaults };
}
return { from, until };
}
export const phenologyParser = makeParser<PhenologyConfig>(
'PHENOLOGY',
DEFAULT_PHENOLOGY_CONFIG,
(scope) => {
const d = DEFAULT_PHENOLOGY_CONFIG;
return {
useGts: bool(scope, 'useGts', d.useGts),
gtsThresholds: readThresholds(scope),
calibrateFromReports: bool(
scope,
'calibrateFromReports',
d.calibrateFromReports,
),
calibratePhases: enumList(
scope,
'calibratePhases',
d.calibratePhases,
PHENOLOGY_PHASES,
),
calendarFallback: readCalendar(scope),
swarmSeason: readSwarmSeason(scope),
maxGapShare: numberIn(
scope,
'maxGapShare',
d.maxGapShare,
0,
MAX_GAP_SHARE,
),
};
},
);
@@ -0,0 +1,100 @@
import { Prisma } from '../../../generated/prisma';
import type { AutomationTriggerType } from '../../../generated/prisma';
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
import { lockRowForUpdate } from '../../prisma/row-lock';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf } from './rule-config.registry';
import type { RuleConfigMap } from './rule-config.types';
export interface ActiveRule<K extends AutomationTriggerType> {
/** null while no row exists yet: the built-in setting applies. */
id: string | null;
active: boolean;
config: RuleConfigMap[K];
actionTemplate: string | null;
}
interface RuleRow {
id: string;
active: boolean;
config: Prisma.JsonValue;
actionTemplate: string | null;
}
const RULE_COLUMNS = {
id: true,
active: true,
config: true,
actionTemplate: true,
} as const;
function toActiveRule<K extends AutomationTriggerType>(
trigger: K,
row: RuleRow | null,
): ActiveRule<K> {
if (row === null) {
const defaults = DEFAULT_RULES[trigger];
return {
id: null,
active: defaults.active ?? true,
config: ruleConfigOf(trigger, defaults.config),
actionTemplate: defaults.actionTemplate,
};
}
return {
id: row.id,
active: row.active,
config: ruleConfigOf(trigger, row.config),
actionTemplate: row.actionTemplate,
};
}
/**
* The rule as it is in force: its row, or the built-in setting while there is
* none. The config is parsed, so every key is valid.
*/
export async function readRule<K extends AutomationTriggerType>(
db: DbClient,
trigger: K,
): Promise<ActiveRule<K>> {
const row = await db.automationRule.findUnique({
where: { triggerType: trigger },
select: RULE_COLUMNS,
});
return toActiveRule(trigger, row);
}
/**
* Creates the rule row if it is missing (without touching an existing one),
* locks it until the transaction ends and reads it. Serialises everything
* that decides "is there already an open task/suggestion" under the rule.
*/
export async function lockRuleIn<K extends AutomationTriggerType>(
tx: Prisma.TransactionClient,
trigger: K,
): Promise<ActiveRule<K> & { id: string }> {
assertTransactionClient(tx, 'lockRuleIn');
const defaults = DEFAULT_RULES[trigger];
await tx.automationRule.createMany({
data: [
{
triggerType: trigger,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
],
skipDuplicates: true,
});
const { id } = await tx.automationRule.findUniqueOrThrow({
where: { triggerType: trigger },
select: { id: true },
});
await lockRowForUpdate(tx, 'AutomationRule', id);
// Read after the lock: a concurrent edit of the rule is visible now.
const row = await tx.automationRule.findUniqueOrThrow({
where: { id },
select: RULE_COLUMNS,
});
return { ...toActiveRule(trigger, row), id };
}
@@ -0,0 +1,91 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import {
chainParser,
colonyEndedCleanupParser,
DEFAULT_CHAIN_CONFIG,
withdrawalEndedParser,
} from './chain.config';
import { inspectionIntervalParser } from './inspection-interval.config';
import { phenologyParser } from './phenology.config';
import { seasonAutopilotParser } from './season-autopilot.config';
import { numberFormatParser, simpleParser } from './simple.config';
import { splitScheduleParser } from './split-schedule.config';
import { varroaThresholdParser } from './varroa-threshold.config';
import { waterContentParser } from './water-content.config';
import type {
RuleConfigIssue,
RuleConfigMap,
RuleConfigParser,
} from './rule-config.types';
/** The number-format rules keep the defaults they always had. */
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
export const RULE_CONFIG_PARSERS: {
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
} = {
VARROA_THRESHOLD: varroaThresholdParser,
INSPECTION_INTERVAL: inspectionIntervalParser,
QUEEN_CELLS_SWARM_CHECK: chainParser(
'QUEEN_CELLS_SWARM_CHECK',
DEFAULT_CHAIN_CONFIG,
),
QUEEN_ACCEPTANCE_CHECK: chainParser(
'QUEEN_ACCEPTANCE_CHECK',
DEFAULT_CHAIN_CONFIG,
),
SPLIT_SCHEDULE: splitScheduleParser,
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
WITHDRAWAL_ENDED: withdrawalEndedParser,
HONEY_WATER_CONTENT: waterContentParser,
SEASON_AUTOPILOT: seasonAutopilotParser,
PHENOLOGY: phenologyParser,
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
'HONEY_BATCH_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
'BOTTLING_LOT_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
'{ART}-{YYYY}-{SEQ:4}',
),
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
'PRODUCTION_BATCH_NUMBER_FORMAT',
'HS-{YYYY}-{SEQ:4}',
),
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
notify: false,
}),
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
notify: true,
}),
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
notify: false,
withMinGrams: true,
}),
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
};
/** The effective setting of a rule: valid keys as stored, the rest default. */
export function ruleConfigOf<K extends AutomationTriggerType>(
trigger: K,
json: unknown,
): RuleConfigMap[K] {
return RULE_CONFIG_PARSERS[trigger].parse(json);
}
/** The invalid known keys of a config a client wants to save. */
export function validateRuleConfig(
trigger: AutomationTriggerType,
json: unknown,
): RuleConfigIssue[] {
return RULE_CONFIG_PARSERS[trigger].validate(json);
}
@@ -0,0 +1,440 @@
import { Logger } from '@nestjs/common';
import { AutomationTriggerType } from '../../../generated/prisma';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
return validateRuleConfig(trigger, config).map((issue) => issue.field);
}
describe('rule config', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('every trigger has defaults and a parser', () => {
for (const t of ALL_TRIGGERS) {
expect(DEFAULT_RULES[t]).toBeDefined();
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
}
});
it.each(ALL_TRIGGERS)(
'the default config of %s parses to itself and validates without issues',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
expect(validateRuleConfig(trigger, config)).toEqual([]);
},
);
it.each(ALL_TRIGGERS)(
'%s falls back to its defaults for null, arrays and scalars',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
for (const junk of [null, undefined, [], 'x', 5]) {
expect(ruleConfigOf(trigger, junk)).toEqual(config);
expect(validateRuleConfig(trigger, junk)).toEqual([]);
}
},
);
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
).toEqual({
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 7,
winterRedAbove: 0.5,
}));
it('lets the band format win over the legacy key', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 7,
naturalFall: { redAbove: 9 },
}).naturalFall.redAbove,
).toBe(9));
it('ignores an invalid legacy threshold', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
.redAbove,
).toBe(10));
it('reports an invalid legacy threshold with field and message', () => {
const issues = validateRuleConfig('VARROA_THRESHOLD', {
thresholdMitesPerDay: 0,
});
expect(issues).toEqual([
{
field: 'thresholdMitesPerDay',
message: expect.any(String) as string,
},
]);
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
[],
);
});
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
'naturalFall.redAbove',
]);
expect(
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
).toEqual(['infestationPercent.summer.redAbove']);
expect(
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
).toEqual([]);
});
it('reports an explicit null on a known key', () => {
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
'queenMaxAgeYears',
]);
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
'swarmSeason',
]);
});
it('accepts null on optional keys as clearing them', () => {
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: null, PROPOLIS: 50 },
}),
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('SPLIT_SCHEDULE', {
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
}).QUEEN,
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
expect(
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
).toEqual([]);
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
.gtsThresholds,
).toEqual({});
});
it('lets a client remove a gts threshold by leaving it out', () => {
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
.gtsThresholds,
).toEqual({ KIRSCHE: 400 });
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
).toEqual({});
});
it('keeps yellowFrom at or below a legacy threshold under five', () => {
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 3,
});
expect(naturalFall.redAbove).toBe(3);
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
});
it('falls back per invalid key and keeps the valid ones', () =>
expect(
ruleConfigOf('SEASON_AUTOPILOT', {
queenMaxAgeYears: 'x',
honeySuperMinFrames: 10,
}),
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
it('logs a warning with trigger and field but without the value', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
expect(logged).toContain('SEASON_AUTOPILOT');
expect(logged).toContain('queenMaxAgeYears');
expect(logged).not.toContain('secret-value');
});
it('rejects SCHWARMZEIT 10 days with the field name', () =>
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SCHWARMZEIT: 10 },
}),
).toEqual([
{
field: 'intervalDays.SCHWARMZEIT',
message: expect.stringContaining('7') as string,
},
]));
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
}),
).toEqual([]);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
).toEqual(['intervalDays.SOMMER']);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
).toEqual(['intervalDays.HERBST']);
});
it('merges a partial interval config over the defaults', () =>
expect(
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
).toEqual({
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 10,
SPAETSOMMER: 21,
HERBST: 21,
},
}));
it('rejects yellowFrom above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
naturalFall: { yellowFrom: 12, redAbove: 8 },
}),
).toEqual(['naturalFall.yellowFrom']));
it('rejects percent bands where yellowFrom is above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
}),
).toEqual(['infestationPercent.summer.yellowFrom']));
it('rejects duplicate or out-of-range months', () => {
expect(
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
).toEqual(['naturalFall.seasonMonths']);
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summerMonths: [0, 13] },
}),
).toEqual(['infestationPercent.summerMonths']);
});
it('checks the varroa limits', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
'youngColonyTypes',
]);
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ notify: 'yes' })).toEqual(['notify']);
});
it('reports no issue for keys it does not know', () =>
expect(
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
).toEqual([]));
it('rejects a calendar fallback that runs backwards', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
).toEqual(['calendarFallback.KIRSCHE']);
// The whole map falls back so the phases stay in order.
expect(
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
.calendarFallback,
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
});
it('rejects an invalid month-day', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
).toEqual([]);
});
it('checks the phenology limits', () => {
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
'gtsThresholds.SALWEIDE',
]);
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
'calibratePhases',
]);
expect(
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
).toEqual(['swarmSeason.from']);
});
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
SALWEIDE: 200,
}));
it('checks the season autopilot limits', () => {
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
'honeySuperMinFrames',
]);
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
'varroaMeasureIntervalDays',
]);
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
expect(
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
).toEqual(['winterVarroaWindow.from']);
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
expect(fields({ rules: { A3: false } })).toEqual([]);
expect(
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
});
it('checks the water content limits', () => {
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
expect(fields({ confirmAbovePercent: 31 })).toEqual([
'confirmAbovePercent',
]);
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
).toEqual([]);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
});
it('checks the split schedule steps', () => {
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
expect(
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
).toEqual(['QUEEN[0].offsetDays']);
expect(
fields({
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
}),
).toEqual(['QUEEN_CELL[0].windowDays']);
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
'RAISE_OWN[0].step',
]);
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
expect(
fields({
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
}),
).toEqual([]);
});
it('checks dueInDays of chains and simple rules', () => {
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
'dueInDays',
]);
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
'dueInDays',
]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
notify: false,
dueInDays: 14,
});
});
it('reads minGrams per raw material kind', () => {
expect(
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
expect(
fieldsOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
}),
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
});
it('keeps a number format and rejects an empty or oversized one', () => {
expect(
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
).toEqual({ format: 'HB-{SEQ:3}' });
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
'format',
]);
expect(
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
).toEqual(['format']);
});
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
const withNotify = ALL_TRIGGERS.filter(
(trigger) =>
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
);
expect([...withNotify].sort()).toEqual(
[
'COLONY_HEALTH_CRITICAL',
'QUEENLESS_NO_EGGS',
'VARROA_THRESHOLD',
'WITHDRAWAL_ENDED',
].sort(),
);
});
it('starts the optional rules switched off', () => {
const off = ALL_TRIGGERS.filter(
(trigger) => DEFAULT_RULES[trigger].active === false,
).sort();
expect(off).toEqual([
'FEEDING_FOLLOW_UP',
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
]);
});
it('gives every new rule an empty action template', () => {
for (const trigger of [
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
] as const) {
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
}
});
});
@@ -0,0 +1,140 @@
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
import type {
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../../season/season-types';
/** One invalid setting of a rule: the field path and a German message. */
export interface RuleConfigIssue {
field: string;
message: string;
}
export interface RuleConfigParser<T> {
/** The built-in setting; also what an empty or unusable config reads as. */
readonly defaults: T;
/** Never throws: each invalid key falls back to its default (and is logged). */
parse(json: unknown): T;
/** Reports the known keys that are present and invalid; nothing else. */
validate(json: unknown): RuleConfigIssue[];
}
export interface SimpleRuleConfig {
notify: boolean;
dueInDays?: number;
minGrams?: Partial<Record<RawMaterialKind, number>>;
}
export interface NumberFormatConfig {
format: string;
}
export interface VarroaThresholdConfig {
naturalFall: {
seasonMonths: number[];
yellowFrom: number;
redAbove: number;
winterRedAbove: number;
};
infestationPercent: {
summerMonths: number[];
summer: { yellowFrom: number; redAbove: number };
autumnMonths: number[];
autumn: { yellowFrom: number; redAbove: number };
};
youngColonyFactor: number;
youngColonyTypes: ColonyType[];
recheckAfterDays: number;
notify: boolean;
}
export interface InspectionIntervalConfig {
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
}
export interface ChainConfig {
dueInDays: number;
notify: boolean;
}
export interface PlanStepConfig {
step: string;
offsetDays: number;
windowDays?: number;
}
export interface SplitScheduleConfig {
RAISE_OWN: PlanStepConfig[];
QUEEN_CELL: PlanStepConfig[];
QUEEN: PlanStepConfig[];
notify: boolean;
}
export interface WaterContentConfig {
warnAbovePercent: number;
confirmAbovePercent: number;
byHoneyType: Record<
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number }
>;
}
export const AUTOPILOT_RULE_IDS = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
] as const;
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
export interface SeasonAutopilotConfig {
honeySuperMinFrames: number;
varroaMeasureIntervalDays: number;
winterVarroaWindow: { from: string; to: string };
queenMaxAgeYears: number;
suggestionTtlDays: number;
maxStepsPerColony: number;
rules: Record<AutopilotRuleId, boolean>;
}
export interface PhenologyConfig {
useGts: boolean;
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
calibrateFromReports: boolean;
calibratePhases: PhenologyPhaseKey[];
calendarFallback: Record<PhenologyPhaseKey, string>;
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
maxGapShare: number;
}
/** One entry per AutomationTriggerType. */
export interface RuleConfigMap {
VARROA_THRESHOLD: VarroaThresholdConfig;
INSPECTION_INTERVAL: InspectionIntervalConfig;
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
SPLIT_SCHEDULE: SplitScheduleConfig;
COLONY_ENDED_CLEANUP: Record<string, never>;
WITHDRAWAL_ENDED: { notify: boolean };
HONEY_WATER_CONTENT: WaterContentConfig;
SEASON_AUTOPILOT: SeasonAutopilotConfig;
PHENOLOGY: PhenologyConfig;
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
QUEENLESS_NO_EGGS: SimpleRuleConfig;
FEED_STATUS_CRITICAL: SimpleRuleConfig;
SWARM_MOOD_HIGH: SimpleRuleConfig;
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
STOCK_ITEM_LOW: SimpleRuleConfig;
RAW_MATERIAL_LOW: SimpleRuleConfig;
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
FEEDING_FOLLOW_UP: SimpleRuleConfig;
}
@@ -0,0 +1,102 @@
import {
bool,
childScope,
has,
intIn,
makeParser,
monthDay,
report,
} from './parse-helpers';
import {
AUTOPILOT_RULE_IDS,
type SeasonAutopilotConfig,
} from './rule-config.types';
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
honeySuperMinFrames: 8,
varroaMeasureIntervalDays: 28,
winterVarroaWindow: { from: '11-15', to: '12-20' },
queenMaxAgeYears: 2,
suggestionTtlDays: 30,
maxStepsPerColony: 3,
rules: {
A1: true,
A2: true,
A3: true,
A4: true,
A5: true,
A6: true,
A7: true,
A8: true,
A9: true,
A10: true,
},
};
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
'SEASON_AUTOPILOT',
DEFAULT_SEASON_AUTOPILOT_CONFIG,
(scope) => {
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const windowScope = childScope(scope, 'winterVarroaWindow');
let winterVarroaWindow = {
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
};
if (winterVarroaWindow.from > winterVarroaWindow.to) {
report(
windowScope,
has(windowScope, 'from') ? 'from' : 'to',
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
);
winterVarroaWindow = { ...d.winterVarroaWindow };
}
const ruleScope = childScope(scope, 'rules');
const rules = { ...d.rules };
for (const id of AUTOPILOT_RULE_IDS) {
rules[id] = bool(ruleScope, id, d.rules[id]);
}
return {
honeySuperMinFrames: intIn(
scope,
'honeySuperMinFrames',
d.honeySuperMinFrames,
1,
40,
),
varroaMeasureIntervalDays: intIn(
scope,
'varroaMeasureIntervalDays',
d.varroaMeasureIntervalDays,
7,
90,
),
winterVarroaWindow,
queenMaxAgeYears: intIn(
scope,
'queenMaxAgeYears',
d.queenMaxAgeYears,
1,
10,
),
suggestionTtlDays: intIn(
scope,
'suggestionTtlDays',
d.suggestionTtlDays,
1,
365,
),
maxStepsPerColony: intIn(
scope,
'maxStepsPerColony',
d.maxStepsPerColony,
1,
3,
),
rules,
};
},
);
@@ -0,0 +1,93 @@
import { RawMaterialKind } from '../../../generated/prisma';
import {
bool,
childScope,
has,
isCleared,
makeParser,
numberIn,
optionalIntIn,
stringIn,
} from './parse-helpers';
import type {
NumberFormatConfig,
RuleConfigParser,
SimpleRuleConfig,
} from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Far above any minimum stock of wax or propolis. */
const MAX_MIN_GRAMS = 1_000_000_000;
const MAX_NUMBER_FORMAT_LENGTH = 100;
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
export interface SimpleRuleOptions {
/** Whether the rule notifies by default. */
notify: boolean;
/** RAW_MATERIAL_LOW reads a minimum per kind. */
withMinGrams?: boolean;
}
/**
* Rules that create one task: notify, an optional follow-up interval
* (dueInDays) and, for the raw material rule, minGrams per kind.
*/
export function simpleParser(
trigger: string,
options: SimpleRuleOptions,
): RuleConfigParser<SimpleRuleConfig> {
return makeParser<SimpleRuleConfig>(
trigger,
{ notify: options.notify },
(scope) => {
const config: SimpleRuleConfig = {
notify: bool(scope, 'notify', options.notify),
};
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
if (dueInDays !== undefined) {
config.dueInDays = dueInDays;
}
if (
options.withMinGrams &&
has(scope, 'minGrams') &&
!isCleared(scope, 'minGrams')
) {
const minScope = childScope(scope, 'minGrams');
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
for (const kind of RAW_MATERIAL_KINDS) {
if (isCleared(minScope, kind)) {
continue;
}
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
if (!Number.isNaN(grams)) {
minGrams[kind] = grams;
}
}
if (Object.keys(minGrams).length > 0) {
config.minGrams = minGrams;
}
}
return config;
},
);
}
/** The batch/lot number rules: one format string with placeholders. */
export function numberFormatParser(
trigger: string,
defaultFormat: string,
): RuleConfigParser<NumberFormatConfig> {
return makeParser<NumberFormatConfig>(
trigger,
{ format: defaultFormat },
(scope) => ({
format: stringIn(
scope,
'format',
defaultFormat,
1,
MAX_NUMBER_FORMAT_LENGTH,
),
}),
);
}
@@ -0,0 +1,106 @@
import {
asRecord,
bool,
fieldPath,
has,
intIn,
makeParser,
optionalIntIn,
report,
stringIn,
type Scope,
} from './parse-helpers';
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
const MAX_STEPS_PER_VARIANT = 10;
const MAX_OFFSET_DAYS = 120;
const MAX_WINDOW_DAYS = 30;
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
RAISE_OWN: [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
],
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
notify: false,
};
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
}
/** Scope of one list entry: its path is `VARIANT[index]`. */
function stepScope(
scope: Scope,
variant: SplitVariant,
index: number,
raw: unknown,
): Scope | null {
const record = asRecord(raw);
const path = `${fieldPath(scope, variant)}[${index}]`;
if (record === null) {
scope.issues.push({
field: path,
message: 'Bitte jeden Schritt als Gruppe senden.',
});
return null;
}
return { raw: record, path, issues: scope.issues };
}
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
if (!has(scope, variant)) {
return clonedSteps(variant);
}
const list = scope.raw[variant];
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
report(
scope,
variant,
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
);
return clonedSteps(variant);
}
const before = scope.issues.length;
const steps: PlanStepConfig[] = [];
list.forEach((entry: unknown, index) => {
const item = stepScope(scope, variant, index, entry);
if (item === null) {
return;
}
if (!has(item, 'step')) {
scope.issues.push({
field: `${item.path}.step`,
message: 'Bitte den Schritt angeben.',
});
return;
}
const step: PlanStepConfig = {
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
};
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
if (windowDays !== undefined) {
step.windowDays = windowDays;
}
steps.push(step);
});
// One bad entry makes the whole list unreliable: back to the default plan.
return scope.issues.length > before ? clonedSteps(variant) : steps;
}
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
'SPLIT_SCHEDULE',
DEFAULT_SPLIT_SCHEDULE_CONFIG,
(scope) => ({
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
QUEEN: readSteps(scope, 'QUEEN'),
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
}),
);
@@ -0,0 +1,188 @@
import { ColonyType } from '../../../generated/prisma';
import {
bool,
childScope,
enumList,
has,
intIn,
makeParser,
months,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { VarroaThresholdConfig } from './rule-config.types';
const MAX_RECHECK_DAYS = 60;
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
naturalFall: {
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 10,
winterRedAbove: 0.5,
},
infestationPercent: {
summerMonths: [5, 6, 7],
summer: { yellowFrom: 1, redAbove: 5 },
autumnMonths: [8, 9, 10],
autumn: { yellowFrom: 2, redAbove: 5 },
},
youngColonyFactor: 0.5,
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
recheckAfterDays: 14,
notify: true,
};
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
const MAX_MITES_PER_DAY = 1000;
const MAX_PERCENT = 100;
/**
* The key before the bands existed: natural mite fall that counts as red.
* A present but unusable value is reported.
*/
function legacyThreshold(scope: Scope): number | undefined {
if (!has(scope, 'thresholdMitesPerDay')) {
return undefined;
}
const value = scope.raw.thresholdMitesPerDay;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
report(
scope,
'thresholdMitesPerDay',
'Bitte eine Zahl größer als 0 angeben.',
);
return undefined;
}
interface Band {
yellowFrom: number;
redAbove: number;
}
interface BandLimits {
max: number;
/** The red limit itself must be above zero. */
redAboveZero?: boolean;
}
/**
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
* redAbove; a given one that does is reported and replaced.
*/
function readBand(
scope: Scope,
defaults: Band,
limits: BandLimits,
legacyRed?: number,
): Band {
const redAbove = numberIn(
scope,
'redAbove',
legacyRed ?? defaults.redAbove,
0,
limits.max,
{ exclusiveMin: limits.redAboveZero },
);
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
const yellowFrom = numberIn(
scope,
'yellowFrom',
yellowDefault,
0,
limits.max,
);
if (yellowFrom > redAbove) {
report(
scope,
'yellowFrom',
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
);
return { yellowFrom: yellowDefault, redAbove };
}
return { yellowFrom, redAbove };
}
const COLONY_TYPES = Object.values(ColonyType);
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
'VARROA_THRESHOLD',
DEFAULT_VARROA_THRESHOLD_CONFIG,
(scope) => {
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
const legacy = legacyThreshold(scope);
const fall = childScope(scope, 'naturalFall');
const fallBand = readBand(
fall,
d.naturalFall,
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
legacy,
);
const naturalFall = {
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
...fallBand,
winterRedAbove: numberIn(
fall,
'winterRedAbove',
d.naturalFall.winterRedAbove,
0,
MAX_MITES_PER_DAY,
),
};
const percent = childScope(scope, 'infestationPercent');
const infestationPercent = {
summerMonths: months(
percent,
'summerMonths',
d.infestationPercent.summerMonths,
),
summer: readBand(
childScope(percent, 'summer'),
d.infestationPercent.summer,
{ max: MAX_PERCENT },
),
autumnMonths: months(
percent,
'autumnMonths',
d.infestationPercent.autumnMonths,
),
autumn: readBand(
childScope(percent, 'autumn'),
d.infestationPercent.autumn,
{ max: MAX_PERCENT },
),
};
return {
naturalFall,
infestationPercent,
youngColonyFactor: numberIn(
scope,
'youngColonyFactor',
d.youngColonyFactor,
0,
1,
{ exclusiveMin: true },
),
youngColonyTypes: enumList(
scope,
'youngColonyTypes',
d.youngColonyTypes,
COLONY_TYPES,
),
recheckAfterDays: intIn(
scope,
'recheckAfterDays',
d.recheckAfterDays,
0,
MAX_RECHECK_DAYS,
),
notify: bool(scope, 'notify', d.notify),
};
},
);
@@ -0,0 +1,112 @@
import {
childScope,
has,
makeParser,
numberIn,
optionalNumberIn,
report,
type Scope,
} from './parse-helpers';
import type { WaterContentConfig } from './rule-config.types';
const MIN_WATER_PERCENT = 10;
const MAX_WATER_PERCENT = 30;
const MAX_HONEY_TYPES = 50;
const MAX_HONEY_TYPE_LENGTH = 100;
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
function percent(scope: Scope, key: string, fallback: number): number {
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
}
const ORDER_MESSAGE =
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
const types = childScope(scope, 'byHoneyType');
const names = Object.keys(types.raw);
if (names.length > MAX_HONEY_TYPES) {
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
return {};
}
const entries: [
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number },
][] = [];
for (const name of names) {
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
continue;
}
const entry = childScope(types, name);
const before = entry.issues.length;
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
{};
const warn = optionalNumberIn(
entry,
'warnAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
const confirm = optionalNumberIn(
entry,
'confirmAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
if (warn !== undefined && confirm !== undefined && warn > confirm) {
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
}
if (entry.issues.length > before) {
continue;
}
if (warn !== undefined) {
value.warnAbovePercent = warn;
}
if (confirm !== undefined) {
value.confirmAbovePercent = confirm;
}
entries.push([name, value]);
}
// fromEntries defines own properties, so a name like "__proto__" is harmless.
return Object.fromEntries(entries);
}
export const waterContentParser = makeParser<WaterContentConfig>(
'HONEY_WATER_CONTENT',
DEFAULT_WATER_CONTENT_CONFIG,
(scope) => {
const d = DEFAULT_WATER_CONTENT_CONFIG;
let warnAbovePercent = percent(
scope,
'warnAbovePercent',
d.warnAbovePercent,
);
let confirmAbovePercent = percent(
scope,
'confirmAbovePercent',
d.confirmAbovePercent,
);
if (warnAbovePercent > confirmAbovePercent) {
report(
scope,
has(scope, 'warnAbovePercent')
? 'warnAbovePercent'
: 'confirmAbovePercent',
ORDER_MESSAGE,
);
warnAbovePercent = d.warnAbovePercent;
confirmAbovePercent = d.confirmAbovePercent;
}
return {
warnAbovePercent,
confirmAbovePercent,
byHoneyType: readHoneyTypes(scope),
};
},
);
@@ -26,6 +26,24 @@ describe('varroaThresholdOf', () => {
).toBe(7);
});
it('reads naturalFall.redAbove from the band format', () => {
expect(
varroaThresholdOf({
active: true,
config: { naturalFall: { redAbove: 8 } },
}),
).toBe(8);
});
it('prefers the band format over the legacy key', () => {
expect(
varroaThresholdOf({
active: true,
config: { thresholdMitesPerDay: 7, naturalFall: { redAbove: 8 } },
}),
).toBe(8);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
@@ -1,14 +1,19 @@
import type { AutomationRule } from '../../generated/prisma';
import { ruleConfigOf } from './rule-config';
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* Effective varroa threshold of the VARROA_THRESHOLD rule: the red limit of
* the natural mite fall (naturalFall.redAbove; the legacy key
* thresholdMitesPerDay reads the same). Task 7 replaces this with the bands.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not a
* positive finite number (a hand-edited config must never disable the check).
* - active rule: the configured value, or the default if it is not usable
* (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
@@ -19,12 +24,5 @@ export function varroaThresholdOf(
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
}
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
'queenColonyHistory',
'inspection',
'taskTemplate',
'suggestion',
'task',
'varroaMeasurement',
'swarmControlCheck',
@@ -237,6 +238,157 @@ describe('BackupService', () => {
];
expect(call.create.broodFrames).toBe(5);
});
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ where: { id: string }; create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
expect.objectContaining({
id: 'i-2',
feedStatus: null,
notes: 'x\nFutter (alt): halb voll',
}),
expect.objectContaining({
id: 'i-3',
feedStatus: 'CRITICAL',
notes: null,
}),
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
]);
});
});
describe('legacy feedStatus of unexpected type', () => {
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({
feedStatus: null,
notes: 'Futter (alt): 3',
}),
expect.objectContaining({
feedStatus: null,
notes: 'x\nFutter (alt): {"level":"low"}',
}),
expect.objectContaining({ feedStatus: null, notes: 'y' }),
]);
});
});
describe('suggestions', () => {
it('exports and restores suggestions with their decision before tasks', async () => {
const suggestion = {
id: 'suggestion-1',
colonyId: 'colony-1',
status: 'ACCEPTED',
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
decidedAt: '2026-10-14T08:00:00.000Z',
decidedByUserId: 'known-user',
decisionNote: 'Passt',
};
const orphanDecision = {
...suggestion,
id: 'suggestion-2',
dedupeKey: null,
decidedByUserId: 'deleted-user',
};
const open = {
...suggestion,
id: 'suggestion-3',
status: 'OPEN',
decidedAt: null,
decidedByUserId: null,
decisionNote: null,
};
prisma.suggestion.findMany.mockResolvedValue([
suggestion,
orphanDecision,
open,
]);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
prisma.task.findMany.mockResolvedValue([
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
]);
const bundle = await service.exportAll();
expect(bundle.data.suggestion).toEqual([
suggestion,
orphanDecision,
open,
]);
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(summary.imported.suggestion).toBe(3);
const creates = (
tx.suggestion.upsert.mock.calls as [
{ create: { id: string; decidedByUserId: string | null } },
][]
).map(([call]) => call.create);
// A decider that is gone resolves like any other user id; no decision
// stays without one.
expect(creates.map((row) => row.decidedByUserId)).toEqual([
'known-user',
'admin-1',
null,
]);
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
expect(suggestionOrder).toBeLessThan(taskOrder);
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
expect(colonyOrder).toBeLessThan(suggestionOrder);
});
it('does not restore suggestions from an unsealed file', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-10-14T00:00:00.000Z',
data: { suggestion: [{ id: 'suggestion-1' }] },
},
'admin-1',
{ trusted: false },
);
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
});
});
describe('importAll', () => {
+50 -8
View File
@@ -9,6 +9,10 @@ import {
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType, Prisma } from '../../generated/prisma';
import {
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null;
}
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
if (!('feedStatus' in row)) {
return row;
}
const raw = row.feedStatus;
// A value that is neither text nor empty cannot map; keep it in the notes.
const { feedStatus, notesSuffix } =
raw === null || raw === undefined || typeof raw === 'string'
? feedConditionFromInput(raw)
: {
feedStatus: null,
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
};
return {
...row,
feedStatus,
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
};
}
function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
@@ -233,6 +259,7 @@ export class BackupService {
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
@@ -538,16 +565,14 @@ export class BackupService {
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
for (const row of rowsOf('inspection')) {
const inspection = {
...withCurrentFeedVocabulary(row),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.inspection.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
create: inspection as never,
update: updateWith(inspection) as never,
});
}
imported.inspection = rowsOf('inspection').length;
@@ -577,6 +602,23 @@ export class BackupService {
}
imported.taskTemplate = rowsOf('taskTemplate').length;
// Before the tasks: Task.sourceSuggestionId points at them. After the
// colonies: a suggestion may hang on one.
for (const row of rowsOf('suggestion')) {
const data = {
...row,
decidedByUserId: resolveOptionalUserId(
asString(row, 'decidedByUserId'),
),
};
await tx.suggestion.upsert({
where: { id: row.id as string },
create: data as never,
update: updateWith(data) as never,
});
}
imported.suggestion = rowsOf('suggestion').length;
for (const row of rowsOf('task')) {
await tx.task.upsert({
where: { id: row.id as string },
@@ -122,6 +122,14 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => {
settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueue('WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('does nothing when the source type toggle is off', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
@@ -640,6 +648,16 @@ describe('CalendarSyncService', () => {
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
});
it('writes no Graph event for a WITHDRAWAL entry before Task 12', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }),
]);
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
});
it('builds titles per source type matching the read-only calendar view', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
@@ -103,6 +103,8 @@ export class CalendarSyncService {
FEEDING: settings.syncFeeding,
HARVEST: settings.syncHarvest,
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
// Switch in the settings arrives with Task 3.
WITHDRAWAL: false,
};
return flagBySourceType[sourceType];
}
@@ -499,6 +501,9 @@ export class CalendarSyncService {
date: assignment.startDate,
};
}
case 'WITHDRAWAL':
// Switch Task 3, content Task 12: nothing to write before that.
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
import {
addDays,
berlinDateTime,
berlinDayOf,
berlinToday,
dayToDate,
daysBetween,
} from './dates';
describe('dates', () => {
it('berlinToday at 2026-03-28T23:30Z is 2026-03-29', () => {
expect(berlinToday(new Date('2026-03-28T23:30:00Z'))).toBe('2026-03-29');
});
it('the 04:30 Berlin job time is the same calendar day', () => {
expect(berlinToday(new Date('2026-10-13T02:30:00Z'))).toBe('2026-10-13');
});
it('berlinToday defaults to the current instant', () => {
expect(berlinToday()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('berlinDayOf uses the Berlin calendar day, not the UTC one', () => {
expect(berlinDayOf(new Date('2026-12-31T23:30:00Z'))).toBe('2027-01-01');
expect(berlinDayOf(new Date('2026-06-30T21:59:00Z'))).toBe('2026-06-30');
expect(berlinDayOf(new Date('2026-06-30T22:00:00Z'))).toBe('2026-07-01');
});
it('berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts', () => {
expect(berlinDateTime('2026-03-29', '06:30').toISOString()).toBe(
'2026-03-29T04:30:00.000Z',
);
});
it('berlinDateTime 2026-10-25 06:30 is 05:30Z after DST ends', () => {
expect(berlinDateTime('2026-10-25', '06:30').toISOString()).toBe(
'2026-10-25T05:30:00.000Z',
);
});
it('berlinDateTime uses winter and summer offsets on ordinary days', () => {
expect(berlinDateTime('2026-01-15', '06:30').toISOString()).toBe(
'2026-01-15T05:30:00.000Z',
);
expect(berlinDateTime('2026-07-15', '06:30').toISOString()).toBe(
'2026-07-15T04:30:00.000Z',
);
});
it('berlinDateTime handles midnight and the evening before a DST change', () => {
expect(berlinDateTime('2026-03-29', '00:00').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
expect(berlinDateTime('2026-10-25', '00:00').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(berlinDateTime('2026-03-28', '21:00').toISOString()).toBe(
'2026-03-28T20:00:00.000Z',
);
});
it('berlinDateTime rejects malformed input', () => {
expect(() => berlinDateTime('2026-3-29', '06:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '6:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '25:00')).toThrow();
});
it('dayToDate is midnight UTC', () => {
expect(dayToDate('2026-05-05').toISOString()).toBe(
'2026-05-05T00:00:00.000Z',
);
});
it('dayToDate rejects impossible days', () => {
expect(() => dayToDate('2026-02-30')).toThrow();
expect(() => dayToDate('2026-13-01')).toThrow();
expect(() => dayToDate('2026-04-31')).toThrow();
expect(dayToDate('2024-02-29').toISOString()).toBe(
'2024-02-29T00:00:00.000Z',
);
});
it('addDays crosses month ends', () => {
expect(addDays('2026-02-27', 2)).toBe('2026-03-01');
});
it('addDays goes backwards and over year ends', () => {
expect(addDays('2026-01-01', -1)).toBe('2025-12-31');
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
});
it('daysBetween counts calendar days', () => {
expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8);
expect(daysBetween('2026-05-09', '2026-05-01')).toBe(-8);
});
it('daysBetween counts whole days across a DST change', () => {
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
});
});
+101
View File
@@ -0,0 +1,101 @@
const BERLIN_TIME_ZONE = 'Europe/Berlin';
const MS_PER_DAY = 86_400_000;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
// en-CA formats a date as YYYY-MM-DD.
const berlinDayFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: BERLIN_TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
/** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */
export function berlinDayOf(instant: Date): string {
return berlinDayFormat.format(instant);
}
/** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */
export function berlinToday(now: Date = new Date()): string {
return berlinDayOf(now);
}
/** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */
export function dayToDate(day: string): Date {
if (!DAY_PATTERN.test(day)) {
throw new Error(`Invalid day: ${day}`);
}
const date = new Date(`${day}T00:00:00.000Z`);
// 2026-02-30 parses to March 2nd: only a day that round-trips exists.
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) {
throw new Error(`Invalid day: ${day}`);
}
return date;
}
function dayNumber(day: string): number {
return Math.round(dayToDate(day).getTime() / MS_PER_DAY);
}
/** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */
export function addDays(day: string, days: number): string {
const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY);
return result.toISOString().slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from);
}
/** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */
function berlinOffsetMs(instant: Date): number {
const parts: Record<string, number> = {};
for (const part of berlinPartsFormat.formatToParts(instant)) {
if (part.type !== 'literal') {
parts[part.type] = Number(part.value);
}
}
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
// The instant without its milliseconds: the formatter works in seconds.
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* The instant at which the Berlin wall clock shows `day` `hhmm`. The offset
* is taken at the target day itself (applied twice, so the day a clock change
* happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter.
*/
export function berlinDateTime(day: string, hhmm: string): Date {
const time = TIME_PATTERN.exec(hhmm);
if (!time) {
throw new Error(`Invalid time: ${hhmm}`);
}
const wallClockAsUtc =
dayToDate(day).getTime() +
(Number(time[1]) * 60 + Number(time[2])) * 60_000;
let instant = new Date(
wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)),
);
instant = new Date(wallClockAsUtc - berlinOffsetMs(instant));
return instant;
}
@@ -0,0 +1,56 @@
import {
FEED_CONDITION_LABELS_DE,
appendLegacyFeedNote,
feedConditionFromInput,
feedConditionFromLegacy,
} from './feed-vocabulary';
describe('feed vocabulary', () => {
it.each([
[' Ausreichend ', 'GOOD', null],
['niedrig', 'WEAK', null],
['KRITISCH', 'CRITICAL', null],
['', null, null],
[' ', null, null],
[null, null, null],
[undefined, null, null],
['halb voll', null, 'Futter (alt): halb voll'],
])('maps %p', (text, feedStatus, notesSuffix) => {
expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix });
});
it('labels every condition in German', () => {
expect(FEED_CONDITION_LABELS_DE).toEqual({
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
});
});
it.each([
['critical', 'CRITICAL', null],
[' EXCELLENT ', 'EXCELLENT', null],
['niedrig', 'WEAK', null],
['constructor', null, 'Futter (alt): constructor'],
['halb voll', null, 'Futter (alt): halb voll'],
])(
'accepts condition names or legacy text: %p',
(text, feedStatus, notesSuffix) => {
expect(feedConditionFromInput(text)).toEqual({ feedStatus, notesSuffix });
},
);
describe('appendLegacyFeedNote', () => {
it.each([
[null, null, null],
['x', null, 'x'],
[null, 'Futter (alt): a', 'Futter (alt): a'],
['', 'Futter (alt): a', 'Futter (alt): a'],
['x', 'Futter (alt): a', 'x\nFutter (alt): a'],
])('joins %p and %p', (notes, suffix, expected) => {
expect(appendLegacyFeedNote(notes, suffix)).toBe(expected);
});
});
});
@@ -0,0 +1,67 @@
import { ColonyCondition } from '../../generated/prisma';
/** German display labels for the one feed vocabulary (colony card and inspection). */
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string> = {
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
};
export interface LegacyFeedMapping {
feedStatus: ColonyCondition | null;
/** 'Futter (alt): <Text>' for text that maps to no condition, else null. */
notesSuffix: string | null;
}
const LEGACY_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
['ausreichend', 'GOOD'],
['niedrig', 'WEAK'],
['kritisch', 'CRITICAL'],
]);
const LEGACY_NOTE_PREFIX = 'Futter (alt): ';
/**
* Maps the old free-text feed status (trimmed, case-insensitive) onto the
* enum. Empty text maps to nothing; unknown text is kept for the notes.
*/
export function feedConditionFromLegacy(
text: string | null | undefined,
): LegacyFeedMapping {
const trimmed = (text ?? '').trim();
if (trimmed === '') {
return { feedStatus: null, notesSuffix: null };
}
const mapped = LEGACY_WORDS.get(trimmed.toLowerCase());
if (mapped) {
return { feedStatus: mapped, notesSuffix: null };
}
return { feedStatus: null, notesSuffix: `${LEGACY_NOTE_PREFIX}${trimmed}` };
}
/**
* Like feedConditionFromLegacy, but a value that already is a condition name
* (any case) is taken as it is. For import paths that see both old and new data.
*/
export function feedConditionFromInput(
text: string | null | undefined,
): LegacyFeedMapping {
const upper = (text ?? '').trim().toUpperCase();
if (Object.hasOwn(FEED_CONDITION_LABELS_DE, upper)) {
return { feedStatus: upper as ColonyCondition, notesSuffix: null };
}
return feedConditionFromLegacy(text);
}
/** Appends the legacy suffix to the notes, separated by a newline. */
export function appendLegacyFeedNote(
notes: string | null,
suffix: string | null,
): string | null {
if (!suffix) {
return notes;
}
return notes ? `${notes}\n${suffix}` : suffix;
}
@@ -1,4 +1,5 @@
import { DashboardService } from './dashboard.service';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
describe('DashboardService', () => {
@@ -11,6 +12,7 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
};
beforeEach(() => {
@@ -28,6 +30,8 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
// No stored SEASON_AUTOPILOT rule: the built-in setting applies.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
@@ -137,33 +141,44 @@ describe('DashboardService', () => {
});
describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => {
const currentYear = Number(berlinToday().slice(0, 4));
const queenedColony = (hatchYear: number) => [
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear } },
];
it('OLD_QUEEN starts at queenMaxAgeYears (2)', async () => {
prisma.colony.findMany
.mockResolvedValueOnce([]) // attentionColonies
.mockResolvedValueOnce([
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
]);
.mockResolvedValueOnce(queenedColony(currentYear - 2));
const alerts = await service.alerts();
expect(alerts).toHaveLength(1);
expect(alerts[0].type).toBe('OLD_QUEEN');
expect(alerts[0].relatedId).toBe('colony-1');
expect(alerts[0].message).toContain('2 Jahre');
});
it('does not flag a queen younger than the threshold', async () => {
const currentYear = new Date().getFullYear();
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'colony-1',
code: 'V-001',
currentQueen: { hatchYear: currentYear },
},
]);
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 1));
const alerts = await service.alerts();
expect(await service.alerts()).toHaveLength(0);
});
expect(alerts).toHaveLength(0);
it('follows queenMaxAgeYears of the SEASON_AUTOPILOT rule', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: { queenMaxAgeYears: 4 },
});
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 3));
expect(await service.alerts()).toHaveLength(0);
});
});
});
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { readRule } from '../automation/rule-config';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { isQueenAged } from '../season/queen-age';
export interface DashboardSummary {
apiariesActive: number;
@@ -24,10 +27,6 @@ export interface DashboardAlert {
}
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
// Queens rarely stay reliably productive past their third season - laying
// rate and swarming risk both worsen well before then, so this flags a
// colony for evaluation rather than demanding immediate requeening.
const OLD_QUEEN_AGE_YEARS = 3;
@Injectable()
export class DashboardService {
@@ -158,15 +157,23 @@ export class DashboardService {
});
}
}
const currentYear = now.getFullYear();
// The age comes from the rule, like QueenAged and the autopilot: a flag
// for evaluation rather than a demand for immediate requeening.
const { config: autopilot } = await readRule(
this.prisma,
'SEASON_AUTOPILOT',
);
const today = berlinToday(now);
const currentYear = Number(today.slice(0, 4));
for (const colony of queenedColonies) {
// The where clause above already requires currentQueen and its
// hatchYear to exist, but the relation is still nullable in the type.
if (!colony.currentQueen?.hatchYear) {
continue;
}
const ageYears = currentYear - colony.currentQueen.hatchYear;
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
const { hatchYear } = colony.currentQueen;
if (isQueenAged(hatchYear, today, autopilot.queenMaxAgeYears)) {
const ageYears = currentYear - hatchYear;
alerts.push({
type: 'OLD_QUEEN',
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
@@ -56,8 +56,18 @@ export interface DomainEventPayloads {
WithdrawalEnded: { colonyId: string; endedOn: string };
TaskDue: { dueOn: string };
QueenAged: { hatchYear: number; colonyId: string | null };
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
// kind/anchor/firstOfYear came with T4a: absent on older events.
VarroaMeasurementDue: {
dueOn: string;
lastMeasuredOn: string | null;
kind?: 'INTERVAL' | 'WINTER_WINDOW';
anchor?: string;
};
InspectionOverdue: {
dueOn: string;
lastInspectedOn: string | null;
firstOfYear?: boolean;
};
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -1,6 +1,8 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { ColonyCondition } from '../../generated/prisma';
import { FEED_CONDITION_LABELS_DE } from '../common/feed-vocabulary';
import {
PDF_MARGIN,
drawSection,
@@ -83,7 +85,7 @@ export interface ColonyRecordCard {
queenCellsPresent: boolean | null;
gentleness: number | null;
swarmMood: number | null;
feedStatus: string | null;
feedStatus: ColonyCondition | null;
measures: string | null;
notes: string | null;
}[];
@@ -348,7 +350,7 @@ export function renderColonyRecordCardPdf(
yesNo(i.queenCellsPresent),
orDash(i.gentleness),
orDash(i.swarmMood),
orDash(i.feedStatus),
orDash(i.feedStatus ? FEED_CONDITION_LABELS_DE[i.feedStatus] : null),
orDash(i.measures),
orDash(i.notes),
]),
@@ -133,6 +133,42 @@ describe('ImportService', () => {
expect(createArg.data.code).toBe('V-100');
});
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create.mockResolvedValue({ id: 'colony-1' });
const report = await service.importCsv(
'colonies',
Buffer.from(
[
'code,apiaryName,colonyType,feedStatus,notes',
'V-1,Teststand,PRODUCTION_COLONY,niedrig,',
'V-2,Teststand,PRODUCTION_COLONY,halb voll,Hinweis',
'V-3,Teststand,PRODUCTION_COLONY,CRITICAL,',
].join('\n'),
),
'user-1',
);
expect(report.created).toBe(3);
const data = (
prisma.colony.create.mock.calls as [
[{ data: { feedStatus?: string; notes?: string } }],
]
).map(([call]) => call.data);
expect(data[0]).toMatchObject({ feedStatus: 'WEAK' });
expect(data[0].notes).toBeUndefined();
expect(data[1].feedStatus).toBeUndefined();
expect(data[1].notes).toBe('Hinweis\nFutter (alt): halb voll');
expect(data[2]).toMatchObject({ feedStatus: 'CRITICAL' });
expect(report.rows.map((row) => row.message)).toEqual([
undefined,
'Futterangabe „halb voll“ nicht erkannt, in die Notizen übernommen.',
undefined,
]);
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
+22 -3
View File
@@ -6,6 +6,10 @@ import { AuditLogService } from '../audit/audit-log.service';
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { parseCsv } from './csv';
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
@@ -166,6 +170,8 @@ export class ImportService {
};
}
// Old exports carry free text here; known words map to the enum.
const feed = feedConditionFromInput(record.feedStatus);
const plain = {
code: emptyToUndefined(record.code),
apiaryId: apiary.id,
@@ -174,9 +180,13 @@ export class ImportService {
originText: emptyToUndefined(record.originText),
strength: parseNumberField(record.strength),
broodStatus: emptyToUndefined(record.broodStatus),
feedStatus: emptyToUndefined(record.feedStatus),
feedStatus: feed.feedStatus ?? undefined,
healthStatus: emptyToUndefined(record.healthStatus),
notes: emptyToUndefined(record.notes),
notes:
appendLegacyFeedNote(
emptyToUndefined(record.notes) ?? null,
feed.notesSuffix,
) ?? undefined,
};
const dto = plainToInstance(CreateColonyDto, plain);
const errors = await validate(dto);
@@ -199,7 +209,16 @@ export class ImportService {
};
}
const colony = await this.prisma.colony.create({ data: { ...dto } });
return { row: rowNumber, status: 'created', id: colony.id };
return {
row: rowNumber,
status: 'created',
id: colony.id,
...(feed.notesSuffix
? {
message: `Futterangabe „${record.feedStatus}“ nicht erkannt, in die Notizen übernommen.`,
}
: {}),
};
}
private async importQueenRow(
@@ -0,0 +1,39 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateInspectionDto } from './create-inspection.dto';
import { UpdateInspectionDto } from './update-inspection.dto';
async function check(
dtoClass: typeof CreateInspectionDto | typeof UpdateInspectionDto,
feedStatus: unknown,
) {
const dto = plainToInstance(dtoClass, {
colonyId: 'c',
date: '2026-05-01',
feedStatus,
});
return { dto, errors: await validate(dto) };
}
describe('Inspection DTOs feedStatus', () => {
it.each([
['niedrig', 'WEAK'],
[' Kritisch ', 'CRITICAL'],
['AUSREICHEND', 'GOOD'],
['EXCELLENT', 'EXCELLENT'],
['average', 'AVERAGE'],
['', null],
[' ', null],
])('accepts %p as %p', async (input, expected) => {
for (const dtoClass of [CreateInspectionDto, UpdateInspectionDto]) {
const { dto, errors } = await check(dtoClass, input);
expect(errors).toEqual([]);
expect(dto.feedStatus).toBe(expected);
}
});
it.each(['halb voll', 'constructor', 5, {}])('rejects %p', async (input) => {
const { errors } = await check(CreateInspectionDto, input);
expect(errors.map((e) => e.property)).toContain('feedStatus');
});
});
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
@@ -9,6 +10,9 @@ import {
Max,
Min,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ColonyCondition } from '../../../generated/prisma';
import { feedConditionFromInput } from '../../common/feed-vocabulary';
export class CreateInspectionDto {
@ApiProperty()
@@ -99,10 +103,24 @@ export class CreateInspectionDto {
@Max(5)
combBehavior?: number;
@ApiPropertyOptional()
@ApiPropertyOptional({
enum: ColonyCondition,
nullable: true,
description:
'Feed condition. Also accepts the legacy German words ausreichend (GOOD), niedrig (WEAK) and kritisch (CRITICAL), ignoring case and surrounding whitespace; a blank string counts as null. Other text is rejected.',
})
@IsOptional()
@IsString()
feedStatus?: string;
@Transform(({ value }: { value: unknown }) => {
if (typeof value !== 'string') {
return value;
}
if (value.trim() === '') {
return null;
}
return feedConditionFromInput(value).feedStatus ?? value;
})
@IsEnum(ColonyCondition)
feedStatus?: ColonyCondition;
@ApiPropertyOptional()
@IsOptional()
@@ -0,0 +1,93 @@
import { Logger } from '@nestjs/common';
import { JobPartialFailure } from '../system/job-run.service';
import {
DAILY_EVALUATION_STEP_ORDER,
DailyEvaluationStepName,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import type { JobResult } from './job';
function step(
name: DailyEvaluationStepName,
run: (today: string) => Promise<JobResult>,
): { name: DailyEvaluationStepName; run: jest.Mock } {
return { name, run: jest.fn(run) };
}
describe('runDailyEvaluationSteps', () => {
const logger = new Logger('test');
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('has the steps of the plan in this order', () =>
expect(DAILY_EVALUATION_STEP_ORDER).toEqual([
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
]));
it('runs the steps in the fixed order with the day and merges their counters', async () => {
const order: string[] = [];
const digest = step('digest', () => {
order.push('digest');
return Promise.resolve({ digests: 3 });
});
const timeEvents = step('time-events', () => {
order.push('time-events');
return Promise.resolve({ TaskDue: 1 });
});
const { result, failed } = await runDailyEvaluationSteps(
[digest, timeEvents],
'2026-06-10',
logger,
);
expect(order).toEqual(['time-events', 'digest']);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ TaskDue: 1, digests: 3 });
expect(failed).toEqual([]);
});
it('a failing step leaves the others running and the run FAILED with all counters', async () => {
const phenology = step('phenology', () =>
Promise.resolve({ phenologyApiaries: 2 }),
);
const timeEvents = step('time-events', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 1, TaskDue: 0 }, ['TaskDue']),
),
);
const autopilot = step('autopilot', () =>
Promise.reject(new Error('connection reset')),
);
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
const { result, failed, failures } = await runDailyEvaluationSteps(
[phenology, timeEvents, autopilot, digest],
'2026-06-10',
logger,
);
expect(digest.run).toHaveBeenCalled();
expect(failed).toEqual(['time-events', 'autopilot']);
expect(failures).toEqual(['time-events: TaskDue', 'autopilot']);
expect(result).toEqual({
phenologyApiaries: 2,
WithdrawalEnded: 1,
TaskDue: 0,
digests: 4,
});
expect(errorSpy).toHaveBeenCalledWith(
'Step "autopilot" failed: connection reset',
expect.any(String),
);
});
});
@@ -0,0 +1,69 @@
import type { Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { JobPartialFailure } from '../system/job-run.service';
import type { JobResult } from './job';
/** The steps of the daily evaluation, in the order they run. */
export const DAILY_EVALUATION_STEP_ORDER = [
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
] as const;
export type DailyEvaluationStepName =
(typeof DAILY_EVALUATION_STEP_ORDER)[number];
/**
* One step of the daily evaluation. `today` is the Berlin day of the run
* (YYYY-MM-DD). A step commits in transactions of its own; it reports its
* counters, and fails by throwing - a JobPartialFailure if it still has
* counters to report.
*/
export interface DailyEvaluationStep {
readonly name: DailyEvaluationStepName;
run(today: string): Promise<JobResult>;
}
/**
* Runs the steps in DAILY_EVALUATION_STEP_ORDER, whatever order they come
* in. A failing step is logged and does not stop the others; the counters of
* all steps - also the partial ones of a failed step - are merged into
* `result`, `failed` names the steps that failed and `failures` says why.
*/
export async function runDailyEvaluationSteps(
steps: readonly DailyEvaluationStep[],
today: string,
logger: Logger,
): Promise<{
result: JobResult;
failed: DailyEvaluationStepName[];
/** One entry per failed step, with the failed parts: "time-events: QueenAged". */
failures: string[];
}> {
const ordered = [...steps].sort(
(a, b) =>
DAILY_EVALUATION_STEP_ORDER.indexOf(a.name) -
DAILY_EVALUATION_STEP_ORDER.indexOf(b.name),
);
let result: JobResult = {};
const failed: DailyEvaluationStepName[] = [];
const failures: string[] = [];
for (const step of ordered) {
try {
result = { ...result, ...(await step.run(today)) };
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Step "${step.name}" failed: ${message}`, stack);
failed.push(step.name);
if (error instanceof JobPartialFailure) {
result = { ...result, ...error.result };
failures.push(`${step.name}: ${error.failedSteps.join(', ')}`);
} else {
failures.push(step.name);
}
}
}
return { result, failed, failures };
}
@@ -1,146 +1,64 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
/** The database clock: a day the JS clock of the test never shows. */
const dbNow = new Date('2026-06-10T02:30:00.000Z');
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
const COUNTS = {
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
let timeEvents: { name: 'time-events'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
Promise.resolve(
sql.includes('now()')
? [{ now: dbNow }]
: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
),
);
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: queryRaw,
$transaction: transaction,
task: { findMany: taskFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
job = new DailyEvaluationJob(prisma, {
recordAll,
} as unknown as DomainEventsService);
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
});
afterEach(() => jest.restoreAllMocks());
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('evaluates the day of the database clock and reports new events per type', async () => {
const result = await job.run();
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10 - not in the one of the test's own clock.
expect(result).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual(COUNTS);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
it('takes the Berlin day, not the UTC day, around midnight', async () => {
jest.setSystemTime(new Date('2026-06-09T22:30:00.000Z'));
await job.run();
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('a second run on the same day records nothing new', async () => {
await job.run();
it('a failing step fails the run with the counters of all steps', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
timeEvents.run.mockRejectedValue(
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
);
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['time-events'],
result: { WithdrawalEnded: 1 },
message: 'Daily evaluation failed for: time-events: TaskDue',
});
});
it('a failing rule does not stop the others, but fails the run', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
await expect(job.evaluate(dbNow)).rejects.toThrow(
'Daily evaluation failed for: TaskDue',
);
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
});
@@ -1,72 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { berlinToday } from '../common/dates';
import { JobPartialFailure } from '../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
} from './daily-evaluation.rules';
DailyEvaluationStep,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
import { TimeEventsStep } from './steps/time-events.step';
/**
* Records the time-based domain events (CronJob 04:30): one rule per type,
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
* run is caught up and a repeated one adds nothing. The result counts the
* events that were new, per type.
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
* own, and a failing step does not stop the others. The result holds the
* counters of all steps; if a step failed, the run fails with that result
* (JobPartialFailure), so it shows on the System page.
*/
@Injectable()
export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
constructor(private readonly timeEvents: TimeEventsStep) {}
/** Evaluates the current day of the database clock. */
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const [{ now }] = await this.prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS "now"`;
return this.evaluate(now);
}
/**
* Runs every rule for `today`. A failing rule is logged and does not stop
* the others - their events are committed - but fails the run, so it
* shows on the System page; the next run catches up.
*/
async evaluate(today: Date): Promise<JobResult> {
const counts: [string, number][] = [];
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts.push([rule.type, await this.evaluateRule(rule, today)]);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
}
return Object.fromEntries(counts);
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today),
today,
const steps: DailyEvaluationStep[] = [this.timeEvents];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
berlinToday(),
this.logger,
);
if (events.length === 0) {
return 0;
if (failed.length > 0) {
throw new JobPartialFailure(result, failed, failures);
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
return result;
}
}
@@ -1,15 +1,11 @@
import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import {
ACTIVE_SEASON_MONTHS,
CATCH_UP_DAYS,
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyEvent,
inCatchUpWindow,
INSPECTION_INTERVAL_DAYS,
latestCycleOccurrence,
QUEEN_AGED_MIN_YEARS,
VARROA_MEASUREMENT_INTERVAL_DAYS,
withdrawalEndDay,
} from './daily-evaluation.rules';
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC of a 'YYYY-MM-DD' day. */
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
/** Every day from `from` to `to`, both included. */
function daysBetween(from: string, to: string): string[] {
@@ -38,23 +32,25 @@ interface FakeRows {
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
/** Living colonies, all in one apiary. */
colonies?: { id: string; createdAt: Date }[];
/** Living colonies with their current queen (rows the queen query returns). */
queenedColonies?: {
id: string;
currentQueen: { id: string; hatchYear: number | null } | null;
}[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
varroa?: { id: string; colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
/** Stored rule configs by trigger type; others read as the built-in setting. */
rules?: Record<string, object>;
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
@@ -63,32 +59,60 @@ interface FakeDb {
* day themselves, the database filters only narrow the candidates.
*/
function fakeDb(rows: FakeRows): FakeDb {
const latest = (records: { colonyId: string; date: Date }[] = []) =>
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
const colonyFindMany = jest.fn(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select
? (rows.queenedColonies ?? [])
: (rows.colonies ?? []).map((colony) => ({
...colony,
apiaryId: 'apiary-1',
})),
),
);
const db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
groupBy: jest.fn().mockResolvedValue(
(rows.inspections ?? []).map(({ colonyId, date }) => ({
colonyId,
_max: { date },
})),
),
},
automationRule: {
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
const config = rows.rules?.[where.triggerType];
return Promise.resolve(
config === undefined
? null
: { id: 'rule-1', active: true, actionTemplate: null, config },
);
}),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
return { db, queryRaw, taskFindMany, colonyFindMany };
}
const seasons = new SeasonService();
/** One job run on `day`: every rule's events in the catch-up window. */
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
const events: DailyEvent[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
events.push(
...catchUpEvents(
await rule.occurrences(db, d(day), { seasons, day }),
d(day),
),
);
}
return events;
}
@@ -113,13 +137,12 @@ async function runDaily(
return store;
}
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
describe('daily evaluation rules', () => {
it('keeps the values of the backbone plan', () => {
it('keeps the catch-up window of the backbone plan', () => {
expect(CATCH_UP_DAYS).toBe(7);
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
});
it('runs one rule per time-based event type', () => {
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
).toBeNull());
});
describe('latestCycleOccurrence', () => {
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
'2026-06-12',
);
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
'2026-05-22',
);
});
it('is null before the first full interval and for a base in the future', () => {
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
).toBeNull();
});
it('counts whole UTC days, not hours', () =>
expect(
latestCycleOccurrence(
new Date('2026-05-01T20:00:00.000Z'),
21,
at('2026-05-22'),
),
).toBe('2026-05-22'));
it('refuses an interval that is not a positive whole number of days', () => {
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
).toThrow(RangeError);
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
).toThrow(RangeError);
});
});
describe('inCatchUpWindow', () => {
it('spans today and the 7 days before it', () => {
const today = at('2026-06-10');
const today = d('2026-06-10');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
});
it('works across a year boundary', () => {
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
});
});
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
// Never measured or inspected: they count from its creation.
{ id: 'colony-2', createdAt: d('2026-04-12') },
],
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
payload: {
dueOn: '2026-05-29',
lastMeasuredOn: '2026-05-01',
kind: 'INTERVAL',
anchor: 'm-1',
},
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
// colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after.
// colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-27',
lastInspectedOn: '2026-05-20',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
},
]);
});
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
});
it('emits no inspection or varroa event outside March–October', async () => {
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
describe('QueenAged', () => {
const queen = (hatchYear: number | null) => ({
id: 'queen-1',
hatchYear,
});
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
const dueDays = (type: string) =>
[...store.values()]
.filter((event) => event.type === type)
.map((event) => event.dedupeKey.split(':')[2]);
it('fires on the job day, once per queen and year under the January 1 key', async () => {
const { db } = fakeDb({
queenedColonies: [
{ id: 'colony-1', currentQueen: queen(2024) },
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
],
});
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
expect(dueDays('VarroaMeasurementDue')).toEqual([
'2026-03-07',
'2026-04-04',
'2026-05-02',
'2026-05-30',
'2026-06-27',
'2026-07-25',
'2026-08-22',
'2026-09-19',
'2026-10-17',
]);
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
expect(dueDays('InspectionOverdue')).toEqual([
'2026-03-14',
'2026-04-04',
'2026-04-25',
'2026-05-16',
'2026-06-06',
'2026-06-27',
'2026-07-18',
'2026-08-08',
'2026-08-29',
'2026-09-19',
'2026-10-10',
'2026-10-31',
]);
expect(await eventsOn(db, '2026-09-09')).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-1',
payload: { hatchYear: 2024, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-1:2026-01-01',
},
]);
});
it('QueenAged is not limited to January 1–8', async () => {
const { db } = fakeDb({
queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }],
});
for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) {
expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([
'QueenAged:queen-1:2026-01-01',
]);
}
});
it('QueenAged uses queenMaxAgeYears', async () => {
const queened = [{ id: 'colony-1', currentQueen: queen(2025) }];
const { db: byDefault } = fakeDb({ queenedColonies: queened });
const { db: stricter, colonyFindMany } = fakeDb({
queenedColonies: queened,
rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } },
});
expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]);
expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}) as object,
}),
);
});
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
const { db, colonyFindMany } = fakeDb({});
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
let assigned = false;
colonyFindMany.mockImplementation(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select && assigned
? [{ id: 'colony-1', currentQueen: queen(2024) }]
: [],
),
);
const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14'));
expect([...store.keys()]).toEqual([]);
assigned = true;
expect(
(await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey),
).toEqual(['QueenAged:queen-1:2026-01-01']);
await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store);
expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']);
// The next year is a new anchor.
await runDaily(db, ['2027-01-02'], store);
expect([...store.keys()]).toEqual([
'QueenAged:queen-1:2026-01-01',
'QueenAged:queen-1:2027-01-01',
]);
});
});
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
const { db } = fakeDb({
queens: [
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
{ id: 'queen-2027', hatchYear: 2027, colony: null },
{ id: 'queen-unknown', hatchYear: null, colony: null },
],
describe('InspectionOverdue', () => {
it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05.
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
expect(
(await eventsOn(db, '2026-05-05')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([]);
expect(
(await eventsOn(db, '2026-05-06')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-05',
lastInspectedOn: '2026-04-28',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-06',
},
]);
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => {
// Last inspected in autumn: the next one is due in the winter.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }],
});
expect([...store.values()]).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2026',
payload: { hatchYear: 2026, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
},
expect.objectContaining({
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
}) as DailyEvent,
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2027',
payload: { hatchYear: 2027, colonyId: null },
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
},
]);
const winter = await runDaily(
db,
daysBetween('2026-01-01', '2026-03-19'),
);
expect([...winter.keys()]).toEqual([]);
// FRUEHJAHR begins 2026-03-20: due that day, overdue the day after.
const spring = await runDaily(
db,
daysBetween('2026-03-20', '2026-04-10'),
);
expect(keysOf(spring, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-03-21',
]);
expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject(
{
payload: {
dueOn: '2026-03-20',
lastInspectedOn: '2025-10-20',
firstOfYear: true,
},
},
);
});
it('emits nothing for a due date more than a week back (one event per due date)', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const inspections = async (day: string) =>
(await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue');
// Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13.
expect(await inspections('2026-05-13')).toHaveLength(1);
// Due + 9 days.
expect(await inspections('2026-05-14')).toEqual([]);
});
it('keeps the same dedupe key on consecutive days while nothing changes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const keys = async (day: string) =>
(await eventsOn(db, day)).map((event) => event.dedupeKey);
expect(await keys('2026-05-08')).toEqual(
expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']),
);
expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08'));
});
it('a segment interval change gives a second event with another key', async () => {
// Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from
// SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }],
});
const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25'));
expect(keysOf(store, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-06-13',
'InspectionOverdue:colony-1:2026-06-20',
]);
});
it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => {
// HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 -
// but 11-15 is WINTERRUHE, so nothing.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }],
});
expect(
keysOf(
await runDaily(db, daysBetween('2026-11-10', '2027-03-19')),
'InspectionOverdue',
),
).toEqual([]);
const spring = await runDaily(
db,
daysBetween('2027-03-20', '2027-03-25'),
);
expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject(
{ payload: { dueOn: '2027-03-20', firstOfYear: true } },
);
});
it('emits no inspection event inside WINTERRUHE although the due date passes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }],
});
const winter = await runDaily(
db,
daysBetween('2026-11-15', '2027-03-19'),
);
expect(keysOf(winter, 'InspectionOverdue')).toEqual([]);
});
});
describe('VarroaMeasurementDue', () => {
it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-11-15',
]);
});
it('is not due in the winter window after a measurement since 11-01', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]);
});
it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// Measured 2026-11-20 (inside the winter window of the year): the
// next interval day, 12-18, lies in WINTERRUHE.
varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2027-03-20',
]);
expect(
store.get('VarroaMeasurementDue:colony-1:2027-03-20'),
).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } });
});
it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }],
rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } },
});
const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-05-04',
]);
});
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
await eventsOn(db, '2026-06-10');
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// QueenAged only looks on January 1-8.
expect(queenFindMany).not.toHaveBeenCalled();
await eventsOn(db, '2027-01-08');
expect(queenFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}),
);
});
});
+180 -128
View File
@@ -1,22 +1,20 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { addDays, berlinDayOf } from '../common/dates';
import {
AnyNewDomainEvent,
dailyDedupeKey,
utcDay,
} from '../domain-events/domain-event-types';
import { PrismaService } from '../prisma/prisma.service';
import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import type { SeasonService } from '../season/season.service';
import { varroaMeasurementDue } from '../season/varroa-due';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
/** A queen counts as aged from this many years after her hatch year. */
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
/** March to October: varroa and inspection reminders only in these months. */
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
3, 4, 5, 6, 7, 8, 9, 10,
];
const DAY_MS = 24 * 60 * 60 * 1000;
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
@@ -37,18 +35,35 @@ export type DailyEvent<T extends DailyEventType = DailyEventType> =
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
day: string;
event: AnyNewDomainEvent<T>;
/**
* The day of the dedupe key when it is not `day`: an occurrence that
* repeats on every job day but must be recorded once (QueenAged: the
* January 1 of the year).
*/
anchorDay?: string;
}
/** What a rule needs besides the database: the seasons and the run's day. */
export interface DailyRuleContext {
seasons: SeasonService;
/** The Berlin day of the run, YYYY-MM-DD. */
day: string;
}
/**
* One row of the daily evaluation. `occurrences` reads the candidates and
* names the day of each; the job keeps those in the catch-up window
* names the day of each; the step keeps those in the catch-up window
* (catchUpEvents) and records them under their daily dedupe key. A rule
* may narrow its query to the window, but must not rely on that: the day
* it reports decides.
* it reports decides. `today` is dayToDate(ctx.day).
*/
export interface DailyRule<T extends DailyEventType = DailyEventType> {
readonly type: T;
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
occurrences(
db: PrismaService,
today: Date,
ctx: DailyRuleContext,
): Promise<DailyOccurrence<T>[]>;
}
/** Midnight UTC of the UTC day of `date`. */
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
}
/**
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
* in whole UTC days; null if not even the first interval has passed.
*/
export function latestCycleOccurrence(
base: Date,
intervalDays: number,
today: Date,
): string | null {
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
throw new RangeError(
`Interval must be a positive whole number of days, got ${intervalDays}`,
);
}
const elapsedDays = Math.round(
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
);
const cycles = Math.floor(elapsedDays / intervalDays);
if (cycles < 1) {
return null;
}
return utcDay(dayAfter(base, cycles * intervalDays));
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
export function inCatchUpWindow(day: string, today: Date): boolean {
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
}
function inActiveSeason(day: string): boolean {
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
}
/**
* The occurrences in the catch-up window, as events with their daily dedupe
* key: every run within the window repeats the same key, so the unique
@@ -117,9 +104,13 @@ export function catchUpEvents(
): DailyEvent[] {
return occurrences
.filter(({ day }) => inCatchUpWindow(day, today))
.map(({ day, event }) => ({
.map(({ day, anchorDay, event }) => ({
...event,
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
dedupeKey: dailyDedupeKey(
event.type,
event.aggregateId,
anchorDay ?? day,
),
}));
}
@@ -197,34 +188,47 @@ const taskDue: DailyRule<'TaskDue'> = {
},
};
/** Active queen from her second year after hatching: once a year, on January 1. */
/**
* The current queen of a living colony once she is aged
* (SEASON_AUTOPILOT.queenMaxAgeYears): on the first job day she is, once per
* queen and year (the key is anchored on January 1), so a queen assigned
* later in the year, or a changed hatch year, is caught too.
*/
const queenAged: DailyRule<'QueenAged'> = {
type: 'QueenAged',
async occurrences(db, today) {
const year = today.getUTCFullYear();
const day = `${year}-01-01`;
if (!inCatchUpWindow(day, today)) {
return [];
}
const queens = await db.queen.findMany({
async occurrences(db, _today, { day }) {
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const year = Number(day.slice(0, 4));
const colonies = await db.colony.findMany({
where: {
status: 'ACTIVE',
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
...LIVING_COLONY_WHERE,
currentQueen: {
status: 'ACTIVE',
hatchYear: { lte: year - config.queenMaxAgeYears },
},
},
select: {
id: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
});
return queens.flatMap(({ id, hatchYear, colony }) => {
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
return colonies.flatMap(({ id, currentQueen }) => {
if (
currentQueen === null ||
currentQueen.hatchYear === null ||
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
) {
return [];
}
return [
{
day,
anchorDay: `${year}-01-01`,
event: {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: id,
payload: { hatchYear, colonyId: colony?.id ?? null },
aggregateId: currentQueen.id,
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
},
},
];
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
},
};
interface CycleDue {
colonyId: string;
day: string;
/** The UTC day of the last record, null if the colony has none. */
lastOn: string | null;
interface SeasonalColony {
id: string;
apiaryId: string;
/** The Berlin day the colony was created on. */
createdOn: string;
}
async function livingColonies(db: PrismaService): Promise<SeasonalColony[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true, createdAt: true },
});
return colonies.map(({ id, apiaryId, createdAt }) => ({
id,
apiaryId,
createdOn: berlinDayOf(createdAt),
}));
}
/**
* Living colonies whose cycle comes due in the active season: counted from
* the last record, or from the colony's creation if it has none.
* Living colony: varroa measurement due, by the season of its apiary
* (varroaMeasurementDue): the interval after the last measurement, or the
* winter window. One event per due date, on the due day; the 7-day catch-up
* only covers job outages (the A2 suggestion carries the reminder after).
*/
async function colonyCyclesDue(
db: PrismaService,
lastRecordOn: ReadonlyMap<string, Date>,
intervalDays: number,
today: Date,
): Promise<CycleDue[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, createdAt: true },
});
return colonies.flatMap(({ id, createdAt }) => {
const last = lastRecordOn.get(id) ?? null;
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
if (day === null || !inActiveSeason(day)) {
return [];
}
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
});
}
function latestByColony(
rows: { colonyId: string; _max: { date: Date | null } }[],
): ReadonlyMap<string, Date> {
return new Map(
rows.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, _max.date] as const],
),
);
}
/** Living colony: varroa measurement due again in the active season. */
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, today) {
const latest = await db.varroaMeasurement.groupBy({
by: ['colonyId'],
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.varroaMeasurement.findMany({
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
distinct: ['colonyId'],
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
select: { id: true, colonyId: true, date: true },
});
const due = await colonyCyclesDue(
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const states = await seasons.statesFor(
db,
latestByColony(latest),
VARROA_MEASUREMENT_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
colonies.map((colony) => colony.apiaryId),
day,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastMeasuredOn: lastOn },
},
}));
);
const lastByColony = new Map(latest.map((row) => [row.colonyId, row]));
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const last = lastByColony.get(colony.id);
const lastMeasuredOn = last === undefined ? null : utcDay(last.date);
const due =
season === undefined
? null
: varroaMeasurementDue(
{
createdOn: colony.createdOn,
lastMeasuredOn,
lastMeasurementId: last?.id ?? null,
},
season,
config,
day,
);
if (due === null) {
return [];
}
return [
{
day: due.dueOn,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastMeasuredOn,
kind: due.kind,
anchor: due.anchor,
},
},
},
];
});
},
};
/** Living colony: inspection overdue in the active season. */
/**
* Living colony: inspection overdue the day after inspectionDueOn - by the
* interval of the season segment of its apiary, never in WINTERRUHE; after
* the winter on the day after FRUEHJAHR begins ("first of the year"). One
* event per due date; the 7-day catch-up only covers job outages (A1 carries
* the reminder after). A changed interval (new segment) yields a new due
* date and so a second event with another key.
*/
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
type: 'InspectionOverdue',
async occurrences(db, today) {
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.inspection.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
INSPECTION_INTERVAL_DAYS,
today,
const lastInspected = new Map(
latest.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
),
);
return due.map(({ colonyId, day, lastOn }) => ({
const { config } = await readRule(db, 'INSPECTION_INTERVAL');
const states = await seasons.statesFor(
db,
colonies.map((colony) => colony.apiaryId),
day,
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastInspectedOn: lastOn },
},
}));
);
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const lastInspectedOn = lastInspected.get(colony.id) ?? null;
const due =
season === undefined
? null
: inspectionDueOn(
{ createdOn: colony.createdOn, lastInspectedOn },
season,
config,
);
if (due === null) {
return [];
}
return [
{
day: addDays(due.dueOn, 1),
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastInspectedOn,
firstOfYear: due.firstOfYear,
},
},
},
];
});
},
};
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
taskDue,
queenAged,
varroaMeasurementDue,
varroaDue,
inspectionOverdue,
];
+6 -16
View File
@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SeasonModule } from '../season/season.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
import { AppBackupJob } from './app-backup.job';
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
import { Job } from './job';
import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TimeEventsStep } from './steps/time-events.step';
import { TaskTemplatesJob } from './task-templates.job';
/**
* PermissionResolverService for the job context (BackupService clears its
* cache after a restore, which no job does). Not PermissionsModule: its
* PermissionCatalogSyncService would rewrite the permission catalog on
* every job start. Global like PermissionsModule, so modules that inject
* the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
class JobPermissionsModule {}
const JOB_CLASSES = [
DailyEvaluationJob,
TaskTemplatesJob,
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
JobPermissionsModule,
BackupModule,
SystemModule,
SeasonModule,
],
providers: [
...JOB_CLASSES,
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
useFactory: (...jobs: Job[]): Job[] => jobs,
inject: JOB_CLASSES,
},
TimeEventsStep,
JobRegistry,
IdempotencyCleanupService,
],
@@ -0,0 +1,168 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { berlinToday } from '../../common/dates';
import { AnyNewDomainEvent } from '../../domain-events/domain-event-types';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import { TimeEventsStep } from './time-events.step';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('TimeEventsStep', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let step: TimeEventsStep;
beforeEach(() => {
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
]),
$transaction: transaction,
task: { findMany: taskFindMany },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
} as unknown as PrismaService;
step = new TimeEventsStep(
prisma,
{ recordAll } as unknown as DomainEventsService,
new SeasonService(),
);
});
afterEach(() => jest.restoreAllMocks());
it('is the time-events step', () => {
expect(step.name).toBe('time-events');
});
it('reports the new events per type', async () => {
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10.
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
await step.run('2026-06-10');
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await step.run('2026-06-10');
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it.each([
['summer', '2026-06-10T02:30:00.000Z', '2026-06-10'],
['winter', '2026-12-05T03:30:00.000Z', '2026-12-05'],
])(
'the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey (%s)',
async (_season, instant, day) => {
taskFindMany.mockResolvedValue([{ id: 'task-1', dueDate: d(day) }]);
expect(berlinToday(new Date(instant))).toBe(day);
await step.run(berlinToday(new Date(instant)));
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ dedupeKey: `TaskDue:task-1:${day}` }),
]);
},
);
it('a failing rule does not stop the others, but fails the step with their counters', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
const failure = await step.run('2026-06-10').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['TaskDue'],
result: {
WithdrawalEnded: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
},
});
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
it('refuses a malformed day', async () => {
await expect(step.run('2026-02-30')).rejects.toThrow('Invalid day');
});
});
@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { dayToDate } from '../../common/dates';
import { loggableError } from '../../common/loggable-error';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
DailyRuleContext,
} from '../daily-evaluation.rules';
import type { DailyEvaluationStep } from '../daily-evaluation-steps';
import type { JobResult } from '../job';
/**
* Step "time-events": records the time-based domain events, one rule per
* type, see DAILY_EVALUATION_RULES. An occurrence is recorded on every run
* within CATCH_UP_DAYS after its day under the same daily dedupe key, so a
* missed run is caught up and a repeated one adds nothing. The result counts
* the events that were new, per type.
*/
@Injectable()
export class TimeEventsStep implements DailyEvaluationStep {
readonly name = 'time-events';
private readonly logger = new Logger(TimeEventsStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
private readonly seasons: SeasonService,
) {}
/**
* Runs every rule for `day` (the Berlin day of the run). A failing rule is
* logged and does not stop the others - their events are committed - but
* fails the step with the counters of the rules that ran; the next run
* catches up.
*/
async run(day: string): Promise<JobResult> {
const today = dayToDate(day);
const context: DailyRuleContext = { seasons: this.seasons, day };
const counts: JobResult = {};
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts[rule.type] = await this.evaluateRule(rule, today, context);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new JobPartialFailure(counts, failed);
}
return counts;
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(
rule: DailyRule,
today: Date,
context: DailyRuleContext,
): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today, context),
today,
);
if (events.length === 0) {
return 0;
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
}
}
@@ -1,5 +1,6 @@
import { Prisma } from '../../generated/prisma';
import {
LOCK_ORDER,
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
@@ -143,3 +144,98 @@ describe('lockRowsForUpdate', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lock keys per table', () => {
const lockers = [
[
'lockRowForUpdate',
(tx: unknown) =>
lockRowForUpdate(tx as never, 'NotificationPreference', 'u1'),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ? FOR UPDATE',
['u1'],
],
[
'lockRowsForUpdate',
(tx: unknown) =>
lockRowsForUpdate(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR UPDATE',
[['u1', 'u2']],
],
[
'lockRowsForShare',
(tx: unknown) =>
lockRowsForShare(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR SHARE',
[['u1', 'u2']],
],
] as const;
it.each(lockers)(
'%s locks NotificationPreference by "userId" with the id as parameter',
async (_name, lock, sql, values) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lock(tx);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(sql);
expect(query.values).toEqual(values);
},
);
it.each([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`);
expect(query.values).toEqual(['x1']);
});
it('rejects a table outside the map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForShare(tx as never, 'Notification' as never, ['n1']),
).rejects.toThrow('lockRowsForShare: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
expect(LOCK_ORDER).toEqual([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
]);
});
});
+54 -32
View File
@@ -1,29 +1,57 @@
import { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from './db-client';
// Fixed identifier map: a table name is never taken from input.
// `key` is the SQL identifier of the key column: plain id, quoted for a
// camelCase name.
function lockable(table: string, key = 'id') {
return {
table: Prisma.raw(`"${table}"`),
key: Prisma.raw(key === 'id' ? 'id' : `"${key}"`),
};
}
// Fixed identifier map: a table or key column name is never taken from input.
// The order is the lock order: a transaction that locks rows of several
// tables takes them in this order (and ascending key within a table), so two
// of them cannot wait for each other.
const LOCKABLE_TABLES = {
Colony: Prisma.raw('"Colony"'),
Task: Prisma.raw('"Task"'),
BottlingBatch: Prisma.raw('"BottlingBatch"'),
Feeding: Prisma.raw('"Feeding"'),
Harvest: Prisma.raw('"Harvest"'),
Inspection: Prisma.raw('"Inspection"'),
Product: Prisma.raw('"Product"'),
ProductionBatch: Prisma.raw('"ProductionBatch"'),
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
StockItem: Prisma.raw('"StockItem"'),
Treatment: Prisma.raw('"Treatment"'),
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
Queen: lockable('Queen'),
AutomationRule: lockable('AutomationRule'),
BottlingBatch: lockable('BottlingBatch'),
Feeding: lockable('Feeding'),
Harvest: lockable('Harvest'),
Inspection: lockable('Inspection'),
Product: lockable('Product'),
ProductionBatch: lockable('ProductionBatch'),
RawMaterialLot: lockable('RawMaterialLot'),
StockItem: lockable('StockItem'),
Treatment: lockable('Treatment'),
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
/** The tables in the order a transaction must lock them. */
export const LOCK_ORDER = Object.keys(LOCKABLE_TABLES) as LockableTable[];
function entryOf(table: LockableTable, caller: string) {
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error(`${caller}: table not lockable`);
}
return LOCKABLE_TABLES[table];
}
/**
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
* read-modify-write on it is serialised with concurrent writers. Re-read the
* row after locking and use that copy as "before". Resolves to whether the
* row exists.
* row exists. `id` is the value of the table's key column ("id", for
* NotificationPreference "userId").
*/
export async function lockRowForUpdate(
tx: Prisma.TransactionClient,
@@ -31,20 +59,18 @@ export async function lockRowForUpdate(
id: string,
): Promise<boolean> {
assertTransactionClient(tx, 'lockRowForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowForUpdate: table not lockable');
}
const locked = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
const entry = entryOf(table, 'lockRowForUpdate');
const locked = await tx.$queryRaw<unknown[]>(
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ${id} FOR UPDATE`,
);
return locked.length > 0;
}
/**
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
* ONE statement in ascending id order: it meets lockRowsForShare on the same
* ONE statement in ascending key order: it meets lockRowsForShare on the same
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
* ids lock nothing; re-read the rows after locking.
* keys lock nothing; re-read the rows after locking.
*/
export async function lockRowsForUpdate(
tx: Prisma.TransactionClient,
@@ -52,24 +78,22 @@ export async function lockRowsForUpdate(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForUpdate: table not lockable');
}
const entry = entryOf(table, 'lockRowsForUpdate');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR UPDATE`,
);
}
/**
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
* cannot be changed or deleted meanwhile, other readers are not blocked. The
* rows are locked in ascending id order, so two transactions locking
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
* after locking.
* rows are locked in ascending key order, so two transactions locking
* overlapping sets cannot deadlock. Unknown keys lock nothing; re-read the
* rows after locking.
*/
export async function lockRowsForShare(
tx: Prisma.TransactionClient,
@@ -77,14 +101,12 @@ export async function lockRowsForShare(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForShare');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForShare: table not lockable');
}
const entry = entryOf(table, 'lockRowsForShare');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR SHARE`,
);
}
@@ -0,0 +1,73 @@
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
import { inspectionDueOn } from './inspection-due';
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
const SCHWARMZEIT = {
segment: 'SCHWARMZEIT',
springSince: '2026-03-20',
} as const;
describe('inspectionDueOn', () => {
it('adds the interval of the current segment to the last inspection', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-05-05' },
SCHWARMZEIT,
CONFIG,
),
).toEqual({ dueOn: '2026-05-12', firstOfYear: false, intervalDays: 7 }));
it('uses the interval of SOMMER in SOMMER', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-06-25' },
{ segment: 'SOMMER', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-07-09', firstOfYear: false, intervalDays: 14 }));
it('returns null in WINTERRUHE', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-01' },
{ segment: 'WINTERRUHE', springSince: null },
CONFIG,
),
).toBeNull());
it('counts from createdAt without any inspection', () =>
expect(
inspectionDueOn(
{ createdOn: '2026-04-20', lastInspectedOn: null },
SCHWARMZEIT,
CONFIG,
),
).toEqual({ dueOn: '2026-04-27', firstOfYear: false, intervalDays: 7 }));
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-30' },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-03-20', firstOfYear: true, intervalDays: 14 }));
it('a colony created before the spring without an inspection is first-of-year due too', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-09-01', lastInspectedOn: null },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toMatchObject({ dueOn: '2026-03-20', firstOfYear: true }));
it('an inspection on the first day of FRUEHJAHR counts as done', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-03-20' },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
});
@@ -0,0 +1,35 @@
import { addDays } from '../common/dates';
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
import type { SeasonState } from './season-types';
export interface InspectionDue {
dueOn: string;
/** Due because the last inspection lies before this spring ("first of the year"). */
firstOfYear: boolean;
intervalDays: number;
}
/**
* The day an inspection is due: the last inspection (else the creation of
* the colony) plus the interval of today's segment. A colony not inspected
* since before FRUEHJAHR began is due from that day on. Never in WINTERRUHE.
*/
export function inspectionDueOn(
input: { createdOn: string; lastInspectedOn: string | null },
season: Pick<SeasonState, 'segment' | 'springSince'>,
config: InspectionIntervalConfig,
): InspectionDue | null {
if (season.segment === 'WINTERRUHE') {
return null;
}
const intervalDays = config.intervalDays[season.segment];
const base = input.lastInspectedOn ?? input.createdOn;
if (season.springSince !== null && base < season.springSince) {
return { dueOn: season.springSince, firstOfYear: true, intervalDays };
}
return {
dueOn: addDays(base, intervalDays),
firstOfYear: false,
intervalDays,
};
}
@@ -0,0 +1,30 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// The age at which a queen counts as aged is SEASON_AUTOPILOT.queenMaxAgeYears,
// judged by isQueenAged. No second constant may appear in src.
const SRC = join(__dirname, '..');
const THIS_FILE = 'season/queen-age-threshold.spec.ts';
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return sources(path);
}
return entry.name.endsWith('.ts') ? [path] : [];
});
}
function offenders(pattern: RegExp): string[] {
return sources(SRC)
.filter((file) => pattern.test(readFileSync(file, 'utf8')))
.map((file) => relative(SRC, file).split(sep).join('/'))
.filter((file) => file !== THIS_FILE)
.sort();
}
describe('queen age threshold', () => {
it('there is no second queen age threshold in src', () =>
expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
});
@@ -0,0 +1,17 @@
import { isQueenAged } from './queen-age';
describe('isQueenAged', () => {
it('is aged from queenMaxAgeYears after the hatch year', () => {
expect(isQueenAged(2024, '2026-06-10', 2)).toBe(true);
expect(isQueenAged(2025, '2026-06-10', 2)).toBe(false);
expect(isQueenAged(2023, '2026-06-10', 3)).toBe(true);
});
it('changes with the year, not within it', () => {
expect(isQueenAged(2025, '2026-12-31', 2)).toBe(false);
expect(isQueenAged(2025, '2027-01-01', 2)).toBe(true);
});
it('is never aged without a hatch year', () =>
expect(isQueenAged(null, '2026-06-10', 1)).toBe(false));
});
+15
View File
@@ -0,0 +1,15 @@
/**
* An aged queen: the current year minus her hatch year reaches
* `queenMaxAgeYears`. The one definition for QueenAged, the dashboard's
* OLD_QUEEN and autopilot A8. `today` is a YYYY-MM-DD day.
*/
export function isQueenAged(
hatchYear: number | null,
today: string,
queenMaxAgeYears: number,
): boolean {
if (hatchYear === null) {
return false;
}
return Number(today.slice(0, 4)) - hatchYear >= queenMaxAgeYears;
}
@@ -0,0 +1,92 @@
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import { calendarPhase, calendarSeasonState } from './season-calendar';
const FALLBACK = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
describe('calendarPhase', () => {
it('is the WINTER phase of the previous year before SALWEIDE', () =>
expect(calendarPhase('2026-03-10', FALLBACK)).toEqual({
phase: 'WINTER',
since: '2025-11-15',
}));
it('is the latest phase whose calendar day has been reached', () => {
expect(calendarPhase('2026-05-12', FALLBACK)).toEqual({
phase: 'APFEL',
since: '2026-04-28',
});
expect(calendarPhase('2026-04-28', FALLBACK)).toEqual({
phase: 'APFEL',
since: '2026-04-28',
});
expect(calendarPhase('2026-11-15', FALLBACK)).toEqual({
phase: 'WINTER',
since: '2026-11-15',
});
});
});
describe('calendarSeasonState', () => {
const state = (today: string) =>
calendarSeasonState('apiary-1', today, DEFAULT_PHENOLOGY_CONFIG);
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', () =>
expect(state('2026-03-10')).toEqual({
apiaryId: 'apiary-1',
year: 2026,
phase: 'WINTER',
phaseSource: 'CALENDAR',
reportedOn: null,
segment: 'WINTERRUHE',
segmentSince: '2025-11-15',
springSince: null,
gts: null,
gtsThroughDay: null,
note: null,
}));
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', () =>
expect(state('2026-05-12')).toMatchObject({
phase: 'APFEL',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-15',
springSince: '2026-03-20',
}));
it('FRUEHJAHR starts with SALWEIDE and sets springSince', () =>
expect(state('2026-04-01')).toMatchObject({
segment: 'FRUEHJAHR',
segmentSince: '2026-03-20',
springSince: '2026-03-20',
}));
it('WINTERRUHE after WINTER keeps springSince of the year', () =>
expect(state('2026-12-01')).toMatchObject({
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: '2026-03-20',
}));
it('a segment spanning several phases begins at its first phase', () =>
expect(state('2026-06-25')).toMatchObject({
segment: 'SOMMER',
segmentSince: '2026-06-20',
}));
it('stays WINTERRUHE across the year change, springSince only after SALWEIDE', () => {
expect(state('2026-12-31')).toMatchObject({
year: 2026,
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: '2026-03-20',
});
expect(state('2027-01-01')).toMatchObject({
year: 2027,
phase: 'WINTER',
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: null,
});
});
});
@@ -0,0 +1,92 @@
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import { segmentOf } from './season-segment';
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
type SeasonState,
} from './season-types';
type CalendarFallback = Record<PhenologyPhaseKey, string>;
interface PhaseOnDay {
phase: PhenologyPhaseKey;
day: string;
}
/** The phases of `year` whose calendar day has been reached, in phase order. */
function reachedPhases(
year: number,
today: string,
fallback: CalendarFallback,
): PhaseOnDay[] {
return PHENOLOGY_PHASES.map((phase) => ({
phase,
day: `${year}-${fallback[phase]}`,
})).filter(({ day }) => day <= today);
}
/**
* The phase the calendar puts `today` in: the latest phase of the year whose
* day has been reached; before SALWEIDE still the WINTER of the previous year.
*/
export function calendarPhase(
today: string,
fallback: CalendarFallback,
): { phase: PhenologyPhaseKey | null; since: string | null } {
const year = Number(today.slice(0, 4));
const reached = reachedPhases(year, today, fallback);
const latest = reached[reached.length - 1];
if (latest === undefined) {
return { phase: 'WINTER', since: `${year - 1}-${fallback.WINTER}` };
}
return { phase: latest.phase, since: latest.day };
}
/** The day the segment of the latest reached phase began. */
function segmentStart(
reached: readonly PhaseOnDay[],
swarmSeason: PhenologyConfig['swarmSeason'],
fallbackStart: string,
): string {
const latest = reached[reached.length - 1];
if (latest === undefined) {
return fallbackStart;
}
const segment = segmentOf(latest.phase, swarmSeason);
let since = latest.day;
for (let i = reached.length - 2; i >= 0; i -= 1) {
if (segmentOf(reached[i].phase, swarmSeason) !== segment) {
break;
}
since = reached[i].day;
}
return since;
}
/**
* The season of an apiary from the calendar alone (no weather, no report):
* what every apiary gets until the phenology data of Task 25 is there.
*/
export function calendarSeasonState(
apiaryId: string,
today: string,
config: PhenologyConfig,
): SeasonState {
const year = Number(today.slice(0, 4));
const { phase, since } = calendarPhase(today, config.calendarFallback);
const reached = reachedPhases(year, today, config.calendarFallback);
const springDay = `${year}-${config.calendarFallback.SALWEIDE}`;
return {
apiaryId,
year,
phase,
phaseSource: 'CALENDAR',
reportedOn: null,
segment: segmentOf(phase, config.swarmSeason),
segmentSince: segmentStart(reached, config.swarmSeason, since ?? today),
springSince: springDay <= today ? springDay : null,
gts: null,
gtsThroughDay: null,
note: null,
};
}
@@ -0,0 +1,34 @@
import { segmentOf } from './season-segment';
import type { PhenologyPhaseKey } from './season-types';
const DEFAULT_SWARM_SEASON = { from: 'KIRSCHE', until: 'LINDE' } as const;
describe('segmentOf', () => {
it.each<[PhenologyPhaseKey | null, string]>([
[null, 'WINTERRUHE'],
['SALWEIDE', 'FRUEHJAHR'],
['KIRSCHE', 'SCHWARMZEIT'],
['APFEL', 'SCHWARMZEIT'],
['HOLUNDER', 'SCHWARMZEIT'],
['LINDE', 'SOMMER'],
['TRACHTENDE', 'SPAETSOMMER'],
['HERBST', 'HERBST'],
['WINTER', 'WINTERRUHE'],
])('%p -> %p', (phase, segment) => {
expect(segmentOf(phase, DEFAULT_SWARM_SEASON)).toBe(segment);
});
it('honours a configured swarm season start at APFEL', () =>
expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe(
'FRUEHJAHR',
));
it('honours a configured swarm season end at HOLUNDER', () => {
expect(segmentOf('APFEL', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
'SCHWARMZEIT',
);
expect(segmentOf('HOLUNDER', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
'SOMMER',
);
});
});
@@ -0,0 +1,38 @@
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
type SeasonSegmentKey,
} from './season-types';
const phaseIndex = (phase: PhenologyPhaseKey): number =>
PHENOLOGY_PHASES.indexOf(phase);
/**
* The season segment a phase belongs to: WINTERRUHE from WINTER (and before
* the first phase), FRUEHJAHR from SALWEIDE, SCHWARMZEIT from the configured
* start, SOMMER from its end, SPAETSOMMER from TRACHTENDE, HERBST from HERBST.
*/
export function segmentOf(
phase: PhenologyPhaseKey | null,
swarmSeason: PhenologyConfig['swarmSeason'],
): SeasonSegmentKey {
switch (phase) {
case null:
case 'WINTER':
return 'WINTERRUHE';
case 'HERBST':
return 'HERBST';
case 'TRACHTENDE':
return 'SPAETSOMMER';
default: {
const index = phaseIndex(phase);
if (index >= phaseIndex(swarmSeason.until)) {
return 'SOMMER';
}
return index >= phaseIndex(swarmSeason.from)
? 'SCHWARMZEIT'
: 'FRUEHJAHR';
}
}
}
@@ -0,0 +1,51 @@
// Season vocabulary shared by rule settings, the weather sources and the
// season state. The Prisma enums follow in Task 23 with the same values.
export const PHENOLOGY_PHASES = [
'SALWEIDE',
'KIRSCHE',
'APFEL',
'HOLUNDER',
'LINDE',
'TRACHTENDE',
'HERBST',
'WINTER',
] as const;
export const SEASON_SEGMENTS = [
'WINTERRUHE',
'FRUEHJAHR',
'SCHWARMZEIT',
'SOMMER',
'SPAETSOMMER',
'HERBST',
] as const;
export const PHASE_SOURCES = ['REPORT', 'GTS', 'CALENDAR'] as const;
export type PhenologyPhaseKey = (typeof PHENOLOGY_PHASES)[number];
export type SeasonSegmentKey = (typeof SEASON_SEGMENTS)[number];
export type PhaseSourceKey = (typeof PHASE_SOURCES)[number];
export type SeasonNote =
'NO_COORDINATES' | 'WEATHER_GAPS' | 'WEATHER_STALE' | null;
/** Daily mean temperature of one day (YYYY-MM-DD); null when unknown. */
export interface DailyMean {
day: string;
meanTempC: number | null;
}
export interface SeasonState {
apiaryId: string;
year: number;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
reportedOn: string | null;
segment: SeasonSegmentKey;
segmentSince: string;
springSince: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SeasonService } from './season.service';
@Module({
providers: [SeasonService],
exports: [SeasonService],
})
export class SeasonModule {}
@@ -0,0 +1,58 @@
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import type { DbClient } from '../prisma/db-client';
import { SeasonService } from './season.service';
describe('SeasonService', () => {
const findUnique = jest.fn();
const db = { automationRule: { findUnique } } as unknown as DbClient;
const service = new SeasonService();
beforeEach(() => {
findUnique.mockReset().mockResolvedValue(null);
});
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2', 'apiary-1'],
'2026-05-12',
);
expect([...states.keys()]).toEqual(['apiary-1', 'apiary-2']);
expect(states.get('apiary-2')).toMatchObject({
apiaryId: 'apiary-2',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
});
expect(findUnique).toHaveBeenCalledTimes(1);
});
it('reads no rule without apiaries', async () => {
expect((await service.statesFor(db, [], '2026-05-12')).size).toBe(0);
expect(findUnique).not.toHaveBeenCalled();
});
it('follows the stored calendar of the PHENOLOGY rule', async () => {
findUnique.mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: {
...DEFAULT_PHENOLOGY_CONFIG,
calendarFallback: {
...DEFAULT_PHENOLOGY_CONFIG.calendarFallback,
SALWEIDE: '03-01',
KIRSCHE: '04-01',
},
},
});
const states = await service.statesFor(db, ['apiary-1'], '2026-04-05');
expect(states.get('apiary-1')).toMatchObject({
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-01',
springSince: '2026-03-01',
});
});
});
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { readRule } from '../automation/rule-config';
import type { DbClient } from '../prisma/db-client';
import { calendarSeasonState } from './season-calendar';
import type { SeasonState } from './season-types';
/**
* The season of apiaries. Until the phenology data exists (Task 25) every
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
* the callers stay as they are.
*/
@Injectable()
export class SeasonService {
/** The season state of each given apiary on `today` (YYYY-MM-DD, Berlin). */
async statesFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<Map<string, SeasonState>> {
const states = new Map<string, SeasonState>();
if (apiaryIds.length === 0) {
return states;
}
const { config } = await readRule(db, 'PHENOLOGY');
for (const apiaryId of new Set(apiaryIds)) {
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
}
return states;
}
}
@@ -0,0 +1,128 @@
import { DEFAULT_SEASON_AUTOPILOT_CONFIG } from '../automation/rule-config/season-autopilot.config';
import { varroaMeasurementDue } from './varroa-due';
const CONFIG = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const SOMMER = { segment: 'SOMMER', springSince: '2026-03-20' } as const;
const WINTERRUHE = {
segment: 'WINTERRUHE',
springSince: '2026-03-20',
} as const;
const NEVER_MEASURED = {
createdOn: '2025-01-01',
lastMeasuredOn: null,
lastMeasurementId: null,
};
describe('varroaMeasurementDue', () => {
it('is due 28 days after the last measurement outside WINTERRUHE', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-06-01',
lastMeasurementId: 'm-1',
},
SOMMER,
CONFIG,
'2026-06-30',
),
).toEqual({ dueOn: '2026-06-29', kind: 'INTERVAL', anchor: 'm-1' }));
it('counts from createdOn without a measurement and anchors on none', () =>
expect(
varroaMeasurementDue(
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
SOMMER,
CONFIG,
'2026-06-30',
),
).toEqual({ dueOn: '2026-05-18', kind: 'INTERVAL', anchor: 'none' }));
it('uses the configured interval', () =>
expect(
varroaMeasurementDue(
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
SOMMER,
{ ...CONFIG, varroaMeasureIntervalDays: 14 },
'2026-06-30',
),
).toMatchObject({ dueOn: '2026-05-04' }));
it('is due on 11-15 in the winter window without a measurement since 11-01', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-09-20',
lastMeasurementId: 'm-1',
},
WINTERRUHE,
CONFIG,
'2026-11-20',
),
).toEqual({
dueOn: '2026-11-15',
kind: 'WINTER_WINDOW',
anchor: 'winter-2026',
}));
it('is not due in the winter window after a measurement since 11-01', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-11-03',
lastMeasurementId: 'm-2',
},
WINTERRUHE,
CONFIG,
'2026-11-20',
),
).toBeNull());
it('is not due in WINTERRUHE outside the winter window', () => {
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-21'),
).toBeNull();
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-02-10'),
).toBeNull();
});
it('opens the winter window on its first and last day', () => {
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-11-15'),
).toMatchObject({ kind: 'WINTER_WINDOW' });
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-20'),
).toMatchObject({ kind: 'WINTER_WINDOW' });
});
it('moves an interval due day inside the winter to the start of FRUEHJAHR', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-11-20',
lastMeasurementId: 'm-3',
},
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
CONFIG,
'2027-03-25',
),
).toEqual({ dueOn: '2027-03-20', kind: 'INTERVAL', anchor: 'm-3' }));
it('keeps a due day after the start of FRUEHJAHR', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2027-03-10',
lastMeasurementId: 'm-4',
},
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
CONFIG,
'2027-04-01',
),
).toMatchObject({ dueOn: '2027-04-07' }));
});
+67
View File
@@ -0,0 +1,67 @@
import { addDays } from '../common/dates';
import type { SeasonAutopilotConfig } from '../automation/rule-config/rule-config.types';
import type { SeasonState } from './season-types';
export interface VarroaDue {
dueOn: string;
kind: 'INTERVAL' | 'WINTER_WINDOW';
/** Changes with every new measurement (or winter), so one due date stays one reminder. */
anchor: string;
}
/*
* Invariant: the winter window (SEASON_AUTOPILOT.winterVarroaWindow, default
* 11-15) lies inside WINTERRUHE, i.e. it does not begin before the WINTER
* date of PHENOLOGY.calendarFallback (default 11-15). Before WINTER the
* interval rule applies instead.
*/
/** A measurement from this day on counts for the winter window of the year. */
const WINTER_MEASUREMENT_FROM = '11-01';
/**
* The day a varroa measurement is due. Outside WINTERRUHE: the last
* measurement (else the creation of the colony) plus the interval. In
* WINTERRUHE only inside the winter window, once, if there was no
* measurement since 11-01.
*/
export function varroaMeasurementDue(
input: {
createdOn: string;
lastMeasuredOn: string | null;
lastMeasurementId: string | null;
},
season: Pick<SeasonState, 'segment' | 'springSince'>,
config: SeasonAutopilotConfig,
today: string,
): VarroaDue | null {
if (season.segment !== 'WINTERRUHE') {
const base = input.lastMeasuredOn ?? input.createdOn;
const intervalDue = addDays(base, config.varroaMeasureIntervalDays);
// A due day that fell into the winter is taken up at the start of
// FRUEHJAHR, like the first inspection of the year.
const dueOn =
season.springSince !== null && intervalDue < season.springSince
? season.springSince
: intervalDue;
return {
dueOn,
kind: 'INTERVAL',
anchor: input.lastMeasurementId ?? 'none',
};
}
const year = today.slice(0, 4);
const { from, to } = config.winterVarroaWindow;
const inWindow = today >= `${year}-${from}` && today <= `${year}-${to}`;
const measuredThisWinter =
input.lastMeasuredOn !== null &&
input.lastMeasuredOn >= `${year}-${WINTER_MEASUREMENT_FROM}`;
if (!inWindow || measuredThisWinter) {
return null;
}
return {
dueOn: `${year}-${from}`,
kind: 'WINTER_WINDOW',
anchor: `winter-${year}`,
};
}
@@ -40,6 +40,21 @@ describe('summarizeStock', () => {
});
});
it('leaves a BOTTLING movement out: the honey batch ledger is not product stock', () => {
const summary = summarizeStock(10, [
{ type: 'BOTTLING', quantity: -500 },
{ type: 'SHOP_SALE', quantity: -2 },
]);
expect(summary).toEqual({
initial: 10,
soldInShop: 2,
withdrawn: 0,
corrected: 0,
consumed: 0,
remaining: 8,
});
});
it('counts raw material used in production, net of give-backs', () => {
const summary = summarizeStock(4200, [
{ type: 'CONSUMPTION', quantity: -800 },
+7 -2
View File
@@ -7,8 +7,9 @@
* always derived, never stored, so it cannot drift from the ledger.
*/
export type MovementType =
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
import type { BatchMovementType } from '../../generated/prisma';
export type MovementType = BatchMovementType;
export interface LedgerEntry {
type: MovementType;
@@ -45,6 +46,10 @@ export function summarizeStock(
withdrawn -= entry.quantity;
} else if (entry.type === 'CONSUMPTION') {
consumed -= entry.quantity;
} else if (entry.type === 'BOTTLING') {
// Takes honey out of a honey batch (the grams ledger, Task 23); it is
// never booked on a product batch, so it does not count here.
continue;
} else {
corrected += entry.quantity;
}
@@ -0,0 +1,71 @@
import { buildEvidenceLookup } from './evidence-lookup';
function db() {
return {
colony: { findMany: jest.fn().mockResolvedValue([]) },
queen: { findMany: jest.fn().mockResolvedValue([]) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('buildEvidenceLookup', () => {
it('reads each model once for all evidences', async () => {
const client = db();
client.colony.findMany.mockResolvedValue([
{ id: 'c1', code: 'V-1' },
{ id: 'c2', code: 'V-2' },
]);
client.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-9' }]);
client.treatment.findMany.mockResolvedValue([
{ id: 't1', product: 'Oxalsäure' },
]);
client.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hausstand' }]);
client.stockItem.findMany.mockResolvedValue([{ id: 's1', name: 'Gläser' }]);
const lookup = await buildEvidenceLookup(client as never, [
{ colonyId: 'c1', parentColonyId: 'c2', queenId: 'q1' },
{ colonyId: 'c1', treatmentId: 't1', apiaryId: 'a1', stockItemId: 's1' },
]);
for (const model of Object.values(client)) {
expect(model.findMany).toHaveBeenCalledTimes(1);
}
expect(
(client.colony.findMany.mock.calls as { where: unknown }[][])[0][0].where,
).toEqual({
id: { in: ['c1', 'c2'] },
});
expect(lookup.colonyCode('c1')).toBe('V-1');
expect(lookup.colonyCode('c2')).toBe('V-2');
expect(lookup.queenCode('q1')).toBe('K-9');
expect(lookup.treatmentProduct('t1')).toBe('Oxalsäure');
expect(lookup.apiaryName('a1')).toBe('Hausstand');
expect(lookup.stockItemName('s1')).toBe('Gläser');
});
it('asks for no model the evidence does not mention', async () => {
const client = db();
const lookup = await buildEvidenceLookup(client as never, [
{ baseDate: '2026-05-01', count: 3, flag: true, none: null },
{ colonyId: 42 },
]);
for (const model of Object.values(client)) {
expect(model.findMany).not.toHaveBeenCalled();
}
expect(lookup.colonyCode('c1')).toBeNull();
});
it('answers null for an id that no longer exists', async () => {
const client = db();
const lookup = await buildEvidenceLookup(client as never, [
{ queenId: 'gone' },
]);
expect(lookup.queenCode('gone')).toBeNull();
});
});
@@ -0,0 +1,109 @@
import type { DbClient } from '../prisma/db-client';
import type { SuggestionEvidence } from './suggestion.types';
/** Evidence keys whose value is an ID the texts show as a name. */
export const LOOKUP_EVIDENCE_KEYS = [
'colonyId',
'parentColonyId',
'queenId',
'treatmentId',
'apiaryId',
'stockItemId',
] as const;
type LookupKey = (typeof LOOKUP_EVIDENCE_KEYS)[number];
/** Names for the IDs in evidence; null for an ID that no longer exists. */
export interface EvidenceLookup {
colonyCode(id: string): string | null;
queenCode(id: string): string | null;
treatmentProduct(id: string): string | null;
apiaryName(id: string): string | null;
stockItemName(id: string): string | null;
}
type NameMap = ReadonlyMap<string, string>;
function idsOf(
evidences: readonly SuggestionEvidence[],
keys: readonly LookupKey[],
): string[] {
const ids = new Set<string>();
for (const evidence of evidences) {
for (const key of keys) {
const value = evidence[key];
if (typeof value === 'string') {
ids.add(value);
}
}
}
return [...ids];
}
/** No query at all for a model no evidence refers to. */
async function namesOf(
ids: readonly string[],
read: (ids: string[]) => Promise<{ id: string; name: string }[]>,
): Promise<NameMap> {
if (ids.length === 0) {
return new Map();
}
const rows = await read([...ids]);
return new Map(rows.map((row) => [row.id, row.name]));
}
/**
* Reads the names the texts of these suggestions mention: one query per
* model.
*/
export async function buildEvidenceLookup(
db: DbClient,
evidences: readonly SuggestionEvidence[],
): Promise<EvidenceLookup> {
const [colonies, queens, treatments, apiaries, stockItems] =
await Promise.all([
namesOf(idsOf(evidences, ['colonyId', 'parentColonyId']), async (ids) =>
(
await db.colony.findMany({
where: { id: { in: ids } },
select: { id: true, code: true },
})
).map(({ id, code }) => ({ id, name: code })),
),
namesOf(idsOf(evidences, ['queenId']), async (ids) =>
(
await db.queen.findMany({
where: { id: { in: ids } },
select: { id: true, code: true },
})
).map(({ id, code }) => ({ id, name: code })),
),
namesOf(idsOf(evidences, ['treatmentId']), async (ids) =>
(
await db.treatment.findMany({
where: { id: { in: ids } },
select: { id: true, product: true },
})
).map(({ id, product }) => ({ id, name: product })),
),
namesOf(idsOf(evidences, ['apiaryId']), (ids) =>
db.apiary.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
}),
),
namesOf(idsOf(evidences, ['stockItemId']), (ids) =>
db.stockItem.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
}),
),
]);
return {
colonyCode: (id) => colonies.get(id) ?? null,
queenCode: (id) => queens.get(id) ?? null,
treatmentProduct: (id) => treatments.get(id) ?? null,
apiaryName: (id) => apiaries.get(id) ?? null,
stockItemName: (id) => stockItems.get(id) ?? null,
};
}
@@ -0,0 +1,42 @@
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
const PLAN = [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
];
describe('suggestion plan', () => {
it('places every step at base day + offset, with its window end', () => {
expect(planSteps(PLAN, '2026-05-01')).toEqual([
{ step: 'RAISE_CELLS_CHECK', day: '2026-05-10', windowUntil: null },
{ step: 'MATING_CHECK', day: '2026-05-22', windowUntil: '2026-05-25' },
{ step: 'EGGS_CHECK', day: '2026-05-29', windowUntil: null },
]);
});
it('moves the base so the first step lands on the chosen day', () => {
expect(
planSteps(PLAN, '2026-05-01', '2026-05-12').map((s) => s.day),
).toEqual(['2026-05-12', '2026-05-24', '2026-05-31']);
expect(
planSteps(PLAN, '2026-05-01', '2026-05-08').map((s) => s.day),
).toEqual(['2026-05-08', '2026-05-20', '2026-05-27']);
});
it('reads a stored plan and rejects an unusable one', () => {
expect(planOf(PLAN)).toEqual(PLAN);
expect(planOf(null)).toBeNull();
expect(planOf([])).toBeNull();
expect(planOf([{ step: 'X' }])).toBeNull();
expect(planOf('nope')).toBeNull();
});
it('takes the base day from the evidence, else the fallback', () => {
expect(planBaseDate({ baseDate: '2026-05-01' }, '2026-06-01')).toBe(
'2026-05-01',
);
expect(planBaseDate({ baseDate: 'soon' }, '2026-06-01')).toBe('2026-06-01');
expect(planBaseDate({}, '2026-06-01')).toBe('2026-06-01');
});
});
@@ -0,0 +1,71 @@
import { addDays, daysBetween } from '../common/dates';
import type { PlanStepConfig } from '../automation/rule-config';
/** One task a plan suggestion turns into. */
export interface PlannedStep {
step: string;
/** YYYY-MM-DD */
day: string;
/** YYYY-MM-DD, the end of the step's window. */
windowUntil: string | null;
}
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function isPlanStep(value: unknown): value is PlanStepConfig {
if (typeof value !== 'object' || value === null) {
return false;
}
const { step, offsetDays, windowDays } = value as Record<string, unknown>;
return (
typeof step === 'string' &&
Number.isInteger(offsetDays) &&
(windowDays === undefined || Number.isInteger(windowDays))
);
}
/** The stored plan; null when there is none or it is unusable. */
export function planOf(json: unknown): PlanStepConfig[] | null {
if (!Array.isArray(json) || json.length === 0 || !json.every(isPlanStep)) {
return null;
}
return json;
}
/** The base day of a plan: `evidence.baseDate`, else the fallback. */
export function planBaseDate(
evidence: Record<string, unknown>,
fallbackDay: string,
): string {
const { baseDate } = evidence;
return typeof baseDate === 'string' && DAY_PATTERN.test(baseDate)
? baseDate
: fallbackDay;
}
/**
* The day of every step: base day + offset. With `firstStepDay` the base is
* moved so that the first step lands on that day and every other step keeps
* its distance to it.
*/
export function planSteps(
plan: readonly PlanStepConfig[],
baseDate: string,
firstStepDay?: string | null,
): PlannedStep[] {
const shift =
firstStepDay === null || firstStepDay === undefined
? 0
: daysBetween(addDays(baseDate, plan[0].offsetDays), firstStepDay);
return plan.map(({ step, offsetDays, windowDays }) => {
const day = addDays(baseDate, offsetDays + shift);
return {
step,
day,
windowUntil:
windowDays !== undefined && windowDays > 0
? addDays(day, windowDays)
: null,
};
});
}
@@ -0,0 +1,96 @@
import type { EvidenceLookup } from './evidence-lookup';
import {
formatDayDe,
formatNumberDe,
INTENT_TITLES,
PLAN_STEP_TITLES,
renderSuggestion,
SUGGESTION_TEXT_REGISTERS,
SUGGESTION_TEXTS,
} from './suggestion-texts';
const MAX_REASONS = 2;
const MAX_REASON_LENGTH = 90;
const lookup: EvidenceLookup = {
colonyCode: (id) => `V-${id}`,
queenCode: (id) => `Q-${id}`,
treatmentProduct: () => 'Oxalsäure',
apiaryName: () => 'Hausstand',
stockItemName: () => 'Gläser',
};
describe('suggestion texts', () => {
it('every registered template renders its example in at most 2 reasons of at most 90 characters', () => {
for (const [reasonCode, template] of Object.entries(SUGGESTION_TEXTS)) {
const { title, reasons } = template.render(
template.example,
lookup,
'2026-05-05',
);
expect({ reasonCode, empty: title.trim() === '' }).toEqual({
reasonCode,
empty: false,
});
expect(reasons.length).toBeLessThanOrEqual(MAX_REASONS);
for (const reason of reasons) {
expect(reason.length).toBeLessThanOrEqual(MAX_REASON_LENGTH);
}
}
});
it('no reason code is registered twice', () => {
const codes = SUGGESTION_TEXT_REGISTERS.flatMap((register) =>
Object.keys(register),
);
expect(new Set(codes).size).toBe(codes.length);
expect(Object.keys(SUGGESTION_TEXTS)).toHaveLength(codes.length);
});
it('formats numbers and days in German', () => {
expect(formatNumberDe(12.4)).toBe('12,4');
expect(formatNumberDe(12)).toBe('12');
expect(formatNumberDe(0.456, 2)).toBe('0,46');
expect(formatDayDe('2026-05-05')).toBe('05.05.');
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
});
it('falls back to INTENT_TITLES for an unknown reason code', () => {
expect(
renderSuggestion(
{ reasonCode: 'NO_SUCH_CODE', evidence: {}, intent: 'FEED' },
lookup,
'2026-05-05',
),
).toEqual({ title: INTENT_TITLES.FEED, reasons: [] });
});
it('replaces the title with a non-empty override only', () => {
const suggestion = {
reasonCode: 'NO_SUCH_CODE',
evidence: {},
intent: 'INSPECT' as const,
};
expect(
renderSuggestion(suggestion, lookup, '2026-05-05', 'Eigener Titel').title,
).toBe('Eigener Titel');
expect(renderSuggestion(suggestion, lookup, '2026-05-05', '').title).toBe(
INTENT_TITLES.INSPECT,
);
expect(renderSuggestion(suggestion, lookup, '2026-05-05', null).title).toBe(
INTENT_TITLES.INSPECT,
);
});
it('has a title for every intent and every split step', () => {
expect(Object.keys(INTENT_TITLES)).toHaveLength(17);
for (const step of [
'RAISE_CELLS_CHECK',
'MATING_CHECK',
'EGGS_CHECK',
'ACCEPTANCE_CHECK',
]) {
expect(PLAN_STEP_TITLES[step]).toBeTruthy();
}
});
});
@@ -0,0 +1,127 @@
import type {
AutomationTriggerType,
Suggestion,
SuggestionIntent,
} from '../../generated/prisma';
import type { EvidenceLookup } from './evidence-lookup';
import type { SuggestionEvidence } from './suggestion.types';
import { AUTOPILOT_TEXTS } from './texts/autopilot-texts';
import { CHAIN_PLAN_STEP_TITLES, CHAIN_TEXTS } from './texts/chain-texts';
import { CLEANUP_TEXTS } from './texts/cleanup-texts';
import { RULE_TEXTS } from './texts/rule-texts';
export type { EvidenceLookup };
export interface RenderedSuggestionText {
title: string;
/** At most 2, each at most 90 characters. */
reasons: string[];
}
export interface SuggestionTextTemplate {
ruleKey: AutomationTriggerType;
/** Rendered by the text spec: the limits hold for it. */
example: SuggestionEvidence;
render(
evidence: SuggestionEvidence,
lookup: EvidenceLookup,
today: string,
): RenderedSuggestionText;
}
/** Templates by reason code. */
export type SuggestionTextRegister = Readonly<
Record<string, SuggestionTextTemplate>
>;
export const SUGGESTION_TEXT_REGISTERS: readonly SuggestionTextRegister[] = [
RULE_TEXTS,
CHAIN_TEXTS,
AUTOPILOT_TEXTS,
CLEANUP_TEXTS,
];
/** All templates; a reason code in two registers is a programming error. */
function mergeRegisters(
registers: readonly SuggestionTextRegister[],
): SuggestionTextRegister {
const merged: Record<string, SuggestionTextTemplate> = {};
for (const register of registers) {
for (const [reasonCode, template] of Object.entries(register)) {
if (Object.hasOwn(merged, reasonCode)) {
throw new Error(`Reason code registered twice: ${reasonCode}`);
}
merged[reasonCode] = template;
}
}
return merged;
}
export const SUGGESTION_TEXTS: SuggestionTextRegister = mergeRegisters(
SUGGESTION_TEXT_REGISTERS,
);
export const PLAN_STEP_TITLES: Readonly<Record<string, string>> =
CHAIN_PLAN_STEP_TITLES;
/** Title of a suggestion whose reason code has no template. */
export const INTENT_TITLES: Readonly<Record<SuggestionIntent, string>> = {
INSPECT: 'Durchsicht fällig',
SWARM_CHECK: 'Schwarmkontrolle',
QUEEN_CHECK: 'Königin prüfen',
QUEEN_ACCEPTANCE: 'Annahme der Königin prüfen',
QUEEN_REPLACE: 'Königin ersetzen',
SPLIT_PLAN: 'Ableger kontrollieren',
VARROA_MEASURE: 'Varroa messen',
VARROA_RECHECK: 'Varroa nachkontrollieren',
VARROA_TREAT: 'Varroa behandeln',
SUMMER_TREATMENT: 'Sommerbehandlung planen',
FEED: 'Füttern',
HONEY_SUPER: 'Honigraum aufsetzen',
WINTER_CHECK: 'Winterkontrolle',
HEALTH_CHECK: 'Gesundheit prüfen',
FOLLOW_UP: 'Nachkontrolle',
STOCK_REORDER: 'Nachbestellen',
CLEANUP: 'Aufräumen',
};
/**
* Title and reasons of a suggestion, rendered when it is read from its
* reason code and evidence. `titleOverride` (the rule's actionTemplate)
* replaces the title when it is not empty.
*/
export function renderSuggestion(
s: Pick<Suggestion, 'reasonCode' | 'evidence' | 'intent'>,
lookup: EvidenceLookup,
today: string,
titleOverride?: string | null,
): RenderedSuggestionText {
const template = Object.hasOwn(SUGGESTION_TEXTS, s.reasonCode)
? SUGGESTION_TEXTS[s.reasonCode]
: undefined;
const rendered: RenderedSuggestionText = template
? template.render(s.evidence as SuggestionEvidence, lookup, today)
: { title: INTENT_TITLES[s.intent], reasons: [] };
const override = titleOverride?.trim();
return override ? { ...rendered, title: override } : rendered;
}
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
const numberFormats = new Map<number, Intl.NumberFormat>();
/** A number with a decimal comma: 12.4 as '12,4'. */
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
let format = numberFormats.get(maxFractionDigits);
if (!format) {
format = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: maxFractionDigits,
});
numberFormats.set(maxFractionDigits, format);
}
return format.format(value);
}
@@ -0,0 +1,86 @@
import type {
AutomationTriggerType,
Suggestion,
SuggestionIntent,
SuggestionMode,
SuggestionSource,
Task,
TaskPriority,
TaskRelatedType,
} from '../../generated/prisma';
import type { PlanStepConfig } from '../automation/rule-config';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
export type EvidenceValue =
string | number | boolean | null | readonly string[];
/** Only IDs, numbers and YYYY-MM-DD; a plan's base day is `baseDate`. */
export type SuggestionEvidence = Readonly<Record<string, EvidenceValue>>;
export interface ProposeInput {
ruleKey: AutomationTriggerType;
source: SuggestionSource;
mode: SuggestionMode;
intent: SuggestionIntent;
priority?: TaskPriority;
relatedType: TaskRelatedType;
relatedId: string | null;
colonyId: string | null;
reasonCode: string;
evidence: SuggestionEvidence;
requiredPermission: PermissionKey | null;
dueDate?: string | null;
expiresAt?: string | null;
plan?: readonly PlanStepConfig[] | null;
anchor: string;
sourceEventId?: string | null;
/**
* Default false. Rule and chain suggestions pass `config.notify`, the
* autopilot never; the notification itself is wired by Task 7.
*/
notify?: boolean;
}
/** The intent a task created by a legacy rule stands for. */
export const INTENT_OF_LEGACY_TRIGGER: Readonly<
Partial<Record<AutomationTriggerType, SuggestionIntent>>
> = {
QUEENLESS_NO_EGGS: 'QUEEN_CHECK',
VARROA_THRESHOLD: 'VARROA_TREAT',
FEED_STATUS_CRITICAL: 'FEED',
SWARM_MOOD_HIGH: 'SWARM_CHECK',
COLONY_HEALTH_CRITICAL: 'HEALTH_CHECK',
STOCK_ITEM_LOW: 'STOCK_REORDER',
RAW_MATERIAL_LOW: 'STOCK_REORDER',
TREATMENT_FOLLOW_UP: 'FOLLOW_UP',
FEEDING_FOLLOW_UP: 'FOLLOW_UP',
};
/** `<ruleKey>:<relatedId|none>:<anchor>`, e.g. `INSPECTION_INTERVAL:col_1:insp_9`. */
export function suggestionDedupeKey(
ruleKey: AutomationTriggerType,
relatedId: string | null,
anchor: string,
): string {
return `${ruleKey}:${relatedId ?? 'none'}:${anchor}`;
}
export interface AcceptSuggestionInput {
/** YYYY-MM-DD; for a plan the day of its first step. */
dueDate?: string | null;
assignedUserId?: string | null;
}
export interface AcceptSuggestionResult {
suggestionId: string;
status: 'ACCEPTED';
tasks: Task[];
}
export type SuggestionResolveNote =
| 'Bedingung entfällt'
| 'Autopilot aus'
| 'Aufgeräumt'
| 'Mit Durchsicht erledigt';
export type { Suggestion };
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { TasksModule } from '../tasks/tasks.module';
import { SuggestionsService } from './suggestions.service';
/**
* Suggestions of the rules, chains and the season autopilot. The permission resolver
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
*/
@Module({
imports: [TasksModule, AuditModule],
providers: [SuggestionsService],
exports: [SuggestionsService],
})
export class SuggestionsModule {}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,700 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import {
Prisma,
type AutomationTriggerType,
type Suggestion,
type SuggestionIntent,
type SuggestionStatus,
type Task,
type TaskPriority,
type TaskRelatedType,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRuleIn, readRule } from '../automation/rule-config';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from '../prisma/row-lock';
import type { NewTaskInput } from '../tasks/tasks.service';
import { TasksService } from '../tasks/tasks.service';
import { buildEvidenceLookup } from './evidence-lookup';
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
import {
formatDayDe,
INTENT_TITLES,
PLAN_STEP_TITLES,
renderSuggestion,
} from './suggestion-texts';
import {
INTENT_OF_LEGACY_TRIGGER,
suggestionDedupeKey,
type AcceptSuggestionInput,
type AcceptSuggestionResult,
type ProposeInput,
type SuggestionEvidence,
type SuggestionResolveNote,
} from './suggestion.types';
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
const BULK_MIN = 1;
const BULK_MAX = 50;
const MAX_NOTE_LENGTH = 500;
const NOT_OPEN_MESSAGE = 'Der Vorschlag ist nicht mehr offen.';
const COLONY_ENDED_MESSAGE = 'Das Volk ist nicht mehr aktiv.';
interface Target {
/** Scopes the check of an object-less target to one rule. */
ruleKey?: AutomationTriggerType;
colonyId: string | null;
relatedType: TaskRelatedType;
relatedId: string | null;
}
/** The task fields every task of one accepted suggestion shares. */
type SharedTaskFields = Omit<NewTaskInput, 'title' | 'description' | 'dueDate'>;
function notOpen(status: SuggestionStatus): ConflictException {
return new ConflictException({ message: NOT_OPEN_MESSAGE, status });
}
function parseDay(day: string | null | undefined): string | null {
if (!day) {
return null;
}
try {
dayToDate(day);
} catch {
throw new BadRequestException('Bitte ein gültiges Datum wählen.');
}
return day;
}
function assertBulkIds(ids: readonly string[]): void {
if (
ids.length < BULK_MIN ||
ids.length > BULK_MAX ||
new Set(ids).size !== ids.length
) {
throw new BadRequestException(
`Bitte ${BULK_MIN} bis ${BULK_MAX} verschiedene Vorschläge wählen.`,
);
}
}
function taskIntent(task: {
sourceSuggestion: { intent: SuggestionIntent } | null;
sourceAutomationTrigger: keyof typeof INTENT_OF_LEGACY_TRIGGER | null;
}): SuggestionIntent {
return (
task.sourceSuggestion?.intent ??
(task.sourceAutomationTrigger === null
? undefined
: INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger]) ??
'FOLLOW_UP'
);
}
/**
* The one place suggestions are proposed, decided and cleaned up: rule,
* chain and autopilot suggestions share one model and one set of rules (at
* most one open thing per intent and object, an anchor is proposed once).
* Every decision is a compare-and-set under the suggestion's row lock, with
* its audit entry in the same transaction.
*/
@Injectable()
export class SuggestionsService {
private readonly logger = new Logger(SuggestionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly tasks: TasksService,
) {}
/**
* Proposes a suggestion unless its anchor was proposed before (the dedupe
* key) or something of the same intent is already open for the object.
* Locks the colony first, then the rule, so rule, chain and autopilot
* proposals for one colony are serialised and the intent check holds
* across rules. Resolves to the new suggestion, or null.
*/
async proposeIn(
tx: Prisma.TransactionClient,
input: ProposeInput,
): Promise<Suggestion | null> {
assertTransactionClient(tx, 'SuggestionsService.proposeIn');
if (
input.colonyId !== null &&
!(await lockRowForUpdate(tx, 'Colony', input.colonyId))
) {
// The colony is gone: nothing to propose (and no FK error).
return null;
}
await lockRuleIn(tx, input.ruleKey);
const open = await this.openIntentsIn(tx, input);
if (open.has(input.intent)) {
return null;
}
const created = await tx.suggestion.createManyAndReturn({
data: [this.rowOf(input)],
skipDuplicates: true,
});
if (created.length === 0) {
return null;
}
const [suggestion] = created;
await this.auditLog.record(
{
entityType: 'Suggestion',
entityId: suggestion.id,
action: 'CREATE',
afterData: suggestion,
},
tx,
);
return suggestion;
}
/** The intents with an open suggestion or an open task for the object. */
async openIntentsIn(
db: DbClient,
target: Target,
): Promise<Set<SuggestionIntent>> {
const suggestionWhere: Prisma.SuggestionWhereInput =
target.colonyId !== null
? { status: 'OPEN', colonyId: target.colonyId }
: {
status: 'OPEN',
relatedType: target.relatedType,
relatedId: target.relatedId,
...(this.isObjectLess(target) ? { ruleKey: target.ruleKey } : {}),
};
// One after the other, not Promise.all: on a transaction client both
// queries share one connection and are queued anyway, and the order
// stays the same in every transaction.
const suggestions = await db.suggestion.findMany({
where: suggestionWhere,
select: { intent: true },
});
const tasks = await db.task.findMany({
where: {
status: { in: [...OPEN_TASK_STATUSES] },
OR: this.taskObjectsOf(target),
},
select: {
sourceAutomationTrigger: true,
sourceSuggestion: { select: { intent: true } },
},
});
return new Set([
...suggestions.map((suggestion) => suggestion.intent),
...tasks.map(taskIntent),
]);
}
/** Brings an open suggestion up to date with what the rule sees now. */
async refreshIn(
tx: Prisma.TransactionClient,
id: string,
patch: {
evidence: SuggestionEvidence;
dueDate: string | null;
priority: TaskPriority;
expiresAt: string | null;
},
): Promise<void> {
assertTransactionClient(tx, 'SuggestionsService.refreshIn');
await tx.suggestion.updateMany({
where: { id, status: 'OPEN' },
data: {
evidence: patch.evidence,
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
priority: patch.priority,
expiresAt: patch.expiresAt === null ? null : dayToDate(patch.expiresAt),
},
});
}
/**
* OPEN to RESOLVED because the condition is gone; frees the dedupe key in
* the same statement, so the same anchor may be proposed again.
*/
async resolveIn(
tx: Prisma.TransactionClient,
ids: readonly string[],
note: SuggestionResolveNote,
): Promise<number> {
assertTransactionClient(tx, 'SuggestionsService.resolveIn');
if (ids.length === 0) {
return 0;
}
return tx.$executeRaw`UPDATE "Suggestion"
SET status = 'RESOLVED', "dedupeKey" = NULL, "decidedAt" = now(),
"decisionNote" = ${note}, "updatedAt" = now()
WHERE id = ANY(${[...ids]}) AND status = 'OPEN'`;
}
/** OPEN to OBSOLETE for a colony that ended; the dedupe key stays. */
async obsoleteForColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
): Promise<number> {
assertTransactionClient(tx, 'SuggestionsService.obsoleteForColonyIn');
return tx.$executeRaw`UPDATE "Suggestion"
SET status = 'OBSOLETE', "decidedAt" = now(), "updatedAt" = now()
WHERE "colonyId" = ${colonyId} AND status = 'OPEN'`;
}
/**
* OPEN past its expiry day to EXPIRED, in one statement that also frees
* the dedupe key.
*/
async expireDue(today: string): Promise<number> {
return this.prisma.$executeRaw`UPDATE "Suggestion"
SET status = 'EXPIRED', "dedupeKey" = NULL, "decidedAt" = now(),
"updatedAt" = now()
WHERE status = 'OPEN' AND "expiresAt" < ${dayToDate(today)}`;
}
async accept(
id: string,
input: AcceptSuggestionInput,
actingUserId: string,
): Promise<AcceptSuggestionResult> {
return this.prisma.$transaction((tx) =>
this.acceptIn(tx, id, input, actingUserId),
);
}
/** All or nothing: one failing suggestion rolls back every task. */
async bulkAccept(
ids: readonly string[],
actingUserId: string,
): Promise<AcceptSuggestionResult[]> {
assertBulkIds(ids);
return this.prisma.$transaction(
async (tx) => {
await lockRowsForUpdate(tx, 'Suggestion', ids);
await this.assertDatedIn(tx, ids);
const results: AcceptSuggestionResult[] = [];
for (const id of ids) {
results.push(await this.acceptIn(tx, id, {}, actingUserId));
}
return results;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
}
async dismiss(
id: string,
note: string | null,
actingUserId: string,
): Promise<Suggestion> {
this.assertNote(note);
return this.prisma.$transaction((tx) =>
this.dismissIn(tx, id, note, actingUserId),
);
}
/** All or nothing; resolves to how many suggestions were dismissed. */
async bulkDismiss(
ids: readonly string[],
actingUserId: string,
): Promise<number> {
assertBulkIds(ids);
return this.prisma.$transaction(
async (tx) => {
await lockRowsForUpdate(tx, 'Suggestion', ids);
for (const id of ids) {
await this.dismissIn(tx, id, null, actingUserId);
}
return ids.length;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
}
/** DISMISSED back to OPEN, up to 30 days after the decision. */
async reopen(id: string, actingUserId: string): Promise<Suggestion> {
return this.prisma.$transaction(async (tx) => {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'DISMISSED') {
throw notOpen(before.status);
}
await this.assertColonyAliveIn(tx, before);
const open = await this.openIntentsIn(tx, before);
if (open.has(before.intent)) {
const subject =
before.relatedType === 'COLONY' ? 'dieses Volk' : 'diesen Eintrag';
throw new ConflictException({
message: `Für ${subject} ist schon ein Vorschlag „${INTENT_TITLES[before.intent]}“ offen.`,
status: before.status,
});
}
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'OPEN', "decidedAt" = NULL, "decidedByUserId" = NULL,
"decisionNote" = NULL, "updatedAt" = now()
WHERE id = ${id} AND status = 'DISMISSED'
AND "decidedAt" > now() - interval '30 days'`;
if (moved === 0) {
throw new ConflictException({
message:
'Der Vorschlag kann nach 30 Tagen nicht mehr geöffnet werden.',
status: before.status,
});
}
return this.auditedUpdateIn(tx, before, actingUserId);
});
}
private async acceptIn(
tx: Prisma.TransactionClient,
id: string,
input: AcceptSuggestionInput,
actingUserId: string,
): Promise<AcceptSuggestionResult> {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'OPEN') {
throw notOpen(before.status);
}
await this.assertColonyAliveIn(tx, before);
// Everything that can answer 400 comes before the first write.
await this.assertAssigneeIn(tx, input.assignedUserId);
const newTasks = await this.newTasksOf(tx, before, input);
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'ACCEPTED', "decidedAt" = now(),
"decidedByUserId" = ${actingUserId}, "updatedAt" = now()
WHERE id = ${id} AND status = 'OPEN'`;
if (moved === 0) {
throw notOpen(await this.currentStatusIn(tx, id));
}
const tasks: Task[] = [];
for (const newTask of newTasks) {
tasks.push(await this.tasks.createInTx(tx, newTask, actingUserId));
}
await this.auditedUpdateIn(tx, before, actingUserId);
return { suggestionId: id, status: 'ACCEPTED', tasks };
}
private async dismissIn(
tx: Prisma.TransactionClient,
id: string,
note: string | null,
actingUserId: string,
): Promise<Suggestion> {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'OPEN') {
throw notOpen(before.status);
}
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'DISMISSED', "decidedAt" = now(),
"decidedByUserId" = ${actingUserId}, "decisionNote" = ${note},
"updatedAt" = now()
WHERE id = ${id} AND status = 'OPEN'`;
if (moved === 0) {
throw notOpen(await this.currentStatusIn(tx, id));
}
return this.auditedUpdateIn(tx, before, actingUserId);
}
/** Locks the suggestion and reads it afterwards: the copy to decide on. */
private async readLockedIn(
tx: Prisma.TransactionClient,
id: string,
): Promise<Suggestion> {
assertTransactionClient(tx, 'SuggestionsService.readLockedIn');
await lockRowForUpdate(tx, 'Suggestion', id);
const suggestion = await tx.suggestion.findUnique({ where: { id } });
if (!suggestion) {
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
}
return suggestion;
}
private async currentStatusIn(
tx: Prisma.TransactionClient,
id: string,
): Promise<SuggestionStatus> {
const current = await tx.suggestion.findUnique({
where: { id },
select: { status: true },
});
if (!current) {
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
}
return current.status;
}
/** Reads the decided suggestion and writes the audit entry for the change. */
private async auditedUpdateIn(
tx: Prisma.TransactionClient,
before: Suggestion,
actingUserId: string,
): Promise<Suggestion> {
const after = await tx.suggestion.findUniqueOrThrow({
where: { id: before.id },
});
await this.auditLog.record(
{
entityType: 'Suggestion',
entityId: before.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
},
tx,
);
return after;
}
/**
* The tasks accepting `suggestion` creates: one per plan step, else one.
* A plan step is due base day + offset (moved as a whole by a chosen
* date); a DO suggestion needs a day, a PLAN suggestion without one is
* due today.
*/
private async newTasksOf(
tx: Prisma.TransactionClient,
suggestion: Suggestion,
input: AcceptSuggestionInput,
): Promise<NewTaskInput[]> {
const today = berlinToday();
const evidence = suggestion.evidence as SuggestionEvidence;
const chosenDay = parseDay(input.dueDate);
const [rule, lookup, sourceInspectionId] = await Promise.all([
readRule(tx, suggestion.ruleKey),
buildEvidenceLookup(tx, [evidence]),
this.existingInspectionIdIn(tx, evidence),
]);
const text = renderSuggestion(
suggestion,
lookup,
today,
rule.actionTemplate,
);
const shared: SharedTaskFields = {
priority: suggestion.priority,
assignedUserId: input.assignedUserId ?? null,
relatedType: suggestion.relatedType,
relatedId: suggestion.relatedId,
sourceInspectionId,
sourceAutomationTrigger: suggestion.ruleKey,
sourceSuggestionId: suggestion.id,
};
const plan = planOf(suggestion.plan);
if (plan === null && suggestion.plan !== null) {
this.logger.warn(
`Suggestion ${suggestion.id} has a stored plan that cannot be used; creating one task`,
);
}
if (plan) {
const baseDate = planBaseDate(evidence, today);
return planSteps(plan, baseDate, chosenDay).map((step) => ({
...shared,
title: Object.hasOwn(PLAN_STEP_TITLES, step.step)
? PLAN_STEP_TITLES[step.step]
: text.title,
description:
[
...text.reasons,
...(step.windowUntil === null
? []
: [`Zeitfenster bis ${formatDayDe(step.windowUntil)}`]),
].join('\n') || null,
dueDate: dayToDate(step.day),
}));
}
const ownDay =
suggestion.dueDate === null ? null : utcDay(suggestion.dueDate);
const day =
chosenDay ?? ownDay ?? (suggestion.mode === 'PLAN' ? today : null);
if (day === null) {
throw new BadRequestException('Bitte ein Datum wählen.');
}
return [
{
...shared,
title: text.title,
description: text.reasons.join('\n') || null,
dueDate: dayToDate(day),
},
];
}
/** A colony that ended takes no new work: its suggestions are dead. */
private async assertColonyAliveIn(
tx: Prisma.TransactionClient,
suggestion: Suggestion,
): Promise<void> {
if (suggestion.colonyId === null) {
return;
}
const colony = await tx.colony.findUnique({
where: { id: suggestion.colonyId },
select: { status: true },
});
if (!colony || !isLivingColonyStatus(colony.status)) {
throw new ConflictException({
message: COLONY_ENDED_MESSAGE,
status: suggestion.status,
});
}
}
/**
* Bulk accept has no date to give: a DO suggestion without its own date
* would fail one by one, so all of them are named at once.
*/
private async assertDatedIn(
tx: Prisma.TransactionClient,
ids: readonly string[],
): Promise<void> {
const rows = await tx.suggestion.findMany({
where: { id: { in: [...ids] } },
select: { id: true, mode: true, dueDate: true, plan: true },
});
const undated = rows
.filter(
(row) =>
row.mode === 'DO' &&
row.dueDate === null &&
planOf(row.plan) === null,
)
.map((row) => row.id);
if (undated.length > 0) {
throw new BadRequestException({
message: 'Einige Vorschläge brauchen ein Datum.',
issues: [
{
field: 'ids',
message: 'Bitte diese Vorschläge einzeln mit Datum übernehmen.',
ids: undated,
},
],
});
}
}
/** The assignee must be an existing, active user (a task links it by FK). */
private async assertAssigneeIn(
tx: Prisma.TransactionClient,
assignedUserId: string | null | undefined,
): Promise<void> {
if (!assignedUserId) {
return;
}
const user = await tx.user.findUnique({
where: { id: assignedUserId },
select: { isActive: true },
});
if (!user?.isActive) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues: [
{
field: 'assignedUserId',
message: 'Bitte einen aktiven Benutzer wählen.',
},
],
});
}
}
/** The evidence's inspection, if it still exists (a task links it by FK). */
private async existingInspectionIdIn(
tx: Prisma.TransactionClient,
evidence: SuggestionEvidence,
): Promise<string | null> {
const { inspectionId } = evidence;
if (typeof inspectionId !== 'string') {
return null;
}
// Shared lock: the inspection cannot be deleted before the task links it.
await lockRowsForShare(tx, 'Inspection', [inspectionId]);
const found = await tx.inspection.count({ where: { id: inspectionId } });
return found > 0 ? inspectionId : null;
}
/** No colony and no object id: only the rule tells proposals apart. */
private isObjectLess(
target: Target,
): target is Target & { ruleKey: AutomationTriggerType } {
return (
target.colonyId === null &&
target.relatedId === null &&
target.ruleKey !== undefined
);
}
private taskObjectsOf(target: Target): Prisma.TaskWhereInput[] {
const objects: Prisma.TaskWhereInput[] = [];
if (target.colonyId !== null) {
objects.push({ relatedType: 'COLONY', relatedId: target.colonyId });
}
const ownIsColony =
target.relatedType === 'COLONY' && target.relatedId === target.colonyId;
if (
(target.relatedId !== null || target.colonyId === null) &&
!ownIsColony
) {
objects.push({
relatedType: target.relatedType,
relatedId: target.relatedId,
...(this.isObjectLess(target)
? { sourceAutomationTrigger: target.ruleKey }
: {}),
});
}
return objects;
}
private rowOf(input: ProposeInput): Prisma.SuggestionCreateManyInput {
return {
ruleKey: input.ruleKey,
source: input.source,
mode: input.mode,
intent: input.intent,
priority: input.priority ?? 'NORMAL',
relatedType: input.relatedType,
relatedId: input.relatedId,
colonyId: input.colonyId,
reasonCode: input.reasonCode,
evidence: input.evidence,
requiredPermission: input.requiredPermission,
dueDate: input.dueDate ? dayToDate(input.dueDate) : null,
expiresAt: input.expiresAt ? dayToDate(input.expiresAt) : null,
plan: input.plan
? (input.plan as unknown as Prisma.InputJsonArray)
: undefined,
dedupeKey: suggestionDedupeKey(
input.ruleKey,
input.relatedId,
input.anchor,
),
sourceEventId: input.sourceEventId ?? null,
};
}
private assertNote(note: string | null): void {
if (note !== null && note.length > MAX_NOTE_LENGTH) {
throw new BadRequestException(
`Die Notiz darf höchstens ${MAX_NOTE_LENGTH} Zeichen lang sein.`,
);
}
}
}
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the season autopilot suggestions, by reason code. */
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {};
@@ -0,0 +1,12 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the chain suggestions, by reason code. */
export const CHAIN_TEXTS: SuggestionTextRegister = {};
/** Task title of each step of a plan suggestion (SPLIT_SCHEDULE). */
export const CHAIN_PLAN_STEP_TITLES: Readonly<Record<string, string>> = {
RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren',
MATING_CHECK: 'Begattung kontrollieren',
EGGS_CHECK: 'Eier und Stifte kontrollieren',
ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren',
};
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the clean-up suggestions, by reason code. */
export const CLEANUP_TEXTS: SuggestionTextRegister = {};
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */
export const RULE_TEXTS: SuggestionTextRegister = {};
@@ -3,7 +3,11 @@ import { JobRun, Prisma } from '../../generated/prisma';
import { JOB_NAMES } from '../jobs/job-names';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service';
import {
JOB_ABORTED_ERROR,
JobPartialFailure,
JobRunService,
} from './job-run.service';
type RawCall = [readonly string[], ...unknown[]];
@@ -125,6 +129,26 @@ describe('JobRunService', () => {
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
});
it('stores the counters of a partial failure with the FAILED run', async () => {
const run = await service.run('daily-evaluation', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 2, TaskDue: 1 }, [
'time-events',
]),
),
);
expect(run.status).toBe('FAILED');
const [finish] = prisma.$queryRaw.mock.calls;
expect(finish.slice(1)).toEqual([
'FAILED',
'{"WithdrawalEnded":2,"TaskDue":1}',
'Daily evaluation failed for: time-events',
'run-1',
]);
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
});
// finishedAt of every run - finished, failed or aborted - comes from
// the database clock, like the comparisons of cleanup.
it.each([
+20 -2
View File
@@ -20,9 +20,26 @@ const JOB_RUN_RELATED_TYPE = 'JobRun';
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
/**
* A run of several steps in which some failed: `result` holds the counters
* of all steps, the finished ones included. JobRunService stores it with the
* FAILED run, so the System page shows what was done.
*/
export class JobPartialFailure extends Error {
constructor(
readonly result: JobResult,
readonly failedSteps: readonly string[],
/** What the stored error names, e.g. "time-events: QueenAged". */
details: readonly string[] = failedSteps,
) {
super(`Daily evaluation failed for: ${details.join(', ')}`);
this.name = 'JobPartialFailure';
}
}
type RunOutcome =
| { status: 'SUCCEEDED'; result: JobResult }
| { status: 'FAILED'; error: string };
| { status: 'FAILED'; error: string; result?: JobResult };
/**
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
@@ -116,6 +133,7 @@ export class JobRunService {
const failed = await this.finish(runId, {
status: 'FAILED',
error: message,
...(error instanceof JobPartialFailure ? { result: error.result } : {}),
});
await this.notifyFailure(job, runId);
return failed;
@@ -127,7 +145,7 @@ export class JobRunService {
*/
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
const result =
outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null;
outcome.result === undefined ? null : JSON.stringify(outcome.result);
const error = outcome.status === 'FAILED' ? outcome.error : null;
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
UPDATE "JobRun"
@@ -34,6 +34,7 @@ export interface NewTaskInput {
sourceInspectionId?: string | null;
sourceTemplateId?: string | null;
sourceAutomationTrigger?: AutomationTriggerType | null;
sourceSuggestionId?: string | null;
}
export interface TaskFilter {
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { AutomationModule } from '../automation/automation.module';
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { WorkerHealthServer } from './worker-health-server.service';
@@ -23,12 +25,14 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
JobPermissionsModule,
NotificationsModule,
DomainEventsModule,
WebhooksModule,
CalendarSyncModule,
TasksModule,
AutomationModule,
SuggestionsModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],