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

@@ -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', () => {