diff --git a/ApiCore-Backend/prisma/migrations/20260928042023_follow_up_triggers/migration.sql b/ApiCore-Backend/prisma/migrations/20260928042023_follow_up_triggers/migration.sql new file mode 100644 index 0000000..ebc59e4 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260928042023_follow_up_triggers/migration.sql @@ -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'; + diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 1687d23..82ad6bc 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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 { diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 08e156b..9534c98 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -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); }); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 62cc842..9a2fb24 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -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; actionTemplate: string; + /** Rules without a sensible built-in setting start switched off. */ + active?: boolean; } const DEFAULT_RULES: Record = { @@ -60,6 +64,20 @@ const DEFAULT_RULES: Record = { 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 = { * 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 { + 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 { + 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 { + 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 { 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); diff --git a/ApiCore-Backend/src/feedings/feedings.module.ts b/ApiCore-Backend/src/feedings/feedings.module.ts index 6239eb2..8f88131 100644 --- a/ApiCore-Backend/src/feedings/feedings.module.ts +++ b/ApiCore-Backend/src/feedings/feedings.module.ts @@ -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], diff --git a/ApiCore-Backend/src/feedings/feedings.service.spec.ts b/ApiCore-Backend/src/feedings/feedings.service.spec.ts index 6a58c00..94f3710 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.spec.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.spec.ts @@ -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, ); }); diff --git a/ApiCore-Backend/src/feedings/feedings.service.ts b/ApiCore-Backend/src/feedings/feedings.service.ts index 33eaa90..8ce102c 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.ts @@ -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 { @@ -62,6 +64,7 @@ export class FeedingsService { afterData: feeding, }); await this.calendarSync.enqueue('FEEDING', feeding.id); + await this.automation.evaluateFeeding(feeding); return feeding; } diff --git a/ApiCore-Backend/src/treatments/treatments.module.ts b/ApiCore-Backend/src/treatments/treatments.module.ts index 53b63d1..cbb3c6b 100644 --- a/ApiCore-Backend/src/treatments/treatments.module.ts +++ b/ApiCore-Backend/src/treatments/treatments.module.ts @@ -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], diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index 6468f7b..365e85e 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -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, ); }); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index c8b605f..aeaca5b 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -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 { @@ -98,6 +100,7 @@ export class TreatmentsService { afterData: treatment, }); await this.calendarSync.enqueue('TREATMENT', treatment.id); + await this.automation.evaluateTreatment(treatment); return treatment; } diff --git a/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts new file mode 100644 index 0000000..ef151f8 --- /dev/null +++ b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts @@ -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; + 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 { + 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 = {}) { + 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'); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 34a8602..032cc93 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -25,6 +25,8 @@ const TRIGGER_LABELS: Record = { 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 = { @@ -44,6 +46,10 @@ const TRIGGER_DESCRIPTIONS: Record = { "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; 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 = { 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 }) { /> )} + {!numberFormatRule ? ( +
+ + setDueInDays(event.target.value)} + /> +
+ ) : null} {rule.triggerType === "VARROA_THRESHOLD" ? (
diff --git a/ApiCore-Frontend/src/lib/automation/types.ts b/ApiCore-Frontend/src/lib/automation/types.ts index fe257e0..9b93d59 100644 --- a/ApiCore-Frontend/src/lib/automation/types.ts +++ b/ApiCore-Frontend/src/lib/automation/types.ts @@ -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;