feat(automation): suggest follow-up checks after treatments and feedings

Two new rules, "Kontrolle nach Behandlung" and "Kontrolle nach
Fütterung", create a check task when a treatment or feeding is recorded.
The due date counts from the measure itself (end of treatment, else its
date; feeding date) plus the configured number of days, and the task
names the product and date. At most one open check per colony and rule.

Both rules ship switched off and without an interval: the right timing
depends on the product (package leaflet) and season, so the beekeeper
sets it. Every task-creating rule can now carry a "due after N days"
setting in the automation page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 06:23:13 +02:00
1 parent 888edf7e1d
commit 5cf4e87ca3
13 files changed
+318 -11

No files matched your search

@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
+2
View File
@@ -1074,6 +1074,8 @@ enum AutomationTriggerType {
STOCK_ITEM_LOW
HONEY_BATCH_NUMBER_FORMAT
BOTTLING_LOT_NUMBER_FORMAT
TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP
}
model AutomationRule {
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationTriggerType } from '../../generated/prisma';
describe('AutomationService', () => {
let service: AutomationService;
@@ -82,7 +83,9 @@ describe('AutomationService', () => {
const result = await service.findAll();
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
Object.values(AutomationTriggerType).length,
);
expect(result).toHaveLength(3);
});
@@ -8,10 +8,12 @@ import {
AutomationTriggerType,
Colony,
Inspection,
Feeding,
Prisma,
StockItem,
SwarmControlCheck,
TaskRelatedType,
Treatment,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@@ -20,6 +22,8 @@ import { formatGeneratedNumber } from './number-format';
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> = {
@@ -60,6 +64,20 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// 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,
},
};
/**
@@ -68,7 +86,19 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
* look depends on season and practice, so the rule stays date-less unless the
* beekeeper configures one.
*/
function dueDateFromConfig(dueInDays: unknown): Date | undefined {
function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
function dueDateFromConfig(
dueInDays: unknown,
baseDate: Date = new Date(),
): Date | undefined {
if (
typeof dueInDays !== 'number' ||
!Number.isInteger(dueInDays) ||
@@ -77,7 +107,7 @@ function dueDateFromConfig(dueInDays: unknown): Date | undefined {
) {
return undefined;
}
const due = new Date();
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
@@ -102,6 +132,7 @@ export class AutomationService {
update: {},
create: {
triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
@@ -255,6 +286,44 @@ export class AutomationService {
await this.triggerAction(rule, 'STORAGE', item.id);
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
);
}
async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateFollowUp(
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/** A check counted from the measure's own date, not from today. */
private async evaluateFollowUp(
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
});
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
async generateNumber(
@@ -315,6 +384,7 @@ export class AutomationService {
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
const alreadyOpen = await this.prisma.task.findFirst({
where: {
@@ -339,7 +409,8 @@ export class AutomationService {
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
dueDate: dueDateFromConfig(config?.dueInDays),
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
});
await this.webhooks.dispatch('task.created', task);
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { FeedingsController } from './feedings.controller';
import { ColonyFeedingsController } from './colony-feedings.controller';
import { FeedingsService } from './feedings.service';
@Module({
imports: [AutomationModule],
controllers: [FeedingsController, ColonyFeedingsController],
providers: [FeedingsService],
exports: [FeedingsService],
@@ -1,4 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { FeedingsService } from './feedings.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -35,6 +36,7 @@ describe('FeedingsService', () => {
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService,
{ evaluateFeeding: jest.fn() } as unknown as AutomationService,
);
});
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
@@ -17,6 +18,7 @@ export class FeedingsService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly calendarSync: CalendarSyncService,
private readonly automation: AutomationService,
) {}
async findAll(): Promise<Feeding[]> {
@@ -62,6 +64,7 @@ export class FeedingsService {
afterData: feeding,
});
await this.calendarSync.enqueue('FEEDING', feeding.id);
await this.automation.evaluateFeeding(feeding);
return feeding;
}
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { TreatmentsController } from './treatments.controller';
import { ColonyTreatmentsController } from './colony-treatments.controller';
import { TreatmentsService } from './treatments.service';
@Module({
imports: [AutomationModule],
controllers: [TreatmentsController, ColonyTreatmentsController],
providers: [TreatmentsService],
exports: [TreatmentsService],
@@ -1,4 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { TreatmentsService } from './treatments.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -37,6 +38,7 @@ describe('TreatmentsService', () => {
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
calendarSync as unknown as CalendarSyncService,
{ evaluateTreatment: jest.fn() } as unknown as AutomationService,
);
});
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AutomationService } from '../automation/automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
@@ -18,6 +19,7 @@ export class TreatmentsService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly calendarSync: CalendarSyncService,
private readonly automation: AutomationService,
) {}
async findAll(): Promise<Treatment[]> {
@@ -98,6 +100,7 @@ export class TreatmentsService {
afterData: treatment,
});
await this.calendarSync.enqueue('TREATMENT', treatment.id);
await this.automation.evaluateTreatment(treatment);
return treatment;
}
@@ -0,0 +1,173 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface RuleBody {
id: string;
triggerType: string;
active: boolean;
}
/**
* Follow-up checks after a treatment or feeding: off by default (no built-in
* intervals), due relative to the measure itself, never duplicated.
*/
describe('Follow-up controls (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let token: string;
let apiaryId: string;
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
async function rule(triggerType: string): Promise<RuleBody> {
const response = await request(server())
.get('/api/v1/automation-rules')
.set(auth())
.expect(200);
const found = (response.body as RuleBody[]).find(
(r) => r.triggerType === triggerType,
);
if (!found) throw new Error(`rule ${triggerType} missing`);
return found;
}
async function newColony(code: string) {
return prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
}
function treat(colonyId: string, body: Record<string, unknown> = {}) {
return request(server())
.post('/api/v1/treatments')
.set(auth())
.send({
colonyId,
date: '2026-08-01T00:00:00.000Z',
product: 'Testmittel',
...body,
})
.expect(201);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
data: {
email: `followup-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 12),
name: 'Follow-up',
role: { connect: { key: 'ADMIN' } },
},
});
token = (
await login(app, `followup-${suffix}@example.com`, 'SuperSecret123!')
).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Follow-up ${suffix}` } })
).id;
});
afterAll(async () => {
// Leave the shared rules as other suites expect them: off.
await prisma.automationRule.updateMany({
where: {
triggerType: { in: ['TREATMENT_FOLLOW_UP', 'FEEDING_FOLLOW_UP'] },
},
data: { active: false, config: {} },
});
await app.close();
});
it('ships both follow-up rules switched off, without a built-in interval', async () => {
const treatmentRule = await rule('TREATMENT_FOLLOW_UP');
const feedingRule = await rule('FEEDING_FOLLOW_UP');
expect(treatmentRule.active).toBe(false);
expect(feedingRule.active).toBe(false);
const colony = await newColony('OFF');
await treat(colony.id);
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
});
it('suggests a check due the configured days after the treatment ended', async () => {
const treatmentRule = await rule('TREATMENT_FOLLOW_UP');
await request(server())
.patch(`/api/v1/automation-rules/${treatmentRule.id}`)
.set(auth())
.send({ active: true, config: { dueInDays: 14 } })
.expect(200);
const colony = await newColony('TREAT');
await treat(colony.id, {
startDate: '2026-08-01T00:00:00.000Z',
endDate: '2026-08-10T00:00:00.000Z',
});
const tasks = await prisma.task.findMany({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
});
expect(tasks).toHaveLength(1);
expect(tasks[0].dueDate?.toISOString()).toBe('2026-08-24T00:00:00.000Z');
expect(tasks[0].description).toContain('Testmittel');
expect(tasks[0].status).toBe('OPEN');
// A second treatment while the check is still open adds nothing.
await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' });
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
}),
).toBe(1);
});
it('suggests a check after a feeding, counted from the feeding date', async () => {
const feedingRule = await rule('FEEDING_FOLLOW_UP');
await request(server())
.patch(`/api/v1/automation-rules/${feedingRule.id}`)
.set(auth())
.send({ active: true, config: { dueInDays: 7 } })
.expect(200);
const colony = await newColony('FEED');
await request(server())
.post('/api/v1/feedings')
.set(auth())
.send({
colonyId: colony.id,
date: '2026-09-01T00:00:00.000Z',
feedType: 'SUGAR_WATER',
amount: 5,
unit: 'l',
})
.expect(201);
const task = await prisma.task.findFirstOrThrow({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'FEEDING_FOLLOW_UP',
},
});
expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
});
});
@@ -25,6 +25,8 @@ const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)",
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung",
FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung",
};
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
@@ -44,6 +46,10 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
"Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
BOTTLING_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
TREATMENT_FOLLOW_UP:
"Nach jeder erfassten Behandlung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach Behandlungsende. Die passende Frist hängt vom Mittel ab (Packungsbeilage) – deshalb ab Werk aus und ohne voreingestellte Frist.",
FEEDING_FOLLOW_UP:
"Nach jeder erfassten Fütterung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach der Fütterung. Ab Werk aus und ohne voreingestellte Frist.",
};
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
@@ -60,6 +66,11 @@ function ruleFormat(rule: AutomationRule): string {
return config?.format ?? "";
}
function ruleDueInDays(rule: AutomationRule): string {
const config = rule.config as { dueInDays?: number } | null;
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
}
function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? "");
@@ -70,6 +81,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [format, setFormat] = useState(ruleFormat(rule));
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const numberFormatRule = isNumberFormatRule(rule);
const mutation = useMutation({
@@ -96,13 +108,17 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
mutation.mutate({ config: { format } });
return;
}
const input: { config?: Record<string, unknown>; actionTemplate?: string } = {
actionTemplate: actionTemplate || undefined,
};
if (rule.triggerType === "VARROA_THRESHOLD") {
input.config = { thresholdMitesPerDay: Number(threshold) || 10 };
// Empty = task without a due date; otherwise whole days, 0-365.
const days = dueInDays.trim() === "" ? undefined : Math.round(Number(dueInDays));
if (days !== undefined && (!Number.isFinite(days) || days < 0 || days > 365)) {
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
return;
}
mutation.mutate(input);
const config: Record<string, unknown> = { dueInDays: days };
if (rule.triggerType === "VARROA_THRESHOLD") {
config.thresholdMitesPerDay = Number(threshold) || 10;
}
mutation.mutate({ actionTemplate: actionTemplate || undefined, config });
}
return (
@@ -150,6 +166,21 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
/>
</div>
)}
{!numberFormatRule ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`due-${rule.id}`}>Fällig nach (Tagen)</Label>
<Input
id={`due-${rule.id}`}
type="number"
inputMode="numeric"
min={0}
max={365}
placeholder="leer = ohne Termin"
value={dueInDays}
onChange={(event) => setDueInDays(event.target.value)}
/>
</div>
) : null}
{rule.triggerType === "VARROA_THRESHOLD" ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
+3 -1
View File
@@ -6,7 +6,9 @@ export type AutomationTriggerType =
| "COLONY_HEALTH_CRITICAL"
| "STOCK_ITEM_LOW"
| "HONEY_BATCH_NUMBER_FORMAT"
| "BOTTLING_LOT_NUMBER_FORMAT";
| "BOTTLING_LOT_NUMBER_FORMAT"
| "TREATMENT_FOLLOW_UP"
| "FEEDING_FOLLOW_UP";
export interface AutomationRule {
id: string;