diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts index 98edceb..5835b31 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts @@ -9,7 +9,7 @@ describe('ApiaryFieldService', () => { apiary: { findUnique: jest.Mock }; colony: { findMany: jest.Mock }; inspection: { groupBy: jest.Mock; findMany: jest.Mock }; - notification: { groupBy: jest.Mock }; + $queryRaw: jest.Mock; queen: { findMany: jest.Mock }; task: { findMany: jest.Mock }; }; @@ -23,6 +23,7 @@ describe('ApiaryFieldService', () => { colonyType: 'PRODUCTION_COLONY', hiveType: null, strength: 5, + occupiedFrames: 12, broodStatus: null, feedStatus: null, healthStatus: null, @@ -55,7 +56,7 @@ describe('ApiaryFieldService', () => { groupBy: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]), }, - notification: { groupBy: jest.fn().mockResolvedValue([]) }, + $queryRaw: jest.fn().mockResolvedValue([]), queen: { findMany: jest.fn().mockResolvedValue([]) }, task: { findMany: jest.fn().mockResolvedValue([]) }, }; @@ -172,27 +173,23 @@ describe('ApiaryFieldService', () => { expect(inspectionArgs.omit).toEqual({ userId: true }); }); + const lastQuery = () => + (prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0]; + it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => { grant('colonies:read'); - prisma.notification.groupBy.mockResolvedValue([ - { relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } }, - { relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } }, + prisma.$queryRaw.mockResolvedValue([ + { relatedId: 'c2', priority: 'WARNUNG', count: 2 }, + { relatedId: 'c2', priority: 'KRITISCH', count: 1 }, ]); const overview = await service.colonyOverview('apiary-1', 'u1'); - expect(prisma.notification.groupBy).toHaveBeenCalledWith( - expect.objectContaining({ - by: ['relatedId', 'priority'], - where: { - relatedType: 'COLONY', - relatedId: { in: ['c2', 'c10'] }, - read: false, - priority: { in: ['WARNUNG', 'KRITISCH'] }, - userId: 'u1', - }, - }), - ); + const query = lastQuery(); + expect(query.sql).toContain('"userId" ='); + expect(query.sql).toContain('"read" = false'); + expect(query.values).toContain('u1'); + expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10'])); expect(overview.rows[0]).toMatchObject({ code: 'V-2', openWarnings: 2, @@ -203,4 +200,16 @@ describe('ApiaryFieldService', () => { openCritical: 0, }); }); + + it('requires the notification permissions to be a subset of the held ones, stale keys included', async () => { + grant('colonies:read', 'varroa:read'); + + await service.colonyOverview('apiary-1', 'u1'); + + const query = lastQuery(); + expect(query.sql).toContain('"requiredPermissions" <@'); + // The held set is passed as is: a key outside the catalog is never + // contained in it, so such a row stays hidden (parity with findOwn). + expect(query.values).toContainEqual(['colonies:read', 'varroa:read']); + }); }); diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.ts index 9e1e4e6..26ad35f 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '../../generated/prisma'; import type { ColonyOverview, ColonyOverviewRow, @@ -83,6 +84,7 @@ export class ApiaryFieldService { colonyType: true, hiveType: true, strength: true, + occupiedFrames: true, broodStatus: true, feedStatus: true, healthStatus: true, @@ -96,13 +98,14 @@ export class ApiaryFieldService { const inspectionsVisible = permissions.has('inspections:read'); const [lastInspections, warnings] = await Promise.all([ inspectionsVisible ? this.lastInspectionDates(colonies) : null, - this.warningCounts(colonies, userId), + this.warningCounts(colonies, userId, permissions), ]); const rows = colonies.map((colony): ColonyOverviewRow => ({ id: colony.id, code: colony.code, status: colony.status, strength: colony.strength, + occupiedFrames: colony.occupiedFrames, broodStatus: colony.broodStatus, feedStatus: colony.feedStatus, healthStatus: colony.healthStatus, @@ -138,27 +141,33 @@ export class ApiaryFieldService { private async warningCounts( colonies: PackColony[], userId: string, + held: ReadonlySet, ): Promise> { - const groups = await this.prisma.notification.groupBy({ - by: ['relatedId', 'priority'], - where: { - relatedType: 'COLONY', - relatedId: { in: colonies.map((colony) => colony.id) }, - read: false, - priority: { in: ['WARNUNG', 'KRITISCH'] }, - userId, - }, - _count: { _all: true }, - }); const counts = new Map(); + if (colonies.length === 0) { + return counts; + } + // Exactly the rule of NotificationsService.findOwn, decided in SQL: a + // row counts only when every key in requiredPermissions is held (subset + // test, so a key outside the catalog hides the row too). + const groups = await this.prisma.$queryRaw< + { relatedId: string; priority: string; count: number }[] + >(Prisma.sql` + SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count" + FROM "Notification" + WHERE "relatedType" = 'COLONY' + AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))}) + AND "read" = false + AND "priority"::text IN ('WARNUNG', 'KRITISCH') + AND "userId" = ${userId} + AND "requiredPermissions" <@ ${[...held]}::text[] + GROUP BY "relatedId", "priority" + `); for (const group of groups) { - if ( - group.relatedId && - (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') - ) { + if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') { counts.set(group.relatedId, { ...counts.get(group.relatedId), - [group.priority]: group._count._all, + [group.priority]: group.count, }); } } diff --git a/ApiCore-Backend/src/apiaries/apiary-field.types.ts b/ApiCore-Backend/src/apiaries/apiary-field.types.ts index 12aa33c..b1c450c 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.types.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.types.ts @@ -14,6 +14,7 @@ export interface ColonyOverviewRow { code: string; status: ColonyStatus; strength: number | null; + occupiedFrames: number | null; broodStatus: ColonyCondition | null; feedStatus: ColonyCondition | null; healthStatus: ColonyCondition | null; @@ -56,6 +57,7 @@ export interface OfflinePackV1 { | 'colonyType' | 'hiveType' | 'strength' + | 'occupiedFrames' | 'broodStatus' | 'feedStatus' | 'healthStatus' diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index ae22fed..932a384 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -54,6 +54,7 @@ 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 { AutopilotModule } from './autopilot/autopilot.module'; import { SystemModule } from './system/system.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; @@ -108,6 +109,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce DocumentsModule, AutomationModule, SuggestionsModule, + AutopilotModule, LabelTemplatesModule, ImportModule, PublicModule, diff --git a/ApiCore-Backend/src/automation/automation.handlers.spec.ts b/ApiCore-Backend/src/automation/automation.handlers.spec.ts index 6111082..7ef9d32 100644 --- a/ApiCore-Backend/src/automation/automation.handlers.spec.ts +++ b/ApiCore-Backend/src/automation/automation.handlers.spec.ts @@ -154,12 +154,17 @@ describe('AutomationEventHandlers', () => { async (method, type, model, core) => { const record = { id: 'record-1', colonyId: 'colony-1' }; tx[model].findUnique.mockResolvedValueOnce(record); + tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' }); await run(method, type); expect(tx[model].findUnique).toHaveBeenCalledWith({ where: { id: 'record-1' }, }); + expect(tx.colony.findUnique).toHaveBeenCalledWith({ + where: { id: 'colony-1' }, + select: { status: true }, + }); expect(automation[core]).toHaveBeenCalledWith(tx, record); }, ); @@ -174,8 +179,41 @@ describe('AutomationEventHandlers', () => { }, ); + it.each(cases)( + 'rule handlers write nothing for an ended colony: %s on %s', + async (method, type, model, core) => { + tx[model].findUnique.mockResolvedValueOnce({ + id: 'record-1', + colonyId: 'colony-1', + }); + tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' }); + + await run(method, type); + + expect(automation[core]).not.toHaveBeenCalled(); + }, + ); + + it.each(cases)( + 'rule handlers write nothing for a colony that is gone: %s on %s', + async (method, type, model, core) => { + tx[model].findUnique.mockResolvedValueOnce({ + id: 'record-1', + colonyId: 'colony-1', + }); + + await run(method, type); + + expect(automation[core]).not.toHaveBeenCalled(); + }, + ); + it('lets an evaluation failure propagate, so the delivery is retried', async () => { - tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' }); + tx.inspection.findUnique.mockResolvedValueOnce({ + id: 'record-1', + colonyId: 'colony-1', + }); + tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' }); automation.evaluateInspectionIn.mockRejectedValue( new Error('notifications down'), ); @@ -192,22 +230,24 @@ describe('AutomationEventHandlers', () => { const event = () => envelope('ColonyStatusChanged', 'colony-1', { from, to }); - it('hands the transition of the event to evaluateColonyHealthIn', async () => { - tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); + it('hands the transition and the event to evaluateColonyHealthIn', async () => { + tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' }); await handlers.colonyHealth(event(), asTx()); expect(tx.colony.findUnique).toHaveBeenCalledWith({ where: { id: 'colony-1' }, - select: { id: true }, + select: { status: true }, }); // The rule decides on the snapshots of the event, not on the colony - // as it is now: a later change must not hide the transition. + // as it is now: a later change must not hide the transition. The + // event is the anchor of the suggestion. expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith( tx, 'colony-1', from, to, + { id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') }, ); }); @@ -216,6 +256,14 @@ describe('AutomationEventHandlers', () => { expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled(); }); + + it('skips a colony that ended', async () => { + tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' }); + + await handlers.colonyHealth(event(), asTx()); + + expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled(); + }); }); describe('raw-material-low', () => { diff --git a/ApiCore-Backend/src/automation/automation.handlers.ts b/ApiCore-Backend/src/automation/automation.handlers.ts index e95e0d3..3e06ae5 100644 --- a/ApiCore-Backend/src/automation/automation.handlers.ts +++ b/ApiCore-Backend/src/automation/automation.handlers.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { isLivingColonyStatus } from '../colonies/colony-status'; import { Prisma } from '../../generated/prisma'; import { StockService } from '../stock/stock.service'; import { AutomationService } from './automation.service'; @@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient; /** * Evaluates the automation rules on domain events. Pure DB writer: it reads * the record the event is about through the delivery transaction and lets - * AutomationService write task, warning and audit entries in it. A record - * deleted before the event was dispatched is skipped. Errors propagate, so - * the delivery is rolled back as a whole and retried. + * AutomationService write suggestion, notification and audit entries in it. + * A record deleted before the event was dispatched, or of a colony that + * ended, is skipped. Errors propagate, so the delivery is rolled back as a + * whole and retried. */ @Injectable() export class AutomationEventHandlers { @@ -21,6 +23,18 @@ export class AutomationEventHandlers { private readonly stock: StockService, ) {} + /** + * Fast path only: proposeForRuleIn re-checks the colony under its lock. + * Whether the colony still lives (a colony that ended takes no proposals). + */ + private async isLivingColonyIn(tx: Tx, colonyId: string): Promise { + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { status: true }, + }); + return colony !== null && isLivingColonyStatus(colony.status); + } + @OnDomainEvent('InspectionRecorded', 'automation.inspection-rules') async inspectionRules( event: AnyDomainEventEnvelope<'InspectionRecorded'>, @@ -29,14 +43,17 @@ export class AutomationEventHandlers { const inspection = await tx.inspection.findUnique({ where: { id: event.aggregateId }, }); - if (inspection === null) { + if ( + inspection === null || + !(await this.isLivingColonyIn(tx, inspection.colonyId)) + ) { return; } await this.automation.evaluateInspectionIn(tx, inspection); } // A correction can lift a count over the threshold, so it is evaluated - // like a new measurement; the open-task check keeps it to one task. + // like a new measurement; the open-suggestion check keeps it to one. @OnDomainEvent( ['VarroaMeasured', 'VarroaCorrected'], 'automation.varroa-threshold', @@ -48,7 +65,10 @@ export class AutomationEventHandlers { const measurement = await tx.varroaMeasurement.findUnique({ where: { id: event.aggregateId }, }); - if (measurement === null) { + if ( + measurement === null || + !(await this.isLivingColonyIn(tx, measurement.colonyId)) + ) { return; } await this.automation.evaluateVarroaMeasurementIn(tx, measurement); @@ -62,7 +82,10 @@ export class AutomationEventHandlers { const treatment = await tx.treatment.findUnique({ where: { id: event.aggregateId }, }); - if (treatment === null) { + if ( + treatment === null || + !(await this.isLivingColonyIn(tx, treatment.colonyId)) + ) { return; } await this.automation.evaluateTreatmentIn(tx, treatment); @@ -76,7 +99,10 @@ export class AutomationEventHandlers { const feeding = await tx.feeding.findUnique({ where: { id: event.aggregateId }, }); - if (feeding === null) { + if ( + feeding === null || + !(await this.isLivingColonyIn(tx, feeding.colonyId)) + ) { return; } await this.automation.evaluateFeedingIn(tx, feeding); @@ -89,18 +115,15 @@ export class AutomationEventHandlers { event: AnyDomainEventEnvelope<'ColonyStatusChanged'>, tx: Tx, ): Promise { - const colony = await tx.colony.findUnique({ - where: { id: event.aggregateId }, - select: { id: true }, - }); - if (colony === null) { + if (!(await this.isLivingColonyIn(tx, event.aggregateId))) { return; } await this.automation.evaluateColonyHealthIn( tx, - colony.id, + event.aggregateId, event.payload.from, event.payload.to, + { id: event.id, occurredAt: event.occurredAt }, ); } diff --git a/ApiCore-Backend/src/automation/automation.module.ts b/ApiCore-Backend/src/automation/automation.module.ts index d0972ef..d3f102b 100644 --- a/ApiCore-Backend/src/automation/automation.module.ts +++ b/ApiCore-Backend/src/automation/automation.module.ts @@ -1,18 +1,18 @@ import { Module } from '@nestjs/common'; -import { NotificationsModule } from '../notifications/notifications.module'; import { StockModule } from '../stock/stock.module'; -import { TasksModule } from '../tasks/tasks.module'; +import { SuggestionsModule } from '../suggestions/suggestions.module'; import { AutomationController } from './automation.controller'; import { AutomationEventHandlers } from './automation.handlers'; import { AutomationService } from './automation.service'; /** * Automation rules and the handlers that evaluate them on domain events. - * Rule tasks go through TasksService (audit entry, TaskCreated - and with - * it calendar entry and task.created webhook), so no WebhooksService here. + * The rules propose through SuggestionsService; the task follows when the + * beekeeper accepts (TasksService, with calendar entry and task.created + * webhook), so neither a TasksModule nor a WebhooksService is needed here. */ @Module({ - imports: [TasksModule, NotificationsModule, StockModule], + imports: [SuggestionsModule, StockModule], controllers: [AutomationController], providers: [AutomationService, AutomationEventHandlers], exports: [AutomationService], diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index a4cdb95..5ee5baf 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -2,8 +2,7 @@ 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'; -import { NotificationsService } from '../notifications/notifications.service'; -import { TasksService } from '../tasks/tasks.service'; +import { SuggestionsService } from '../suggestions/suggestions.service'; import { AutomationTriggerType, Prisma, @@ -18,6 +17,8 @@ type Rule = { actionTemplate: string; }; +type RuleLookup = { where: { triggerType?: string; id?: string } }; + describe('AutomationService', () => { let service: AutomationService; // The transaction client: evaluations read and write only through it. @@ -26,9 +27,14 @@ describe('AutomationService', () => { createMany: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; update: jest.Mock; }; - task: { findFirst: jest.Mock }; + colony: { findUnique: jest.Mock }; + suggestion: { findFirst: jest.Mock }; + treatment: { findMany: jest.Mock }; + stockMovement: { findFirst: jest.Mock }; + rawMaterialLot: { findFirst: jest.Mock }; $queryRaw: jest.Mock; }; let prisma: { @@ -45,8 +51,13 @@ describe('AutomationService', () => { $connect: jest.Mock; }; let auditLog: { record: jest.Mock }; - let notifications: { recordForPermission: jest.Mock }; - let tasks: { createInTx: jest.Mock }; + let suggestions: { + proposeIn: jest.Mock; + refreshIn: jest.Mock; + resolveIn: jest.Mock; + }; + // The rule rows of the transaction, by trigger type. + let rules: Map; const queenlessRule: Rule = { id: 'rule-1', @@ -72,23 +83,52 @@ describe('AutomationService', () => { const queenlessInspection = { id: 'inspection-1', colonyId: 'colony-1', + date: new Date('2026-05-05T00:00:00.000Z'), queenSeen: false, eggsPresent: false, feedStatus: null, }; const asTx = () => tx as unknown as Prisma.TransactionClient; + const withRules = (...list: Rule[]) => { + for (const rule of list) { + rules.set(rule.triggerType, rule); + } + }; + const ruleOf = ({ where }: RuleLookup): Rule | undefined => + where.triggerType === undefined + ? [...rules.values()].find((rule) => rule.id === where.id) + : rules.get(where.triggerType); beforeEach(() => { + rules = new Map(); tx = { automationRule: { createMany: jest.fn().mockResolvedValue({ count: 0 }), findMany: jest.fn().mockResolvedValue([]), - findUnique: jest.fn().mockResolvedValue(null), + // No row: the built-in setting applies. + findUnique: jest.fn((query: RuleLookup) => + Promise.resolve(ruleOf(query) ?? null), + ), + findUniqueOrThrow: jest.fn((query: RuleLookup) => { + const rule = ruleOf(query); + return rule + ? Promise.resolve(rule) + : Promise.reject(new Error('no rule row')); + }), update: jest.fn(), }, - task: { findFirst: jest.fn().mockResolvedValue(null) }, - $queryRaw: jest.fn().mockResolvedValue([]), + colony: { + findUnique: jest.fn().mockResolvedValue({ + status: 'ACTIVE', + colonyType: 'PRODUCTION_COLONY', + }), + }, + suggestion: { findFirst: jest.fn().mockResolvedValue(null) }, + treatment: { findMany: jest.fn().mockResolvedValue([]) }, + stockMovement: { findFirst: jest.fn().mockResolvedValue(null) }, + rawMaterialLot: { findFirst: jest.fn().mockResolvedValue(null) }, + $queryRaw: jest.fn().mockResolvedValue([{ id: 'locked' }]), }; prisma = { automationRule: { @@ -102,21 +142,21 @@ describe('AutomationService', () => { $connect: jest.fn(), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; - notifications = { - recordForPermission: jest.fn().mockResolvedValue([{ id: 'n-1' }]), + suggestions = { + proposeIn: jest.fn().mockResolvedValue({ id: 'suggestion-1' }), + refreshIn: jest.fn().mockResolvedValue(undefined), + resolveIn: jest.fn().mockResolvedValue(1), }; - tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) }; service = new AutomationService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, - notifications as unknown as NotificationsService, - tasks as unknown as TasksService, + suggestions as unknown as SuggestionsService, ); }); - it('sends no webhook itself: task.created follows from the TaskCreated event', () => { + it('proposes through SuggestionsService and sends no task or notification itself', () => { expect(Reflect.getMetadata('design:paramtypes', AutomationService)).toEqual( - [PrismaService, AuditLogService, NotificationsService, TasksService], + [PrismaService, AuditLogService, SuggestionsService], ); }); @@ -164,7 +204,12 @@ describe('AutomationService', () => { errorSpy.mockRestore(); }); - const check = { id: 'check-1', colonyId: 'colony-1' }; + const check = { + id: 'check-1', + colonyId: 'colony-1', + date: new Date('2026-05-05T00:00:00.000Z'), + swarmMood: 'HIGH', + }; const item = { id: 'item-1', quantity: 1, minThreshold: 5 }; // [legacy method, log subject, call, core it delegates to, core args after tx] const wrappers: [ @@ -207,7 +252,7 @@ describe('AutomationService', () => { it.each(wrappers)( '%s never throws and logs the failure with the record', async (_name, subject, evaluate) => { - tx.automationRule.createMany.mockRejectedValue(new Error('db down')); + tx.automationRule.findUnique.mockRejectedValue(new Error('db down')); await expect(evaluate(service)).resolves.toBeUndefined(); @@ -239,7 +284,7 @@ describe('AutomationService', () => { expect(errorSpy).not.toHaveBeenCalled(); }); - it('leaves no task, notification or audit entry when the notification write fails (one rollback)', async () => { + it('leaves no suggestion or audit entry when the audit write fails (one rollback)', async () => { // A transaction fake: writes through tx are staged and kept only when // the work resolves - like Postgres, a rejected callback keeps none. const committed: string[] = []; @@ -252,79 +297,81 @@ describe('AutomationService', () => { return result; }, ); - tx.automationRule.findUnique.mockResolvedValue({ - ...queenlessRule, - triggerType: 'SWARM_MOOD_HIGH', - }); - tasks.createInTx.mockImplementation((client: unknown) => { - staged.push(client === tx ? 'task' : 'task outside tx'); - return Promise.resolve({ id: 'task-1' }); - }); - notifications.recordForPermission.mockRejectedValue( - new Error('notifications down'), - ); - auditLog.record.mockImplementation((_entry: unknown, client: unknown) => { - staged.push(client === tx ? 'audit' : 'audit outside tx'); - return Promise.resolve(); + withRules({ ...queenlessRule, triggerType: 'SWARM_MOOD_HIGH' }); + suggestions.proposeIn.mockImplementation((client: unknown) => { + staged.push(client === tx ? 'suggestion' : 'suggestion outside tx'); + return Promise.resolve({ id: 'suggestion-1' }); }); + auditLog.record.mockRejectedValue(new Error('audit down')); await service.evaluateSwarmControlCheck({ id: 'check-1', colonyId: 'colony-1', + date: new Date('2026-05-05T00:00:00.000Z'), swarmMood: 'HIGH', } as never); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); + expect(suggestions.proposeIn).toHaveBeenCalledTimes(1); expect(committed).toEqual([]); - expect(auditLog.record).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledTimes(1); const [message] = errorSpy.mock.calls[0] as [string]; - expect(message).toContain('notifications down'); + expect(message).toContain('audit down'); }); }); - describe('triggerActionIn (via the …In cores)', () => { + describe('proposeForRuleIn', () => { + const target = { + relatedType: 'COLONY' as const, + relatedId: 'colony-1', + colonyId: 'colony-1', + intent: 'QUEEN_CHECK' as const, + reasonCode: 'QUEENLESS_NO_EGGS', + evidence: { + colonyId: 'colony-1', + inspectionId: 'inspection-1', + inspectedOn: '2026-05-05', + }, + anchor: 'inspection-1', + requiredPermission: 'inspections:read' as const, + }; + beforeEach(() => { - tx.automationRule.findMany.mockResolvedValue([queenlessRule]); + withRules(queenlessRule); }); - it('triggerActionIn creates the task through TasksService.createInTx on the handler transaction', async () => { - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + it('proposeForRuleIn writes a suggestion under the rule lock and an audit entry with suggestionId', async () => { + const result = await service.proposeForRuleIn( + asTx(), + 'QUEENLESS_NO_EGGS', + { ...target, dueDate: '2026-05-12', sourceEventId: 'event-1' }, + ); - // System task: no acting user. Audit entry, TaskCreated - and with it - // calendar entry and task.created webhook - come from createInTx. - expect(tasks.createInTx).toHaveBeenCalledWith( - tx, - { - title: 'Kontrolle empfohlen', - priority: 'HIGH', - relatedType: 'COLONY', - relatedId: 'colony-1', - sourceInspectionId: 'inspection-1', - sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', - description: undefined, - dueDate: undefined, - }, - null, - ); - expect(notifications.recordForPermission).toHaveBeenCalledWith( - 'tasks:read', - { - priority: 'WARNUNG', - category: 'SUGGESTION', - message: 'Kontrolle empfohlen', - relatedType: 'COLONY', - relatedId: 'colony-1', - }, - tx, - ); + expect(result).toEqual({ id: 'suggestion-1' }); + expect(suggestions.proposeIn).toHaveBeenCalledWith(tx, { + ruleKey: 'QUEENLESS_NO_EGGS', + source: 'RULE', + mode: 'PLAN', + intent: 'QUEEN_CHECK', + priority: 'HIGH', + relatedType: 'COLONY', + relatedId: 'colony-1', + colonyId: 'colony-1', + reasonCode: 'QUEENLESS_NO_EGGS', + evidence: target.evidence, + requiredPermission: 'inspections:read', + dueDate: '2026-05-12', + anchor: 'inspection-1', + sourceEventId: 'event-1', + // The default setting of the rule. + notify: true, + }); expect(auditLog.record).toHaveBeenCalledWith( { entityType: 'AutomationRule', entityId: 'rule-1', action: 'CREATE', afterData: { - taskId: 'task-1', + suggestionId: 'suggestion-1', relatedType: 'COLONY', relatedId: 'colony-1', triggerType: 'QUEENLESS_NO_EGGS', @@ -332,62 +379,83 @@ describe('AutomationService', () => { }, tx, ); - // Nothing of the evaluation runs on the root client. + // The rule row is locked in this transaction, after the colony. + const lockCalls = tx.$queryRaw.mock.calls as [Prisma.Sql][]; + const lockedTables = lockCalls.map( + ([query]) => /FROM "(\w+)"/.exec(query.sql)?.[1], + ); + expect(lockedTables).toEqual(['Colony', 'AutomationRule']); expect(prisma.$transaction).not.toHaveBeenCalled(); - expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); }); - it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => { - notifications.recordForPermission.mockRejectedValue( - new Error('notifications down'), + it('passes the notify setting of the rule on', async () => { + withRules({ ...queenlessRule, config: { notify: false } }); + + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); + + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ notify: false }), ); + }); + + it('has no date unless the caller gives one', async () => { + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); + + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ dueDate: null, sourceEventId: null }), + ); + }); + + it('writes no audit entry when nothing was proposed', async () => { + suggestions.proposeIn.mockResolvedValue(null); await expect( - service.evaluateInspectionIn(asTx(), queenlessInspection as never), - ).rejects.toThrow('notifications down'); + service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), + ).resolves.toBeNull(); expect(auditLog.record).not.toHaveBeenCalled(); }); - it('still creates at most one open task per rule and object', async () => { - tx.task.findFirst.mockResolvedValue({ id: 'open-task' }); + it('proposes nothing for an inactive rule', async () => { + withRules({ ...queenlessRule, active: false }); - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + await expect( + service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), + ).resolves.toBeNull(); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); - expect(tx.task.findFirst).toHaveBeenCalledWith({ - where: { - sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', - relatedType: 'COLONY', - relatedId: 'colony-1', - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - select: { id: true }, + it('proposes nothing for a colony that ended or is gone', async () => { + tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' }); + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); + + tx.colony.findUnique.mockResolvedValue(null); + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); + + tx.$queryRaw.mockResolvedValue([]); + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); + + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + + it('locks no colony for a target without one', async () => { + withRules({ ...queenlessRule, triggerType: 'STOCK_ITEM_LOW' }); + + await service.proposeForRuleIn(asTx(), 'STOCK_ITEM_LOW', { + ...target, + relatedType: 'STORAGE', + relatedId: 'item-1', + colonyId: null, }); - expect(tasks.createInTx).not.toHaveBeenCalled(); - expect(notifications.recordForPermission).not.toHaveBeenCalled(); - expect(auditLog.record).not.toHaveBeenCalled(); + + expect(tx.colony.findUnique).not.toHaveBeenCalled(); + expect(suggestions.proposeIn).toHaveBeenCalledTimes(1); }); - it('checks for an open task and creates one under a lock on the rule row', async () => { - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); - - const [sql, ...values] = tx.$queryRaw.mock.calls[0] as [ - TemplateStringsArray, - ...unknown[], - ]; - expect(sql.join('?')).toMatch( - /SELECT id FROM "AutomationRule" WHERE id = \? FOR UPDATE/, - ); - expect(values).toEqual(['rule-1']); - // Lock first, then check, then create - all on the one transaction. - const [lockOrder] = tx.$queryRaw.mock.invocationCallOrder; - const [checkOrder] = tx.task.findFirst.mock.invocationCallOrder; - const [createOrder] = tasks.createInTx.mock.invocationCallOrder; - expect(lockOrder).toBeLessThan(checkOrder); - expect(checkOrder).toBeLessThan(createOrder); - }); - - it('seeds the default rules on the given transaction client', async () => { - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + it('seeds a missing rule row through the lock, never through the root client', async () => { + await service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target); expect(tx.automationRule.createMany).toHaveBeenCalledWith( expect.objectContaining({ skipDuplicates: true }), @@ -395,194 +463,376 @@ describe('AutomationService', () => { expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); }); - it('refuses the root client: lock and duplicate check need a transaction', async () => { + it('refuses the root client: the lock needs a transaction', async () => { + await expect( + service.proposeForRuleIn( + prisma as unknown as Prisma.TransactionClient, + 'QUEENLESS_NO_EGGS', + target, + ), + ).rejects.toThrow('needs the transaction client'); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('lets a failing proposeIn propagate and writes nothing else', async () => { + suggestions.proposeIn.mockRejectedValue(new Error('suggestions down')); + + await expect( + service.proposeForRuleIn(asTx(), 'QUEENLESS_NO_EGGS', target), + ).rejects.toThrow('suggestions down'); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + }); + + describe('the …In cores', () => { + let propose: jest.SpyInstance; + + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + }); + + it('refuse the root client', async () => { await expect( service.evaluateInspectionIn( prisma as unknown as Prisma.TransactionClient, queenlessInspection as never, ), ).rejects.toThrow('needs the transaction client'); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); - it('lets a failing createInTx propagate and writes nothing else', async () => { - tasks.createInTx.mockRejectedValue(new Error('tasks down')); + it('read the rules on the given transaction client and write no seed', async () => { + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); - await expect( - service.evaluateInspectionIn(asTx(), queenlessInspection as never), - ).rejects.toThrow('tasks down'); - expect(notifications.recordForPermission).not.toHaveBeenCalled(); - expect(auditLog.record).not.toHaveBeenCalled(); + expect(tx.automationRule.findUnique).toHaveBeenCalled(); + expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); + expect(prisma.automationRule.findMany).not.toHaveBeenCalled(); }); }); describe('evaluateInspectionIn', () => { - it('reads only the active inspection rules', async () => { + let propose: jest.SpyInstance; + + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + }); + + it('proposes a queen check anchored on the inspection when the queen was not seen and no eggs are present', async () => { + withRules(queenlessRule); + await service.evaluateInspectionIn(asTx(), queenlessInspection as never); - expect(tx.automationRule.findMany).toHaveBeenCalledWith({ - where: { - triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, - active: true, + expect(propose).toHaveBeenCalledTimes(1); + expect(propose).toHaveBeenCalledWith(tx, 'QUEENLESS_NO_EGGS', { + relatedType: 'COLONY', + relatedId: 'colony-1', + colonyId: 'colony-1', + intent: 'QUEEN_CHECK', + reasonCode: 'QUEENLESS_NO_EGGS', + evidence: { + colonyId: 'colony-1', + inspectionId: 'inspection-1', + inspectedOn: '2026-05-05', }, + anchor: 'inspection-1', + requiredPermission: 'inspections:read', + dueDate: null, }); }); - it('creates a task when queen not seen and no eggs present', async () => { - tx.automationRule.findMany.mockResolvedValue([queenlessRule]); + it('dates the suggestion from the inspection day when the rule has dueInDays', async () => { + withRules({ ...queenlessRule, config: { dueInDays: 3 } }); await service.evaluateInspectionIn(asTx(), queenlessInspection as never); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); - expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); + expect(propose).toHaveBeenCalledWith( + tx, + 'QUEENLESS_NO_EGGS', + expect.objectContaining({ dueDate: '2026-05-08' }), + ); }); it('does not trigger when the queen was seen', async () => { - tx.automationRule.findMany.mockResolvedValue([queenlessRule]); + withRules(queenlessRule); await service.evaluateInspectionIn(asTx(), { ...queenlessInspection, queenSeen: true, } as never); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); - it('triggers when feed status is CRITICAL', async () => { - tx.automationRule.findMany.mockResolvedValue([feedRule]); + it('proposes feeding when the feed status is CRITICAL', async () => { + withRules(feedRule); await service.evaluateInspectionIn(asTx(), { + ...queenlessInspection, id: 'inspection-3', - colonyId: 'colony-1', queenSeen: true, eggsPresent: true, feedStatus: 'CRITICAL', } as never); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); - const [[, input]] = tasks.createInTx.mock.calls as [ - [unknown, { sourceAutomationTrigger: string }], - ]; - expect(input.sourceAutomationTrigger).toBe('FEED_STATUS_CRITICAL'); + expect(propose).toHaveBeenCalledTimes(1); + expect(propose).toHaveBeenCalledWith( + tx, + 'FEED_STATUS_CRITICAL', + expect.objectContaining({ + intent: 'FEED', + reasonCode: 'FEED_STATUS_CRITICAL', + anchor: 'inspection-3', + requiredPermission: 'inspections:read', + }), + ); }); - it('does not trigger a deactivated rule', async () => { - tx.automationRule.findMany.mockResolvedValue([]); + it('proposes both findings of one inspection', async () => { + withRules(queenlessRule, feedRule); await service.evaluateInspectionIn(asTx(), { ...queenlessInspection, feedStatus: 'CRITICAL', } as never); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).toHaveBeenCalledTimes(2); + }); + + it('does not trigger a deactivated rule', async () => { + withRules( + { ...queenlessRule, active: false }, + { ...feedRule, active: false }, + ); + + await service.evaluateInspectionIn(asTx(), { + ...queenlessInspection, + feedStatus: 'CRITICAL', + } as never); + + expect(propose).not.toHaveBeenCalled(); + }); + + it('reads no rule when there is no finding', async () => { + await service.evaluateInspectionIn(asTx(), { + ...queenlessInspection, + queenSeen: true, + } as never); + + expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); }); }); describe('evaluateVarroaMeasurementIn', () => { - const naturalFall = (calculatedValue: number | null) => + let propose: jest.SpyInstance; + const measurement = ( + calculatedValue: number | null, + overrides: Record = {}, + ) => ({ id: 'measurement-1', colonyId: 'colony-1', + date: new Date('2026-08-10T00:00:00.000Z'), method: 'NATURAL_MITE_FALL', calculatedValue, miteCount: null, + ...overrides, }) as never; - it('creates a task when the calculated value meets the threshold', async () => { - tx.automationRule.findUnique.mockResolvedValue(varroaRule); - - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(12)); - - expect(tx.automationRule.findUnique).toHaveBeenCalledWith({ - where: { triggerType: 'VARROA_THRESHOLD' }, - }); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(varroaRule); }); - it('does not trigger when below the threshold', async () => { - tx.automationRule.findUnique.mockResolvedValue(varroaRule); + it('proposes a treatment check when the mite fall is red', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(12.4)); - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(5)); - - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).toHaveBeenCalledWith(tx, 'VARROA_THRESHOLD', { + relatedType: 'COLONY', + relatedId: 'colony-1', + colonyId: 'colony-1', + intent: 'VARROA_TREAT', + reasonCode: 'VARROA_RED', + evidence: { + colonyId: 'colony-1', + measurementId: 'measurement-1', + measuredOn: '2026-08-10', + value: 12.4, + redAbove: 10, + unit: 'MITES_PER_DAY', + }, + anchor: 'measurement-1', + requiredPermission: 'varroa:read', + dueDate: null, + }); }); - it('does not trigger a deactivated rule', async () => { - tx.automationRule.findUnique.mockResolvedValue({ - ...varroaRule, - active: false, - }); + it('10.0 mites per day is yellow and proposes nothing', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(10)); - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(50)); - - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); - // varroaThresholdOf is the one source of the default 10: a hand-edited - // config can never switch the check off or make every count trigger. - it.each([ - ['zero', 0], - ['negative', -3], - ['a string', '5'], - ])( - 'falls back to the default threshold 10 for a %s config value', - async (_label, thresholdMitesPerDay) => { - tx.automationRule.findUnique.mockResolvedValue({ - ...varroaRule, - config: { thresholdMitesPerDay }, - }); + it('proposes nothing for a deactivated rule', async () => { + withRules({ ...varroaRule, active: false }); - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(9)); - expect(tasks.createInTx).not.toHaveBeenCalled(); + await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(10)); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); - }, - ); + expect(propose).not.toHaveBeenCalled(); + }); - it('uses the default threshold 10 for a cleared config', async () => { - tx.automationRule.findUnique.mockResolvedValue({ - ...varroaRule, - config: null, + it('proposes nothing inside the post-treatment window', async () => { + tx.treatment.findMany.mockResolvedValue([ + { + date: new Date('2026-08-05T00:00:00.000Z'), + startDate: null, + endDate: null, + }, + ]); + + await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); + + expect(tx.treatment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { colonyId: 'colony-1' } }), + ); + expect(propose).not.toHaveBeenCalled(); + }); + + it('proposes again once the recheck day has passed', async () => { + tx.treatment.findMany.mockResolvedValue([ + { + date: new Date('2026-07-10T00:00:00.000Z'), + startDate: null, + endDate: null, + }, + ]); + + await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); + + expect(propose).toHaveBeenCalledTimes(1); + }); + + it('halves the limits of a young colony', async () => { + tx.colony.findUnique.mockResolvedValue({ + status: 'ACTIVE', + colonyType: 'YOUNG_COLONY', }); - await service.evaluateVarroaMeasurementIn(asTx(), naturalFall(10)); + await service.evaluateVarroaMeasurementIn(asTx(), measurement(6)); - expect(tasks.createInTx).toHaveBeenCalledTimes(1); + expect(propose).toHaveBeenCalledWith( + tx, + 'VARROA_THRESHOLD', + expect.objectContaining({ + evidence: expect.objectContaining({ redAbove: 5 }) as unknown, + }), + ); + }); + + it('rates a percentage method with its summer band', async () => { + await service.evaluateVarroaMeasurementIn( + asTx(), + measurement(6, { method: 'POWDERED_SUGAR' }), + ); + + expect(propose).toHaveBeenCalledWith( + tx, + 'VARROA_THRESHOLD', + expect.objectContaining({ + evidence: expect.objectContaining({ + value: 6, + redAbove: 5, + unit: 'PERCENT', + }) as unknown, + }), + ); + }); + + it('proposes nothing for a method without a band in that month', async () => { + await service.evaluateVarroaMeasurementIn( + asTx(), + measurement(30, { + method: 'WASHING_METHOD', + date: new Date('2026-04-10T00:00:00.000Z'), + }), + ); + + expect(propose).not.toHaveBeenCalled(); }); it('ignores a raw board count without a per-day rate', async () => { - // 15 mites over an unknown number of days says nothing about the - // per-day threshold - comparing them would be a unit error. - tx.automationRule.findUnique.mockResolvedValue(varroaRule); + await service.evaluateVarroaMeasurementIn( + asTx(), + measurement(null, { miteCount: 15 }), + ); - await service.evaluateVarroaMeasurementIn(asTx(), { - id: 'measurement-3', - colonyId: 'colony-1', - method: 'NATURAL_MITE_FALL', - calculatedValue: null, - miteCount: 15, - } as never); - - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); - it('ignores sample-based methods, whose values use another unit', async () => { - tx.automationRule.findUnique.mockResolvedValue(varroaRule); + it('proposes nothing for a colony that is gone', async () => { + tx.colony.findUnique.mockResolvedValue(null); - await service.evaluateVarroaMeasurementIn(asTx(), { - id: 'measurement-4', - colonyId: 'colony-1', - method: 'WASHING_METHOD', - calculatedValue: 30, - miteCount: 30, - } as never); + await service.evaluateVarroaMeasurementIn(asTx(), measurement(50)); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + }); + + describe('a corrected measurement with an open suggestion', () => { + beforeEach(() => { + tx.suggestion.findFirst.mockResolvedValue({ id: 'open-1' }); + }); + + it('looks for the open suggestion of this measurement by its dedupe key', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(12)); + + expect(tx.suggestion.findFirst).toHaveBeenCalledWith({ + where: { + ruleKey: 'VARROA_THRESHOLD', + status: 'OPEN', + dedupeKey: 'VARROA_THRESHOLD:colony-1:measurement-1', + }, + select: { id: true }, + }); + }); + + it('refreshes it while the corrected value is still red, and proposes nothing new', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(25)); + + expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'open-1', { + reasonCode: 'VARROA_RED', + evidence: expect.objectContaining({ + measurementId: 'measurement-1', + value: 25, + }) as unknown, + dueDate: null, + priority: 'HIGH', + expiresAt: null, + }); + expect(propose).not.toHaveBeenCalled(); + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + }); + + it('resolves it once the corrected value is no longer red', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(10)); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['open-1'], + 'Bedingung entfällt', + ); + expect(suggestions.refreshIn).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + }); + + it('resolves it when the corrected measurement has no value any more', async () => { + await service.evaluateVarroaMeasurementIn(asTx(), measurement(null)); + + expect(suggestions.resolveIn).toHaveBeenCalledTimes(1); + }); }); }); describe('evaluateSwarmControlCheckIn', () => { + let propose: jest.SpyInstance; const swarmRule: Rule = { id: 'rule-4', triggerType: 'SWARM_MOOD_HIGH', @@ -590,33 +840,50 @@ describe('AutomationService', () => { config: {}, actionTemplate: 'Schwarmkontrolle dringend', }; - - it('creates a task when swarm mood is HIGH', async () => { - tx.automationRule.findUnique.mockResolvedValue(swarmRule); - - await service.evaluateSwarmControlCheckIn(asTx(), { + const check = (swarmMood: string) => + ({ id: 'check-1', colonyId: 'colony-1', - swarmMood: 'HIGH', - } as never); + date: new Date('2026-05-05T00:00:00.000Z'), + swarmMood, + }) as never; - expect(tasks.createInTx).toHaveBeenCalledTimes(1); + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(swarmRule); }); - it('does not trigger for a low swarm mood', async () => { - tx.automationRule.findUnique.mockResolvedValue(swarmRule); + it('proposes a swarm check anchored on the check when the swarm mood is HIGH', async () => { + await service.evaluateSwarmControlCheckIn(asTx(), check('HIGH')); - await service.evaluateSwarmControlCheckIn(asTx(), { - id: 'check-2', + expect(propose).toHaveBeenCalledWith(tx, 'SWARM_MOOD_HIGH', { + relatedType: 'COLONY', + relatedId: 'colony-1', colonyId: 'colony-1', - swarmMood: 'LOW', - } as never); + intent: 'SWARM_CHECK', + reasonCode: 'SWARM_MOOD_HIGH', + evidence: { + colonyId: 'colony-1', + checkId: 'check-1', + checkedOn: '2026-05-05', + }, + anchor: 'check-1', + requiredPermission: 'swarm-control:read', + dueDate: null, + }); + }); - expect(tasks.createInTx).not.toHaveBeenCalled(); + it('does not trigger for a low swarm mood or an inactive rule', async () => { + await service.evaluateSwarmControlCheckIn(asTx(), check('LOW')); + withRules({ ...swarmRule, active: false }); + await service.evaluateSwarmControlCheckIn(asTx(), check('HIGH')); + + expect(propose).not.toHaveBeenCalled(); }); }); describe('evaluateColonyHealthIn', () => { + let propose: jest.SpyInstance; const healthRule: Rule = { id: 'rule-5', triggerType: 'COLONY_HEALTH_CRITICAL', @@ -626,9 +893,14 @@ describe('AutomationService', () => { }; const state = (healthStatus: string | null, status = 'ACTIVE') => ({ status, healthStatus }) as never; + const event = { + id: 'event-1', + occurredAt: new Date('2026-10-13T08:00:00.000Z'), + }; beforeEach(() => { - tx.automationRule.findUnique.mockResolvedValue(healthRule); + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(healthRule); }); it.each([ @@ -639,21 +911,22 @@ describe('AutomationService', () => { ['GOOD', 'AVERAGE', 0], ['GOOD', null, 0], ])( - 'colony-health fires only on the transition into CRITICAL (%s -> %s: %i task)', + 'colony-health fires only on the transition into CRITICAL (%s -> %s: %i suggestion)', async (from, to, expected) => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state(from), state(to), + event, ); - expect(tasks.createInTx).toHaveBeenCalledTimes(expected); + expect(propose).toHaveBeenCalledTimes(expected); }, ); // ColonyStatusChanged fires on every status or health change: without - // the transition the rule must cost nothing - no seeding write, no read. + // the transition the rule must cost nothing - no read at all. it.each([ ['CRITICAL', 'CRITICAL'], ['GOOD', 'AVERAGE'], @@ -666,6 +939,7 @@ describe('AutomationService', () => { 'colony-1', state(from), state(to), + event, ); expect(tx.automationRule.createMany).not.toHaveBeenCalled(); @@ -680,31 +954,52 @@ describe('AutomationService', () => { 'colony-1', state('CRITICAL', 'ACTIVE'), state('CRITICAL', 'QUEENLESS'), + event, ); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); - it('hangs the task on the colony', async () => { + it('hangs the suggestion on the colony and anchors it on the event', async () => { await service.evaluateColonyHealthIn( asTx(), 'colony-1', state('GOOD'), state('CRITICAL'), + event, ); - const [[, input]] = tasks.createInTx.mock.calls as [ - [unknown, { relatedType: string; relatedId: string }], - ]; - expect(input).toMatchObject({ + expect(propose).toHaveBeenCalledWith(tx, 'COLONY_HEALTH_CRITICAL', { relatedType: 'COLONY', relatedId: 'colony-1', - sourceAutomationTrigger: 'COLONY_HEALTH_CRITICAL', + colonyId: 'colony-1', + intent: 'HEALTH_CHECK', + reasonCode: 'COLONY_HEALTH_CRITICAL', + evidence: { colonyId: 'colony-1', changedOn: '2026-10-13' }, + anchor: 'event-1', + requiredPermission: 'colonies:read', + dueDate: null, + sourceEventId: 'event-1', }); }); + + it('does not trigger an inactive rule', async () => { + withRules({ ...healthRule, active: false }); + + await service.evaluateColonyHealthIn( + asTx(), + 'colony-1', + state('GOOD'), + state('CRITICAL'), + event, + ); + + expect(propose).not.toHaveBeenCalled(); + }); }); describe('evaluateStockItemIn', () => { + let propose: jest.SpyInstance; const stockRule: Rule = { id: 'rule-6', triggerType: 'STOCK_ITEM_LOW', @@ -713,8 +1008,13 @@ describe('AutomationService', () => { actionTemplate: 'Nachbestellung prüfen', }; - it('creates a task when quantity drops below minThreshold', async () => { - tx.automationRule.findUnique.mockResolvedValue(stockRule); + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(stockRule); + }); + + it('the stock rule anchors on the newest IN movement', async () => { + tx.stockMovement.findFirst.mockResolvedValue({ id: 'movement-9' }); await service.evaluateStockItemIn(asTx(), { id: 'item-1', @@ -722,25 +1022,69 @@ describe('AutomationService', () => { minThreshold: 5, } as never); - const [[, input]] = tasks.createInTx.mock.calls as [ - [unknown, { relatedType: string; relatedId: string }], - ]; - expect(input).toMatchObject({ + expect(tx.stockMovement.findFirst).toHaveBeenCalledWith({ + where: { stockItemId: 'item-1', type: 'IN' }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + select: { id: true }, + }); + expect(propose).toHaveBeenCalledWith(tx, 'STOCK_ITEM_LOW', { relatedType: 'STORAGE', relatedId: 'item-1', + colonyId: null, + intent: 'STOCK_REORDER', + reasonCode: 'STOCK_ITEM_LOW', + evidence: { stockItemId: 'item-1', quantity: 2, minThreshold: 5 }, + anchor: 'movement-9', + requiredPermission: 'storage:read', + dueDate: null, }); }); - it('does not trigger when minThreshold is not set', async () => { - tx.automationRule.findUnique.mockResolvedValue(stockRule); + it('dates the reorder from today when the rule has dueInDays', async () => { + jest.useFakeTimers({ now: new Date('2026-10-14T10:00:00.000Z') }); + try { + withRules({ ...stockRule, config: { dueInDays: 2 } }); + await service.evaluateStockItemIn(asTx(), { + id: 'item-1', + quantity: 2, + minThreshold: 5, + } as never); + + expect(propose).toHaveBeenCalledWith( + tx, + 'STOCK_ITEM_LOW', + expect.objectContaining({ dueDate: '2026-10-16' }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('anchors on "none" for an item that was never restocked', async () => { await service.evaluateStockItemIn(asTx(), { - id: 'item-2', + id: 'item-1', quantity: 2, - minThreshold: null, + minThreshold: 5, } as never); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).toHaveBeenCalledWith( + tx, + 'STOCK_ITEM_LOW', + expect.objectContaining({ anchor: 'none' }), + ); + }); + + it('does not trigger an inactive rule', async () => { + withRules({ ...stockRule, active: false }); + + await service.evaluateStockItemIn(asTx(), { + id: 'item-1', + quantity: 2, + minThreshold: 5, + } as never); + + expect(propose).not.toHaveBeenCalled(); }); // Every stock movement evaluates its item: while it is not below its @@ -760,12 +1104,14 @@ describe('AutomationService', () => { expect(tx.automationRule.createMany).not.toHaveBeenCalled(); expect(tx.automationRule.findUnique).not.toHaveBeenCalled(); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(tx.stockMovement.findFirst).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }, ); }); describe('evaluateRawMaterialLowIn', () => { + let propose: jest.SpyInstance; const rawRule: Rule = { id: 'rule-7', triggerType: 'RAW_MATERIAL_LOW', @@ -776,49 +1122,95 @@ describe('AutomationService', () => { const evaluate = (kind: RawMaterialKind, grams: number) => service.evaluateRawMaterialLowIn(asTx(), kind, grams); - it('creates one task per kind when the kind drops below its minimum', async () => { - tx.automationRule.findUnique.mockResolvedValue(rawRule); + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(rawRule); + }); + + it('proposes one reorder per kind, anchored on the newest lot of the kind', async () => { + tx.rawMaterialLot.findFirst.mockResolvedValue({ id: 'lot-3' }); await evaluate('BEESWAX', 400); - const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; - expect(input).toMatchObject({ + expect(tx.rawMaterialLot.findFirst).toHaveBeenCalledWith({ + where: { kind: 'BEESWAX' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: { id: true }, + }); + expect(propose).toHaveBeenCalledWith(tx, 'RAW_MATERIAL_LOW', { relatedType: 'GENERAL', relatedId: 'BEESWAX', - sourceAutomationTrigger: 'RAW_MATERIAL_LOW', - description: 'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.', + colonyId: null, + intent: 'STOCK_REORDER', + reasonCode: 'RAW_MATERIAL_LOW', + evidence: { kind: 'BEESWAX', remainingGrams: 400, minGrams: 1000 }, + anchor: 'lot-3', + requiredPermission: 'raw-material:read', + dueDate: null, }); }); + it('dates the reorder from today when the rule has dueInDays', async () => { + jest.useFakeTimers({ now: new Date('2026-10-14T10:00:00.000Z') }); + try { + withRules({ + ...rawRule, + config: { ...(rawRule.config as object), dueInDays: 3 }, + }); + + await evaluate('BEESWAX', 0); + + expect(propose).toHaveBeenCalledWith( + tx, + 'RAW_MATERIAL_LOW', + expect.objectContaining({ dueDate: '2026-10-17' }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('anchors on "none" while the kind has no lot', async () => { + await evaluate('BEESWAX', 0); + + expect(propose).toHaveBeenCalledWith( + tx, + 'RAW_MATERIAL_LOW', + expect.objectContaining({ anchor: 'none' }), + ); + }); + it('does not trigger at or above the minimum', async () => { - tx.automationRule.findUnique.mockResolvedValue(rawRule); - await evaluate('BEESWAX', 1000); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); it('does not trigger for a kind without a minimum', async () => { - tx.automationRule.findUnique.mockResolvedValue(rawRule); - await evaluate('PROPOLIS', 0); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); }); it('does not trigger when the rule is inactive', async () => { - tx.automationRule.findUnique.mockResolvedValue({ - ...rawRule, - active: false, - }); + withRules({ ...rawRule, active: false }); await evaluate('BEESWAX', 0); - expect(tasks.createInTx).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + }); + + it('is off by default: without a rule row nothing is proposed', async () => { + rules.clear(); + + await evaluate('BEESWAX', 0); + + expect(propose).not.toHaveBeenCalled(); }); }); describe('follow-up checks', () => { + let propose: jest.SpyInstance; const followUpRule = (triggerType: string): Rule => ({ id: 'rule-9', triggerType, @@ -827,10 +1219,12 @@ describe('AutomationService', () => { actionTemplate: 'Kontrollieren', }); + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + }); + it('evaluateTreatmentIn dates the check from the end of the treatment', async () => { - tx.automationRule.findUnique.mockResolvedValue( - followUpRule('TREATMENT_FOLLOW_UP'), - ); + withRules(followUpRule('TREATMENT_FOLLOW_UP')); await service.evaluateTreatmentIn(asTx(), { id: 'treatment-1', @@ -840,23 +1234,42 @@ describe('AutomationService', () => { product: 'Ameisensäure', } as never); - expect(tx.automationRule.findUnique).toHaveBeenCalledWith({ - where: { triggerType: 'TREATMENT_FOLLOW_UP' }, - }); - const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; - expect(input).toMatchObject({ + expect(propose).toHaveBeenCalledWith(tx, 'TREATMENT_FOLLOW_UP', { relatedType: 'COLONY', relatedId: 'colony-1', - sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP', - dueDate: new Date('2026-08-24T00:00:00.000Z'), - description: 'Nach Behandlung mit Ameisensäure am 10.08.2026.', + colonyId: 'colony-1', + intent: 'FOLLOW_UP', + reasonCode: 'TREATMENT_FOLLOW_UP', + evidence: { + colonyId: 'colony-1', + treatmentId: 'treatment-1', + endedOn: '2026-08-10', + }, + anchor: 'treatment-1', + requiredPermission: 'treatments:read', + dueDate: '2026-08-24', }); }); + it('evaluateTreatmentIn counts from the treatment day without an end date', async () => { + withRules(followUpRule('TREATMENT_FOLLOW_UP')); + + await service.evaluateTreatmentIn(asTx(), { + id: 'treatment-1', + colonyId: 'colony-1', + date: new Date('2026-08-01T00:00:00.000Z'), + endDate: null, + } as never); + + expect(propose).toHaveBeenCalledWith( + tx, + 'TREATMENT_FOLLOW_UP', + expect.objectContaining({ dueDate: '2026-08-15' }), + ); + }); + it('evaluateFeedingIn dates the check from the feeding day', async () => { - tx.automationRule.findUnique.mockResolvedValue( - followUpRule('FEEDING_FOLLOW_UP'), - ); + withRules(followUpRule('FEEDING_FOLLOW_UP')); await service.evaluateFeedingIn(asTx(), { id: 'feeding-1', @@ -866,29 +1279,38 @@ describe('AutomationService', () => { unit: 'l', } as never); - const [[, input]] = tasks.createInTx.mock.calls as [[unknown, unknown]]; - expect(input).toMatchObject({ - sourceAutomationTrigger: 'FEEDING_FOLLOW_UP', - dueDate: new Date('2026-09-15T00:00:00.000Z'), - description: 'Nach Fütterung (5 l) am 01.09.2026.', + expect(propose).toHaveBeenCalledWith(tx, 'FEEDING_FOLLOW_UP', { + relatedType: 'COLONY', + relatedId: 'colony-1', + colonyId: 'colony-1', + intent: 'FOLLOW_UP', + reasonCode: 'FEEDING_FOLLOW_UP', + evidence: { + colonyId: 'colony-1', + feedingId: 'feeding-1', + fedOn: '2026-09-01', + }, + anchor: 'feeding-1', + requiredPermission: 'feedings:read', + dueDate: '2026-09-15', }); }); - it('creates no follow-up while the rule is off', async () => { - tx.automationRule.findUnique.mockResolvedValue({ - ...followUpRule('FEEDING_FOLLOW_UP'), - active: false, - }); - - await service.evaluateFeedingIn(asTx(), { + it('proposes no follow-up while the rule is off (the default)', async () => { + withRules({ ...followUpRule('FEEDING_FOLLOW_UP'), active: false }); + const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date('2026-09-01T00:00:00.000Z'), amount: 5, unit: 'l', - } as never); + } as never; - expect(tasks.createInTx).not.toHaveBeenCalled(); + await service.evaluateFeedingIn(asTx(), feeding); + rules.clear(); + await service.evaluateFeedingIn(asTx(), feeding); + + expect(propose).not.toHaveBeenCalled(); }); const rootClient = () => prisma as unknown as Prisma.TransactionClient; diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 6ac6f55..75bbaaf 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -9,9 +9,16 @@ 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'; -import { NotificationsService } from '../notifications/notifications.service'; -import { TasksService } from '../tasks/tasks.service'; +import { addDays, berlinDayOf, berlinToday } from '../common/dates'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import type { PermissionKey } from '../auth/permissions/permission-catalog'; import type { ColonyStateSnapshot } from '../domain-events/domain-event-types'; +import { SuggestionsService } from '../suggestions/suggestions.service'; +import { + suggestionDedupeKey, + type SuggestionEvidence, +} from '../suggestions/suggestion.types'; +import { treatmentWindowsIn } from '../treatments/treatment-windows'; import { AutomationRule, AutomationTriggerType, @@ -20,6 +27,8 @@ import { Prisma, RawMaterialKind, StockItem, + Suggestion, + SuggestionIntent, SwarmControlCheck, TaskRelatedType, Treatment, @@ -27,8 +36,13 @@ 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'; +import { + DEFAULT_RULES, + lockRuleIn, + readRule, + validateRuleConfig, +} from './rule-config'; +import { rateMeasurement } from './varroa-bands'; type Tx = Prisma.TransactionClient; @@ -39,11 +53,6 @@ export type NumberFormatTrigger = | 'RAW_MATERIAL_LOT_NUMBER_FORMAT' | 'PRODUCTION_BATCH_NUMBER_FORMAT'; -const RAW_MATERIAL_KIND_LABELS: Record = { - BEESWAX: 'Bienenwachs', - PROPOLIS: 'Propolis', -}; - /** What {ART} turns into in the raw material number format. */ export const RAW_MATERIAL_KIND_CODES: Record = { BEESWAX: 'WA', @@ -63,37 +72,44 @@ function isUniqueViolation(error: unknown): boolean { ); } -/** - * 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 - * look depends on season and practice, so the rule stays date-less unless the - * beekeeper configures one. - */ -function formatDay(date: Date): string { - return date.toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - timeZone: 'UTC', - }); +/** The rules that propose instead of creating anything themselves. */ +export type RuleSuggestionTrigger = + | 'QUEENLESS_NO_EGGS' + | 'FEED_STATUS_CRITICAL' + | 'VARROA_THRESHOLD' + | 'SWARM_MOOD_HIGH' + | 'COLONY_HEALTH_CRITICAL' + | 'STOCK_ITEM_LOW' + | 'RAW_MATERIAL_LOW' + | 'TREATMENT_FOLLOW_UP' + | 'FEEDING_FOLLOW_UP'; + +export interface RuleSuggestionTarget { + relatedType: TaskRelatedType; + relatedId: string; + colonyId: string | null; + intent: SuggestionIntent; + reasonCode: string; + evidence: SuggestionEvidence; + anchor: string; + requiredPermission: PermissionKey; + dueDate?: string | null; + sourceEventId?: string | null; } -function dueDateFromConfig( - dueInDays: unknown, - baseDate: Date = new Date(), -): Date | undefined { - if ( - typeof dueInDays !== 'number' || - !Number.isInteger(dueInDays) || - dueInDays < 0 || - dueInDays > 365 - ) { - return undefined; - } - const due = new Date(baseDate); - due.setUTCHours(0, 0, 0, 0); - due.setUTCDate(due.getUTCDate() + dueInDays); - return due; +/** The anchor of a finding that has no record to hang on. */ +const NO_ANCHOR = 'none'; + +/** + * Optional per-rule follow-up interval (config.dueInDays) counted from the + * business day. There is no built-in default: how soon a queenless colony or + * a high mite fall needs a look depends on season and practice, so the + * suggestion stays date-less unless the beekeeper configures one (accepting + * it then creates a task due today). + */ +function dueDayOf(config: object, baseDay: string): string | null { + const { dueInDays } = config as { dueInDays?: unknown }; + return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null; } @Injectable() @@ -103,8 +119,7 @@ export class AutomationService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, - private readonly notifications: NotificationsService, - private readonly tasks: TasksService, + private readonly suggestions: SuggestionsService, ) {} /** @@ -216,42 +231,58 @@ export class AutomationService { async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise { assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn'); - await this.ensureDefaults(tx); - const rules = await tx.automationRule.findMany({ - where: { - triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] }, - active: true, - }, - }); - const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule])); - - const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS'); - if ( - queenlessRule && - inspection.queenSeen === false && - inspection.eggsPresent === false - ) { - await this.triggerActionIn( + if (inspection.queenSeen === false && inspection.eggsPresent === false) { + await this.proposeOnInspectionIn( tx, - queenlessRule, - 'COLONY', - inspection.colonyId, - inspection.id, + 'QUEENLESS_NO_EGGS', + 'QUEEN_CHECK', + inspection, ); } - - const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); - if (feedRule && inspection.feedStatus === 'CRITICAL') { - await this.triggerActionIn( + if (inspection.feedStatus === 'CRITICAL') { + await this.proposeOnInspectionIn( tx, - feedRule, - 'COLONY', - inspection.colonyId, - inspection.id, + 'FEED_STATUS_CRITICAL', + 'FEED', + inspection, ); } } + /** The reason code of an inspection rule is the rule's own name. */ + private async proposeOnInspectionIn( + tx: Tx, + trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL', + intent: SuggestionIntent, + inspection: Inspection, + ): Promise { + const rule = await readRule(tx, trigger); + if (!rule.active) { + return; + } + const inspectedOn = berlinDayOf(inspection.date); + await this.proposeForRuleIn(tx, trigger, { + relatedType: 'COLONY', + relatedId: inspection.colonyId, + colonyId: inspection.colonyId, + intent, + reasonCode: trigger, + evidence: { + colonyId: inspection.colonyId, + inspectionId: inspection.id, + inspectedOn, + }, + anchor: inspection.id, + requiredPermission: 'inspections:read', + dueDate: dueDayOf(rule.config, inspectedOn), + }); + } + + /** + * A red measurement outside the effect of a treatment proposes a + * treatment check. The light comes from the same bands as the colony view + * (season, young colony, method), so rule and screen never disagree. + */ async evaluateVarroaMeasurementIn( tx: Tx, measurement: VarroaMeasurement, @@ -260,26 +291,78 @@ export class AutomationService { tx, 'AutomationService.evaluateVarroaMeasurementIn', ); - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType: 'VARROA_THRESHOLD' }, + const rule = await readRule(tx, 'VARROA_THRESHOLD'); + if (!rule.active) { + return; + } + const colony = await tx.colony.findUnique({ + where: { id: measurement.colonyId }, + select: { colonyType: true }, }); - // null for an inactive rule; the one source of the default value. - const threshold = varroaThresholdOf(rule); - if (!rule || threshold === null) { + if (colony === null) { return; } - // The threshold is a natural mite fall rate (mites per day). Only that - // method yields a value in this unit - a mite count from a sugar roll or - // wash is per bee sample and would be compared against the wrong scale, - // as would a raw board count without the number of days it covered. - if (measurement.method !== 'NATURAL_MITE_FALL') { + const rating = rateMeasurement( + measurement, + colony.colonyType, + rule.config, + await treatmentWindowsIn(tx, measurement.colonyId), + ); + // A correction can move a measurement out of red (or into it): an open + // suggestion of this measurement follows the corrected value. + const open = await tx.suggestion.findFirst({ + where: { + ruleKey: 'VARROA_THRESHOLD', + status: 'OPEN', + dedupeKey: suggestionDedupeKey( + 'VARROA_THRESHOLD', + measurement.colonyId, + measurement.id, + ), + }, + select: { id: true }, + }); + if ( + rating.light !== 'red' || + rating.band === null || + measurement.calculatedValue === null + ) { + if (open !== null) { + await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt'); + } return; } - const value = measurement.calculatedValue; - if (value !== null && value >= threshold) { - await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId); + const measuredOn = berlinDayOf(measurement.date); + const evidence = { + colonyId: measurement.colonyId, + measurementId: measurement.id, + measuredOn, + value: measurement.calculatedValue, + redAbove: rating.band.redAbove, + unit: rating.band.unit, + }; + const dueDate = dueDayOf(rule.config, measuredOn); + if (open !== null) { + await this.suggestions.refreshIn(tx, open.id, { + reasonCode: 'VARROA_RED', + evidence, + dueDate, + priority: 'HIGH', + expiresAt: null, + }); + return; } + await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', { + relatedType: 'COLONY', + relatedId: measurement.colonyId, + colonyId: measurement.colonyId, + intent: 'VARROA_TREAT', + reasonCode: 'VARROA_RED', + evidence, + anchor: measurement.id, + requiredPermission: 'varroa:read', + dueDate, + }); } async evaluateSwarmControlCheckIn( @@ -290,24 +373,38 @@ export class AutomationService { tx, 'AutomationService.evaluateSwarmControlCheckIn', ); - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType: 'SWARM_MOOD_HIGH' }, - }); - if (!rule || !rule.active) { + if (check.swarmMood !== 'HIGH') { return; } - if (check.swarmMood === 'HIGH') { - await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId); + const rule = await readRule(tx, 'SWARM_MOOD_HIGH'); + if (!rule.active) { + return; } + const checkedOn = berlinDayOf(check.date); + await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', { + relatedType: 'COLONY', + relatedId: check.colonyId, + colonyId: check.colonyId, + intent: 'SWARM_CHECK', + reasonCode: 'SWARM_MOOD_HIGH', + evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn }, + anchor: check.id, + requiredPermission: 'swarm-control:read', + dueDate: dueDayOf(rule.config, checkedOn), + }); } - /** Fires only on the transition into CRITICAL, not on every edit while already critical. */ + /** + * Fires only on the transition into CRITICAL, not on every edit while + * already critical. The event is the anchor: a later transition into + * CRITICAL is a new finding. + */ async evaluateColonyHealthIn( tx: Tx, colonyId: string, from: ColonyStateSnapshot, to: ColonyStateSnapshot, + event: { id: string; occurredAt: Date }, ): Promise { assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn'); // ColonyStatusChanged comes with every status or health edit: decide on @@ -315,16 +412,29 @@ export class AutomationService { if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') { return; } - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, - }); - if (!rule || !rule.active) { + const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL'); + if (!rule.active) { return; } - await this.triggerActionIn(tx, rule, 'COLONY', colonyId); + const changedOn = berlinDayOf(event.occurredAt); + await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', { + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + intent: 'HEALTH_CHECK', + reasonCode: 'COLONY_HEALTH_CRITICAL', + evidence: { colonyId, changedOn }, + anchor: event.id, + requiredPermission: 'colonies:read', + dueDate: dueDayOf(rule.config, changedOn), + sourceEventId: event.id, + }); } + /** + * Anchored on the newest incoming movement: a dismissed reorder stays + * dismissed until the item was restocked and ran low again. + */ async evaluateStockItemIn(tx: Tx, item: StockItem): Promise { assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn'); // Every movement of the item comes here: only one below its minimum @@ -332,22 +442,39 @@ export class AutomationService { if (item.minThreshold === null || item.quantity >= item.minThreshold) { return; } - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType: 'STOCK_ITEM_LOW' }, - }); - if (!rule || !rule.active) { + const rule = await readRule(tx, 'STOCK_ITEM_LOW'); + if (!rule.active) { return; } - await this.triggerActionIn(tx, rule, 'STORAGE', item.id); + const restock = await tx.stockMovement.findFirst({ + where: { stockItemId: item.id, type: 'IN' }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + select: { id: true }, + }); + await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', { + relatedType: 'STORAGE', + relatedId: item.id, + colonyId: null, + intent: 'STOCK_REORDER', + reasonCode: 'STOCK_ITEM_LOW', + evidence: { + stockItemId: item.id, + quantity: item.quantity, + minThreshold: item.minThreshold, + }, + anchor: restock?.id ?? NO_ANCHOR, + requiredPermission: 'storage:read', + dueDate: dueDayOf(rule.config, berlinToday()), + }); } /** * After raw material left its lots (consumption, withdrawal, inventory): * compares what is left of the kind across all lots with config.minGrams. - * The task is about the kind, not one lot - a new lot of the same kind is - * the remedy - so it hangs on GENERAL with the kind as relatedId, which - * keeps one open task per kind. + * The suggestion is about the kind, not one lot - a new lot of the same + * kind is the remedy - so it hangs on GENERAL with the kind as relatedId, + * which keeps one open suggestion per kind, and is anchored on the newest + * lot of the kind. */ async evaluateRawMaterialLowIn( tx: Tx, @@ -355,69 +482,73 @@ export class AutomationService { remainingGrams: number, ): Promise { assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn'); - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType: 'RAW_MATERIAL_LOW' }, - }); - if (!rule || !rule.active) { + const rule = await readRule(tx, 'RAW_MATERIAL_LOW'); + if (!rule.active) { return; } - const config = rule.config as { - minGrams?: Partial>; - } | null; - const minGrams = config?.minGrams?.[kind]; + const minGrams = rule.config.minGrams?.[kind]; if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { return; } - await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, { - description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, + const newestLot = await tx.rawMaterialLot.findFirst({ + where: { kind }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: { id: true }, + }); + await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', { + relatedType: 'GENERAL', + relatedId: kind, + colonyId: null, + intent: 'STOCK_REORDER', + reasonCode: 'RAW_MATERIAL_LOW', + evidence: { kind, remainingGrams, minGrams }, + anchor: newestLot?.id ?? NO_ANCHOR, + requiredPermission: 'raw-material:read', + dueDate: dueDayOf(rule.config, berlinToday()), }); } async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise { assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn'); - const measureDate = treatment.endDate ?? treatment.date; - await this.evaluateFollowUpIn( - tx, - 'TREATMENT_FOLLOW_UP', - treatment.colonyId, - measureDate, - `Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`, - ); + const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP'); + if (!rule.active) { + return; + } + const endedOn = berlinDayOf(treatment.endDate ?? treatment.date); + await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', { + relatedType: 'COLONY', + relatedId: treatment.colonyId, + colonyId: treatment.colonyId, + intent: 'FOLLOW_UP', + reasonCode: 'TREATMENT_FOLLOW_UP', + evidence: { + colonyId: treatment.colonyId, + treatmentId: treatment.id, + endedOn, + }, + anchor: treatment.id, + requiredPermission: 'treatments:read', + dueDate: dueDayOf(rule.config, endedOn), + }); } async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise { assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn'); - await this.evaluateFollowUpIn( - tx, - '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. Its public - * callers check the transaction client, so an error names them. - */ - private async evaluateFollowUpIn( - tx: Tx, - triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP', - colonyId: string, - measureDate: Date, - description: string, - ): Promise { - await this.ensureDefaults(tx); - const rule = await tx.automationRule.findUnique({ - where: { triggerType }, - }); - if (!rule || !rule.active) { + const rule = await readRule(tx, 'FEEDING_FOLLOW_UP'); + if (!rule.active) { return; } - await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, { - baseDate: measureDate, - description, + const fedOn = berlinDayOf(feeding.date); + await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', { + relatedType: 'COLONY', + relatedId: feeding.colonyId, + colonyId: feeding.colonyId, + intent: 'FOLLOW_UP', + reasonCode: 'FEEDING_FOLLOW_UP', + evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn }, + anchor: feeding.id, + requiredPermission: 'feedings:read', + dueDate: dueDayOf(rule.config, fedOn), }); } @@ -547,84 +678,80 @@ export class AutomationService { } /** - * Leaves a suggestion task (never a record of work done) plus a warning, - * all on the caller's transaction: task, its audit entry and TaskCreated - * (TasksService.createInTx - the task.created webhook and the calendar - * entry follow from that event), the warning and the rule's audit entry. - * A failing write rolls back all of it; a handler delivery is retried. + * Proposes what a rule found, on the caller's transaction: the suggestion + * (SuggestionsService.proposeIn: colony then rule lock, one open thing per + * intent and object, an anchor is proposed once, the notification when the + * rule says `notify`) and the rule's audit entry. A failing write rolls + * back all of it; a handler delivery is retried. Resolves to null when + * nothing was proposed (rule off, colony ended, anchor or intent taken). * - * At most one open task per rule and object: while the reorder task for a - * stock item or the check task for a colony is still open, further triggers - * (the next removal below minimum, a second high varroa count) add nothing. - * Repeating the same alarm would only bury the one task that matters. - * - * The check runs under a lock on the rule's row, held until the caller - * commits: two evaluations at the same moment (parallel requests, a - * handler and a legacy path, several pods) would otherwise both see - * "no open task" and both create one. + * The colony is locked and read before the rule is locked - the order + * every proposal uses - so a colony that ends in between is not missed. */ - private async triggerActionIn( + async proposeForRuleIn( tx: Tx, - rule: AutomationRule, - relatedType: TaskRelatedType, - relatedId: string, - sourceInspectionId?: string, - options: { baseDate?: Date; description?: string } = {}, - ): Promise { - await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`; - const alreadyOpen = await tx.task.findFirst({ - where: { - sourceAutomationTrigger: rule.triggerType, - relatedType, - relatedId, - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, - select: { id: true }, + trigger: RuleSuggestionTrigger, + target: RuleSuggestionTarget, + ): Promise { + assertTransactionClient(tx, 'AutomationService.proposeForRuleIn'); + if ( + target.colonyId !== null && + !(await this.isLivingColonyLockedIn(tx, target.colonyId)) + ) { + return null; + } + const rule = await lockRuleIn(tx, trigger); + if (!rule.active) { + return null; + } + const suggestion = await this.suggestions.proposeIn(tx, { + ruleKey: trigger, + source: 'RULE', + mode: 'PLAN', + intent: target.intent, + priority: 'HIGH', + relatedType: target.relatedType, + relatedId: target.relatedId, + colonyId: target.colonyId, + reasonCode: target.reasonCode, + evidence: target.evidence, + requiredPermission: target.requiredPermission, + dueDate: target.dueDate ?? null, + anchor: target.anchor, + sourceEventId: target.sourceEventId ?? null, + notify: rule.config.notify, }); - if (alreadyOpen) { - return; + if (suggestion === null) { + return null; } - const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; - const config = rule.config as { dueInDays?: unknown } | null; - const task = await this.tasks.createInTx( - tx, - { - title, - priority: 'HIGH', - relatedType, - relatedId, - sourceInspectionId, - sourceAutomationTrigger: rule.triggerType, - description: options.description, - dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate), - }, - null, - ); - // Task 7 replaces this path by suggestions. - await this.notifications.recordForPermission( - 'tasks:read', - { - priority: 'WARNUNG', - category: 'SUGGESTION', - message: title, - relatedType, - relatedId, - }, - tx, - ); await this.auditLog.record( { entityType: 'AutomationRule', entityId: rule.id, action: 'CREATE', afterData: { - taskId: task.id, - relatedType, - relatedId, - triggerType: rule.triggerType, + suggestionId: suggestion.id, + relatedType: target.relatedType, + relatedId: target.relatedId, + triggerType: trigger, }, }, tx, ); + return suggestion; + } + + private async isLivingColonyLockedIn( + tx: Tx, + colonyId: string, + ): Promise { + if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) { + return false; + } + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { status: true }, + }); + return colony !== null && isLivingColonyStatus(colony.status); } } diff --git a/ApiCore-Backend/src/automation/rule-config/simple.config.ts b/ApiCore-Backend/src/automation/rule-config/simple.config.ts index 33d514c..a83f338 100644 --- a/ApiCore-Backend/src/automation/rule-config/simple.config.ts +++ b/ApiCore-Backend/src/automation/rule-config/simple.config.ts @@ -29,7 +29,7 @@ export interface SimpleRuleOptions { } /** - * Rules that create one task: notify, an optional follow-up interval + * Rules that propose one suggestion: notify, an optional follow-up interval * (dueInDays) and, for the raw material rule, minGrams per kind. */ export function simpleParser( diff --git a/ApiCore-Backend/src/automation/varroa-threshold.spec.ts b/ApiCore-Backend/src/automation/varroa-threshold.spec.ts deleted file mode 100644 index 808afc2..0000000 --- a/ApiCore-Backend/src/automation/varroa-threshold.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, - varroaThresholdOf, -} from './varroa-threshold'; - -describe('varroaThresholdOf', () => { - it('uses the default when no rule exists yet (default rule is active)', () => { - expect(varroaThresholdOf(null)).toBe( - DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, - ); - expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10); - }); - - it('returns null for an inactive rule', () => { - expect( - varroaThresholdOf({ - active: false, - config: { thresholdMitesPerDay: 7 }, - }), - ).toBeNull(); - }); - - it('returns the configured threshold of an active rule', () => { - expect( - varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }), - ).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 }], - ['a negative number', { thresholdMitesPerDay: -1 }], - ['NaN', { thresholdMitesPerDay: Number.NaN }], - ['a missing key', {}], - ['a null config', null], - ])('falls back to the default for %s', (_label, config) => { - expect(varroaThresholdOf({ active: true, config })).toBe( - DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY, - ); - }); -}); diff --git a/ApiCore-Backend/src/automation/varroa-threshold.ts b/ApiCore-Backend/src/automation/varroa-threshold.ts deleted file mode 100644 index 2299b40..0000000 --- a/ApiCore-Backend/src/automation/varroa-threshold.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 = - DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove; - -/** - * 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 usable - * (a hand-edited config must never disable the check). - */ -export function varroaThresholdOf( - rule: Pick | null, -): number | null { - if (rule === null) { - return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY; - } - if (!rule.active) { - return null; - } - return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove; -} diff --git a/ApiCore-Backend/src/autopilot/autopilot-input.spec.ts b/ApiCore-Backend/src/autopilot/autopilot-input.spec.ts new file mode 100644 index 0000000..a37e9b4 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot-input.spec.ts @@ -0,0 +1,90 @@ +import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config'; +import { calendarSeasonState } from '../season/season-calendar'; +import { loadAutopilotInputIn } from './autopilot-input'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); +const season = calendarSeasonState( + 'api-1', + '2026-08-20', + ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config), +); + +function tx(treatments: object[], varroaCount = 0) { + return { + colony: { + findUnique: jest.fn().mockResolvedValue({ + id: 'c1', + code: 'B3', + status: 'ACTIVE', + colonyType: 'PRODUCTION_COLONY', + apiaryId: 'api-1', + createdAt: d('2026-04-01'), + feedStatus: null, + currentQueen: null, + }), + }, + inspection: { findFirst: jest.fn().mockResolvedValue(null) }, + varroaMeasurement: { + findFirst: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(varroaCount), + }, + treatment: { + findMany: jest.fn().mockResolvedValue(treatments), + count: jest.fn().mockResolvedValue(0), + }, + feeding: { count: jest.fn().mockResolvedValue(0) }, + winterCheck: { findMany: jest.fn().mockResolvedValue([]) }, + }; +} + +describe('loadAutopilotInputIn', () => { + it('takes the treatment with the latest end day, not the latest date', async () => { + const db = tx([ + { + id: 'old', + date: d('2026-07-01'), + startDate: null, + endDate: d('2026-08-10'), + }, + { + id: 'backdated', + date: d('2026-07-20'), + startDate: null, + endDate: null, + }, + { id: 'single', date: d('2026-08-01'), startDate: null, endDate: null }, + ]); + + const input = await loadAutopilotInputIn(db as never, 'c1', season); + + expect(input?.lastTreatment).toEqual({ + id: 'old', + startDay: '2026-07-01', + endDay: '2026-08-10', + }); + }); + + it('counts only measurements after the end day', async () => { + const db = tx([ + { + id: 't', + date: d('2026-08-01'), + startDate: null, + endDate: d('2026-08-10'), + }, + ]); + + await loadAutopilotInputIn(db as never, 'c1', season); + + expect(db.varroaMeasurement.count).toHaveBeenCalledWith({ + where: { colonyId: 'c1', date: { gt: d('2026-08-10') } }, + }); + }); + + it('has no last treatment without treatments', async () => { + const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season); + + expect(input?.lastTreatment).toBeNull(); + expect(input?.varroaSinceLastTreatmentEnd).toBe(false); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/autopilot-input.ts b/ApiCore-Backend/src/autopilot/autopilot-input.ts new file mode 100644 index 0000000..a27c9ec --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot-input.ts @@ -0,0 +1,139 @@ +import type { Prisma } from '../../generated/prisma'; +import { berlinDayOf, dayToDate } from '../common/dates'; +import type { SeasonState } from '../season/season-types'; +import { utcDay } from '../domain-events/domain-event-types'; +import type { AutopilotInput } from './autopilot.types'; + +/** + * Until Task 27 passes the real start of the late summer, the feedings and + * treatments of A4-A6 are counted from this day of the year (the built-in + * calendar day of TRACHTENDE). + */ +const LATE_SUMMER_FALLBACK_START = '08-01'; + +const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const; + +function lateSummerStart(season: SeasonState): string | null { + return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST' + ? `${season.year}-${LATE_SUMMER_FALLBACK_START}` + : null; +} + +/** + * What the autopilot rules need of one colony, in a fixed sequence of + * queries (no query per rule, none per record). Resolves to null for a + * colony that does not exist. The caller holds the colony row lock, so the + * picture stays consistent while the rules run. + */ +export async function loadAutopilotInputIn( + tx: Prisma.TransactionClient, + colonyId: string, + season: SeasonState, +): Promise { + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { + id: true, + code: true, + status: true, + colonyType: true, + apiaryId: true, + createdAt: true, + feedStatus: true, + currentQueen: { select: { id: true, hatchYear: true } }, + }, + }); + if (colony === null) { + return null; + } + const inspection = await tx.inspection.findFirst({ + where: { colonyId }, + orderBy: [...LATEST_FIRST], + select: { + id: true, + date: true, + colonyStrength: true, + feedStatus: true, + queenCellsPresent: true, + }, + }); + const varroa = await tx.varroaMeasurement.findFirst({ + where: { colonyId }, + orderBy: [...LATEST_FIRST], + select: { id: true, date: true }, + }); + // The latest END day wins, not the latest entry: a backdated or + // overlapping treatment must not hide one that ends later. + const treatments = await tx.treatment.findMany({ + where: { colonyId }, + orderBy: [{ date: 'desc' }, { id: 'asc' }], + select: { id: true, date: true, startDate: true, endDate: true }, + }); + const lastTreatment = treatments + .map((treatment) => ({ + id: treatment.id, + startDay: utcDay(treatment.startDate ?? treatment.date), + endDay: utcDay(treatment.endDate ?? treatment.date), + })) + .reduce<{ id: string; startDay: string; endDay: string } | null>( + (latest, candidate) => + latest === null || candidate.endDay > latest.endDay + ? candidate + : latest, + null, + ); + const varroaSinceLastTreatmentEnd = + lastTreatment !== null && + (await tx.varroaMeasurement.count({ + where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } }, + })) > 0; + const feedingsSinceInspection = await tx.feeding.count({ + where: { + colonyId, + ...(inspection === null ? {} : { date: { gte: inspection.date } }), + }, + }); + const lateSummer = lateSummerStart(season); + const sinceLateSummer = + lateSummer === null ? null : { gte: dayToDate(lateSummer) }; + const feedingsSinceLateSummer = + sinceLateSummer === null + ? 0 + : await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } }); + const treatmentsSinceLateSummer = + sinceLateSummer === null + ? 0 + : await tx.treatment.count({ + where: { colonyId, date: sinceLateSummer }, + }); + const winterChecks = await tx.winterCheck.findMany({ + where: { colonyId, winterYear: { gte: season.year - 1 } }, + select: { winterYear: true, season: true }, + orderBy: [{ winterYear: 'asc' }, { season: 'asc' }], + }); + return { + colony: { + id: colony.id, + code: colony.code, + status: colony.status, + colonyType: colony.colonyType, + apiaryId: colony.apiaryId, + createdOn: berlinDayOf(colony.createdAt), + feedStatus: colony.feedStatus, + }, + lastInspection: + inspection === null + ? null + : { ...inspection, date: utcDay(inspection.date) }, + lastVarroa: + varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) }, + lastTreatment, + varroaSinceLastTreatmentEnd, + feedingsSinceInspection, + feedingsSinceLateSummer, + treatmentsSinceLateSummer, + queen: colony.currentQueen, + winterChecks, + season, + }; +} diff --git a/ApiCore-Backend/src/autopilot/autopilot-rules.spec.ts b/ApiCore-Backend/src/autopilot/autopilot-rules.spec.ts new file mode 100644 index 0000000..6ad95a1 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot-rules.spec.ts @@ -0,0 +1,372 @@ +import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config'; +import { calendarSeasonState } from '../season/season-calendar'; +import { evaluateAutopilot } from './autopilot-rules'; +import type { AutopilotConfigs, AutopilotInput } from './autopilot.types'; + +type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD'; + +function activeRule(trigger: RuleName, override?: object) { + return { + id: null, + active: true, + config: ruleConfigOf(trigger, { + ...DEFAULT_RULES[trigger].config, + ...override, + }), + actionTemplate: null, + }; +} + +function configs(autopilotOverride?: object): AutopilotConfigs { + return { + autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride), + interval: activeRule('INSPECTION_INTERVAL'), + varroa: activeRule('VARROA_THRESHOLD'), + phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config), + } as AutopilotConfigs; +} + +function input( + today: string, + patch: Partial = {}, +): AutopilotInput { + return { + colony: { + id: 'col_1', + code: 'B3', + status: 'ACTIVE', + colonyType: 'PRODUCTION_COLONY', + apiaryId: 'api_1', + createdOn: '2025-04-01', + feedStatus: null, + }, + lastInspection: null, + lastVarroa: { id: 'var_1', date: today }, + lastTreatment: null, + varroaSinceLastTreatmentEnd: false, + feedingsSinceInspection: 0, + feedingsSinceLateSummer: 0, + treatmentsSinceLateSummer: 0, + queen: null, + winterChecks: [], + season: calendarSeasonState('api_1', today, configs().phenology), + ...patch, + }; +} + +const inspectionOn = (date: string) => ({ + id: 'insp_9', + date, + colonyStrength: null, + feedStatus: null, + queenCellsPresent: null, +}); + +const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' }; + +describe('autopilot rules', () => { + it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => { + const today = '2026-05-12'; + const result = evaluateAutopilot( + input(today, { lastInspection: inspectionOn('2026-05-04') }), + configs(), + today, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + rule: 'A1', + ruleKey: 'INSPECTION_INTERVAL', + intent: 'INSPECT', + mode: 'DO', + reasonCode: 'INSPECTION_DUE', + dueDate: '2026-05-11', + priority: 'HIGH', + anchor: 'insp_9', + requiredPermission: 'inspections:read', + }); + expect(result[0].evidence).toMatchObject({ + colonyId: 'col_1', + inspectionId: 'insp_9', + lastInspectedOn: '2026-05-04', + intervalDays: 7, + segment: 'SCHWARMZEIT', + }); + }); + + it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => { + const today = '2026-05-12'; + const inTwo = evaluateAutopilot( + input(today, { lastInspection: inspectionOn('2026-05-07') }), + configs(), + today, + ); + expect(inTwo[0]).toMatchObject({ + dueDate: '2026-05-14', + priority: 'NORMAL', + }); + + const inThree = evaluateAutopilot( + input(today, { lastInspection: inspectionOn('2026-05-08') }), + configs(), + today, + ); + expect(inThree).toEqual([]); + + const winter = '2026-12-05'; + expect( + evaluateAutopilot( + input(winter, { + lastInspection: inspectionOn('2026-09-01'), + lastVarroa: { id: 'var_1', date: '2026-12-01' }, + }), + configs(), + winter, + ), + ).toEqual([]); + }); + + it('A1: due today is not overdue; no inspection ever uses the anchor none', () => { + const today = '2026-05-12'; + const dueToday = evaluateAutopilot( + input(today, { lastInspection: inspectionOn('2026-05-05') }), + configs(), + today, + ); + expect(dueToday[0].priority).toBe('NORMAL'); + + const base = input(today); + const never = evaluateAutopilot( + input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }), + configs(), + today, + ); + expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' }); + expect(never[0].evidence).toMatchObject({ + inspectionId: null, + lastInspectedOn: null, + }); + }); + + it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => { + const today = '2026-04-01'; + const result = evaluateAutopilot( + input(today, { lastInspection: inspectionOn('2025-09-20') }), + configs(), + today, + ); + + expect(result[0]).toMatchObject({ + reasonCode: 'INSPECTION_FIRST_OF_YEAR', + dueDate: '2026-03-20', + priority: 'HIGH', + anchor: 'insp_9', + }); + }); + + it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => { + const today = '2026-06-10'; + const result = evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-06-08'), + lastVarroa: { id: 'var_7', date: '2026-05-06' }, + }), + configs(), + today, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + rule: 'A2', + ruleKey: 'SEASON_AUTOPILOT', + intent: 'VARROA_MEASURE', + mode: 'DO', + reasonCode: 'VARROA_MEASURE_DUE', + dueDate: '2026-06-03', + priority: 'NORMAL', + anchor: 'var_7', + requiredPermission: 'varroa:read', + }); + }); + + it('A2: a measurement 27 days ago yields nothing', () => { + const today = '2026-06-10'; + expect( + evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-06-08'), + lastVarroa: { id: 'var_7', date: '2026-05-14' }, + }), + configs(), + today, + ), + ).toEqual([]); + }); + + it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => { + const today = '2026-11-20'; + const result = evaluateAutopilot( + input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }), + configs(), + today, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + intent: 'VARROA_MEASURE', + reasonCode: 'VARROA_WINTER_MEASURE', + dueDate: '2026-11-15', + anchor: 'winter-2026', + }); + }); + + it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => { + const today = '2026-08-16'; + const result = evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-08-10'), + lastVarroa: { id: 'var_3', date: '2026-08-01' }, + lastTreatment: TREATMENT, + }), + configs(), + today, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + rule: 'A3', + intent: 'VARROA_RECHECK', + mode: 'DO', + reasonCode: 'VARROA_RECHECK_DUE', + dueDate: '2026-08-16', + priority: 'NORMAL', + anchor: 'tre_1', + requiredPermission: 'varroa:read', + }); + expect(result[0].evidence).toMatchObject({ + treatmentId: 'tre_1', + endedOn: '2026-08-02', + }); + }); + + it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => { + const early = '2026-08-15'; + expect( + evaluateAutopilot( + input(early, { + lastInspection: inspectionOn('2026-08-10'), + lastTreatment: TREATMENT, + }), + configs(), + early, + ), + ).toEqual([]); + + const late = '2026-08-20'; + expect( + evaluateAutopilot( + input(late, { + lastInspection: inspectionOn('2026-08-15'), + lastTreatment: TREATMENT, + varroaSinceLastTreatmentEnd: true, + }), + configs(), + late, + ), + ).toEqual([]); + }); + + it('A3: honours recheckAfterDays of the varroa rule', () => { + const today = '2026-08-09'; + const cfg = configs(); + cfg.varroa = activeRule('VARROA_THRESHOLD', { + recheckAfterDays: 7, + }) as AutopilotConfigs['varroa']; + const result = evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-08-05'), + lastTreatment: TREATMENT, + }), + cfg, + today, + ); + + expect(result.map((c) => c.rule)).toEqual(['A3']); + expect(result[0].dueDate).toBe('2026-08-09'); + }); + + it('returns the candidates in the order A1, A2', () => { + const today = '2026-06-10'; + const result = evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-05-20'), + lastVarroa: { id: 'var_7', date: '2026-04-20' }, + }), + configs(), + today, + ); + + expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']); + }); + + it('A2 is not proposed while A3 applies: the recheck is the measurement', () => { + const today = '2026-06-10'; + const result = evaluateAutopilot( + input(today, { + lastInspection: inspectionOn('2026-05-20'), + lastVarroa: { id: 'var_7', date: '2026-04-20' }, + lastTreatment: { + id: 'tre_1', + startDay: '2026-05-01', + endDay: '2026-05-10', + }, + }), + configs(), + today, + ); + + expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']); + }); + + it('A3 only looks at treatments that ended at most 60 days ago', () => { + const today = '2026-08-16'; + const base = { lastInspection: inspectionOn('2026-08-10') }; + const at = (endDay: string) => + evaluateAutopilot( + input(today, { + ...base, + lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay }, + }), + configs(), + today, + ).filter((c) => c.rule === 'A3'); + + expect(at('2026-06-17')).toHaveLength(1); // 60 days ago + expect(at('2026-06-16')).toHaveLength(0); // 61 days ago + }); + + it('a rule switched off in config.rules yields nothing', () => { + const today = '2026-05-12'; + const base = input(today, { lastInspection: inspectionOn('2026-05-04') }); + expect( + evaluateAutopilot(base, configs(), today).map((c) => c.rule), + ).toEqual(['A1']); + + const a1Off = configs({ + rules: { ...configs().autopilot.config.rules, A1: false }, + }); + expect(evaluateAutopilot(base, a1Off, today)).toEqual([]); + }); + + it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => { + const today = '2026-05-12'; + const base = input(today, { lastInspection: inspectionOn('2026-05-04') }); + const off = configs(); + off.autopilot = { ...off.autopilot, active: false }; + expect(evaluateAutopilot(base, off, today)).toEqual([]); + + const noInterval = configs(); + noInterval.interval = { ...noInterval.interval, active: false }; + expect(evaluateAutopilot(base, noInterval, today)).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/autopilot-rules.ts b/ApiCore-Backend/src/autopilot/autopilot-rules.ts new file mode 100644 index 0000000..848bc6a --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot-rules.ts @@ -0,0 +1,180 @@ +import type { AutopilotRuleId } from '../automation/rule-config'; +import { addDays, daysBetween } from '../common/dates'; +import { inspectionDueOn } from '../season/inspection-due'; +import { varroaMeasurementDue } from '../season/varroa-due'; +import type { + AutopilotCandidate, + AutopilotConfigs, + AutopilotInput, + AutopilotRule, +} from './autopilot.types'; + +/** + * A3 only follows treatments that ended at most this many days ago, so old + * history without a later measurement does not produce suggestions. + */ +const RECHECK_MAX_AGE_DAYS = 60; + +/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */ +const INSPECTION_LEAD_DAYS = 2; + +const a1Inspection: AutopilotRule = (input, configs, today) => { + if (!configs.interval.active) { + return null; + } + const due = inspectionDueOn( + { + createdOn: input.colony.createdOn, + lastInspectedOn: input.lastInspection?.date ?? null, + }, + input.season, + configs.interval.config, + ); + if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) { + return null; + } + return { + rule: 'A1', + ruleKey: 'INSPECTION_INTERVAL', + intent: 'INSPECT', + mode: 'DO', + reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE', + evidence: { + colonyId: input.colony.id, + inspectionId: input.lastInspection?.id ?? null, + lastInspectedOn: input.lastInspection?.date ?? null, + dueOn: due.dueOn, + intervalDays: due.intervalDays, + segment: input.season.segment, + }, + dueDate: due.dueOn, + priority: due.dueOn < today ? 'HIGH' : 'NORMAL', + anchor: input.lastInspection?.id ?? 'none', + requiredPermission: 'inspections:read', + }; +}; + +/** A2: the varroa measurement interval ran out, or the winter window is open. */ +const a2VarroaMeasure: AutopilotRule = (input, configs, today) => { + const due = varroaMeasurementDue( + { + createdOn: input.colony.createdOn, + lastMeasuredOn: input.lastVarroa?.date ?? null, + lastMeasurementId: input.lastVarroa?.id ?? null, + }, + input.season, + configs.autopilot.config, + today, + ); + if (due === null || due.dueOn > today) { + return null; + } + return { + rule: 'A2', + ruleKey: 'SEASON_AUTOPILOT', + intent: 'VARROA_MEASURE', + mode: 'DO', + reasonCode: + due.kind === 'WINTER_WINDOW' + ? 'VARROA_WINTER_MEASURE' + : 'VARROA_MEASURE_DUE', + evidence: { + colonyId: input.colony.id, + measurementId: input.lastVarroa?.id ?? null, + lastMeasuredOn: input.lastVarroa?.date ?? null, + dueOn: due.dueOn, + intervalDays: configs.autopilot.config.varroaMeasureIntervalDays, + }, + dueDate: due.dueOn, + priority: 'NORMAL', + anchor: due.anchor, + requiredPermission: 'varroa:read', + }; +}; + +/** A3: a treatment ended long enough ago and nothing was measured since. */ +const a3VarroaRecheck: AutopilotRule = (input, configs, today) => { + const treatment = input.lastTreatment; + if ( + treatment === null || + input.varroaSinceLastTreatmentEnd || + daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS + ) { + return null; + } + const recheckAfterDays = configs.varroa.config.recheckAfterDays; + const dueOn = addDays(treatment.endDay, recheckAfterDays); + if (dueOn > today) { + return null; + } + return { + rule: 'A3', + ruleKey: 'SEASON_AUTOPILOT', + intent: 'VARROA_RECHECK', + mode: 'DO', + reasonCode: 'VARROA_RECHECK_DUE', + evidence: { + colonyId: input.colony.id, + treatmentId: treatment.id, + endedOn: treatment.endDay, + recheckOn: dueOn, + }, + dueDate: dueOn, + priority: 'NORMAL', + anchor: treatment.id, + // The action is a varroa measurement. + requiredPermission: 'varroa:read', + }; +}; + +/** A4-A10 follow with Task 27. */ +export const AUTOPILOT_RULES: Readonly< + Partial> +> = { + A1: a1Inspection, + A2: a2VarroaMeasure, + A3: a3VarroaRecheck, +}; + +const RULE_ORDER: readonly AutopilotRuleId[] = [ + 'A1', + 'A2', + 'A3', + 'A4', + 'A5', + 'A6', + 'A7', + 'A8', + 'A9', + 'A10', +]; + +/** + * The candidates for one colony, always in the order A1...A10 so concurrent + * syncs take the proposal locks in the same order. A switched-off autopilot + * or rule yields nothing. + */ +export function evaluateAutopilot( + input: AutopilotInput, + configs: AutopilotConfigs, + today: string, +): AutopilotCandidate[] { + if (!configs.autopilot.active) { + return []; + } + const candidates: AutopilotCandidate[] = []; + for (const id of RULE_ORDER) { + const rule = AUTOPILOT_RULES[id]; + if (rule === undefined || !configs.autopilot.config.rules[id]) { + continue; + } + const candidate = rule(input, configs, today); + if (candidate !== null) { + candidates.push(candidate); + } + } + // The recheck after a treatment is the measurement: A2 would ask twice. + return candidates.some((candidate) => candidate.rule === 'A3') + ? candidates.filter((candidate) => candidate.rule !== 'A2') + : candidates; +} diff --git a/ApiCore-Backend/src/autopilot/autopilot.handlers.spec.ts b/ApiCore-Backend/src/autopilot/autopilot.handlers.spec.ts new file mode 100644 index 0000000..991fd76 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.handlers.spec.ts @@ -0,0 +1,181 @@ +import type { Prisma } from '../../generated/prisma'; +import type { + AnyDomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import { AutopilotEventHandlers } from './autopilot.handlers'; +import type { AutopilotService } from './autopilot.service'; + +const EXPECTED_TYPES: DomainEventType[] = [ + 'InspectionRecorded', + 'InspectionCorrected', + 'VarroaMeasured', + 'VarroaCorrected', + 'TreatmentRecorded', + 'TreatmentCorrected', + 'FeedingRecorded', + 'FeedingCorrected', + 'HarvestRecorded', + 'HarvestCorrected', + 'ColonyStatusChanged', + 'ColonySplit', + 'QueenAssigned', + 'TaskCompleted', + 'TaskUpdated', + 'WithdrawalEnded', + 'QueenAged', + 'InspectionOverdue', + 'VarroaMeasurementDue', +]; + +function event( + type: DomainEventType, + aggregateId: string, + payload: object, +): AnyDomainEventEnvelope { + return { + id: 'evt-1', + type, + aggregateType: 'Colony', + aggregateId, + payload, + actorUserId: null, + correlationId: 'cor-1', + occurredAt: new Date(), + attempts: 0, + } as AnyDomainEventEnvelope; +} + +describe('AutopilotEventHandlers', () => { + let autopilot: { syncColonyIn: jest.Mock }; + let tx: { task: { findUnique: jest.Mock } }; + let handlers: AutopilotEventHandlers; + + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') }); + autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) }; + tx = { task: { findUnique: jest.fn() } }; + handlers = new AutopilotEventHandlers( + autopilot as unknown as AutopilotService, + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const run = (e: AnyDomainEventEnvelope) => + handlers.colonySync( + e as Parameters[0], + tx as unknown as Prisma.TransactionClient, + ); + const synced = () => + autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]); + + it('is registered as autopilot.colony-sync for the listed events', () => { + const entries = domainEventHandlerMetadata( + // eslint-disable-next-line @typescript-eslint/unbound-method + AutopilotEventHandlers.prototype.colonySync, + ); + + expect(entries).toHaveLength(1); + expect(entries[0].handlerId).toBe('autopilot.colony-sync'); + expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort()); + }); + + it.each([ + ['InspectionRecorded', { colonyId: 'c1' }], + ['InspectionCorrected', { colonyId: 'c1' }], + ['VarroaMeasured', { colonyId: 'c1' }], + ['VarroaCorrected', { colonyId: 'c1' }], + ['TreatmentRecorded', { colonyId: 'c1' }], + ['TreatmentCorrected', { colonyId: 'c1' }], + ['FeedingRecorded', { colonyId: 'c1' }], + ['FeedingCorrected', { colonyId: 'c1' }], + ['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }], + ['QueenAged', { colonyId: 'c1', hatchYear: 2024 }], + ] as [DomainEventType, object][])( + '%s syncs the colony of its payload for the Berlin day', + async (type, payload) => { + await run(event(type, 'aggregate-1', payload)); + + expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1); + expect(autopilot.syncColonyIn).toHaveBeenCalledWith( + tx, + 'c1', + '2026-05-12', + ); + }, + ); + + it.each([ + 'ColonyStatusChanged', + 'InspectionOverdue', + 'VarroaMeasurementDue', + ] as const)('%s syncs the aggregate colony', async (type) => { + await run(event(type, 'c9', {})); + + expect(synced()).toEqual(['c9']); + }); + + it('syncs every colony of a harvest, in id order', async () => { + await run( + event('HarvestRecorded', 'h1', { + apiaryId: 'a', + colonyIds: ['c3', 'c1', 'c2', 'c1'], + }), + ); + + expect(synced()).toEqual(['c1', 'c2', 'c3']); + }); + + it('syncs the new and the parent colony of a split', async () => { + await run(event('ColonySplit', 'new', { parentColonyId: 'mother' })); + + expect(synced()).toEqual(['mother', 'new']); + }); + + it('syncs the colony and the previous colony of a queen assignment', async () => { + await run( + event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }), + ); + await run( + event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }), + ); + + expect(synced()).toEqual(['c1', 'c2', 'c2']); + }); + + it('syncs a colony task on completion and update, nothing for other tasks', async () => { + await run( + event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }), + ); + await run( + event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }), + ); + tx.task.findUnique + .mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' }) + .mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' }) + .mockResolvedValueOnce(null); + await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null })); + await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null })); + await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null })); + + expect(synced()).toEqual(['c1', 'c4']); + }); + + it('does nothing for a queen event without a colony', async () => { + await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 })); + + expect(autopilot.syncColonyIn).not.toHaveBeenCalled(); + }); + + it('lets errors propagate so the delivery is retried', async () => { + autopilot.syncColonyIn.mockRejectedValue(new Error('db down')); + + await expect( + run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })), + ).rejects.toThrow('db down'); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/autopilot.handlers.ts b/ApiCore-Backend/src/autopilot/autopilot.handlers.ts new file mode 100644 index 0000000..993cee1 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.handlers.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import { berlinToday } from '../common/dates'; +import type { + AnyDomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { AutopilotService } from './autopilot.service'; + +const COLONY_SYNC_EVENTS = [ + 'InspectionRecorded', + 'InspectionCorrected', + 'VarroaMeasured', + 'VarroaCorrected', + 'TreatmentRecorded', + 'TreatmentCorrected', + 'FeedingRecorded', + 'FeedingCorrected', + 'HarvestRecorded', + 'HarvestCorrected', + 'ColonyStatusChanged', + 'ColonySplit', + 'QueenAssigned', + 'TaskCompleted', + 'TaskUpdated', + 'WithdrawalEnded', + 'QueenAged', + 'InspectionOverdue', + 'VarroaMeasurementDue', +] as const satisfies readonly DomainEventType[]; + +type ColonySyncEvent = AnyDomainEventEnvelope< + (typeof COLONY_SYNC_EVENTS)[number] +>; + +/** The colony of a task, if it is about one. */ +async function taskColonyIdIn( + tx: Prisma.TransactionClient, + taskId: string, +): Promise { + const task = await tx.task.findUnique({ + where: { id: taskId }, + select: { relatedType: true, relatedId: true }, + }); + return task?.relatedType === 'COLONY' ? task.relatedId : null; +} + +/** The colonies an event is about; none if it is about no colony. */ +async function colonyIdsOf( + event: ColonySyncEvent, + tx: Prisma.TransactionClient, +): Promise<(string | null)[]> { + switch (event.type) { + case 'InspectionRecorded': + case 'InspectionCorrected': + case 'VarroaMeasured': + case 'VarroaCorrected': + case 'TreatmentRecorded': + case 'TreatmentCorrected': + case 'FeedingRecorded': + case 'FeedingCorrected': + case 'WithdrawalEnded': + case 'QueenAged': + return [event.payload.colonyId]; + case 'HarvestRecorded': + case 'HarvestCorrected': + return event.payload.colonyIds; + case 'ColonyStatusChanged': + case 'InspectionOverdue': + case 'VarroaMeasurementDue': + return [event.aggregateId]; + case 'ColonySplit': + // The aggregate is the new colony. + return [event.aggregateId, event.payload.parentColonyId]; + case 'QueenAssigned': + return [event.payload.colonyId, event.payload.previousColonyId]; + case 'TaskCompleted': + return [ + event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null, + ]; + case 'TaskUpdated': + return [await taskColonyIdIn(tx, event.aggregateId)]; + } +} + +/** + * Reconciles the autopilot suggestions of the colonies an event touches. + * Pure DB writer through the delivery transaction; the colonies are synced + * in id order, so two deliveries never lock them the other way round. + */ +@Injectable() +export class AutopilotEventHandlers { + constructor(private readonly autopilot: AutopilotService) {} + + @OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync') + async colonySync( + event: ColonySyncEvent, + tx: Prisma.TransactionClient, + ): Promise { + const colonyIds = [ + ...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)), + ].sort(); + if (colonyIds.length === 0) { + return; + } + const today = berlinToday(); + for (const colonyId of colonyIds) { + await this.autopilot.syncColonyIn(tx, colonyId, today); + } + } +} diff --git a/ApiCore-Backend/src/autopilot/autopilot.module.ts b/ApiCore-Backend/src/autopilot/autopilot.module.ts new file mode 100644 index 0000000..5501127 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { SeasonModule } from '../season/season.module'; +import { SuggestionsModule } from '../suggestions/suggestions.module'; +import { AutopilotEventHandlers } from './autopilot.handlers'; +import { AutopilotService } from './autopilot.service'; +import { AutopilotStep } from './autopilot.step'; +import { SuggestionsExpiryStep } from './suggestions-expiry.step'; + +/** + * The season autopilot: the event handler and the daily steps that keep the + * autopilot suggestions of each colony in line with the rules. Imported by + * AppModule and WorkerModule (the handler) and by JobsModule (the steps). + */ +@Module({ + imports: [SeasonModule, SuggestionsModule], + providers: [ + AutopilotService, + AutopilotEventHandlers, + AutopilotStep, + SuggestionsExpiryStep, + ], + exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep], +}) +export class AutopilotModule {} diff --git a/ApiCore-Backend/src/autopilot/autopilot.service.spec.ts b/ApiCore-Backend/src/autopilot/autopilot.service.spec.ts new file mode 100644 index 0000000..a0ad792 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.service.spec.ts @@ -0,0 +1,333 @@ +import { Logger } from '@nestjs/common'; +import { + DEFAULT_RULES, + readRule, + ruleConfigOf, +} from '../automation/rule-config'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { calendarSeasonState } from '../season/season-calendar'; +import type { SuggestionsService } from '../suggestions/suggestions.service'; +import { loadAutopilotInputIn } from './autopilot-input'; +import { AutopilotService } from './autopilot.service'; +import type { AutopilotInput } from './autopilot.types'; + +jest.mock('../prisma/row-lock'); +jest.mock('../automation/rule-config', () => ({ + ...jest.requireActual('../automation/rule-config'), + readRule: jest.fn(), +})); +jest.mock('./autopilot-input'); + +const lockRow = jest.mocked(lockRowForUpdate); +const readRuleMock = jest.mocked(readRule); +const loadInput = jest.mocked(loadAutopilotInputIn); + +const TODAY = '2026-05-12'; +const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config); + +function rule(trigger: string, override: object = {}, active = true) { + return { + id: null, + active, + config: ruleConfigOf(trigger as never, { + ...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config, + ...override, + }), + actionTemplate: null, + }; +} + +function input(lastInspectedOn: string | null): AutopilotInput { + return { + colony: { + id: 'col-1', + code: 'B3', + status: 'ACTIVE', + colonyType: 'PRODUCTION_COLONY', + apiaryId: 'api-1', + createdOn: '2026-04-20', + feedStatus: null, + }, + lastInspection: + lastInspectedOn === null + ? null + : { + id: 'insp-9', + date: lastInspectedOn, + colonyStrength: null, + feedStatus: null, + queenCellsPresent: null, + }, + lastVarroa: { id: 'var-1', date: '2026-05-10' }, + lastTreatment: null, + varroaSinceLastTreatmentEnd: false, + feedingsSinceInspection: 0, + feedingsSinceLateSummer: 0, + treatmentsSinceLateSummer: 0, + queen: null, + winterChecks: [], + season: calendarSeasonState('api-1', TODAY, PHENOLOGY), + }; +} + +describe('AutopilotService', () => { + let tx: { + $connect?: undefined; + colony: { findUnique: jest.Mock }; + suggestion: { findMany: jest.Mock }; + }; + let suggestions: { + proposeIn: jest.Mock; + refreshIn: jest.Mock; + resolveIn: jest.Mock; + }; + let seasons: { statesFor: jest.Mock }; + let service: AutopilotService; + let rules: Record>; + + beforeEach(() => { + jest.resetAllMocks(); + tx = { + colony: { + findUnique: jest + .fn() + .mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }), + }, + suggestion: { findMany: jest.fn().mockResolvedValue([]) }, + }; + suggestions = { + proposeIn: jest.fn().mockResolvedValue({ id: 'new' }), + refreshIn: jest.fn().mockResolvedValue(undefined), + resolveIn: jest + .fn() + .mockImplementation((_tx, ids: string[]) => ids.length), + }; + seasons = { + statesFor: jest + .fn() + .mockResolvedValue( + new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]), + ), + }; + rules = { + SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'), + INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'), + VARROA_THRESHOLD: rule('VARROA_THRESHOLD'), + PHENOLOGY: rule('PHENOLOGY'), + }; + readRuleMock.mockImplementation((_db, trigger) => + Promise.resolve(rules[trigger] as never), + ); + lockRow.mockResolvedValue(true); + loadInput.mockResolvedValue(input('2026-05-03')); + service = new AutopilotService( + suggestions as unknown as SuggestionsService, + seasons, + ); + }); + + const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY); + + it('locks the colony row first and does nothing for an ended colony', async () => { + tx.colony.findUnique.mockResolvedValue({ + status: 'DECEASED', + apiaryId: 'api-1', + }); + + const result = await sync(); + + expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1'); + expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan( + tx.colony.findUnique.mock.invocationCallOrder[0], + ); + expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + expect(loadInput).not.toHaveBeenCalled(); + }); + + it('does nothing when the colony row is gone', async () => { + lockRow.mockResolvedValue(false); + + expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + expect(tx.colony.findUnique).not.toHaveBeenCalled(); + }); + + it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => { + const result = await sync(); + + expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 }); + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + ruleKey: 'INSPECTION_INTERVAL', + source: 'AUTOPILOT', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: 'col-1', + colonyId: 'col-1', + reasonCode: 'INSPECTION_DUE', + dueDate: '2026-05-10', + anchor: 'insp-9', + requiredPermission: 'inspections:read', + expiresAt: '2026-06-11', + }), + ); + }); + + it('keeps an overdue suggestion alive for the full ttl from today', async () => { + loadInput.mockResolvedValue(input('2026-03-25')); + + await sync(); + + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + dueDate: '2026-04-01', + expiresAt: '2026-06-11', + }), + ); + }); + + it('refreshes an open suggestion with the same key instead of creating one', async () => { + tx.suggestion.findMany.mockResolvedValue([ + { + id: 'sug-1', + dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9', + expiresAt: new Date('2026-06-01T00:00:00.000Z'), + }, + ]); + + const result = await sync(); + + expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 }); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', { + reasonCode: 'INSPECTION_DUE', + evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object, + dueDate: '2026-05-10', + priority: 'HIGH', + expiresAt: '2026-06-01', + }); + }); + + it('drops a candidate when proposeIn finds an open task of the same intent', async () => { + suggestions.proposeIn.mockResolvedValue(null); + + expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + }); + + it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => { + tx.suggestion.findMany.mockResolvedValue([ + { + id: 'sug-old', + dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1', + expiresAt: null, + }, + ]); + + const result = await sync(); + + expect(tx.suggestion.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' }, + }), + ); + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['sug-old'], + 'Bedingung entfällt', + ); + expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 }); + expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan( + suggestions.proposeIn.mock.invocationCallOrder[0], + ); + }); + + it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => { + rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false); + tx.suggestion.findMany.mockResolvedValue([ + { id: 'a', dedupeKey: 'k1', expiresAt: null }, + { id: 'b', dedupeKey: 'k2', expiresAt: null }, + ]); + + const result = await sync(); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['a', 'b'], + 'Autopilot aus', + ); + expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 }); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + expect(loadInput).not.toHaveBeenCalled(); + }); + + it('proposes the candidates in the order A1, A2', async () => { + loadInput.mockResolvedValue({ + ...input('2026-04-20'), + lastVarroa: { id: 'var-1', date: '2026-04-01' }, + }); + + await sync(); + + expect( + suggestions.proposeIn.mock.calls.map( + (call: [unknown, { intent: string }]) => call[1].intent, + ), + ).toEqual(['INSPECT', 'VARROA_MEASURE']); + }); + + it('uses preloaded configs and seasons without reading rules or seasons again', async () => { + const season = calendarSeasonState('api-1', TODAY, PHENOLOGY); + const preload = { + configs: { + autopilot: rules.SEASON_AUTOPILOT, + interval: rules.INSPECTION_INTERVAL, + varroa: rules.VARROA_THRESHOLD, + phenology: PHENOLOGY, + }, + seasons: new Map([['api-1', season]]), + }; + readRuleMock.mockClear(); + + const result = await service.syncColonyIn( + tx as never, + 'col-1', + TODAY, + preload, + ); + + expect(result.opened).toBe(1); + expect(readRuleMock).not.toHaveBeenCalled(); + expect(seasons.statesFor).not.toHaveBeenCalled(); + expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season); + }); + + it('preloadFor reads the four rules and the seasons once', async () => { + const preload = await service.preloadFor(tx as never, ['api-1'], TODAY); + + expect(readRuleMock).toHaveBeenCalledTimes(4); + expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY); + expect(preload.seasons.has('api-1')).toBe(true); + expect(preload.configs.phenology).toEqual(PHENOLOGY); + }); + + it('warns and does nothing when the apiary has no season state or the input is gone', async () => { + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + seasons.statesFor.mockResolvedValue(new Map()); + + expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('season')); + + seasons.statesFor.mockResolvedValue( + new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]), + ); + loadInput.mockResolvedValue(null); + expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/autopilot.service.ts b/ApiCore-Backend/src/autopilot/autopilot.service.ts new file mode 100644 index 0000000..d0d56a0 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import { readRule } from '../automation/rule-config'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import { addDays } from '../common/dates'; +import { utcDay } from '../domain-events/domain-event-types'; +import { assertTransactionClient, type DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import type { SeasonState } from '../season/season-types'; +import { SeasonService } from '../season/season.service'; +import { SuggestionsService } from '../suggestions/suggestions.service'; +import { suggestionDedupeKey } from '../suggestions/suggestion.types'; +import { loadAutopilotInputIn } from './autopilot-input'; +import { evaluateAutopilot } from './autopilot-rules'; +import type { + AutopilotCandidate, + AutopilotConfigs, + AutopilotSyncResult, +} from './autopilot.types'; + +/** Rules and seasons a daily step reads once for all its colonies. */ +export interface AutopilotPreload { + configs: AutopilotConfigs; + seasons: ReadonlyMap; +} + +const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 }; + +/** + * A suggestion lives suggestionTtlDays from its due day, from today if it is + * already overdue: a condition that has lasted longer than the ttl would + * otherwise expire the day it is proposed and never be seen. + */ +function expiresOn(dueDate: string, today: string, ttlDays: number): string { + return addDays(dueDate > today ? dueDate : today, ttlDays); +} + +/** + * Reconciles the open autopilot suggestions of one colony with what the + * rules say today. It writes suggestions only, never tasks or colony data, + * and leaves rule and chain suggestions alone. + */ +@Injectable() +export class AutopilotService { + private readonly logger = new Logger(AutopilotService.name); + + constructor( + private readonly suggestions: SuggestionsService, + private readonly seasons: SeasonService, + ) {} + + /** The rules and the season of the given apiaries, read once for many colonies. */ + async preloadFor( + db: DbClient, + apiaryIds: readonly string[], + today: string, + ): Promise { + return { + configs: await this.readConfigs(db), + seasons: await this.seasons.statesFor(db, apiaryIds, today), + }; + } + + /** + * Locks the colony row, then: ended colony - nothing; autopilot off - all + * open autopilot suggestions RESOLVED; otherwise suggestions whose key is + * gone are RESOLVED, those with the same key refreshed, new candidates + * proposed (in the order A1...A10, so concurrent syncs take the proposal + * locks in the same order). A candidate is dropped by `proposeIn` when an + * open suggestion or task of the same intent exists. A colony that has + * ended is skipped here; its open suggestions become OBSOLETE through the + * handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied: + * it limits the next-steps view, not what is proposed. `preload` spares a + * daily step the rule and season reads per colony. + */ + async syncColonyIn( + tx: Prisma.TransactionClient, + colonyId: string, + today: string, + preload?: AutopilotPreload, + ): Promise { + assertTransactionClient(tx, 'AutopilotService.syncColonyIn'); + if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) { + return NOTHING; + } + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { status: true, apiaryId: true }, + }); + if (colony === null || !isLivingColonyStatus(colony.status)) { + return NOTHING; + } + const configs = preload?.configs ?? (await this.readConfigs(tx)); + const autopilot = configs.autopilot; + const open = await tx.suggestion.findMany({ + where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' }, + select: { id: true, dedupeKey: true, expiresAt: true }, + orderBy: { id: 'asc' }, + }); + if (!autopilot.active) { + const resolved = await this.suggestions.resolveIn( + tx, + open.map((suggestion) => suggestion.id), + 'Autopilot aus', + ); + return { ...NOTHING, resolved }; + } + const season = ( + preload?.seasons ?? + (await this.seasons.statesFor(tx, [colony.apiaryId], today)) + ).get(colony.apiaryId); + if (season === undefined) { + this.logger.warn(`No season state for the apiary of colony ${colonyId}`); + return NOTHING; + } + const input = await loadAutopilotInputIn(tx, colonyId, season); + if (input === null) { + this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`); + return NOTHING; + } + const candidates = evaluateAutopilot(input, configs, today); + const keyOf = (candidate: AutopilotCandidate): string => + suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor); + const currentKeys = new Set(candidates.map(keyOf)); + const resolved = await this.suggestions.resolveIn( + tx, + open + .filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey)) + .map((s) => s.id), + 'Bedingung entfällt', + ); + const openByKey = new Map( + open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])), + ); + const ttlDays = autopilot.config.suggestionTtlDays; + let opened = 0; + let refreshed = 0; + for (const candidate of candidates) { + const existing = openByKey.get(keyOf(candidate)); + if (existing !== undefined) { + await this.suggestions.refreshIn(tx, existing.id, { + reasonCode: candidate.reasonCode, + evidence: candidate.evidence, + dueDate: candidate.dueDate, + priority: candidate.priority, + expiresAt: + existing.expiresAt === null ? null : utcDay(existing.expiresAt), + }); + refreshed += 1; + continue; + } + const created = await this.suggestions.proposeIn(tx, { + ruleKey: candidate.ruleKey, + source: 'AUTOPILOT', + mode: candidate.mode, + intent: candidate.intent, + priority: candidate.priority, + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: candidate.reasonCode, + evidence: candidate.evidence, + requiredPermission: candidate.requiredPermission, + dueDate: candidate.dueDate, + expiresAt: expiresOn(candidate.dueDate, today, ttlDays), + anchor: candidate.anchor, + notify: false, + }); + if (created !== null) { + opened += 1; + } + } + return { opened, refreshed, resolved }; + } + + private async readConfigs(db: DbClient): Promise { + return { + autopilot: await readRule(db, 'SEASON_AUTOPILOT'), + interval: await readRule(db, 'INSPECTION_INTERVAL'), + varroa: await readRule(db, 'VARROA_THRESHOLD'), + phenology: (await readRule(db, 'PHENOLOGY')).config, + }; + } +} diff --git a/ApiCore-Backend/src/autopilot/autopilot.step.spec.ts b/ApiCore-Backend/src/autopilot/autopilot.step.spec.ts new file mode 100644 index 0000000..9196963 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.step.spec.ts @@ -0,0 +1,95 @@ +import { Logger } from '@nestjs/common'; +import type { PrismaService } from '../prisma/prisma.service'; +import { JobPartialFailure } from '../system/job-run.service'; +import { AutopilotStep } from './autopilot.step'; +import type { AutopilotService } from './autopilot.service'; + +describe('AutopilotStep', () => { + const tx = { tx: true }; + let findMany: jest.Mock; + let transaction: jest.Mock; + let syncColonyIn: jest.Mock; + let preloadFor: jest.Mock; + const PRELOAD = { configs: {}, seasons: new Map() }; + let step: AutopilotStep; + + beforeEach(() => { + findMany = jest.fn().mockResolvedValue([ + { id: 'c1', apiaryId: 'a1' }, + { id: 'c2', apiaryId: 'a1' }, + { id: 'c3', apiaryId: 'a2' }, + ]); + transaction = jest.fn((work: (client: unknown) => unknown) => work(tx)); + syncColonyIn = jest + .fn() + .mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 }); + preloadFor = jest.fn().mockResolvedValue(PRELOAD); + step = new AutopilotStep( + { + colony: { findMany }, + $transaction: transaction, + } as unknown as PrismaService, + { syncColonyIn, preloadFor } as unknown as AutopilotService, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('is the autopilot step', () => { + expect(step.name).toBe('autopilot'); + }); + + it('syncs every living colony in a transaction of its own and sums up', async () => { + const result = await step.run('2026-05-12'); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] }, + }, + orderBy: { id: 'asc' }, + }), + ); + expect(transaction).toHaveBeenCalledTimes(3); + expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD); + expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD); + expect(result).toEqual({ + autopilotColonies: 3, + suggestionsOpened: 3, + suggestionsResolved: 6, + }); + }); + + it('reads rules and seasons once for all colonies', async () => { + await step.run('2026-05-12'); + + expect(preloadFor).toHaveBeenCalledTimes(1); + expect(preloadFor).toHaveBeenCalledWith( + expect.anything(), + ['a1', 'a2'], + '2026-05-12', + ); + }); + + it('a failing colony does not stop the others; the step fails with the counters', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + syncColonyIn + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 }); + + const failure = await step.run('2026-05-12').then( + () => null, + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(JobPartialFailure); + expect(failure).toMatchObject({ + result: { + autopilotColonies: 2, + suggestionsOpened: 2, + suggestionsResolved: 2, + }, + failedSteps: ['1 colony'], + }); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/autopilot.step.ts b/ApiCore-Backend/src/autopilot/autopilot.step.ts new file mode 100644 index 0000000..8789be1 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.step.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; +import { loggableError } from '../common/loggable-error'; +import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps'; +import type { JobResult } from '../jobs/job'; +import { PrismaService } from '../prisma/prisma.service'; +import { JobPartialFailure } from '../system/job-run.service'; +import { AutopilotService } from './autopilot.service'; + +/** + * Step "autopilot" of the daily evaluation: reconciles the suggestions of + * every living colony, one transaction (and one colony row lock) per colony. + * A failing colony is logged and does not stop the others; the step then + * fails with the counters of the colonies that went through, and the next + * run tries again. + */ +@Injectable() +export class AutopilotStep implements DailyEvaluationStep { + readonly name = 'autopilot'; + private readonly logger = new Logger(AutopilotStep.name); + + constructor( + private readonly prisma: PrismaService, + private readonly autopilot: AutopilotService, + ) {} + + async run(today: string): Promise { + const colonies = await this.prisma.colony.findMany({ + where: LIVING_COLONY_WHERE, + select: { id: true, apiaryId: true }, + orderBy: { id: 'asc' }, + }); + // Rules and seasons are the same for every colony: read them once. + const preload = await this.autopilot.preloadFor( + this.prisma, + [...new Set(colonies.map((colony) => colony.apiaryId))], + today, + ); + let synced = 0; + let failed = 0; + let opened = 0; + let resolved = 0; + for (const { id } of colonies) { + try { + const result = await this.prisma.$transaction((tx) => + this.autopilot.syncColonyIn(tx, id, today, preload), + ); + synced += 1; + opened += result.opened; + resolved += result.resolved; + } catch (error: unknown) { + const { message, stack } = loggableError(error); + this.logger.error( + `Autopilot of colony ${id} failed: ${message}`, + stack, + ); + failed += 1; + } + } + const counts: JobResult = { + autopilotColonies: synced, + suggestionsOpened: opened, + suggestionsResolved: resolved, + }; + if (failed > 0) { + throw new JobPartialFailure(counts, [ + `${failed} ${failed === 1 ? 'colony' : 'colonies'}`, + ]); + } + return counts; + } +} diff --git a/ApiCore-Backend/src/autopilot/autopilot.types.ts b/ApiCore-Backend/src/autopilot/autopilot.types.ts new file mode 100644 index 0000000..8d3e2f7 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot.types.ts @@ -0,0 +1,81 @@ +import type { + ColonyCondition, + ColonyStatus, + ColonyType, + SuggestionIntent, + SuggestionMode, + TaskPriority, + WinterCheckSeason, +} from '../../generated/prisma'; +import type { PermissionKey } from '../auth/permissions/permission-catalog'; +import type { + ActiveRule, + AutopilotRuleId, + PhenologyConfig, +} from '../automation/rule-config'; +import type { SeasonState } from '../season/season-types'; +import type { SuggestionEvidence } from '../suggestions/suggestion.types'; + +/** What the autopilot rules see of one living colony. Days are YYYY-MM-DD. */ +export interface AutopilotInput { + colony: { + id: string; + code: string; + status: ColonyStatus; + colonyType: ColonyType; + apiaryId: string; + createdOn: string; + feedStatus: ColonyCondition | null; + }; + lastInspection: { + id: string; + date: string; + colonyStrength: number | null; + feedStatus: ColonyCondition | null; + queenCellsPresent: boolean | null; + } | null; + lastVarroa: { id: string; date: string } | null; + lastTreatment: { id: string; startDay: string; endDay: string } | null; + /** A measurement after the end of the last treatment exists. */ + varroaSinceLastTreatmentEnd: boolean; + // A4-A6 (Task 27). + feedingsSinceInspection: number; + feedingsSinceLateSummer: number; + treatmentsSinceLateSummer: number; + queen: { id: string; hatchYear: number | null } | null; + winterChecks: { winterYear: number; season: WinterCheckSeason }[]; + season: SeasonState; +} + +export interface AutopilotConfigs { + autopilot: ActiveRule<'SEASON_AUTOPILOT'>; + interval: ActiveRule<'INSPECTION_INTERVAL'>; + /** Only `recheckAfterDays` is used here (A3). */ + varroa: ActiveRule<'VARROA_THRESHOLD'>; + phenology: PhenologyConfig; +} + +export interface AutopilotCandidate { + rule: AutopilotRuleId; + ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL'; + intent: SuggestionIntent; + mode: SuggestionMode; + reasonCode: string; + evidence: SuggestionEvidence; + dueDate: string; + priority: TaskPriority; + anchor: string; + requiredPermission: PermissionKey; +} + +export type AutopilotRule = ( + input: AutopilotInput, + configs: AutopilotConfigs, + today: string, +) => AutopilotCandidate | null; + +export interface AutopilotSyncResult { + opened: number; + refreshed: number; + resolved: number; +} diff --git a/ApiCore-Backend/src/autopilot/suggestions-expiry.step.spec.ts b/ApiCore-Backend/src/autopilot/suggestions-expiry.step.spec.ts new file mode 100644 index 0000000..bc46dcf --- /dev/null +++ b/ApiCore-Backend/src/autopilot/suggestions-expiry.step.spec.ts @@ -0,0 +1,29 @@ +import type { SuggestionsService } from '../suggestions/suggestions.service'; +import { SuggestionsExpiryStep } from './suggestions-expiry.step'; + +describe('SuggestionsExpiryStep', () => { + it('expires the open suggestions past their day and reports the count', async () => { + const expireDue = jest.fn().mockResolvedValue(3); + const step = new SuggestionsExpiryStep({ + expireDue, + } as unknown as SuggestionsService); + + expect(step.name).toBe('suggestions-expiry'); + expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 3 }); + expect(expireDue).toHaveBeenCalledWith('2026-05-12'); + }); + + it('is idempotent: a second run expires nothing more', async () => { + const expireDue = jest + .fn() + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(0); + const step = new SuggestionsExpiryStep({ + expireDue, + } as unknown as SuggestionsService); + + await step.run('2026-05-12'); + + expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 0 }); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/suggestions-expiry.step.ts b/ApiCore-Backend/src/autopilot/suggestions-expiry.step.ts new file mode 100644 index 0000000..da52abd --- /dev/null +++ b/ApiCore-Backend/src/autopilot/suggestions-expiry.step.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps'; +import type { JobResult } from '../jobs/job'; +import { SuggestionsService } from '../suggestions/suggestions.service'; + +/** + * Step "suggestions-expiry": OPEN suggestions past their expiry day become + * EXPIRED (one statement, which also frees their dedupe key). Running it + * twice for one day changes nothing the second time. + */ +@Injectable() +export class SuggestionsExpiryStep implements DailyEvaluationStep { + readonly name = 'suggestions-expiry'; + + constructor(private readonly suggestions: SuggestionsService) {} + + async run(today: string): Promise { + return { suggestionsExpired: await this.suggestions.expireDue(today) }; + } +} diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 8719872..99240d0 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -518,6 +518,44 @@ describe('ColoniesService', () => { ); }); + it('writing occupiedFrames leaves strength untouched', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + strength: 7, + }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + strength: 7, + occupiedFrames: 14, + }); + + await service.update('colony-1', { occupiedFrames: 14 }, 'user-1'); + + expect(tx.colony.update).toHaveBeenCalledWith({ + where: { id: 'colony-1' }, + data: { occupiedFrames: 14 }, + }); + }); + + it('updateIn locks the colony row before reading before', async () => { + tx.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue(parentColony); + + const result = await service.updateIn( + tx as never, + 'colony-1', + { notes: 'x' }, + 'user-1', + ); + + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.colony.findUnique.mock.invocationCallOrder[0], + ); + expect(result.before.id).toBe('colony-1'); + expect(result.after).toBe(parentColony); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + it('update records no event when status and health stay the same', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); tx.colony.update.mockResolvedValue(parentColony); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index dbdee6b..dd6c435 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -8,7 +8,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; -import { Colony, ColonyStatus } from '../../generated/prisma'; +import { Colony, ColonyStatus, Prisma } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto'; @@ -105,51 +105,63 @@ export class ColoniesService { dto: UpdateColonyDto, actingUserId: string, ): Promise { - // Fail fast with 404; everything else is decided on the locked row. - await this.findById(id); + const { after } = await this.prisma.$transaction((tx) => + this.updateIn(tx, id, dto, actingUserId), + ); + return after; + } - const colony = await this.prisma.$transaction(async (tx) => { - const before = await lockColony(tx, id); - const movesApiary = - dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; - if (movesApiary) { - await this.assertApiaryActive(dto.apiaryId!); - } - const updated = await tx.colony.update({ - where: { id }, - data: { ...dto }, - }); - // Correcting the apiary has to leave the same trace a deliberate move - // does - otherwise the colony's location history would silently skip a - // stand it was recorded on. - if (movesApiary) { - await tx.colonyApiaryHistory.create({ - data: { - colonyId: id, - apiaryId: updated.apiaryId, - movedByUserId: actingUserId, - notes: 'Korrektur über Volk bearbeiten', - }, - }); - } - await this.events.recordAll( - tx, - colonyTransitionEvents(before, updated, actingUserId), - ); - await this.auditLog.record( - { - entityType: 'Colony', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: updated, - }, - tx, - ); - return updated; + /** + * The core of update, for callers that already hold a transaction (an + * inspection handing values over to the colony card). Locks the colony row + * and reads "before" only afterwards (404 when missing); writes the apiary + * history, the status events and the audit entry with the change. + */ + async updateIn( + tx: Prisma.TransactionClient, + id: string, + dto: UpdateColonyDto, + actingUserId: string, + ): Promise<{ before: Colony; after: Colony }> { + const before = await lockColony(tx, id); + const movesApiary = + dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; + if (movesApiary) { + await this.assertApiaryActive(dto.apiaryId!); + } + const updated = await tx.colony.update({ + where: { id }, + data: { ...dto }, }); - return colony; + // Correcting the apiary has to leave the same trace a deliberate move + // does - otherwise the colony's location history would silently skip a + // stand it was recorded on. + if (movesApiary) { + await tx.colonyApiaryHistory.create({ + data: { + colonyId: id, + apiaryId: updated.apiaryId, + movedByUserId: actingUserId, + notes: 'Korrektur über Volk bearbeiten', + }, + }); + } + await this.events.recordAll( + tx, + colonyTransitionEvents(before, updated, actingUserId), + ); + await this.auditLog.record( + { + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); + return { before, after: updated }; } async move( diff --git a/ApiCore-Backend/src/colonies/colony-takeover.spec.ts b/ApiCore-Backend/src/colonies/colony-takeover.spec.ts new file mode 100644 index 0000000..f233a3c --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-takeover.spec.ts @@ -0,0 +1,117 @@ +import { + broodConditionFromPattern, + planColonyTakeover, +} from './colony-takeover'; + +const inspection = { + colonyStrength: 12 as number | null, + broodPattern: 4 as number | null, + feedStatus: 'GOOD' as 'GOOD' | null, +}; +const allowed = { + canUpdateColony: true, + colonyEnded: false, + hasNewerInspection: false, +}; + +describe('colony takeover', () => { + it.each([ + [5, 'EXCELLENT'], + [4, 'GOOD'], + [3, 'AVERAGE'], + [2, 'WEAK'], + [1, 'CRITICAL'], + [null, null], + ])('brood pattern %p -> %p', (pattern, expected) => { + expect(broodConditionFromPattern(pattern)).toBe(expected); + }); + + it('takes over only requested fields that have a value', () => { + const result = planColonyTakeover( + { ...inspection, feedStatus: null }, + ['occupiedFrames', 'feedStatus'], + allowed, + ); + expect(result.patch).toEqual({ occupiedFrames: 12 }); + expect(result.takeover).toEqual({ + applied: ['occupiedFrames'], + skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }], + }); + }); + + it('maps the brood pattern to the brood status', () => { + const result = planColonyTakeover(inspection, ['broodStatus'], allowed); + expect(result.patch).toEqual({ broodStatus: 'GOOD' }); + }); + + it('takes nothing over when nothing is requested', () => { + const result = planColonyTakeover(inspection, [], allowed); + expect(result.patch).toEqual({}); + expect(result.takeover).toEqual({ applied: [], skipped: [] }); + }); + + it('skips everything with NO_PERMISSION when the caller may not update colonies', () => { + const result = planColonyTakeover( + inspection, + ['occupiedFrames', 'broodStatus', 'feedStatus'], + { ...allowed, canUpdateColony: false, hasNewerInspection: true }, + ); + expect(result.patch).toEqual({}); + expect(result.takeover.applied).toEqual([]); + expect(result.takeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NO_PERMISSION' }, + { field: 'broodStatus', reason: 'NO_PERMISSION' }, + { field: 'feedStatus', reason: 'NO_PERMISSION' }, + ]); + }); + + it('skips everything with NEWER_INSPECTION when a later inspection exists', () => { + const result = planColonyTakeover(inspection, ['occupiedFrames'], { + ...allowed, + hasNewerInspection: true, + }); + expect(result.patch).toEqual({}); + expect(result.takeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NEWER_INSPECTION' }, + ]); + }); + + it('skips every field with COLONY_ENDED when the colony no longer exists', () => { + const result = planColonyTakeover( + inspection, + ['occupiedFrames', 'feedStatus'], + { ...allowed, colonyEnded: true }, + ); + expect(result.patch).toEqual({}); + expect(result.takeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'COLONY_ENDED' }, + { field: 'feedStatus', reason: 'COLONY_ENDED' }, + ]); + }); + + it.each([45, -1])( + 'skips OUT_OF_RANGE for the value %p outside the colony card range', + (value) => { + const result = planColonyTakeover( + { ...inspection, colonyStrength: value }, + ['occupiedFrames'], + allowed, + ); + expect(result.patch).toEqual({}); + expect(result.takeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'OUT_OF_RANGE' }, + ]); + }, + ); + + it('accepts the bounds 0 and 40', () => { + for (const value of [0, 40]) { + const result = planColonyTakeover( + { ...inspection, colonyStrength: value }, + ['occupiedFrames'], + allowed, + ); + expect(result.patch).toEqual({ occupiedFrames: value }); + } + }); +}); diff --git a/ApiCore-Backend/src/colonies/colony-takeover.ts b/ApiCore-Backend/src/colonies/colony-takeover.ts new file mode 100644 index 0000000..d54b951 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-takeover.ts @@ -0,0 +1,121 @@ +import type { + Colony, + ColonyCondition, + Inspection, +} from '../../generated/prisma'; + +export const COLONY_TAKEOVER_FIELDS = [ + 'occupiedFrames', + 'broodStatus', + 'feedStatus', +] as const; +export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number]; + +export type TakeoverSkipReason = + | 'NO_VALUE' + | 'NO_PERMISSION' + | 'NEWER_INSPECTION' + | 'COLONY_ENDED' + | 'OUT_OF_RANGE'; + +export interface ColonyTakeover { + applied: ColonyTakeoverField[]; + skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[]; +} + +export const MAX_OCCUPIED_FRAMES = 40; + +const CONDITION_BY_BROOD_PATTERN: Record = { + 5: 'EXCELLENT', + 4: 'GOOD', + 3: 'AVERAGE', + 2: 'WEAK', + 1: 'CRITICAL', +}; + +/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */ +export function broodConditionFromPattern( + pattern: number | null, +): ColonyCondition | null { + if (pattern === null) { + return null; + } + return CONDITION_BY_BROOD_PATTERN[pattern] ?? null; +} + +/** Only values that exist: a takeover never writes null. */ +type TakeoverPatch = { + [K in ColonyTakeoverField]?: NonNullable; +}; +type TakeoverSource = Pick< + Inspection, + 'colonyStrength' | 'broodPattern' | 'feedStatus' +>; + +interface FieldPlan { + patch: TakeoverPatch | null; + skip: TakeoverSkipReason | null; +} + +function planField( + field: ColonyTakeoverField, + inspection: TakeoverSource, +): FieldPlan { + switch (field) { + case 'occupiedFrames': { + const frames = inspection.colonyStrength; + if (frames === null) { + return { patch: null, skip: 'NO_VALUE' }; + } + if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) { + return { patch: null, skip: 'OUT_OF_RANGE' }; + } + return { patch: { occupiedFrames: frames }, skip: null }; + } + case 'broodStatus': { + const status = broodConditionFromPattern(inspection.broodPattern); + return status === null + ? { patch: null, skip: 'NO_VALUE' } + : { patch: { broodStatus: status }, skip: null }; + } + case 'feedStatus': + return inspection.feedStatus === null + ? { patch: null, skip: 'NO_VALUE' } + : { patch: { feedStatus: inspection.feedStatus }, skip: null }; + } +} + +/** + * What a saved inspection hands over to the colony card. Values come from the + * stored inspection only. A missing permission, an ended colony and a newer + * inspection each skip every requested field: a newer inspection blocks all + * fields, not only the one it would overwrite (spec 7.9). + */ +export function planColonyTakeover( + inspection: TakeoverSource, + requested: readonly ColonyTakeoverField[], + ctx: { + canUpdateColony: boolean; + colonyEnded: boolean; + hasNewerInspection: boolean; + }, +): { patch: TakeoverPatch; takeover: ColonyTakeover } { + const patch: TakeoverPatch = {}; + const takeover: ColonyTakeover = { applied: [], skipped: [] }; + for (const field of requested) { + const plan: FieldPlan = !ctx.canUpdateColony + ? { patch: null, skip: 'NO_PERMISSION' } + : ctx.colonyEnded + ? { patch: null, skip: 'COLONY_ENDED' } + : ctx.hasNewerInspection + ? { patch: null, skip: 'NEWER_INSPECTION' } + : planField(field, inspection); + if (plan.skip !== null) { + takeover.skipped.push({ field, reason: plan.skip }); + continue; + } + Object.assign(patch, plan.patch); + takeover.applied.push(field); + } + return { patch, takeover }; +} diff --git a/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts index daea247..bb4a7b8 100644 --- a/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts +++ b/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts @@ -25,13 +25,29 @@ export class CreateColonyDto { @IsString() originText?: string; - @ApiPropertyOptional({ minimum: 1, maximum: 10 }) + @ApiPropertyOptional({ + minimum: 1, + maximum: 10, + deprecated: true, + description: 'Volksstärke (alt, Skala 1–10)', + }) @IsOptional() @IsInt() @Min(1) @Max(10) strength?: number; + @ApiPropertyOptional({ + minimum: 0, + maximum: 40, + description: 'Volksstärke in besetzten Waben', + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(40, { message: 'Höchstens 40 besetzte Waben.' }) + occupiedFrames?: number; + @ApiPropertyOptional({ enum: ColonyCondition }) @IsOptional() @IsEnum(ColonyCondition) diff --git a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts index 9f995dd..04f7c56 100644 --- a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts +++ b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts @@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType( 'originText', 'colonyType', 'strength', + 'occupiedFrames', 'broodStatus', 'feedStatus', 'healthStatus', diff --git a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts index f7deef2..98f2a3a 100644 --- a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts +++ b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts @@ -115,6 +115,7 @@ const contracts: Record = { 'hiveType', 'originText', 'strength', + 'occupiedFrames', 'broodStatus', 'feedStatus', 'healthStatus', @@ -334,6 +335,7 @@ const contracts: Record = { clearable: [ 'durationMinutes', 'miteCount', + 'sampleBees', 'calculatedValue', 'assessment', 'notes', diff --git a/ApiCore-Backend/src/export/export.service.spec.ts b/ApiCore-Backend/src/export/export.service.spec.ts index c396a01..47645a3 100644 --- a/ApiCore-Backend/src/export/export.service.spec.ts +++ b/ApiCore-Backend/src/export/export.service.spec.ts @@ -59,6 +59,7 @@ describe('ExportService', () => { colonyType: 'PRODUCTION_COLONY', status: 'ACTIVE', strength: 8, + occupiedFrames: 14, healthStatus: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), }, @@ -67,9 +68,10 @@ describe('ExportService', () => { const csv = await service.exportColoniesCsv(); const lines = csv.split('\n'); expect(lines[0]).toBe( - 'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt', + 'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt,occupiedFrames', ); expect(lines[1]).toContain('"V-1, Ost"'); + expect(lines[1]).toMatch(/,8,,2026-01-01T00:00:00.000Z,14$/); }); it('exports honey batches as CSV', async () => { diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts index a43dfc6..80c78aa 100644 --- a/ApiCore-Backend/src/export/export.service.ts +++ b/ApiCore-Backend/src/export/export.service.ts @@ -122,6 +122,8 @@ export class ExportService { 'strength', 'healthStatus', 'createdAt', + // Last: consumers of the CSV read columns by position. + 'occupiedFrames', ]); } diff --git a/ApiCore-Backend/src/import/import.service.spec.ts b/ApiCore-Backend/src/import/import.service.spec.ts index 7e779d2..181a9ef 100644 --- a/ApiCore-Backend/src/import/import.service.spec.ts +++ b/ApiCore-Backend/src/import/import.service.spec.ts @@ -169,6 +169,32 @@ describe('ImportService', () => { ]); }); + it('carries strength and occupiedFrames from the CSV', 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,strength,occupiedFrames', + 'V-1,Teststand,PRODUCTION_COLONY,6,14', + 'V-2,Teststand,PRODUCTION_COLONY,,41', + ].join('\n'), + ), + 'user-1', + ); + + expect(report.created).toBe(1); + expect(report.errors).toBe(1); + expect(report.rows[1].message).toContain('Höchstens 40 besetzte Waben.'); + const [[createArg]] = prisma.colony.create.mock.calls as [ + [{ data: object }], + ]; + expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 }); + }); + it('reports a validation error for an invalid colonyType', async () => { prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' }); diff --git a/ApiCore-Backend/src/import/import.service.ts b/ApiCore-Backend/src/import/import.service.ts index 6a5857d..3546146 100644 --- a/ApiCore-Backend/src/import/import.service.ts +++ b/ApiCore-Backend/src/import/import.service.ts @@ -179,6 +179,7 @@ export class ImportService { hiveType: emptyToUndefined(record.hiveType), originText: emptyToUndefined(record.originText), strength: parseNumberField(record.strength), + occupiedFrames: parseNumberField(record.occupiedFrames), broodStatus: emptyToUndefined(record.broodStatus), feedStatus: feed.feedStatus ?? undefined, healthStatus: emptyToUndefined(record.healthStatus), diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts index b2afe2e..02e5c24 100644 --- a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -1,8 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + ArrayUnique, + IsArray, IsBoolean, IsDateString, IsEnum, + IsIn, IsInt, IsNumber, IsOptional, @@ -10,6 +14,10 @@ import { Max, Min, } from 'class-validator'; +import { + COLONY_TAKEOVER_FIELDS, + type ColonyTakeoverField, +} from '../../colonies/colony-takeover'; import { Transform } from 'class-transformer'; import { ColonyCondition } from '../../../generated/prisma'; import { feedConditionFromInput } from '../../common/feed-vocabulary'; @@ -33,10 +41,15 @@ export class CreateInspectionDto { @IsNumber() temperature?: number; - @ApiPropertyOptional() + @ApiPropertyOptional({ + minimum: 0, + maximum: 40, + description: 'Volksstärke in besetzten Waben', + }) @IsOptional() @IsInt() @Min(0) + @Max(40, { message: 'Höchstens 40 besetzte Waben.' }) colonyStrength?: number; @ApiPropertyOptional({ description: 'Anzahl Brutwaben' }) @@ -146,4 +159,17 @@ export class CreateInspectionDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + enum: COLONY_TAKEOVER_FIELDS, + isArray: true, + description: + 'Colony card fields to fill from this inspection, in the same request. The server takes the values from the saved inspection.', + }) + @IsOptional() + @IsArray() + @ArrayUnique() + @ArrayMaxSize(3) + @IsIn(COLONY_TAKEOVER_FIELDS, { each: true }) + applyToColony?: ColonyTakeoverField[]; } diff --git a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts index cd64e52..1313cbd 100644 --- a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts @@ -1,11 +1,13 @@ -import { PartialType } from '@nestjs/swagger'; +import { OmitType, PartialType } from '@nestjs/swagger'; import { CreateInspectionDto } from './create-inspection.dto'; /** * Every field stays correctable, `colonyId` included: a record filed against * the wrong colony is a data-entry error, and re-filing it is the only way to - * repair the colony's history without deleting the record. + * repair the colony's history without deleting the record. `applyToColony` is + * not part of a correction: a correction takes nothing over to the colony. */ -export class UpdateInspectionDto extends PartialType(CreateInspectionDto, { - skipNullProperties: false, -}) {} +export class UpdateInspectionDto extends PartialType( + OmitType(CreateInspectionDto, ['applyToColony'] as const), + { skipNullProperties: false }, +) {} diff --git a/ApiCore-Backend/src/inspections/inspections.module.ts b/ApiCore-Backend/src/inspections/inspections.module.ts index 47e276f..8c13a0e 100644 --- a/ApiCore-Backend/src/inspections/inspections.module.ts +++ b/ApiCore-Backend/src/inspections/inspections.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; import { TasksModule } from '../tasks/tasks.module'; +import { ColoniesModule } from '../colonies/colonies.module'; import { InspectionsController } from './inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller'; import { InspectionsService } from './inspections.service'; /** * Rules, calendar and webhooks follow from the inspection's domain events - * (DomainEventsModule is global), so no AutomationModule here. + * (DomainEventsModule is global), so no AutomationModule here. The + * PermissionResolverService comes from the global resolver module. */ @Module({ - imports: [TasksModule], + imports: [TasksModule, ColoniesModule], controllers: [InspectionsController, ColonyInspectionsController], providers: [InspectionsService], exports: [InspectionsService], diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index c6af63c..d48f353 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -4,13 +4,21 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { TasksService } from '../tasks/tasks.service'; +import { ColoniesService } from '../colonies/colonies.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { Prisma } from '../../generated/prisma'; describe('InspectionsService', () => { let service: InspectionsService; let tx: { $queryRaw: jest.Mock; - inspection: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock }; + colony: { findUniqueOrThrow: jest.Mock }; + inspection: { + findUnique: jest.Mock; + findFirst: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; }; let prisma: { $connect: jest.Mock; @@ -25,6 +33,8 @@ describe('InspectionsService', () => { let auditLog: { record: jest.Mock }; let events: { record: jest.Mock }; let tasks: { createInTx: jest.Mock }; + let colonies: { updateIn: jest.Mock }; + let resolver: { getPermissions: jest.Mock }; const inspection = { id: 'inspection-1', @@ -35,8 +45,12 @@ describe('InspectionsService', () => { beforeEach(() => { tx = { $queryRaw: jest.fn().mockResolvedValue([]), + colony: { + findUniqueOrThrow: jest.fn().mockResolvedValue({ status: 'ACTIVE' }), + }, inspection: { findUnique: jest.fn(), + findFirst: jest.fn().mockResolvedValue(null), create: jest.fn(), update: jest.fn(), }, @@ -56,11 +70,17 @@ describe('InspectionsService', () => { auditLog = { record: jest.fn().mockResolvedValue(undefined) }; events = { record: jest.fn().mockResolvedValue(undefined) }; tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) }; + colonies = { updateIn: jest.fn().mockResolvedValue({}) }; + resolver = { + getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])), + }; service = new InspectionsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, events as unknown as DomainEventsService, tasks as unknown as TasksService, + colonies as unknown as ColoniesService, + resolver as unknown as PermissionResolverService, ); }); @@ -72,6 +92,8 @@ describe('InspectionsService', () => { AuditLogService, DomainEventsService, TasksService, + ColoniesService, + PermissionResolverService, ]); }); @@ -90,7 +112,10 @@ describe('InspectionsService', () => { 'user-1', ); - expect(result).toBe(inspection); + expect(result).toEqual({ + ...inspection, + colonyTakeover: { applied: [], skipped: [] }, + }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(tx.inspection.create).toHaveBeenCalledWith({ data: { @@ -122,6 +147,164 @@ describe('InspectionsService', () => { expect(tasks.createInTx).not.toHaveBeenCalled(); }); + describe('applyToColony', () => { + const saved = { + ...inspection, + colonyStrength: 14, + broodPattern: 4, + feedStatus: 'GOOD', + }; + + it('locks the colony, then takes the stored values over via updateIn in the same transaction', async () => { + tx.inspection.create.mockResolvedValue(saved); + + const result = await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + colonyStrength: 14, + applyToColony: ['occupiedFrames', 'broodStatus', 'feedStatus'], + }, + 'user-1', + ); + + const lock = tx.$queryRaw.mock.invocationCallOrder[0]; + expect(lock).toBeLessThan( + tx.inspection.create.mock.invocationCallOrder[0], + ); + expect(lock).toBeLessThan( + tx.inspection.findFirst.mock.invocationCallOrder[0], + ); + expect(lock).toBeLessThan(colonies.updateIn.mock.invocationCallOrder[0]); + // Permissions are resolved before the transaction opens. + expect(resolver.getPermissions.mock.invocationCallOrder[0]).toBeLessThan( + prisma.$transaction.mock.invocationCallOrder[0], + ); + const createArg = ( + tx.inspection.create.mock.calls[0] as [{ data: object }] + )[0]; + expect(createArg.data).not.toHaveProperty('applyToColony'); + expect(colonies.updateIn).toHaveBeenCalledWith( + tx, + 'colony-1', + { occupiedFrames: 14, broodStatus: 'GOOD', feedStatus: 'GOOD' }, + 'user-1', + ); + expect(result.colonyTakeover.applied).toEqual([ + 'occupiedFrames', + 'broodStatus', + 'feedStatus', + ]); + }); + + it('takes no lock and resolves no permission for an empty applyToColony', async () => { + tx.inspection.create.mockResolvedValue(saved); + + const result = await service.create( + { colonyId: 'colony-1', date: '2026-01-01', applyToColony: [] }, + 'user-1', + ); + + expect(tx.$queryRaw).not.toHaveBeenCalled(); + expect(resolver.getPermissions).not.toHaveBeenCalled(); + expect(tx.inspection.findFirst).not.toHaveBeenCalled(); + expect(result.colonyTakeover).toEqual({ applied: [], skipped: [] }); + }); + + it('orders two inspections by date, then createdAt, then id', async () => { + tx.inspection.create.mockResolvedValue({ + ...saved, + date: new Date('2026-01-01'), + createdAt: new Date('2026-01-02T10:00:00Z'), + }); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + applyToColony: ['feedStatus'], + }, + 'user-1', + ); + + const [[arg]] = tx.inspection.findFirst.mock.calls as [ + [{ where: { colonyId: string; OR: object[] } }], + ]; + expect(arg.where.OR).toEqual([ + { date: { gt: new Date('2026-01-01') } }, + { + date: new Date('2026-01-01'), + createdAt: { gt: new Date('2026-01-02T10:00:00Z') }, + }, + { + date: new Date('2026-01-01'), + createdAt: new Date('2026-01-02T10:00:00Z'), + id: { gt: 'inspection-1' }, + }, + ]); + }); + + it('skips with COLONY_ENDED for an ended colony but still saves the inspection', async () => { + tx.inspection.create.mockResolvedValue(saved); + tx.colony.findUniqueOrThrow.mockResolvedValue({ status: 'DISSOLVED' }); + + const result = await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + applyToColony: ['occupiedFrames'], + }, + 'user-1', + ); + + expect(colonies.updateIn).not.toHaveBeenCalled(); + expect(result.id).toBe('inspection-1'); + expect(result.colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'COLONY_ENDED' }, + ]); + }); + + it('skips with NEWER_INSPECTION and writes no colony change', async () => { + tx.inspection.create.mockResolvedValue(saved); + tx.inspection.findFirst.mockResolvedValue({ id: 'later' }); + + const result = await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + applyToColony: ['occupiedFrames'], + }, + 'user-1', + ); + + expect(colonies.updateIn).not.toHaveBeenCalled(); + expect(result.colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NEWER_INSPECTION' }, + ]); + }); + + it('skips with NO_PERMISSION without colonies:update', async () => { + tx.inspection.create.mockResolvedValue(saved); + resolver.getPermissions.mockResolvedValue( + new Set(['inspections:create']), + ); + + const result = await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + applyToColony: ['feedStatus'], + }, + 'user-1', + ); + + expect(colonies.updateIn).not.toHaveBeenCalled(); + expect(result.colonyTakeover.skipped).toEqual([ + { field: 'feedStatus', reason: 'NO_PERMISSION' }, + ]); + }); + }); + it('create fails as a whole when its event cannot be written', async () => { tx.inspection.create.mockResolvedValue(inspection); events.record.mockRejectedValue(new Error('outbox down')); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index a9b0a61..44e183e 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -1,16 +1,28 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { isLivingColonyStatus } from '../colonies/colony-status'; import { PrismaService } from '../prisma/prisma.service'; import { DbClient } from '../prisma/db-client'; import { lockRowForUpdate } from '../prisma/row-lock'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { TasksService } from '../tasks/tasks.service'; -import { Inspection, Task } from '../../generated/prisma'; +import { ColoniesService } from '../colonies/colonies.service'; +import { + ColonyTakeover, + ColonyTakeoverField, + planColonyTakeover, +} from '../colonies/colony-takeover'; +import { Inspection, Prisma, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { optionalDate } from '../common/patch-values'; +export type InspectionWithTakeover = Inspection & { + colonyTakeover: ColonyTakeover; +}; + /** * Inspections are written together with their audit entry and domain event * in one transaction: InspectionRecorded, InspectionCorrected. Automation @@ -26,6 +38,8 @@ export class InspectionsService { private readonly auditLog: AuditLogService, private readonly events: DomainEventsService, private readonly tasks: TasksService, + private readonly colonies: ColoniesService, + private readonly permissionResolver: PermissionResolverService, ) {} async findAll(): Promise { @@ -46,10 +60,24 @@ export class InspectionsService { async create( dto: CreateInspectionDto, actingUserId: string, - ): Promise { + ): Promise { + const { applyToColony = [], ...data } = dto; + // Resolved before the transaction (it may query the database): the + // permission is only needed when a takeover was requested. + const canUpdateColony = + applyToColony.length > 0 && + (await this.permissionResolver.getPermissions(actingUserId)).has( + 'colonies:update', + ); return this.prisma.$transaction(async (tx) => { + if (applyToColony.length > 0) { + // Colony first (before the inspection row takes a key-share lock on + // it): serializes concurrent inspections of one colony, so "is there + // a newer inspection" below is decided on a stable state. + await lockRowForUpdate(tx, 'Colony', dto.colonyId); + } const inspection = await tx.inspection.create({ - data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + data: { ...data, date: new Date(dto.date), userId: actingUserId }, }); await this.auditLog.record( { @@ -68,10 +96,69 @@ export class InspectionsService { payload: { colonyId: inspection.colonyId }, actorUserId: actingUserId, }); - return inspection; + const colonyTakeover = await this.takeOver( + tx, + inspection, + applyToColony, + canUpdateColony, + actingUserId, + ); + return { ...inspection, colonyTakeover }; }); } + /** + * Hands values of the just-saved inspection over to the colony card. The + * values come from the stored row, never from the request. + */ + private async takeOver( + tx: Prisma.TransactionClient, + inspection: Inspection, + requested: readonly ColonyTakeoverField[], + canUpdateColony: boolean, + actingUserId: string, + ): Promise { + if (requested.length === 0) { + return { applied: [], skipped: [] }; + } + // The colony row is locked (see create), so both reads below see a state + // no concurrent inspection or colony change can alter. + const colony = await tx.colony.findUniqueOrThrow({ + where: { id: inspection.colonyId }, + select: { status: true }, + }); + // Deterministic order of two inspections: date, then createdAt, then id. + const newer = await tx.inspection.findFirst({ + where: { + colonyId: inspection.colonyId, + OR: [ + { date: { gt: inspection.date } }, + { date: inspection.date, createdAt: { gt: inspection.createdAt } }, + { + date: inspection.date, + createdAt: inspection.createdAt, + id: { gt: inspection.id }, + }, + ], + }, + select: { id: true }, + }); + const { patch, takeover } = planColonyTakeover(inspection, requested, { + canUpdateColony, + colonyEnded: !isLivingColonyStatus(colony.status), + hasNewerInspection: newer !== null, + }); + if (Object.keys(patch).length > 0) { + await this.colonies.updateIn( + tx, + inspection.colonyId, + patch, + actingUserId, + ); + } + return takeover; + } + /** * A correction evaluates no automation rule, as before the event * backbone. InspectionCorrected moves the calendar entry when the date or diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts index 624d6c7..584e3a5 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts @@ -1,6 +1,8 @@ import { Logger } from '@nestjs/common'; import { JobPartialFailure } from '../system/job-run.service'; import { DailyEvaluationJob } from './daily-evaluation.job'; +import type { AutopilotStep } from '../autopilot/autopilot.step'; +import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step'; import type { TimeEventsStep } from './steps/time-events.step'; describe('DailyEvaluationJob', () => { @@ -11,14 +13,29 @@ describe('DailyEvaluationJob', () => { VarroaMeasurementDue: 0, InspectionOverdue: 0, }; + const AUTOPILOT_COUNTS = { + autopilotColonies: 4, + suggestionsOpened: 2, + suggestionsResolved: 1, + }; let timeEvents: { name: 'time-events'; run: jest.Mock }; + let autopilot: { name: 'autopilot'; run: jest.Mock }; + let expiry: { name: 'suggestions-expiry'; run: jest.Mock }; let job: DailyEvaluationJob; beforeEach(() => { 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); + autopilot = { name: 'autopilot', run: jest.fn() }; + autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS); + expiry = { name: 'suggestions-expiry', run: jest.fn() }; + expiry.run.mockResolvedValue({ suggestionsExpired: 3 }); + job = new DailyEvaluationJob( + timeEvents as unknown as TimeEventsStep, + autopilot as unknown as AutopilotStep, + expiry as unknown as SuggestionsExpiryStep, + ); }); afterEach(() => { @@ -31,8 +48,44 @@ describe('DailyEvaluationJob', () => { }); it('runs the steps for the Berlin day of the run and reports their counters', async () => { - expect(await job.run()).toEqual(COUNTS); + expect(await job.run()).toEqual({ + ...COUNTS, + ...AUTOPILOT_COUNTS, + suggestionsExpired: 3, + }); expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10'); + expect(autopilot.run).toHaveBeenCalledWith('2026-06-10'); + expect(expiry.run).toHaveBeenCalledWith('2026-06-10'); + }); + + it('runs time-events, autopilot and suggestions-expiry in that order', async () => { + await job.run(); + + expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan( + autopilot.run.mock.invocationCallOrder[0], + ); + expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan( + expiry.run.mock.invocationCallOrder[0], + ); + }); + + it('a failing autopilot step does not stop the expiry step', async () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + autopilot.run.mockRejectedValue( + new JobPartialFailure({ autopilotColonies: 1 }, ['1 colony']), + ); + + const failure = await job.run().then( + () => null, + (error: unknown) => error, + ); + + expect(expiry.run).toHaveBeenCalled(); + expect(failure).toMatchObject({ + failedSteps: ['autopilot'], + result: { autopilotColonies: 1, suggestionsExpired: 3 }, + message: 'Daily evaluation failed for: autopilot: 1 colony', + }); }); it('takes the Berlin day, not the UTC day, around midnight', async () => { diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts index 6da6a61..1877c8c 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +import { AutopilotStep } from '../autopilot/autopilot.step'; +import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step'; import { berlinToday } from '../common/dates'; import { JobPartialFailure } from '../system/job-run.service'; import { @@ -21,11 +23,19 @@ export class DailyEvaluationJob implements Job { readonly name: JobName = 'daily-evaluation'; private readonly logger = new Logger(DailyEvaluationJob.name); - constructor(private readonly timeEvents: TimeEventsStep) {} + constructor( + private readonly timeEvents: TimeEventsStep, + private readonly autopilot: AutopilotStep, + private readonly suggestionsExpiry: SuggestionsExpiryStep, + ) {} /** Evaluates the current Berlin day. */ async run(): Promise { - const steps: DailyEvaluationStep[] = [this.timeEvents]; + const steps: DailyEvaluationStep[] = [ + this.timeEvents, + this.autopilot, + this.suggestionsExpiry, + ]; const { result, failed, failures } = await runDailyEvaluationSteps( steps, berlinToday(), diff --git a/ApiCore-Backend/src/jobs/jobs.module.ts b/ApiCore-Backend/src/jobs/jobs.module.ts index 3f3b47e..654d191 100644 --- a/ApiCore-Backend/src/jobs/jobs.module.ts +++ b/ApiCore-Backend/src/jobs/jobs.module.ts @@ -2,6 +2,7 @@ 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 { AutopilotModule } from '../autopilot/autopilot.module'; import { BackupModule } from '../backup/backup.module'; import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service'; import { DomainEventsModule } from '../domain-events/domain-events.module'; @@ -47,6 +48,7 @@ const JOB_CLASSES = [ BackupModule, SystemModule, SeasonModule, + AutopilotModule, ], providers: [ ...JOB_CLASSES, diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts index b3a1c75..95a11f4 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -24,6 +24,7 @@ describe('NotificationsService', () => { createManyAndReturn: jest.Mock; findMany: jest.Mock; findFirstOrThrow: jest.Mock; + findFirst: jest.Mock; updateMany: jest.Mock; }; user: { findMany: jest.Mock }; @@ -39,6 +40,7 @@ describe('NotificationsService', () => { ), findMany: jest.fn().mockResolvedValue([]), findFirstOrThrow: jest.fn(), + findFirst: jest.fn(), updateMany: jest.fn(), }, user: { findMany: jest.fn().mockResolvedValue([]) }, @@ -351,17 +353,33 @@ describe('NotificationsService', () => { }); it('answers 404 when the notification is foreign or unknown', async () => { - prisma.notification.updateMany.mockResolvedValue({ count: 0 }); + prisma.notification.findFirst.mockResolvedValue(null); await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf( NotFoundException, ); - expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + expect(prisma.notification.findFirst).toHaveBeenCalledWith({ where: { id: 'n1', userId: 'user-1' }, - data: { read: true }, + select: { requiredPermissions: true }, }); + expect(prisma.notification.updateMany).not.toHaveBeenCalled(); }); - it('marks the own notification as read', async () => { + it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => { + prisma.notification.findFirst.mockResolvedValue({ + requiredPermissions: ['colonies:read'], + }); + await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(prisma.notification.updateMany).not.toHaveBeenCalled(); + expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled(); + }); + + it('marks the own visible notification as read', async () => { + held.add('colonies:read'); + prisma.notification.findFirst.mockResolvedValue({ + requiredPermissions: ['colonies:read'], + }); prisma.notification.updateMany.mockResolvedValue({ count: 1 }); prisma.notification.findFirstOrThrow.mockResolvedValue({ id: 'n1', @@ -371,16 +389,30 @@ describe('NotificationsService', () => { await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({ read: true, }); + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { id: 'n1', userId: 'user-1' }, + data: { read: true }, + }); }); - it('marks only the own unread notifications as read', async () => { - prisma.notification.updateMany.mockResolvedValue({ count: 3 }); + it('marks only the own unread notifications the user may see', async () => { + held.add('colonies:read'); + prisma.notification.findMany.mockResolvedValue([ + { id: 'a', requiredPermissions: [] }, + { id: 'b', requiredPermissions: ['colonies:read'] }, + { id: 'c', requiredPermissions: ['varroa:read'] }, + ]); + prisma.notification.updateMany.mockResolvedValue({ count: 2 }); await expect(service.markAllRead('user-1')).resolves.toEqual({ - updated: 3, + updated: 2, + }); + expect(prisma.notification.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', read: false }, + select: { id: true, requiredPermissions: true }, }); expect(prisma.notification.updateMany).toHaveBeenCalledWith({ - where: { userId: 'user-1', read: false }, + where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false }, data: { read: true }, }); }); diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts index ccf6de3..91a1462 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -168,8 +168,19 @@ export class NotificationsService { .map(toView); } - /** One statement; a foreign or unknown id matches nothing and answers 404. */ + /** + * A foreign, unknown or no longer visible row (same rule as findOwn) + * answers 404 and is left untouched. + */ async markRead(id: string, userId: string): Promise { + const row = await this.prisma.notification.findFirst({ + where: { id, userId }, + select: { requiredPermissions: true }, + }); + const held = await this.permissionResolver.getPermissions(userId); + if (!row || !row.requiredPermissions.every((key) => held.has(key))) { + throw new NotFoundException('Notification not found'); + } const { count } = await this.prisma.notification.updateMany({ where: { id, userId }, data: { read: true }, @@ -183,9 +194,21 @@ export class NotificationsService { }); } + /** Marks only the unread rows the user may see. */ async markAllRead(userId: string): Promise<{ updated: number }> { - const { count } = await this.prisma.notification.updateMany({ + const unread = await this.prisma.notification.findMany({ where: { userId, read: false }, + select: { id: true, requiredPermissions: true }, + }); + const held = await this.permissionResolver.getPermissions(userId); + const visible = unread + .filter((row) => row.requiredPermissions.every((key) => held.has(key))) + .map((row) => row.id); + if (visible.length === 0) { + return { updated: 0 }; + } + const { count } = await this.prisma.notification.updateMany({ + where: { id: { in: visible }, userId, read: false }, data: { read: true }, }); return { updated: count }; diff --git a/ApiCore-Backend/src/suggestions/chains/chain-planner.spec.ts b/ApiCore-Backend/src/suggestions/chains/chain-planner.spec.ts new file mode 100644 index 0000000..b5283ea --- /dev/null +++ b/ApiCore-Backend/src/suggestions/chains/chain-planner.spec.ts @@ -0,0 +1,174 @@ +import { DEFAULT_SPLIT_SCHEDULE_CONFIG } from '../../automation/rule-config/split-schedule.config'; +import { DEFAULT_CHAIN_CONFIG } from '../../automation/rule-config/chain.config'; +import { + queenAcceptanceCandidate, + queenCellsCandidate, + splitScheduleCandidate, +} from './chain-planner'; + +const inspection = (queenCellsPresent: boolean | null) => ({ + id: 'insp-1', + colonyId: 'col-1', + // 10:30 Berlin time on 2026-05-05 + date: new Date('2026-05-05T08:30:00.000Z'), + queenCellsPresent, +}); + +const split = (queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null) => ({ + parentColonyId: 'col-0', + lineageId: 'lin-1', + queenSource, + splitOn: '2026-05-01', +}); + +describe('chain planner', () => { + it('queen cells on 2026-05-05 propose a swarm check due 2026-05-12', () => { + const candidate = queenCellsCandidate( + inspection(true), + DEFAULT_CHAIN_CONFIG, + ); + + expect(candidate).toMatchObject({ + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + intent: 'SWARM_CHECK', + anchor: 'insp-1', + baseDate: '2026-05-05', + dueDate: '2026-05-12', + plan: null, + requiredPermission: 'inspections:read', + }); + expect(candidate?.evidence).toEqual({ + colonyId: 'col-1', + inspectionId: 'insp-1', + inspectedOn: '2026-05-05', + baseDate: '2026-05-05', + }); + }); + + it('no queen cells (false or unknown) yield nothing', () => { + expect( + queenCellsCandidate(inspection(false), DEFAULT_CHAIN_CONFIG), + ).toBeNull(); + expect( + queenCellsCandidate(inspection(null), DEFAULT_CHAIN_CONFIG), + ).toBeNull(); + }); + + it('uses the configured dueInDays', () => { + const candidate = queenCellsCandidate(inspection(true), { + ...DEFAULT_CHAIN_CONFIG, + dueInDays: 3, + }); + expect(candidate?.dueDate).toBe('2026-05-08'); + }); + + it('RAISE_OWN yields one SPLIT_PLAN with offsets 9, 21 (window 3) and 28 from the split date', () => { + const candidate = splitScheduleCandidate( + 'col-new', + split('RAISE_OWN'), + DEFAULT_SPLIT_SCHEDULE_CONFIG, + ); + + expect(candidate).toMatchObject({ + ruleKey: 'SPLIT_SCHEDULE', + intent: 'SPLIT_PLAN', + anchor: 'lin-1', + baseDate: '2026-05-01', + dueDate: '2026-05-10', + requiredPermission: 'colonies:read', + }); + expect(candidate?.plan).toEqual([ + { step: 'RAISE_CELLS_CHECK', offsetDays: 9 }, + { step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }, + { step: 'EGGS_CHECK', offsetDays: 28 }, + ]); + expect(candidate?.evidence).toMatchObject({ + colonyId: 'col-new', + parentColonyId: 'col-0', + lineageId: 'lin-1', + baseDate: '2026-05-01', + }); + }); + + it('QUEEN_CELL yields one step at +21; QUEEN yields an acceptance check at +7', () => { + const cell = splitScheduleCandidate( + 'col-new', + split('QUEEN_CELL'), + DEFAULT_SPLIT_SCHEDULE_CONFIG, + ); + expect(cell?.intent).toBe('SPLIT_PLAN'); + expect(cell?.plan).toEqual([{ step: 'EGGS_CHECK', offsetDays: 21 }]); + expect(cell?.dueDate).toBe('2026-05-22'); + + const queen = splitScheduleCandidate( + 'col-new', + split('QUEEN'), + DEFAULT_SPLIT_SCHEDULE_CONFIG, + ); + expect(queen).toMatchObject({ + intent: 'QUEEN_ACCEPTANCE', + requiredPermission: 'queens:read', + anchor: 'lin-1', + dueDate: '2026-05-08', + }); + expect(queen?.plan).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]); + }); + + it('a split without queenSource yields nothing', () => { + expect( + splitScheduleCandidate( + 'col-new', + split(null), + DEFAULT_SPLIT_SCHEDULE_CONFIG, + ), + ).toBeNull(); + }); + + it('the due date is the earliest step, whatever the order of the steps', () => { + const candidate = splitScheduleCandidate('col-new', split('RAISE_OWN'), { + ...DEFAULT_SPLIT_SCHEDULE_CONFIG, + RAISE_OWN: [ + { step: 'EGGS_CHECK', offsetDays: 28 }, + { step: 'RAISE_CELLS_CHECK', offsetDays: 9 }, + ], + }); + expect(candidate?.dueDate).toBe('2026-05-10'); + }); + + it('a variant without steps yields nothing', () => { + expect( + splitScheduleCandidate('col-new', split('QUEEN'), { + ...DEFAULT_SPLIT_SCHEDULE_CONFIG, + QUEEN: [], + }), + ).toBeNull(); + }); + + it('a queen assignment proposes an acceptance check +7, anchored on the history row', () => { + const candidate = queenAcceptanceCandidate( + { + colonyId: 'col-1', + historyId: 'hist-1', + assignedOn: '2026-05-05', + previousColonyId: null, + }, + 'queen-1', + DEFAULT_CHAIN_CONFIG, + ); + + expect(candidate).toMatchObject({ + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + anchor: 'hist-1', + baseDate: '2026-05-05', + dueDate: '2026-05-12', + plan: null, + requiredPermission: 'queens:read', + }); + expect(candidate.evidence).toMatchObject({ + colonyId: 'col-1', + queenId: 'queen-1', + baseDate: '2026-05-05', + }); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/chains/chain-planner.ts b/ApiCore-Backend/src/suggestions/chains/chain-planner.ts new file mode 100644 index 0000000..1460d9a --- /dev/null +++ b/ApiCore-Backend/src/suggestions/chains/chain-planner.ts @@ -0,0 +1,121 @@ +import type { Inspection } from '../../../generated/prisma'; +import type { + ChainConfig, + PlanStepConfig, + SplitScheduleConfig, +} from '../../automation/rule-config'; +import type { PermissionKey } from '../../auth/permissions/permission-catalog'; +import { addDays, berlinDayOf } from '../../common/dates'; +import type { DomainEventPayloads } from '../../domain-events/domain-event-types'; +import type { SuggestionEvidence } from '../suggestion.types'; + +export interface ChainCandidate { + ruleKey: + 'QUEEN_CELLS_SWARM_CHECK' | 'QUEEN_ACCEPTANCE_CHECK' | 'SPLIT_SCHEDULE'; + intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN'; + reasonCode: string; + anchor: string; + /** YYYY-MM-DD, the day the offsets count from. */ + baseDate: string; + /** YYYY-MM-DD, the day of the first (or only) check. */ + dueDate: string; + plan: PlanStepConfig[] | null; + requiredPermission: PermissionKey; + evidence: SuggestionEvidence; +} + +/** A queen-cell finding on an inspection: check for a swarm a week later. */ +export function queenCellsCandidate( + inspection: Pick< + Inspection, + 'id' | 'colonyId' | 'date' | 'queenCellsPresent' + >, + config: ChainConfig, +): ChainCandidate | null { + if (inspection.queenCellsPresent !== true) { + return null; + } + const baseDate = berlinDayOf(inspection.date); + return { + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + intent: 'SWARM_CHECK', + reasonCode: 'QUEEN_CELLS_SWARM_CHECK', + anchor: inspection.id, + baseDate, + dueDate: addDays(baseDate, config.dueInDays), + plan: null, + requiredPermission: 'inspections:read', + evidence: { + colonyId: inspection.colonyId, + inspectionId: inspection.id, + inspectedOn: baseDate, + baseDate, + }, + }; +} + +/** A queen put into a colony: check that it was accepted. */ +export function queenAcceptanceCandidate( + payload: DomainEventPayloads['QueenAssigned'], + queenId: string, + config: ChainConfig, +): ChainCandidate { + return { + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + reasonCode: 'QUEEN_ASSIGNED_ACCEPTANCE', + anchor: payload.historyId, + baseDate: payload.assignedOn, + dueDate: addDays(payload.assignedOn, config.dueInDays), + plan: null, + requiredPermission: 'queens:read', + evidence: { + colonyId: payload.colonyId, + queenId, + historyId: payload.historyId, + assignedOn: payload.assignedOn, + baseDate: payload.assignedOn, + }, + }; +} + +/** + * The follow-up plan of a split, by where the new colony's queen comes + * from. `colonyId` is the new colony. No queen source, no chain. + */ +export function splitScheduleCandidate( + colonyId: string, + payload: DomainEventPayloads['ColonySplit'], + config: SplitScheduleConfig, +): ChainCandidate | null { + const { queenSource } = payload; + if (queenSource === null) { + return null; + } + const plan = config[queenSource].map((step) => ({ ...step })); + if (plan.length === 0) { + return null; + } + const isAcceptance = queenSource === 'QUEEN'; + return { + ruleKey: 'SPLIT_SCHEDULE', + intent: isAcceptance ? 'QUEEN_ACCEPTANCE' : 'SPLIT_PLAN', + reasonCode: `SPLIT_SCHEDULE_${queenSource}`, + anchor: payload.lineageId, + baseDate: payload.splitOn, + dueDate: addDays( + payload.splitOn, + Math.min(...plan.map((step) => step.offsetDays)), + ), + plan, + requiredPermission: isAcceptance ? 'queens:read' : 'colonies:read', + evidence: { + colonyId, + parentColonyId: payload.parentColonyId, + lineageId: payload.lineageId, + queenSource, + splitOn: payload.splitOn, + baseDate: payload.splitOn, + }, + }; +} diff --git a/ApiCore-Backend/src/suggestions/chains/chains.handlers.spec.ts b/ApiCore-Backend/src/suggestions/chains/chains.handlers.spec.ts new file mode 100644 index 0000000..d8afd11 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/chains/chains.handlers.spec.ts @@ -0,0 +1,360 @@ +import type { Prisma } from '../../../generated/prisma'; +import { lockRowForUpdate } from '../../prisma/row-lock'; +import type { + AnyDomainEventEnvelope, + DomainEventEnvelope, + DomainEventType, +} from '../../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../../domain-events/on-domain-event.decorator'; +import type { SuggestionsService } from '../suggestions.service'; +import { ChainEventHandlers } from './chains.handlers'; + +jest.mock('../../prisma/row-lock'); + +function envelope( + type: T, + aggregateId: string, + payload: DomainEventEnvelope['payload'], +): AnyDomainEventEnvelope { + return { + id: 'ev-1', + type, + aggregateType: 'Inspection', + aggregateId, + payload, + actorUserId: null, + correlationId: 'corr-1', + occurredAt: new Date('2026-05-05T08:00:00.000Z'), + attempts: 0, + } as unknown as AnyDomainEventEnvelope; +} + +interface RuleRow { + id: string; + active: boolean; + config: unknown; + actionTemplate: string | null; +} + +const assignedEvent = () => + envelope('QueenAssigned', 'queen-1', { + colonyId: 'col-1', + historyId: 'hist-1', + assignedOn: '2026-05-05', + previousColonyId: null, + }); + +const splitEvent = (queenSource: 'RAISE_OWN' | 'QUEEN' | null) => + envelope('ColonySplit', 'col-new', { + parentColonyId: 'col-0', + lineageId: 'lin-1', + queenSource, + splitOn: '2026-05-01', + }); + +describe('ChainEventHandlers', () => { + let tx: { + colony: { findUnique: jest.Mock }; + inspection: { findUnique: jest.Mock; findMany: jest.Mock }; + suggestion: { findMany: jest.Mock }; + automationRule: { findUnique: jest.Mock }; + }; + let suggestions: { proposeIn: jest.Mock; resolveIn: jest.Mock }; + let handlers: ChainEventHandlers; + const asTx = () => tx as unknown as Prisma.TransactionClient; + const rule = (overrides: Partial = {}): RuleRow => ({ + id: 'rule-1', + active: true, + config: {}, + actionTemplate: null, + ...overrides, + }); + const inspectionEvent = () => + envelope('InspectionRecorded', 'insp-1', { colonyId: 'col-1' }); + const inspectionRow = (overrides: object = {}) => ({ + id: 'insp-1', + colonyId: 'col-1', + date: new Date('2026-05-05T08:30:00.000Z'), + createdAt: new Date('2026-05-05T08:31:00.000Z'), + queenCellsPresent: true, + queenSeen: false, + eggsPresent: false, + ...overrides, + }); + const oldCheck = (inspectionId = 'insp-0') => ({ + id: 'old', + evidence: { colonyId: 'col-1', inspectionId }, + }); + const firstWhere = () => + (tx.suggestion.findMany.mock.calls[0] as [{ where: object }])[0].where; + + beforeEach(() => { + (lockRowForUpdate as jest.Mock).mockReset().mockResolvedValue(true); + tx = { + colony: { + findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }), + }, + inspection: { + findUnique: jest.fn().mockResolvedValue(inspectionRow()), + findMany: jest.fn().mockResolvedValue([ + { + id: 'insp-0', + date: new Date('2026-04-28T08:30:00.000Z'), + createdAt: new Date('2026-04-28T08:31:00.000Z'), + }, + ]), + }, + suggestion: { findMany: jest.fn().mockResolvedValue([]) }, + automationRule: { findUnique: jest.fn().mockResolvedValue(rule()) }, + }; + suggestions = { + proposeIn: jest.fn().mockResolvedValue({ id: 'sug-1' }), + resolveIn: jest.fn().mockResolvedValue(0), + }; + handlers = new ChainEventHandlers( + suggestions as unknown as SuggestionsService, + ); + }); + + it('registers its three handler ids', () => { + const ids = ( + ['queenCells', 'queenAcceptance', 'splitSchedule'] as const + ).flatMap((name) => + // eslint-disable-next-line @typescript-eslint/unbound-method -- only the decorator metadata is read + domainEventHandlerMetadata(ChainEventHandlers.prototype[name]).map( + (entry) => entry.handlerId, + ), + ); + expect(ids).toEqual([ + 'chains.queen-cells', + 'chains.queen-acceptance', + 'chains.split-schedule', + ]); + }); + + it('the handlers pass notify from the chain config to proposeIn', async () => { + tx.automationRule.findUnique.mockResolvedValue( + rule({ config: { notify: true, dueInDays: 5 } }), + ); + await handlers.queenCells(inspectionEvent(), asTx()); + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + source: 'CHAIN', + mode: 'PLAN', + intent: 'SWARM_CHECK', + relatedType: 'COLONY', + relatedId: 'col-1', + colonyId: 'col-1', + anchor: 'insp-1', + dueDate: '2026-05-10', + notify: true, + sourceEventId: 'ev-1', + }), + ); + + suggestions.proposeIn.mockClear(); + tx.automationRule.findUnique.mockResolvedValue( + rule({ config: { notify: true } }), + ); + await handlers.queenAcceptance(assignedEvent(), asTx()); + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ intent: 'QUEEN_ACCEPTANCE', notify: true }), + ); + + suggestions.proposeIn.mockClear(); + await handlers.splitSchedule(splitEvent('RAISE_OWN'), asTx()); + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + ruleKey: 'SPLIT_SCHEDULE', + intent: 'SPLIT_PLAN', + colonyId: 'col-new', + relatedId: 'col-new', + notify: true, + plan: expect.any(Array) as unknown, + }), + ); + }); + + it('notify defaults to false', async () => { + await handlers.queenAcceptance(assignedEvent(), asTx()); + expect(suggestions.proposeIn).toHaveBeenCalledWith( + tx, + expect.objectContaining({ notify: false }), + ); + }); + + it('locks the colony before it resolves or proposes anything', async () => { + const order: string[] = []; + (lockRowForUpdate as jest.Mock).mockImplementation(() => { + order.push('lock'); + return Promise.resolve(true); + }); + tx.suggestion.findMany.mockResolvedValue([oldCheck()]); + suggestions.resolveIn.mockImplementation(() => { + order.push('resolve'); + return Promise.resolve(1); + }); + suggestions.proposeIn.mockImplementation(() => { + order.push('propose'); + return Promise.resolve(null); + }); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Colony', 'col-1'); + expect(order[0]).toBe('lock'); + expect(order.indexOf('resolve')).toBeLessThan(order.indexOf('propose')); + }); + + it('resolves all open swarm checks when the inspection finds queen cells', async () => { + tx.suggestion.findMany.mockResolvedValue([oldCheck()]); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(firstWhere()).toEqual({ + colonyId: 'col-1', + status: 'OPEN', + source: 'CHAIN', + intent: 'SWARM_CHECK', + }); + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['old'], + 'Mit Durchsicht erledigt', + ); + }); + + it('leaves a swarm check open whose base inspection is not older than this one', async () => { + // A backdated inspection: the open check rests on a newer inspection. + tx.inspection.findMany.mockResolvedValue([ + { + id: 'insp-0', + date: new Date('2026-05-09T08:30:00.000Z'), + createdAt: new Date('2026-05-09T08:31:00.000Z'), + }, + ]); + tx.suggestion.findMany.mockResolvedValue([oldCheck()]); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + [], + 'Mit Durchsicht erledigt', + ); + }); + + it('on the same day the later created inspection wins', async () => { + tx.inspection.findMany.mockResolvedValue([ + { + id: 'insp-0', + date: new Date('2026-05-05T08:30:00.000Z'), + createdAt: new Date('2026-05-05T08:30:00.000Z'), + }, + ]); + tx.suggestion.findMany.mockResolvedValue([oldCheck()]); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['old'], + 'Mit Durchsicht erledigt', + ); + }); + + it('re-running the event for the same inspection resolves nothing', async () => { + tx.inspection.findMany.mockResolvedValue([ + { + id: 'insp-1', + date: new Date('2026-05-05T08:30:00.000Z'), + createdAt: new Date('2026-05-05T08:31:00.000Z'), + }, + ]); + tx.suggestion.findMany.mockResolvedValue([oldCheck('insp-1')]); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + [], + 'Mit Durchsicht erledigt', + ); + }); + + it('without queen cells only swarm checks due up to the inspection day are resolved, and nothing is proposed', async () => { + tx.inspection.findUnique.mockResolvedValue( + inspectionRow({ queenCellsPresent: false }), + ); + + await handlers.queenCells(inspectionEvent(), asTx()); + + expect(firstWhere()).toEqual({ + colonyId: 'col-1', + status: 'OPEN', + source: 'CHAIN', + intent: 'SWARM_CHECK', + dueDate: { lte: new Date('2026-05-05T00:00:00.000Z') }, + }); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('resolves acceptance checks only when the queen or eggs were seen', async () => { + tx.inspection.findUnique.mockResolvedValue( + inspectionRow({ queenCellsPresent: false }), + ); + await handlers.queenCells(inspectionEvent(), asTx()); + expect(tx.suggestion.findMany).toHaveBeenCalledTimes(1); + + tx.suggestion.findMany.mockClear(); + tx.inspection.findUnique.mockResolvedValue( + inspectionRow({ queenCellsPresent: false, eggsPresent: true }), + ); + await handlers.queenCells(inspectionEvent(), asTx()); + const intents = ( + tx.suggestion.findMany.mock.calls as [{ where: { intent: string } }][] + ).map(([arg]) => arg.where.intent); + expect(intents).toEqual(['SWARM_CHECK', 'QUEEN_ACCEPTANCE']); + }); + + it('nothing for an ended colony', async () => { + tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' }); + + await handlers.queenCells(inspectionEvent(), asTx()); + await handlers.queenAcceptance(assignedEvent(), asTx()); + await handlers.splitSchedule(splitEvent('QUEEN'), asTx()); + + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + }); + + it('nothing for an inactive rule', async () => { + tx.automationRule.findUnique.mockResolvedValue(rule({ active: false })); + + await handlers.queenCells(inspectionEvent(), asTx()); + await handlers.queenAcceptance(assignedEvent(), asTx()); + await handlers.splitSchedule(splitEvent('QUEEN'), asTx()); + + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('nothing for a colony or inspection that no longer exists', async () => { + (lockRowForUpdate as jest.Mock).mockResolvedValue(false); + await handlers.queenCells(inspectionEvent(), asTx()); + expect(tx.colony.findUnique).not.toHaveBeenCalled(); + + (lockRowForUpdate as jest.Mock).mockResolvedValue(true); + tx.inspection.findUnique.mockResolvedValue(null); + await handlers.queenCells(inspectionEvent(), asTx()); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('a split without queenSource proposes nothing', async () => { + await handlers.splitSchedule(splitEvent(null), asTx()); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/chains/chains.handlers.ts b/ApiCore-Backend/src/suggestions/chains/chains.handlers.ts new file mode 100644 index 0000000..cb33e14 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/chains/chains.handlers.ts @@ -0,0 +1,263 @@ +import { Injectable } from '@nestjs/common'; +import type { + Inspection, + Prisma, + SuggestionIntent, +} from '../../../generated/prisma'; +import { readRule } from '../../automation/rule-config'; +import { isLivingColonyStatus } from '../../colonies/colony-status'; +import { berlinDayOf, dayToDate } from '../../common/dates'; +import type { AnyDomainEventEnvelope } from '../../domain-events/domain-event-types'; +import { OnDomainEvent } from '../../domain-events/on-domain-event.decorator'; +import { lockRowForUpdate } from '../../prisma/row-lock'; +import type { ProposeInput } from '../suggestion.types'; +import { SuggestionsService } from '../suggestions.service'; +import { + queenAcceptanceCandidate, + queenCellsCandidate, + splitScheduleCandidate, + type ChainCandidate, +} from './chain-planner'; + +type Tx = Prisma.TransactionClient; + +/** + * Follow-up chains of the beekeeping craft: queen cells lead to a swarm + * check, a new queen to an acceptance check, a split to its schedule. Each + * handler locks the colony first (the same order as the colony sync), then + * lets SuggestionsService do the dedupe and the notification. Writes only + * through `tx`; an ended colony takes no new chain. + */ +@Injectable() +export class ChainEventHandlers { + constructor(private readonly suggestions: SuggestionsService) {} + + @OnDomainEvent('InspectionRecorded', 'chains.queen-cells') + async queenCells( + event: AnyDomainEventEnvelope<'InspectionRecorded'>, + tx: Tx, + ): Promise { + const { colonyId } = event.payload; + if (!(await this.lockLivingColonyIn(tx, colonyId))) { + return; + } + const inspection = await tx.inspection.findUnique({ + where: { id: event.aggregateId }, + }); + if (inspection === null) { + return; + } + // Clean up first: an open check of the same intent would block the new one. + await this.resolveAnsweredChecksIn(tx, inspection); + const rule = await readRule(tx, 'QUEEN_CELLS_SWARM_CHECK'); + if (!rule.active) { + return; + } + const candidate = queenCellsCandidate(inspection, rule.config); + if (candidate !== null) { + await this.proposeIn( + tx, + colonyId, + candidate, + rule.config.notify, + event.id, + ); + } + } + + @OnDomainEvent('QueenAssigned', 'chains.queen-acceptance') + async queenAcceptance( + event: AnyDomainEventEnvelope<'QueenAssigned'>, + tx: Tx, + ): Promise { + const { colonyId } = event.payload; + if (!(await this.lockLivingColonyIn(tx, colonyId))) { + return; + } + const rule = await readRule(tx, 'QUEEN_ACCEPTANCE_CHECK'); + if (!rule.active) { + return; + } + const candidate = queenAcceptanceCandidate( + event.payload, + event.aggregateId, + rule.config, + ); + await this.proposeIn(tx, colonyId, candidate, rule.config.notify, event.id); + } + + // The event's aggregate is the new colony. + @OnDomainEvent('ColonySplit', 'chains.split-schedule') + async splitSchedule( + event: AnyDomainEventEnvelope<'ColonySplit'>, + tx: Tx, + ): Promise { + const colonyId = event.aggregateId; + if (!(await this.lockLivingColonyIn(tx, colonyId))) { + return; + } + const rule = await readRule(tx, 'SPLIT_SCHEDULE'); + if (!rule.active) { + return; + } + const candidate = splitScheduleCandidate( + colonyId, + event.payload, + rule.config, + ); + if (candidate !== null) { + await this.proposeIn( + tx, + colonyId, + candidate, + rule.config.notify, + event.id, + ); + } + } + + /** Locks the colony row; true when the colony exists and still lives. */ + private async lockLivingColonyIn(tx: Tx, colonyId: string): Promise { + if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) { + return false; + } + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { status: true }, + }); + return colony !== null && isLivingColonyStatus(colony.status); + } + + /** + * An inspection answers the open chain checks of its colony: queen cells + * found replace every open swarm check that rests on an older inspection, + * otherwise the swarm checks due by now are done; an acceptance check is + * done once the queen or eggs were seen. A backdated inspection never + * closes a check that rests on a newer one. + */ + private async resolveAnsweredChecksIn( + tx: Tx, + inspection: Pick< + Inspection, + | 'id' + | 'colonyId' + | 'date' + | 'createdAt' + | 'queenCellsPresent' + | 'queenSeen' + | 'eggsPresent' + >, + ): Promise { + const dueUpTo = { lte: dayToDate(berlinDayOf(inspection.date)) }; + const swarmChecks = await this.openChecksIn( + tx, + inspection.colonyId, + 'SWARM_CHECK', + inspection.queenCellsPresent === true ? undefined : dueUpTo, + ); + await this.suggestions.resolveIn( + tx, + await this.restingOnOlderIn(tx, swarmChecks, inspection), + 'Mit Durchsicht erledigt', + ); + if (inspection.queenSeen === true || inspection.eggsPresent === true) { + const acceptanceChecks = await this.openChecksIn( + tx, + inspection.colonyId, + 'QUEEN_ACCEPTANCE', + dueUpTo, + ); + await this.suggestions.resolveIn( + tx, + acceptanceChecks.map((check) => check.id), + 'Mit Durchsicht erledigt', + ); + } + } + + private async openChecksIn( + tx: Tx, + colonyId: string, + intent: SuggestionIntent, + dueDate: { lte: Date } | undefined, + ): Promise<{ id: string; evidence: Prisma.JsonValue }[]> { + return tx.suggestion.findMany({ + where: { + colonyId, + status: 'OPEN', + source: 'CHAIN', + intent, + ...(dueDate === undefined ? {} : { dueDate }), + }, + select: { id: true, evidence: true }, + }); + } + + /** + * The checks whose base inspection (evidence.inspectionId) is older than + * `inspection`: by date, on the same date by creation time. A check whose + * base inspection is gone counts as older. + */ + private async restingOnOlderIn( + tx: Tx, + checks: readonly { id: string; evidence: Prisma.JsonValue }[], + inspection: Pick, + ): Promise { + const baseIdOf = (evidence: Prisma.JsonValue): string | null => { + const id = (evidence as { inspectionId?: unknown } | null)?.inspectionId; + return typeof id === 'string' ? id : null; + }; + const baseIds = checks.flatMap((check) => baseIdOf(check.evidence) ?? []); + if (baseIds.length === 0) { + return checks.map((check) => check.id); + } + const bases = new Map( + ( + await tx.inspection.findMany({ + where: { id: { in: baseIds } }, + select: { id: true, date: true, createdAt: true }, + }) + ).map((base) => [base.id, base]), + ); + const isOlder = (base: { id: string; date: Date; createdAt: Date }) => + base.id !== inspection.id && + (base.date.getTime() < inspection.date.getTime() || + (base.date.getTime() === inspection.date.getTime() && + base.createdAt.getTime() < inspection.createdAt.getTime())); + return checks + .filter((check) => { + const baseId = baseIdOf(check.evidence); + const base = baseId === null ? undefined : bases.get(baseId); + return base === undefined || isOlder(base); + }) + .map((check) => check.id); + } + + private async proposeIn( + tx: Tx, + colonyId: string, + candidate: ChainCandidate, + notify: boolean, + sourceEventId: string, + ): Promise { + const input: ProposeInput = { + ruleKey: candidate.ruleKey, + source: 'CHAIN', + mode: 'PLAN', + intent: candidate.intent, + priority: 'NORMAL', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: candidate.reasonCode, + evidence: candidate.evidence, + requiredPermission: candidate.requiredPermission, + dueDate: candidate.dueDate, + plan: candidate.plan, + anchor: candidate.anchor, + sourceEventId, + notify, + }; + await this.suggestions.proposeIn(tx, input); + } +} diff --git a/ApiCore-Backend/src/suggestions/suggestion-texts.ts b/ApiCore-Backend/src/suggestions/suggestion-texts.ts index adf54a7..1b6dbda 100644 --- a/ApiCore-Backend/src/suggestions/suggestion-texts.ts +++ b/ApiCore-Backend/src/suggestions/suggestion-texts.ts @@ -106,22 +106,5 @@ export function renderSuggestion( 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(); - -/** 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); -} +export { formatDayDe } from './texts/format-day'; +export { formatNumberDe } from './texts/format-number'; diff --git a/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts b/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts index c0dfea7..0448b09 100644 --- a/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts +++ b/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts @@ -140,4 +140,31 @@ describe('SuggestionViewsService', () => { skip: 10, }); }); + + it('counts only open suggestions the user may see in the summary', async () => { + const { service, prisma } = build([]); + prisma.suggestion.groupBy.mockResolvedValue([ + { priority: 'NORMAL', _count: { _all: 2 } }, + { priority: 'URGENT', _count: { _all: 1 } }, + ]); + + const summary = await service.summary('user_1'); + + const [args] = prisma.suggestion.groupBy.mock.calls[0] as [ + { where: { AND: unknown[] } }, + ]; + expect(args.where.AND).toEqual([ + { + OR: [ + { requiredPermission: null }, + { requiredPermission: { in: ['inspections:read'] } }, + ], + }, + { status: 'OPEN' }, + ]); + expect(summary).toEqual({ + open: 3, + byPriority: { LOW: 0, NORMAL: 2, HIGH: 0, URGENT: 1 }, + }); + }); }); diff --git a/ApiCore-Backend/src/suggestions/suggestion.types.ts b/ApiCore-Backend/src/suggestions/suggestion.types.ts index 69895a3..4d2fad3 100644 --- a/ApiCore-Backend/src/suggestions/suggestion.types.ts +++ b/ApiCore-Backend/src/suggestions/suggestion.types.ts @@ -36,7 +36,8 @@ export interface ProposeInput { sourceEventId?: string | null; /** * Default false. Rule and chain suggestions pass `config.notify`, the - * autopilot never; the notification itself is wired by Task 7. + * autopilot never; a new row then notifies the holders of `tasks:read` and + * of the required permission. */ notify?: boolean; } diff --git a/ApiCore-Backend/src/suggestions/suggestions.module.ts b/ApiCore-Backend/src/suggestions/suggestions.module.ts index 2e2b363..af35e97 100644 --- a/ApiCore-Backend/src/suggestions/suggestions.module.ts +++ b/ApiCore-Backend/src/suggestions/suggestions.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { TasksModule } from '../tasks/tasks.module'; +import { ChainEventHandlers } from './chains/chains.handlers'; import { SuggestionViewsService } from './suggestion-views.service'; import { SuggestionsController } from './suggestions.controller'; import { SuggestionsService } from './suggestions.service'; @@ -10,9 +12,9 @@ import { SuggestionsService } from './suggestions.service'; * the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs). */ @Module({ - imports: [TasksModule, AuditModule], + imports: [TasksModule, AuditModule, NotificationsModule], controllers: [SuggestionsController], - providers: [SuggestionsService, SuggestionViewsService], + providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers], exports: [SuggestionsService, SuggestionViewsService], }) export class SuggestionsModule {} diff --git a/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts b/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts index c26515c..3f1e337 100644 --- a/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts +++ b/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts @@ -7,6 +7,7 @@ import { lockRowsForShare, lockRowsForUpdate, } from '../prisma/row-lock'; +import type { NotificationsService } from '../notifications/notifications.service'; import type { TasksService } from '../tasks/tasks.service'; import { SuggestionsService } from './suggestions.service'; import type { ProposeInput } from './suggestion.types'; @@ -91,8 +92,9 @@ describe('SuggestionsService', () => { task: { findMany: jest.Mock }; inspection: { count: jest.Mock }; user: { findUnique: jest.Mock }; - colony: { findUnique: jest.Mock }; + colony: { findUnique: jest.Mock; findMany: jest.Mock }; }; + let notifications: { recordForPermission: jest.Mock }; let prisma: { $connect: jest.Mock; $transaction: jest.Mock } & { $executeRaw: jest.Mock; }; @@ -119,8 +121,12 @@ describe('SuggestionsService', () => { }, colony: { findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }), + findMany: jest.fn().mockResolvedValue([{ id: 'col-1', code: 'LWG' }]), }, }; + notifications = { + recordForPermission: jest.fn().mockResolvedValue([]), + }; prisma = { $connect: jest.fn(), $executeRaw: jest.fn().mockResolvedValue(0), @@ -160,6 +166,7 @@ describe('SuggestionsService', () => { prisma as unknown as PrismaService, audit as unknown as AuditLogService, tasks as unknown as TasksService, + notifications as unknown as NotificationsService, ); }); @@ -291,6 +298,122 @@ describe('SuggestionsService', () => { ).resolves.not.toBeNull(); }); + describe('notify', () => { + const rulePlan: ProposeInput = { + ...proposal, + ruleKey: 'QUEENLESS_NO_EGGS', + source: 'RULE', + mode: 'PLAN', + intent: 'QUEEN_CHECK', + priority: 'HIGH', + reasonCode: 'QUEENLESS_NO_EGGS', + evidence: { + colonyId: 'col-1', + inspectionId: 'insp-9', + inspectedOn: '2026-05-05', + }, + requiredPermission: 'inspections:read', + anchor: 'insp-9', + }; + + beforeEach(() => { + tx.suggestion.createManyAndReturn.mockResolvedValue([ + suggestionRow({ + id: 'sug-new', + ruleKey: 'QUEENLESS_NO_EGGS', + source: 'RULE', + mode: 'PLAN', + intent: 'QUEEN_CHECK', + priority: 'HIGH', + reasonCode: 'QUEENLESS_NO_EGGS', + evidence: rulePlan.evidence, + plan: null, + }), + ]); + }); + + it('with notify and a new row writes one SUGGESTION notification to tasks:read plus the required permission', async () => { + await service.proposeIn(tx as never, { ...rulePlan, notify: true }); + + expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); + expect(notifications.recordForPermission).toHaveBeenCalledWith( + ['tasks:read', 'inspections:read'], + { + priority: 'WARNUNG', + category: 'SUGGESTION', + message: expect.stringMatching(/^LWG: .+/) as string, + link: '/colonies/col-1', + relatedType: 'Suggestion', + relatedId: 'sug-new', + dedupeKeyPrefix: 'suggestion:sug-new', + }, + tx, + ); + }); + + it('writes an URGENT suggestion as KRITISCH and links a colony-less one to /suggestions', async () => { + tx.suggestion.createManyAndReturn.mockResolvedValue([ + suggestionRow({ + id: 'sug-urgent', + priority: 'URGENT', + colonyId: null, + relatedType: 'GENERAL', + relatedId: 'BEESWAX', + reasonCode: 'NO_SUCH_CODE', + evidence: {}, + plan: null, + }), + ]); + + await service.proposeIn(tx as never, { + ...rulePlan, + colonyId: null, + relatedType: 'GENERAL', + relatedId: 'BEESWAX', + notify: true, + }); + + const [, content] = notifications.recordForPermission.mock.calls[0] as [ + unknown, + { priority: string; link: string }, + ]; + expect(content).toMatchObject({ + priority: 'KRITISCH', + link: '/suggestions', + }); + }); + + it('notify false or absent writes none', async () => { + await service.proposeIn(tx as never, { ...rulePlan, notify: false }); + await service.proposeIn(tx as never, rulePlan); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('a skipped row (existing dedupe key or open intent) writes none', async () => { + tx.suggestion.createManyAndReturn.mockResolvedValue([]); + await service.proposeIn(tx as never, { ...rulePlan, notify: true }); + tx.suggestion.findMany.mockResolvedValue([{ intent: 'QUEEN_CHECK' }]); + await service.proposeIn(tx as never, { ...rulePlan, notify: true }); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('addresses only tasks:read when the suggestion needs no permission', async () => { + await service.proposeIn(tx as never, { + ...rulePlan, + requiredPermission: null, + notify: true, + }); + + expect(notifications.recordForPermission).toHaveBeenCalledWith( + ['tasks:read'], + expect.anything(), + tx, + ); + }); + }); + it('rejects the root client', async () => { await expect( service.proposeIn(prisma as never, proposal), @@ -338,9 +461,9 @@ describe('SuggestionsService', () => { expect(calls[1][1].description).toContain('bis 25.05.'); expect(calls[0][1].description).toBeNull(); expect(calls.map((c) => c[1].title)).toEqual([ - 'Weiselzellen kontrollieren', - 'Begattung kontrollieren', - 'Eier und Stifte kontrollieren', + 'Nachschaffungszellen prüfen', + 'Begattung prüfen', + 'Stifte prüfen', ]); for (const [client, input, actingUserId] of calls) { expect(client).toBe(tx); @@ -1000,6 +1123,25 @@ describe('SuggestionsService', () => { expect(tx.$executeRaw).not.toHaveBeenCalled(); }); + it('refreshIn also updates the reason code when given', async () => { + await service.refreshIn(tx as never, 'sug-1', { + reasonCode: 'INSPECTION_FIRST_OF_YEAR', + evidence: { colonyId: 'col-1' }, + dueDate: null, + priority: 'NORMAL', + expiresAt: null, + }); + + const args = argOf<{ data: Record }>( + tx.suggestion.updateMany, + 0, + 0, + ); + expect(args.data).toMatchObject({ + reasonCode: 'INSPECTION_FIRST_OF_YEAR', + }); + }); + it('refreshIn updates an open suggestion only', async () => { await service.refreshIn(tx as never, 'sug-1', { evidence: { colonyId: 'col-1' }, diff --git a/ApiCore-Backend/src/suggestions/suggestions.service.ts b/ApiCore-Backend/src/suggestions/suggestions.service.ts index aaf0091..c4c7cdf 100644 --- a/ApiCore-Backend/src/suggestions/suggestions.service.ts +++ b/ApiCore-Backend/src/suggestions/suggestions.service.ts @@ -17,6 +17,7 @@ import { } from '../../generated/prisma'; import { AuditLogService } from '../audit/audit-log.service'; import { lockRuleIn, readRule } from '../automation/rule-config'; +import { NotificationsService } from '../notifications/notifications.service'; import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits'; import { isLivingColonyStatus } from '../colonies/colony-status'; import { berlinToday, dayToDate } from '../common/dates'; @@ -122,6 +123,7 @@ export class SuggestionsService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly tasks: TasksService, + private readonly notifications: NotificationsService, ) {} /** @@ -143,7 +145,7 @@ export class SuggestionsService { // The colony is gone: nothing to propose (and no FK error). return null; } - await lockRuleIn(tx, input.ruleKey); + const rule = await lockRuleIn(tx, input.ruleKey); const open = await this.openIntentsIn(tx, input); if (open.has(input.intent)) { return null; @@ -165,9 +167,57 @@ export class SuggestionsService { }, tx, ); + if (input.notify) { + await this.notifyIn(tx, suggestion, input, rule.actionTemplate); + } return suggestion; } + /** + * One SUGGESTION notification per user who may read tasks and the + * suggestion's area (its title can name data of that area). Written in the + * proposing transaction, so a failing write rolls the suggestion back and + * the delivery is retried. + */ + private async notifyIn( + tx: Prisma.TransactionClient, + suggestion: Suggestion, + input: ProposeInput, + actionTemplate: string | null, + ): Promise { + const lookup = await buildEvidenceLookup(tx, [ + suggestion.evidence as SuggestionEvidence, + ]); + const { title } = renderSuggestion( + suggestion, + lookup, + berlinToday(), + actionTemplate, + ); + const colonyCode = + suggestion.colonyId === null + ? null + : lookup.colonyCode(suggestion.colonyId); + await this.notifications.recordForPermission( + input.requiredPermission === null + ? ['tasks:read'] + : ['tasks:read', input.requiredPermission], + { + priority: suggestion.priority === 'URGENT' ? 'KRITISCH' : 'WARNUNG', + category: 'SUGGESTION', + message: colonyCode === null ? title : `${colonyCode}: ${title}`, + link: + suggestion.colonyId === null + ? '/suggestions' + : `/colonies/${suggestion.colonyId}`, + relatedType: 'Suggestion', + relatedId: suggestion.id, + dedupeKeyPrefix: `suggestion:${suggestion.id}`, + }, + tx, + ); + } + /** The intents with an open suggestion or an open task for the object. */ async openIntentsIn( db: DbClient, @@ -210,6 +260,8 @@ export class SuggestionsService { tx: Prisma.TransactionClient, id: string, patch: { + /** Given when the same anchor can carry another text (e.g. first of the year). */ + reasonCode?: string; evidence: SuggestionEvidence; dueDate: string | null; priority: TaskPriority; @@ -220,6 +272,9 @@ export class SuggestionsService { await tx.suggestion.updateMany({ where: { id, status: 'OPEN' }, data: { + ...(patch.reasonCode === undefined + ? {} + : { reasonCode: patch.reasonCode }), evidence: patch.evidence, dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate), priority: patch.priority, diff --git a/ApiCore-Backend/src/suggestions/texts/autopilot-texts.spec.ts b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.spec.ts new file mode 100644 index 0000000..dfc8d27 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.spec.ts @@ -0,0 +1,98 @@ +import type { EvidenceLookup } from '../evidence-lookup'; +import { renderSuggestion } from '../suggestion-texts'; + +const lookup: EvidenceLookup = { + colonyCode: () => 'B3', + queenCode: () => null, + treatmentProduct: (id) => (id === 't1' ? 'Oxuvar' : null), + apiaryName: () => null, + stockItemName: () => null, +}; + +function render( + reasonCode: string, + evidence: Record, + today = '2026-05-12', +) { + return renderSuggestion( + { reasonCode, evidence, intent: 'INSPECT' }, + lookup, + today, + ); +} + +describe('autopilot texts', () => { + it('INSPECTION_DUE names the days since the last inspection and the interval of the segment', () => { + expect( + render('INSPECTION_DUE', { + lastInspectedOn: '2026-05-04', + intervalDays: 7, + segment: 'SCHWARMZEIT', + }), + ).toEqual({ + title: 'Durchsicht', + reasons: [ + 'Letzte Durchsicht vor 8 Tagen.', + 'In der Schwarmzeit alle 7 Tage.', + ], + }); + }); + + it('INSPECTION_DUE copes with no inspection yet and singular days', () => { + expect( + render('INSPECTION_DUE', { + lastInspectedOn: null, + intervalDays: 14, + segment: 'SOMMER', + }).reasons, + ).toEqual(['Noch keine Durchsicht erfasst.', 'Im Sommer alle 14 Tage.']); + expect( + render('INSPECTION_DUE', { + lastInspectedOn: '2026-05-11', + intervalDays: 7, + segment: 'SCHWARMZEIT', + }).reasons[0], + ).toBe('Letzte Durchsicht vor 1 Tag.'); + }); + + it('INSPECTION_FIRST_OF_YEAR says so', () => { + expect( + render('INSPECTION_FIRST_OF_YEAR', { lastInspectedOn: '2025-09-20' }), + ).toEqual({ + title: 'Erste Durchsicht im Jahr', + reasons: ['Die Saison hat begonnen.', 'Letzte Durchsicht am 20.09.2025.'], + }); + }); + + it('VARROA_MEASURE_DUE and VARROA_WINTER_MEASURE', () => { + expect( + render( + 'VARROA_MEASURE_DUE', + { lastMeasuredOn: '2026-04-07', intervalDays: 28 }, + '2026-05-12', + ), + ).toEqual({ + title: 'Varroa messen', + reasons: ['Letzte Messung vor 35 Tagen.', 'Alle 28 Tage messen.'], + }); + expect(render('VARROA_WINTER_MEASURE', {}).reasons).toEqual([ + 'Vor der Winterbehandlung den Milbenfall messen.', + ]); + }); + + it('VARROA_RECHECK_DUE names the product from the lookup', () => { + expect( + render('VARROA_RECHECK_DUE', { + treatmentId: 't1', + endedOn: '2026-08-02', + recheckOn: '2026-08-16', + }), + ).toEqual({ + title: 'Behandlungserfolg messen', + reasons: [ + 'Behandlung mit Oxuvar endete am 02.08.', + 'Nachkontrolle ab 16.08.', + ], + }); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts index 659be51..e3d0f7f 100644 --- a/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts @@ -1,4 +1,175 @@ -import type { SuggestionTextRegister } from '../suggestion-texts'; +import { daysBetween } from '../../common/dates'; +import type { SuggestionEvidence } from '../suggestion.types'; +import { + formatDayDe, + type SuggestionTextRegister, + type SuggestionTextTemplate, +} from '../suggestion-texts'; + +const MAX_PRODUCT_LENGTH = 40; + +const SEGMENT_PHRASES: Readonly> = { + FRUEHJAHR: 'Im Frühjahr', + SCHWARMZEIT: 'In der Schwarmzeit', + SOMMER: 'Im Sommer', + SPAETSOMMER: 'Im Spätsommer', + HERBST: 'Im Herbst', +}; + +function text(evidence: SuggestionEvidence, key: string): string | null { + const value = evidence[key]; + return typeof value === 'string' ? value : null; +} + +function count(evidence: SuggestionEvidence, key: string): number | null { + const value = evidence[key]; + return typeof value === 'number' ? value : null; +} + +/** 'vor 8 Tagen', 'vor 1 Tag', 'heute'. */ +function agoDe(from: string, today: string): string { + const days = Math.max(0, daysBetween(from, today)); + if (days === 0) { + return 'heute'; + } + return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; +} + +function lastInspectionReason( + evidence: SuggestionEvidence, + today: string, +): string { + const last = text(evidence, 'lastInspectedOn'); + return last === null + ? 'Noch keine Durchsicht erfasst.' + : `Letzte Durchsicht ${agoDe(last, today)}.`; +} + +function intervalReason(evidence: SuggestionEvidence): string[] { + const phrase = SEGMENT_PHRASES[text(evidence, 'segment') ?? '']; + const days = count(evidence, 'intervalDays'); + return phrase !== undefined && days !== null + ? [`${phrase} alle ${days} Tage.`] + : []; +} + +const INSPECTION_DUE: SuggestionTextTemplate = { + ruleKey: 'INSPECTION_INTERVAL', + example: { + colonyId: 'c1', + inspectionId: 'i1', + lastInspectedOn: '2026-05-04', + dueOn: '2026-05-11', + intervalDays: 7, + segment: 'SCHWARMZEIT', + }, + render: (evidence, _lookup, today) => ({ + title: 'Durchsicht', + reasons: [ + lastInspectionReason(evidence, today), + ...intervalReason(evidence), + ], + }), +}; + +const INSPECTION_FIRST_OF_YEAR: SuggestionTextTemplate = { + ruleKey: 'INSPECTION_INTERVAL', + example: { + colonyId: 'c1', + inspectionId: 'i1', + lastInspectedOn: '2025-09-20', + dueOn: '2026-03-20', + intervalDays: 14, + segment: 'FRUEHJAHR', + }, + render: (evidence) => { + const last = text(evidence, 'lastInspectedOn'); + return { + title: 'Erste Durchsicht im Jahr', + reasons: [ + 'Die Saison hat begonnen.', + last === null + ? 'Noch keine Durchsicht erfasst.' + : `Letzte Durchsicht am ${formatDayDe(last, true)}.`, + ], + }; + }, +}; + +const VARROA_MEASURE_DUE: SuggestionTextTemplate = { + ruleKey: 'SEASON_AUTOPILOT', + example: { + colonyId: 'c1', + measurementId: 'm1', + lastMeasuredOn: '2026-05-06', + dueOn: '2026-06-03', + intervalDays: 28, + }, + render: (evidence, _lookup, today) => { + const last = text(evidence, 'lastMeasuredOn'); + const days = count(evidence, 'intervalDays'); + return { + title: 'Varroa messen', + reasons: [ + last === null + ? 'Noch keine Messung erfasst.' + : `Letzte Messung ${agoDe(last, today)}.`, + ...(days === null ? [] : [`Alle ${days} Tage messen.`]), + ], + }; + }, +}; + +const VARROA_WINTER_MEASURE: SuggestionTextTemplate = { + ruleKey: 'SEASON_AUTOPILOT', + example: { + colonyId: 'c1', + measurementId: 'm1', + lastMeasuredOn: '2026-09-01', + dueOn: '2026-11-15', + intervalDays: 28, + }, + render: () => ({ + title: 'Varroa messen', + reasons: ['Vor der Winterbehandlung den Milbenfall messen.'], + }), +}; + +const VARROA_RECHECK_DUE: SuggestionTextTemplate = { + ruleKey: 'SEASON_AUTOPILOT', + example: { + colonyId: 'c1', + treatmentId: 't1', + endedOn: '2026-08-02', + recheckOn: '2026-08-16', + }, + render: (evidence, lookup) => { + const treatmentId = text(evidence, 'treatmentId'); + const product = + (treatmentId === null ? null : lookup.treatmentProduct(treatmentId)) ?? + 'Behandlung'; + const name = product.slice(0, MAX_PRODUCT_LENGTH); + const ended = text(evidence, 'endedOn'); + const recheck = text(evidence, 'recheckOn'); + return { + title: 'Behandlungserfolg messen', + reasons: [ + ended === null + ? `Behandlung mit ${name} beendet.` + : `Behandlung mit ${name} endete am ${formatDayDe(ended)}`, + ...(recheck === null + ? [] + : [`Nachkontrolle ab ${formatDayDe(recheck)}`]), + ], + }; + }, +}; /** Texts of the season autopilot suggestions, by reason code. */ -export const AUTOPILOT_TEXTS: SuggestionTextRegister = {}; +export const AUTOPILOT_TEXTS: SuggestionTextRegister = { + INSPECTION_DUE, + INSPECTION_FIRST_OF_YEAR, + VARROA_MEASURE_DUE, + VARROA_WINTER_MEASURE, + VARROA_RECHECK_DUE, +}; diff --git a/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts b/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts new file mode 100644 index 0000000..b5a9b03 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/chain-texts.spec.ts @@ -0,0 +1,30 @@ +// Imported before anything else: the texts must not depend on suggestion-texts +// having been loaded first. +import { CHAIN_TEXTS } from './chain-texts'; +import { SUGGESTION_TEXTS } from '../suggestion-texts'; + +describe('chain texts', () => { + it('load on their own and are part of the merged register', () => { + expect(Object.keys(CHAIN_TEXTS).length).toBeGreaterThan(0); + for (const code of Object.keys(CHAIN_TEXTS)) { + expect(SUGGESTION_TEXTS[code]).toBe(CHAIN_TEXTS[code]); + } + }); + + it('names the suggestions as the spec does', () => { + const lookup = { + colonyCode: () => 'V-1', + queenCode: () => 'Q-1', + treatmentProduct: () => null, + apiaryName: () => null, + stockItemName: () => null, + }; + const title = (code: string) => + CHAIN_TEXTS[code].render(CHAIN_TEXTS[code].example, lookup, '2026-05-05') + .title; + expect(title('SPLIT_SCHEDULE_RAISE_OWN')).toBe('Ableger-Plan übernehmen'); + expect(title('SPLIT_SCHEDULE_QUEEN_CELL')).toBe('Stifte prüfen'); + expect(title('SPLIT_SCHEDULE_QUEEN')).toBe('Annahme der Königin prüfen'); + expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle'); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/texts/chain-texts.ts b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts index 37d639b..d0f722e 100644 --- a/ApiCore-Backend/src/suggestions/texts/chain-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts @@ -1,12 +1,111 @@ -import type { SuggestionTextRegister } from '../suggestion-texts'; +import type { + SuggestionTextRegister, + SuggestionTextTemplate, +} from '../suggestion-texts'; +import { formatDayDe } from './format-day'; +import type { EvidenceLookup } from '../evidence-lookup'; +import type { SuggestionEvidence } from '../suggestion.types'; + +function dayOf(evidence: SuggestionEvidence, key: string): string { + const value = evidence[key]; + return typeof value === 'string' ? formatDayDe(value) : ''; +} + +function colonyOf( + evidence: SuggestionEvidence, + key: string, + lookup: EvidenceLookup, +): string { + const id = evidence[key]; + return (typeof id === 'string' ? lookup.colonyCode(id) : null) ?? 'Volk'; +} + +/** Split suggestions differ only in the second reason. */ +function splitTemplate( + title: string, + queenSource: string, + reason: string, +): SuggestionTextTemplate { + return { + ruleKey: 'SPLIT_SCHEDULE', + example: { + colonyId: 'col-1', + parentColonyId: 'col-0', + lineageId: 'lin-1', + queenSource, + splitOn: '2026-05-01', + baseDate: '2026-05-01', + }, + render: (evidence, lookup) => ({ + title, + reasons: [ + `Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`, + reason, + ], + }), + }; +} /** Texts of the chain suggestions, by reason code. */ -export const CHAIN_TEXTS: SuggestionTextRegister = {}; +export const CHAIN_TEXTS: SuggestionTextRegister = { + QUEEN_CELLS_SWARM_CHECK: { + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + example: { + colonyId: 'col-1', + inspectionId: 'insp-1', + inspectedOn: '2026-05-05', + baseDate: '2026-05-05', + }, + render: (evidence, lookup) => ({ + title: 'Schwarmkontrolle', + reasons: [ + `Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`, + ], + }), + }, + QUEEN_ASSIGNED_ACCEPTANCE: { + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + example: { + colonyId: 'col-1', + queenId: 'queen-1', + historyId: 'hist-1', + assignedOn: '2026-05-05', + baseDate: '2026-05-05', + }, + render: (evidence, lookup) => { + const queenId = evidence.queenId; + const queen = + (typeof queenId === 'string' ? lookup.queenCode(queenId) : null) ?? + 'Königin'; + return { + title: 'Annahme der Königin prüfen', + reasons: [ + `${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`, + ], + }; + }, + }, + SPLIT_SCHEDULE_RAISE_OWN: splitTemplate( + 'Ableger-Plan übernehmen', + 'RAISE_OWN', + 'Die Königin wird selbst nachgezogen.', + ), + SPLIT_SCHEDULE_QUEEN_CELL: splitTemplate( + 'Stifte prüfen', + 'QUEEN_CELL', + 'Mit Weiselzelle gebildet.', + ), + SPLIT_SCHEDULE_QUEEN: splitTemplate( + 'Annahme der Königin prüfen', + 'QUEEN', + 'Die Königin wurde zugesetzt.', + ), +}; /** Task title of each step of a plan suggestion (SPLIT_SCHEDULE). */ export const CHAIN_PLAN_STEP_TITLES: Readonly> = { - RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren', - MATING_CHECK: 'Begattung kontrollieren', - EGGS_CHECK: 'Eier und Stifte kontrollieren', - ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren', + RAISE_CELLS_CHECK: 'Nachschaffungszellen prüfen', + MATING_CHECK: 'Begattung prüfen', + EGGS_CHECK: 'Stifte prüfen', + ACCEPTANCE_CHECK: 'Annahme der Königin prüfen', }; diff --git a/ApiCore-Backend/src/suggestions/texts/format-day.ts b/ApiCore-Backend/src/suggestions/texts/format-day.ts new file mode 100644 index 0000000..3e21dcc --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/format-day.ts @@ -0,0 +1,5 @@ +/** '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}.`; +} diff --git a/ApiCore-Backend/src/suggestions/texts/format-number.ts b/ApiCore-Backend/src/suggestions/texts/format-number.ts new file mode 100644 index 0000000..12cd410 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/format-number.ts @@ -0,0 +1,13 @@ +const numberFormats = new Map(); + +/** 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); +} diff --git a/ApiCore-Backend/src/suggestions/texts/rule-texts.spec.ts b/ApiCore-Backend/src/suggestions/texts/rule-texts.spec.ts new file mode 100644 index 0000000..3c6c70b --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/rule-texts.spec.ts @@ -0,0 +1,108 @@ +import type { EvidenceLookup } from '../evidence-lookup'; +import { renderSuggestion } from '../suggestion-texts'; +import type { SuggestionEvidence } from '../suggestion.types'; +import { RULE_TEXTS } from './rule-texts'; + +const lookup: EvidenceLookup = { + colonyCode: (id) => (id === 'gone' ? null : 'LWG'), + queenCode: () => null, + treatmentProduct: (id) => (id === 'gone' ? null : 'Ameisensäure'), + apiaryName: () => null, + stockItemName: () => 'Gläser 500 g', +}; + +const TODAY = '2026-10-14'; + +function render( + reasonCode: string, + evidence: SuggestionEvidence, + intent: 'VARROA_TREAT' | 'FOLLOW_UP' | 'STOCK_REORDER' = 'FOLLOW_UP', +) { + return renderSuggestion( + { reasonCode, evidence: evidence as never, intent }, + lookup, + TODAY, + ); +} + +describe('rule texts', () => { + it('has a template for every reason code of the rule suggestions', () => { + expect(Object.keys(RULE_TEXTS).sort()).toEqual( + [ + 'COLONY_HEALTH_CRITICAL', + 'FEEDING_FOLLOW_UP', + 'FEED_STATUS_CRITICAL', + 'QUEENLESS_NO_EGGS', + 'RAW_MATERIAL_LOW', + 'STOCK_ITEM_LOW', + 'SWARM_MOOD_HIGH', + 'TREATMENT_FOLLOW_UP', + 'VARROA_RED', + ].sort(), + ); + }); + + it('VARROA_RED names rate, day and limit with the colony code', () => { + expect(render('VARROA_RED', RULE_TEXTS.VARROA_RED.example)).toEqual({ + title: 'Behandlung prüfen', + reasons: ['12,4 Milben pro Tag am 10.05.', 'Grenze: 10 (LWG).'], + }); + }); + + it('VARROA_RED words a percentage method in percent', () => { + const { reasons } = render('VARROA_RED', { + colonyId: 'c', + measuredOn: '2026-07-10', + value: 6, + redAbove: 5, + unit: 'PERCENT', + }); + + expect(reasons).toEqual(['6 % Befall am 10.07.', 'Grenze: 5 % (LWG).']); + }); + + it('QUEENLESS_NO_EGGS and the inspection rules name the day and the colony', () => { + expect( + render('QUEENLESS_NO_EGGS', RULE_TEXTS.QUEENLESS_NO_EGGS.example).reasons, + ).toEqual(['Königin nicht gesehen, keine Eier am 05.05.']); + expect( + render('FEED_STATUS_CRITICAL', RULE_TEXTS.FEED_STATUS_CRITICAL.example) + .reasons, + ).toEqual(['Futterstatus kritisch am 05.05.']); + }); + + it('STOCK_ITEM_LOW names the item and the minimum', () => { + expect( + render('STOCK_ITEM_LOW', RULE_TEXTS.STOCK_ITEM_LOW.example).reasons, + ).toEqual(['Gläser 500 g: noch 2 auf Lager, Mindestbestand 5.']); + }); + + it('RAW_MATERIAL_LOW names the kind in German', () => { + expect( + render('RAW_MATERIAL_LOW', RULE_TEXTS.RAW_MATERIAL_LOW.example).reasons, + ).toEqual(['Bienenwachs: noch 400 g auf Lager, Mindestmenge 1.000 g.']); + }); + + it('TREATMENT_FOLLOW_UP names the product, or none when the treatment is gone', () => { + expect( + render('TREATMENT_FOLLOW_UP', RULE_TEXTS.TREATMENT_FOLLOW_UP.example) + .reasons, + ).toEqual(['Nach Behandlung mit Ameisensäure am 10.08.']); + expect( + render('TREATMENT_FOLLOW_UP', { + colonyId: 'gone', + treatmentId: 'gone', + endedOn: '2026-08-10', + }).reasons, + ).toEqual(['Nach der Behandlung am 10.08.']); + }); + + it('survives evidence without its optional parts', () => { + for (const [reasonCode, template] of Object.entries(RULE_TEXTS)) { + const { title, reasons } = render(reasonCode, {}); + expect(title).not.toBe(''); + expect(reasons.length).toBeLessThanOrEqual(2); + expect(template.ruleKey).toBeTruthy(); + } + }); +}); diff --git a/ApiCore-Backend/src/suggestions/texts/rule-texts.ts b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts index 1c97811..cf88405 100644 --- a/ApiCore-Backend/src/suggestions/texts/rule-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts @@ -1,4 +1,214 @@ -import type { SuggestionTextRegister } from '../suggestion-texts'; +import { formatDayDe } from './format-day'; +import { formatNumberDe } from './format-number'; +import type { + RenderedSuggestionText, + SuggestionTextRegister, + SuggestionTextTemplate, +} from '../suggestion-texts'; +import type { EvidenceLookup } from '../evidence-lookup'; +import type { SuggestionEvidence } from '../suggestion.types'; -/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */ -export const RULE_TEXTS: SuggestionTextRegister = {}; +const RAW_MATERIAL_LABELS: Readonly> = { + BEESWAX: 'Bienenwachs', + PROPOLIS: 'Propolis', +}; + +function text(evidence: SuggestionEvidence, key: string): string | null { + const value = evidence[key]; + return typeof value === 'string' ? value : null; +} + +function num(evidence: SuggestionEvidence, key: string): number | null { + const value = evidence[key]; + return typeof value === 'number' ? value : null; +} + +/** '05.05.' for an evidence day; empty when the evidence has none. */ +function dayOf(evidence: SuggestionEvidence, key: string): string { + const day = text(evidence, key); + return day === null ? '' : formatDayDe(day); +} + +/** 'Text am 05.05.' (the day's dot ends the sentence), or 'Text.' */ +function dated( + base: string, + evidence: SuggestionEvidence, + key: string, +): string { + const day = dayOf(evidence, key); + return day === '' ? `${base}.` : `${base} am ${day}`; +} + +function colonyCodeOf( + evidence: SuggestionEvidence, + lookup: EvidenceLookup, +): string | null { + const id = text(evidence, 'colonyId'); + return id === null ? null : lookup.colonyCode(id); +} + +function template( + ruleKey: SuggestionTextTemplate['ruleKey'], + example: SuggestionEvidence, + render: ( + evidence: SuggestionEvidence, + lookup: EvidenceLookup, + ) => RenderedSuggestionText, +): SuggestionTextTemplate { + return { ruleKey, example, render }; +} + +function varroaReasons( + evidence: SuggestionEvidence, + lookup: EvidenceLookup, +): string[] { + const value = num(evidence, 'value'); + const limit = num(evidence, 'redAbove'); + const perDay = evidence.unit === 'MITES_PER_DAY'; + const shown = value === null ? '?' : formatNumberDe(value); + const measured = dated( + perDay ? `${shown} Milben pro Tag` : `${shown} % Befall`, + evidence, + 'measuredOn', + ); + if (limit === null) { + return [measured]; + } + const code = colonyCodeOf(evidence, lookup); + const unit = perDay ? '' : ' %'; + return [ + measured, + `Grenze: ${formatNumberDe(limit)}${unit}${code === null ? '' : ` (${code})`}.`, + ]; +} + +/** Texts of the rule suggestions, by reason code. */ +export const RULE_TEXTS: SuggestionTextRegister = { + QUEENLESS_NO_EGGS: template( + 'QUEENLESS_NO_EGGS', + { + colonyId: 'colony-1', + inspectionId: 'insp-1', + inspectedOn: '2026-05-05', + }, + (evidence) => ({ + title: 'Königin prüfen', + reasons: [ + dated('Königin nicht gesehen, keine Eier', evidence, 'inspectedOn'), + ], + }), + ), + FEED_STATUS_CRITICAL: template( + 'FEED_STATUS_CRITICAL', + { + colonyId: 'colony-1', + inspectionId: 'insp-1', + inspectedOn: '2026-05-05', + }, + (evidence) => ({ + title: 'Füttern', + reasons: [dated('Futterstatus kritisch', evidence, 'inspectedOn')], + }), + ), + VARROA_RED: template( + 'VARROA_THRESHOLD', + { + colonyId: 'colony-1', + measurementId: 'm-1', + measuredOn: '2026-05-10', + value: 12.4, + redAbove: 10, + unit: 'MITES_PER_DAY', + }, + (evidence, lookup) => ({ + title: 'Behandlung prüfen', + reasons: varroaReasons(evidence, lookup), + }), + ), + SWARM_MOOD_HIGH: template( + 'SWARM_MOOD_HIGH', + { colonyId: 'colony-1', checkId: 'check-1', checkedOn: '2026-05-05' }, + (evidence) => ({ + title: 'Schwarmkontrolle', + reasons: [dated('Hohe Schwarmstimmung', evidence, 'checkedOn')], + }), + ), + COLONY_HEALTH_CRITICAL: template( + 'COLONY_HEALTH_CRITICAL', + { colonyId: 'colony-1', changedOn: '2026-05-05' }, + (evidence) => ({ + title: 'Gesundheit prüfen', + reasons: [ + dated('Gesundheitsstatus auf kritisch gesetzt', evidence, 'changedOn'), + ], + }), + ), + STOCK_ITEM_LOW: template( + 'STOCK_ITEM_LOW', + { stockItemId: 'item-1', quantity: 2, minThreshold: 5 }, + (evidence, lookup) => { + const itemId = text(evidence, 'stockItemId'); + const name = itemId === null ? null : lookup.stockItemName(itemId); + const quantity = num(evidence, 'quantity'); + const minimum = num(evidence, 'minThreshold'); + return { + title: 'Nachbestellen', + reasons: [ + `${name ?? 'Lagerartikel'}: noch ${quantity === null ? '?' : formatNumberDe(quantity)} auf Lager, Mindestbestand ${minimum === null ? '?' : formatNumberDe(minimum)}.`, + ], + }; + }, + ), + RAW_MATERIAL_LOW: template( + 'RAW_MATERIAL_LOW', + { kind: 'BEESWAX', remainingGrams: 400, minGrams: 1000 }, + (evidence) => { + const kind = text(evidence, 'kind'); + const label = + kind !== null && Object.hasOwn(RAW_MATERIAL_LABELS, kind) + ? RAW_MATERIAL_LABELS[kind] + : 'Rohstoff'; + const remaining = num(evidence, 'remainingGrams'); + const minimum = num(evidence, 'minGrams'); + return { + title: 'Rohstoff nachbestellen', + reasons: [ + `${label}: noch ${remaining === null ? '?' : formatNumberDe(remaining)} g auf Lager, Mindestmenge ${minimum === null ? '?' : formatNumberDe(minimum)} g.`, + ], + }; + }, + ), + TREATMENT_FOLLOW_UP: template( + 'TREATMENT_FOLLOW_UP', + { + colonyId: 'colony-1', + treatmentId: 'treatment-1', + endedOn: '2026-08-10', + }, + (evidence, lookup) => { + const treatmentId = text(evidence, 'treatmentId'); + const product = + treatmentId === null ? null : lookup.treatmentProduct(treatmentId); + return { + title: 'Behandlungserfolg kontrollieren', + reasons: [ + dated( + product === null + ? 'Nach der Behandlung' + : `Nach Behandlung mit ${product}`, + evidence, + 'endedOn', + ), + ], + }; + }, + ), + FEEDING_FOLLOW_UP: template( + 'FEEDING_FOLLOW_UP', + { colonyId: 'colony-1', feedingId: 'feeding-1', fedOn: '2026-09-01' }, + (evidence) => ({ + title: 'Futterabnahme kontrollieren', + reasons: [dated('Nach der Fütterung', evidence, 'fedOn')], + }), + ), +}; diff --git a/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts b/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts index 84330e0..ebad573 100644 --- a/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts +++ b/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts @@ -26,4 +26,23 @@ export class VarroaThresholdsQuery { @IsString() @Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' }) date?: string; + + @ApiPropertyOptional({ + description: + 'Range form: first day (JJJJ-MM-TT); needs `to`, excludes `date`', + example: '2025-01-01', + }) + @IsOptional() + @IsString() + @Matches(DAY_PATTERN, { message: 'from muss ein Tag (JJJJ-MM-TT) sein' }) + from?: string; + + @ApiPropertyOptional({ + description: 'Range form: last day (JJJJ-MM-TT), at most 731 days', + example: '2026-12-31', + }) + @IsOptional() + @IsString() + @Matches(DAY_PATTERN, { message: 'to muss ein Tag (JJJJ-MM-TT) sein' }) + to?: string; } diff --git a/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts b/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts index 7164c04..ab7e990 100644 --- a/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts @@ -100,6 +100,108 @@ describe('VarroaBandsService', () => { ); }); + describe('thresholdSegments', () => { + const range = (from: string, to: string, colonyId?: string) => + service.thresholdSegments({ colonyId, from, to }); + + it('merges days with identical bands into one segment', async () => { + const { segments } = await range('2026-06-05', '2026-06-20'); + + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ + from: '2026-06-05', + to: '2026-06-20', + active: true, + postTreatmentUntil: null, + }); + expect(segments[0].bands.NATURAL_MITE_FALL).toBeDefined(); + }); + + it.each([ + ['2026-02-27', '2026-03-02', '2026-02-28', '2026-03-01'], + ['2026-04-29', '2026-05-02', '2026-04-30', '2026-05-01'], + ['2026-07-30', '2026-08-02', '2026-07-31', '2026-08-01'], + ['2026-10-30', '2026-11-02', '2026-10-31', '2026-11-01'], + ])( + 'splits %s..%s between %s and %s', + async (from, to, lastOfFirst, firstOfSecond) => { + const { segments } = await range(from, to); + + expect(segments.map((segment) => [segment.from, segment.to])).toEqual([ + [from, lastOfFirst], + [firstOfSecond, to], + ]); + }, + ); + + it('splits at treatment windows and names the recheck day', async () => { + prisma.colony.findUnique.mockResolvedValue({ + colonyType: 'PRODUCTION_COLONY', + }); + prisma.treatment.findMany.mockResolvedValue([ + { date: d('2026-07-12'), startDate: d('2026-07-10'), endDate: null }, + ]); + + const { segments } = await range('2026-07-08', '2026-07-30', 'c1'); + + expect(segments.map((s) => [s.from, s.to, s.postTreatmentUntil])).toEqual( + [ + ['2026-07-08', '2026-07-09', null], + ['2026-07-10', '2026-07-25', '2026-07-26'], + ['2026-07-26', '2026-07-30', null], + ], + ); + expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1); + expect(prisma.colony.findUnique).toHaveBeenCalledTimes(1); + }); + + it('returns one inactive segment without bands while the rule is off', async () => { + prisma.automationRule.findUnique.mockResolvedValue({ + id: 'r', + active: false, + config: {}, + actionTemplate: null, + }); + + await expect(range('2026-03-01', '2026-09-30')).resolves.toEqual({ + segments: [ + { + from: '2026-03-01', + to: '2026-09-30', + bands: {}, + postTreatmentUntil: null, + active: false, + }, + ], + }); + }); + + it('accepts exactly 731 days and rejects 732', async () => { + await expect(range('2025-01-01', '2027-01-01')).resolves.toBeDefined(); + await expect(range('2025-01-01', '2027-01-02')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('rejects from after to, invalid days and an unknown colony', async () => { + await expect(range('2026-06-02', '2026-06-01')).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(range('2026-02-30', '2026-03-05')).rejects.toBeInstanceOf( + BadRequestException, + ); + prisma.colony.findUnique.mockResolvedValue(null); + await expect( + range('2026-06-01', '2026-06-02', 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('accepts a single day', async () => { + const { segments } = await range('2026-06-01', '2026-06-01'); + expect(segments).toHaveLength(1); + }); + }); + describe('rate', () => { const measurement = { id: 'm1', diff --git a/ApiCore-Backend/src/varroa/varroa-bands.service.ts b/ApiCore-Backend/src/varroa/varroa-bands.service.ts index 52c3e49..0555ee2 100644 --- a/ApiCore-Backend/src/varroa/varroa-bands.service.ts +++ b/ApiCore-Backend/src/varroa/varroa-bands.service.ts @@ -11,7 +11,7 @@ import { type VarroaBands, type VarroaRating, } from '../automation/varroa-bands'; -import { berlinToday, dayToDate } from '../common/dates'; +import { addDays, berlinToday, dayToDate, daysBetween } from '../common/dates'; import { PrismaService } from '../prisma/prisma.service'; import { treatmentWindowsIn } from '../treatments/treatment-windows'; @@ -20,6 +20,22 @@ export interface VarroaThresholds extends VarroaBands { active: boolean; } +/** A run of consecutive days on which the bands are identical. */ +export interface VarroaThresholdSegment { + from: string; + to: string; + bands: VarroaBands['bands']; + postTreatmentUntil: string | null; + active: boolean; +} + +export interface VarroaThresholdSegments { + segments: VarroaThresholdSegment[]; +} + +/** Longest range of the range query, in days (two years plus a leap day). */ +export const MAX_THRESHOLD_RANGE_DAYS = 731; + type RatableMeasurement = Pick< VarroaMeasurement, 'date' | 'method' | 'calculatedValue' @@ -72,6 +88,68 @@ export class VarroaBandsService { }; } + /** + * The bands over a day range as segments of identical bands (they change by + * month and with treatment windows). Rule, colony type and treatments are + * read once. + */ + async thresholdSegments(query: { + colonyId?: string; + from: string; + to: string; + }): Promise { + const from = validDay(query.from); + const to = validDay(query.to); + const days = daysBetween(from, to) + 1; + if (days < 1 || days > MAX_THRESHOLD_RANGE_DAYS) { + throw new BadRequestException( + `Der Zeitraum muss zwischen 1 und ${MAX_THRESHOLD_RANGE_DAYS} Tage lang sein.`, + ); + } + const rule = await readRule(this.prisma, 'VARROA_THRESHOLD'); + const colonyType = + query.colonyId === undefined + ? null + : await this.colonyTypeOf(query.colonyId); + if (!rule.active) { + return { + segments: [ + { from, to, bands: {}, postTreatmentUntil: null, active: false }, + ], + }; + } + const treatments = + query.colonyId === undefined + ? [] + : await treatmentWindowsIn(this.prisma, query.colonyId); + const segments: VarroaThresholdSegment[] = []; + let key = ''; + for (let offset = 0; offset < days; offset++) { + const day = addDays(from, offset); + const { bands, postTreatmentUntil } = bandsFor( + day, + colonyType, + rule.config, + treatments, + ); + const dayKey = JSON.stringify([bands, postTreatmentUntil]); + const last = segments[segments.length - 1]; + if (last && dayKey === key) { + last.to = day; + } else { + segments.push({ + from: day, + to: day, + bands, + postTreatmentUntil, + active: true, + }); + key = dayKey; + } + } + return { segments }; + } + /** Each measurement of one colony with its rating. */ async rate( colonyId: string, diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index 306b489..435be36 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -34,11 +35,20 @@ export class VarroaController { return this.varroaService.findAll(); } - /** Bands in force on a day for a colony; declared before :id so it is not shadowed. */ + /** Bands in force on a day (`date`) or as segments over a range (`from`, `to`) for a colony; declared before :id so it is not shadowed. */ @RequirePermission('varroa:read', 'varroa:create') @Get('thresholds') getThresholds(@Query() query: VarroaThresholdsQuery) { - return this.varroaBandsService.thresholds(query); + const { colonyId, date, from, to } = query; + if (from === undefined && to === undefined) { + return this.varroaBandsService.thresholds({ colonyId, date }); + } + if (from === undefined || to === undefined || date !== undefined) { + throw new BadRequestException( + 'Bitte entweder date oder from und to angeben.', + ); + } + return this.varroaBandsService.thresholdSegments({ colonyId, from, to }); } @RequirePermission('varroa:read') diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts index e4ac838..409b4ab 100644 --- a/ApiCore-Backend/src/worker/worker.module.ts +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -3,6 +3,7 @@ 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 { AutopilotModule } from '../autopilot/autopilot.module'; import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module'; import { DomainEventsModule } from '../domain-events/domain-events.module'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -33,6 +34,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module'; TasksModule, AutomationModule, SuggestionsModule, + AutopilotModule, WorkerRuntimeModule, ], providers: [WorkerHealthServer], diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index a040910..d49b536 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -50,6 +50,24 @@ describe('Automation rules (e2e)', () => { return id; } + const suggestionsOf = (ruleKey?: AutomationTriggerType) => + prisma.suggestion.findMany({ + where: { colonyId, source: 'RULE', ...(ruleKey ? { ruleKey } : {}) }, + orderBy: { createdAt: 'asc' }, + }); + // Other sources (the season autopilot) propose on the same events. + const ruleSuggestionCount = () => + prisma.suggestion.count({ where: { colonyId, source: 'RULE' } }); + const tasksOfColony = () => + prisma.task.count({ where: { relatedId: colonyId } }); + // Settles every open suggestion, so the one-open-thing guard cannot be + // what keeps a new one from appearing - only the missing evaluation may. + const dismissOpen = () => + prisma.suggestion.updateMany({ + where: { colonyId, status: 'OPEN' }, + data: { status: 'DISMISSED' }, + }); + beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); @@ -91,15 +109,15 @@ describe('Automation rules (e2e)', () => { }); afterAll(async () => { - // Rule tasks and the rules' own entries are written without a user - - // they are not covered by the userId cleanup below. Deliveries go with - // their event (ON DELETE CASCADE). - const ruleTaskIds = ( - await prisma.task.findMany({ - where: { relatedId: colonyId }, + // Rule suggestions and the rules' own entries are written without a + // user - they are not covered by the userId cleanup below. Deliveries go + // with their event (ON DELETE CASCADE). + const suggestionIds = ( + await prisma.suggestion.findMany({ + where: { colonyId }, select: { id: true }, }) - ).map((task) => task.id); + ).map((suggestion) => suggestion.id); const byColony = { where: { colonyId }, select: { id: true } }; const recordIds = [ ...(await prisma.inspection.findMany(byColony)), @@ -108,7 +126,7 @@ describe('Automation rules (e2e)', () => { await prisma.auditLog.deleteMany({ where: { OR: [ - { entityType: 'Task', entityId: { in: ruleTaskIds } }, + { entityType: 'Suggestion', entityId: { in: suggestionIds } }, { entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: colonyId }, @@ -117,13 +135,15 @@ describe('Automation rules (e2e)', () => { }, }); await prisma.domainEvent.deleteMany({ - where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } }, + where: { aggregateId: { in: recordIds } }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ - where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } }, + where: { sourceId: { in: recordIds } }, }); - await prisma.task.deleteMany({ where: { relatedId: colonyId } }); - await prisma.notification.deleteMany({ where: { relatedId: colonyId } }); + await prisma.notification.deleteMany({ + where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, + }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); await prisma.inspection.deleteMany({ where: { colonyId } }); @@ -254,19 +274,32 @@ describe('Automation rules (e2e)', () => { } }); - it('creates a task and notification when queen not seen and no eggs present', async () => { - await record('inspections', { queenSeen: false, eggsPresent: false }); - - const tasks = await prisma.task.findMany({ - where: { relatedId: colonyId }, + it('proposes a queen check and notifies when queen not seen and no eggs present', async () => { + const inspectionId = await record('inspections', { + queenSeen: false, + eggsPresent: false, }); - expect(tasks).toHaveLength(1); - expect(tasks[0].title).toContain('Kontrolle'); + + const suggestions = await suggestionsOf(); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + ruleKey: 'QUEENLESS_NO_EGGS', + source: 'RULE', + mode: 'PLAN', + intent: 'QUEEN_CHECK', + status: 'OPEN', + priority: 'HIGH', + reasonCode: 'QUEENLESS_NO_EGGS', + requiredPermission: 'inspections:read', + dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`, + }); + // The rule proposes, it creates no task. + expect(await tasksOfColony()).toBe(0); const notifications = await prisma.notification.findMany({ - where: { relatedId: colonyId }, + where: { relatedType: 'Suggestion', relatedId: suggestions[0].id }, }); - // One copy per user who may read tasks, never a broadcast. + // One copy per user who may read tasks and inspections, never a broadcast. expect(notifications.length).toBeGreaterThan(0); expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true); expect(new Set(notifications.map((n) => n.userId)).size).toBe( @@ -277,31 +310,28 @@ describe('Automation rules (e2e)', () => { it('does not trigger when the queen was seen', async () => { await record('inspections', { queenSeen: true, eggsPresent: false }); - const tasks = await prisma.task.findMany({ - where: { relatedId: colonyId }, - }); - expect(tasks).toHaveLength(1); + expect(await suggestionsOf()).toHaveLength(1); }); - it('triggers a feeding task when feed status is kritisch', async () => { + it('proposes feeding when feed status is kritisch, without a notification', async () => { await record('inspections', { feedStatus: 'CRITICAL' }); - const tasks = await prisma.task.findMany({ - where: { relatedId: colonyId }, - }); - expect(tasks).toHaveLength(2); - expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true); + const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL'); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' }); + // The rule is off for notifications by default (notify: false). + expect( + await prisma.notification.count({ + where: { relatedType: 'Suggestion', relatedId: suggestions[0].id }, + }), + ).toBe(0); + expect(await tasksOfColony()).toBe(0); }); it('evaluates no rule on a corrected inspection', async () => { - const before = await prisma.task.count({ where: { relatedId: colonyId } }); const id = await record('inspections', { queenSeen: true }); - // Close the open tasks so the one-open-task guard cannot be what keeps - // a new task from appearing - only the missing evaluation may. - await prisma.task.updateMany({ - where: { relatedId: colonyId }, - data: { status: 'DONE' }, - }); + await dismissOpen(); + const before = await ruleSuggestionCount(); await request(app.getHttpServer()) .patch(`/api/v1/inspections/${id}`) @@ -310,57 +340,74 @@ describe('Automation rules (e2e)', () => { .expect(200); await drainDomainEvents(app, [id]); - expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( - before, - ); + expect(await ruleSuggestionCount()).toBe(before); }); - it('triggers a treatment check when the varroa threshold is exceeded', async () => { - const before = await prisma.task.count({ where: { relatedId: colonyId } }); - await record('varroa-measurements', { + it('proposes a treatment check when the mite fall is red', async () => { + const id = await record('varroa-measurements', { method: 'NATURAL_MITE_FALL', miteCount: 20, - // One day on the board: 20 mites per day, above the default 10. + date: '2026-08-10T00:00:00.000Z', + // One day on the board: 20 mites per day, above the limit of 10. durationMinutes: 24 * 60, }); - const tasks = await prisma.task.findMany({ - where: { relatedId: colonyId }, + const suggestions = await suggestionsOf('VARROA_THRESHOLD'); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + intent: 'VARROA_TREAT', + reasonCode: 'VARROA_RED', + status: 'OPEN', + requiredPermission: 'varroa:read', + evidence: { + colonyId, + measurementId: id, + measuredOn: '2026-08-10', + value: 20, + redAbove: 10, + unit: 'MITES_PER_DAY', + }, }); - expect(tasks).toHaveLength(before + 1); - expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true); + expect(await tasksOfColony()).toBe(0); }); - it('does not compare a sample-based count against the per-day threshold', async () => { - await prisma.task.updateMany({ - where: { relatedId: colonyId }, - data: { status: 'DONE' }, + it('rates a sample-based count with the percentage band', async () => { + await dismissOpen(); + const before = await ruleSuggestionCount(); + // Summer band: red above 5 %. 5 of 500 bees is 1 %: yellow, no proposal. + await record('varroa-measurements', { + method: 'POWDERED_SUGAR', + miteCount: 5, + sampleBees: 500, + date: '2026-07-10T00:00:00.000Z', }); - const before = await prisma.task.count({ where: { relatedId: colonyId } }); + expect(await ruleSuggestionCount()).toBe(before); + + // 50 of 500 bees is 10 %: red. await record('varroa-measurements', { method: 'POWDERED_SUGAR', miteCount: 50, + sampleBees: 500, + date: '2026-07-11T00:00:00.000Z', }); - expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( - before, - ); + const red = await prisma.suggestion.findMany({ + where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' }, + }); + expect(red).toHaveLength(1); + expect(red[0].evidence).toMatchObject({ unit: 'PERCENT', value: 10 }); }); - it('re-evaluates a corrected varroa measurement against the threshold', async () => { - await prisma.task.updateMany({ - where: { relatedId: colonyId }, - data: { status: 'DONE' }, - }); - const before = await prisma.task.count({ where: { relatedId: colonyId } }); - // Five mites per day: below the threshold. + it('re-evaluates a corrected varroa measurement against the limit', async () => { + await dismissOpen(); + const before = await ruleSuggestionCount(); + // Five mites per day: yellow in August, no proposal. const id = await record('varroa-measurements', { method: 'NATURAL_MITE_FALL', miteCount: 5, + date: '2026-08-20T00:00:00.000Z', durationMinutes: 24 * 60, }); - expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( - before, - ); + expect(await ruleSuggestionCount()).toBe(before); // The count was miscounted: 25 mites per day. await request(app.getHttpServer()) @@ -370,16 +417,35 @@ describe('Automation rules (e2e)', () => { .expect(200); await drainDomainEvents(app, [id]); - const tasks = await prisma.task.findMany({ - where: { - relatedId: colonyId, - sourceAutomationTrigger: 'VARROA_THRESHOLD', - }, + const open = await prisma.suggestion.findMany({ + where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' }, }); - expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1); - expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( - before + 1, - ); + expect(open).toHaveLength(1); + expect(open[0].dedupeKey).toBe(`VARROA_THRESHOLD:${colonyId}:${id}`); + expect(await ruleSuggestionCount()).toBe(before + 1); + + // Corrected again while still red: the open suggestion follows the value. + const correct = async (miteCount: number) => { + await request(app.getHttpServer()) + .patch(`/api/v1/varroa-measurements/${id}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ miteCount }) + .expect(200); + await drainDomainEvents(app, [id]); + }; + await correct(30); + const refreshed = await prisma.suggestion.findUniqueOrThrow({ + where: { id: open[0].id }, + }); + expect(refreshed.status).toBe('OPEN'); + expect(refreshed.evidence).toMatchObject({ value: 30 }); + + // Corrected below the limit: the condition is gone, the suggestion resolves. + await correct(1); + const resolved = await prisma.suggestion.findUniqueOrThrow({ + where: { id: open[0].id }, + }); + expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null }); }); it('lets an admin clear a rule config, which falls back to its defaults', async () => { @@ -430,25 +496,19 @@ describe('Automation rules (e2e)', () => { swarmMood: 'HIGH', }) .expect(201); - const swarmTasks = () => - prisma.task.count({ - where: { - relatedId: colonyId, - sourceAutomationTrigger: 'SWARM_MOOD_HIGH', - }, + const swarmSuggestions = () => + prisma.suggestion.count({ + where: { colonyId, ruleKey: 'SWARM_MOOD_HIGH' }, }); - // While active, high swarm mood leaves a task - the rule really fires. - // (Swarm checks stay on the post-commit path: evaluated before the - // response, no event to drain.) + // While active, high swarm mood leaves a suggestion - the rule really + // fires. (Swarm checks stay on the post-commit path: evaluated before + // the response, no event to drain.) await highSwarmMood(); - expect(await swarmTasks()).toBe(1); - // Close it so the one-open-task guard cannot be what keeps a new task + expect(await swarmSuggestions()).toBe(1); + // Settle it so the one-open-thing guard cannot be what keeps a new one // from appearing - only the deactivation may. - await prisma.task.updateMany({ - where: { relatedId: colonyId }, - data: { status: 'DONE' }, - }); + await dismissOpen(); try { await request(app.getHttpServer()) @@ -459,7 +519,7 @@ describe('Automation rules (e2e)', () => { await highSwarmMood(); - expect(await swarmTasks()).toBe(1); + expect(await swarmSuggestions()).toBe(1); } finally { await prisma.automationRule.update({ where: { id: swarmRuleId }, diff --git a/ApiCore-Backend/test/autopilot.e2e-spec.ts b/ApiCore-Backend/test/autopilot.e2e-spec.ts new file mode 100644 index 0000000..99b20ad --- /dev/null +++ b/ApiCore-Backend/test/autopilot.e2e-spec.ts @@ -0,0 +1,409 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AutopilotService } from '../src/autopilot/autopilot.service'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { login } from './utils/api'; +import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +/** Fixed day inside the swarm season by calendar; 2001 keeps it apart from other files. */ +const TODAY = '2001-05-12'; + +/** + * The autopilot sync against a real PostgreSQL, on fixed days in 2001. The + * colonies are created 2001-04-20, so no other file's data is reached. + */ +describe('Autopilot (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let autopilot: AutopilotService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + let apiaryId: string; + let userId: string; + const colonyIds: string[] = []; + const roleIds: string[] = []; + const userIds: string[] = []; + + /** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */ + async function colonyWithLateInspection(name: string): Promise { + const colony = await prisma.colony.create({ + data: { + code: `AP-E2E-${suffix}-${name}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + createdAt: d('2001-04-20'), + }, + }); + colonyIds.push(colony.id); + await prisma.inspection.create({ + data: { colonyId: colony.id, userId, date: d('2001-05-03') }, + }); + await prisma.varroaMeasurement.create({ + data: { + colonyId: colony.id, + userId, + date: d('2001-05-10'), + method: 'NATURAL_MITE_FALL', + }, + }); + return colony.id; + } + + const sync = (colonyId: string, day = TODAY) => + prisma.$transaction((tx) => autopilot.syncColonyIn(tx, colonyId, day)); + + const suggestionsOf = (colonyId: string, intent = 'INSPECT') => + prisma.suggestion.findMany({ + where: { colonyId, source: 'AUTOPILOT', intent: intent as 'INSPECT' }, + orderBy: { createdAt: 'asc' }, + }); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + autopilot = app.get(AutopilotService); + apiaryId = ( + await prisma.apiary.create({ data: { name: `Autopilot e2e ${suffix}` } }) + ).id; + userId = ( + await prisma.user.create({ + data: { + email: `autopilot-e2e-${suffix}@example.com`, + passwordHash: 'x', + name: 'Autopilot e2e', + role: { connect: { key: 'STAFF' } }, + }, + }) + ).id; + }); + + afterAll(async () => { + const inspections = await prisma.inspection.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: inspections.map((i) => i.id) } }, + }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.inspection.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.varroaMeasurement.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.treatment.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.delete({ where: { id: apiaryId } }); + await prisma.auditLog.deleteMany({ where: { userId } }); + await prisma.user.delete({ where: { id: userId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); + await app.close(); + }); + + it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', async () => { + const colonyId = await colonyWithLateInspection('overdue'); + const inspection = await prisma.inspection.findFirstOrThrow({ + where: { colonyId }, + }); + + const result = await sync(colonyId); + + expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 }); + const [suggestion] = await suggestionsOf(colonyId); + expect(suggestion).toMatchObject({ + status: 'OPEN', + mode: 'DO', + priority: 'HIGH', + ruleKey: 'INSPECTION_INTERVAL', + reasonCode: 'INSPECTION_DUE', + requiredPermission: 'inspections:read', + relatedType: 'COLONY', + relatedId: colonyId, + dedupeKey: `INSPECTION_INTERVAL:${colonyId}:${inspection.id}`, + }); + expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-10'); + expect(suggestion.expiresAt?.toISOString().slice(0, 10)).toBe('2001-06-11'); + }); + + it('a second sync refreshes instead of creating another suggestion', async () => { + const colonyId = await colonyWithLateInspection('refresh'); + await sync(colonyId); + + const again = await sync(colonyId, '2001-05-13'); + + expect(again).toEqual({ opened: 0, refreshed: 1, resolved: 0 }); + expect(await suggestionsOf(colonyId)).toHaveLength(1); + }); + + it('after a new inspection the INSPECT suggestion is RESOLVED', async () => { + const colonyId = await colonyWithLateInspection('resolved'); + await sync(colonyId); + await prisma.inspection.create({ + data: { colonyId, userId, date: d(TODAY) }, + }); + + const result = await sync(colonyId); + + expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 1 }); + const [suggestion] = await suggestionsOf(colonyId); + expect(suggestion).toMatchObject({ + status: 'RESOLVED', + decisionNote: 'Bedingung entfällt', + dedupeKey: null, + }); + }); + + it('two colony syncs in parallel leave one INSPECT suggestion', async () => { + const colonyId = await colonyWithLateInspection('parallel'); + + await Promise.all([sync(colonyId), sync(colonyId), sync(colonyId)]); + + const open = (await suggestionsOf(colonyId)).filter( + (suggestion) => suggestion.status === 'OPEN', + ); + expect(open).toHaveLength(1); + }); + + it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => { + const colonyId = await colonyWithLateInspection('returns'); + await sync(colonyId); + const fresh = await prisma.inspection.create({ + data: { colonyId, userId, date: d(TODAY) }, + }); + await sync(colonyId); + await prisma.inspection.delete({ where: { id: fresh.id } }); + + const result = await sync(colonyId); + + expect(result.opened).toBe(1); + expect( + (await suggestionsOf(colonyId)).map((suggestion) => suggestion.status), + ).toEqual(['RESOLVED', 'OPEN']); + }); + + it('a dismissed suggestion keeps its anchor: the same condition is not proposed again', async () => { + const colonyId = await colonyWithLateInspection('dismissed'); + await sync(colonyId); + await prisma.suggestion.updateMany({ + where: { colonyId, intent: 'INSPECT' }, + data: { status: 'DISMISSED' }, + }); + + const result = await sync(colonyId); + + expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 }); + expect(await suggestionsOf(colonyId)).toHaveLength(1); + }); + + it('an ended colony gets nothing', async () => { + const colonyId = await colonyWithLateInspection('ended'); + await prisma.colony.update({ + where: { id: colonyId }, + data: { status: 'DECEASED' }, + }); + + expect(await sync(colonyId)).toEqual({ + opened: 0, + refreshed: 0, + resolved: 0, + }); + expect(await suggestionsOf(colonyId)).toHaveLength(0); + }); + + it('proposes VARROA_MEASURE (A2) when the last measurement is older than 28 days', async () => { + const colonyId = await colonyWithLateInspection('varroa'); + await prisma.varroaMeasurement.updateMany({ + where: { colonyId }, + data: { date: d('2001-04-07') }, + }); + + await sync(colonyId); + + const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE'); + expect(measure).toMatchObject({ + status: 'OPEN', + ruleKey: 'SEASON_AUTOPILOT', + reasonCode: 'VARROA_MEASURE_DUE', + requiredPermission: 'varroa:read', + }); + expect(measure.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-05'); + }); + + it('autopilot.colony-sync resolves a stale autopilot suggestion when an inspection event is delivered', async () => { + // Real today: a colony inspected and measured today has no candidate. + const colony = await prisma.colony.create({ + data: { + code: `AP-E2E-${suffix}-event`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyIds.push(colony.id); + const inspection = await prisma.inspection.create({ + data: { colonyId: colony.id, userId, date: new Date() }, + }); + await prisma.varroaMeasurement.create({ + data: { + colonyId: colony.id, + userId, + date: new Date(), + method: 'NATURAL_MITE_FALL', + }, + }); + const stale = await prisma.suggestion.create({ + data: { + ruleKey: 'INSPECTION_INTERVAL', + source: 'AUTOPILOT', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: colony.id, + colonyId: colony.id, + reasonCode: 'INSPECTION_DUE', + evidence: {}, + dedupeKey: `INSPECTION_INTERVAL:${colony.id}:stale`, + }, + }); + await prisma.$transaction((tx) => + app.get(DomainEventsService).record(tx, { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: inspection.id, + payload: { colonyId: colony.id }, + }), + ); + + await drainDomainEvents(app, [inspection.id]); + + const after = await prisma.suggestion.findUniqueOrThrow({ + where: { id: stale.id }, + }); + expect(after.status).toBe('RESOLVED'); + expect(after.decisionNote).toBe('Bedingung entfällt'); + }); + + /** A colony with a treatment that ended 2001-04-28 and an older measurement. */ + async function colonyAfterTreatment( + name: string, + measurements: string[], + ): Promise { + const colonyId = await colonyWithLateInspection(name); + await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); + for (const date of measurements) { + await prisma.varroaMeasurement.create({ + data: { colonyId, userId, date: d(date), method: 'NATURAL_MITE_FALL' }, + }); + } + await prisma.treatment.create({ + data: { + colonyId, + userId, + date: d('2001-04-14'), + endDate: d('2001-04-28'), + product: 'Oxuvar', + }, + }); + return colonyId; + } + + it('A3: treatment ended 14 days ago -> VARROA_RECHECK for varroa readers, and no A2 beside it', async () => { + const colonyId = await colonyAfterTreatment('recheck', ['2001-04-01']); + + await sync(colonyId); + + const [recheck] = await suggestionsOf(colonyId, 'VARROA_RECHECK'); + expect(recheck).toMatchObject({ + status: 'OPEN', + mode: 'DO', + reasonCode: 'VARROA_RECHECK_DUE', + requiredPermission: 'varroa:read', + }); + expect(recheck.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-12'); + expect(await suggestionsOf(colonyId, 'VARROA_MEASURE')).toHaveLength(0); + }); + + it('A3: a measurement on the end day does not count, one the day after does', async () => { + const onEndDay = await colonyAfterTreatment('endday', ['2001-04-28']); + const dayAfter = await colonyAfterTreatment('dayafter', ['2001-04-29']); + + await sync(onEndDay); + await sync(dayAfter); + + expect(await suggestionsOf(onEndDay, 'VARROA_RECHECK')).toHaveLength(1); + expect(await suggestionsOf(dayAfter, 'VARROA_RECHECK')).toHaveLength(0); + }); + + it('A2 winter window: no measurement since 11-01 -> VARROA_WINTER_MEASURE with anchor winter-2001', async () => { + const colonyId = await colonyWithLateInspection('winter'); + await prisma.varroaMeasurement.updateMany({ + where: { colonyId }, + data: { date: d('2001-09-01') }, + }); + + await sync(colonyId, '2001-11-20'); + + const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE'); + expect(measure).toMatchObject({ + status: 'OPEN', + reasonCode: 'VARROA_WINTER_MEASURE', + dedupeKey: `SEASON_AUTOPILOT:${colonyId}:winter-2001`, + }); + expect(await suggestionsOf(colonyId, 'INSPECT')).toHaveLength(0); + }); + + it('a user without varroa:read sees neither the A2 nor the A3 suggestion', async () => { + const password = 'SuperSecret123!'; + const tokenFor = async (label: string, keys: string[]) => { + const role = await prisma.role.create({ + data: { + key: `AP_E2E_${label.toUpperCase()}_${suffix}`, + name: `Autopilot e2e ${label}`, + permissions: { + create: keys.map((key) => ({ permission: { connect: { key } } })), + }, + }, + }); + roleIds.push(role.id); + const email = `autopilot-e2e-${label}-${suffix}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: `Autopilot e2e ${label}`, + role: { connect: { id: role.id } }, + }, + }); + userIds.push(user.id); + return (await login(app, email, password)).accessToken; + }; + const full = await tokenFor('full', ['tasks:read', 'varroa:read']); + const noVarroa = await tokenFor('novarroa', ['tasks:read']); + const colonyId = await colonyAfterTreatment('visible', ['2001-04-01']); + await sync(colonyId); + const list = (token: string) => + request(app.getHttpServer()) + .get(`/api/v1/suggestions?colonyId=${colonyId}`) + .set('Authorization', `Bearer ${token}`); + + const seen = await list(full); + const hidden = await list(noVarroa); + + expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1); + expect(hidden.body).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/test/chains.e2e-spec.ts b/ApiCore-Backend/test/chains.e2e-spec.ts new file mode 100644 index 0000000..f8bebfd --- /dev/null +++ b/ApiCore-Backend/test/chains.e2e-spec.ts @@ -0,0 +1,365 @@ +import { INestApplication } from '@nestjs/common'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers'; +import { SuggestionsService } from '../src/suggestions/suggestions.service'; +import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; + +/** + * The biotechnical chains against a real PostgreSQL: an inspection, a queen + * assignment or a split leaves one PLAN suggestion; later inspections clean + * up the checks they answer. The events are recorded directly (the services + * that record them have their own e2e tests) and drained like the worker. + */ +describe('Chains (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let events: DomainEventsService; + let suggestions: SuggestionsService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + let apiaryId: string; + let colonyId: string; + let userId: string; + let aggregateIds: string[] = []; + + // Only the chains: the season autopilot writes its own suggestions for the + // same colony when the inspection event is delivered. + const chainSuggestions = () => + prisma.suggestion.findMany({ + where: { colonyId, source: 'CHAIN' }, + orderBy: { createdAt: 'asc' }, + }); + + const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') => + prisma.suggestion.findMany({ + where: { colonyId, source: 'CHAIN', status: 'OPEN', intent }, + }); + + const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10); + + /** Records an inspection and runs the handlers of its event. */ + async function inspect( + day: string, + findings: { + queenCellsPresent?: boolean | null; + queenSeen?: boolean | null; + eggsPresent?: boolean | null; + } = {}, + ): Promise { + const inspection = await prisma.inspection.create({ + data: { + colonyId, + userId, + date: new Date(`${day}T10:00:00.000Z`), + queenCellsPresent: findings.queenCellsPresent ?? null, + queenSeen: findings.queenSeen ?? null, + eggsPresent: findings.eggsPresent ?? null, + }, + }); + await recordAndDrain(inspection.id, () => + prisma.$transaction((tx) => + events.record(tx, { + type: 'InspectionRecorded', + aggregateType: 'Inspection', + aggregateId: inspection.id, + payload: { colonyId }, + }), + ), + ); + return inspection.id; + } + + async function assignQueen(assignedOn: string): Promise { + const queenId = `queen-${suffix}-${aggregateIds.length}`; + const historyId = `hist-${suffix}-${aggregateIds.length}`; + await recordAndDrain(queenId, () => + prisma.$transaction((tx) => + events.record(tx, { + type: 'QueenAssigned', + aggregateType: 'Queen', + aggregateId: queenId, + payload: { colonyId, historyId, assignedOn, previousColonyId: null }, + }), + ), + ); + return historyId; + } + + async function split( + queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null, + ): Promise { + await recordAndDrain(colonyId, () => + prisma.$transaction((tx) => + events.record(tx, { + type: 'ColonySplit', + aggregateType: 'Colony', + aggregateId: colonyId, + payload: { + parentColonyId: `parent-${suffix}`, + lineageId: `lineage-${suffix}-${aggregateIds.length}`, + queenSource, + splitOn: '2026-05-01', + }, + }), + ), + ); + } + + async function recordAndDrain( + aggregateId: string, + record: () => Promise, + ): Promise { + aggregateIds = [...aggregateIds, aggregateId]; + await record(); + await drainDomainEvents(app, [aggregateId]); + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + events = app.get(DomainEventsService); + suggestions = app.get(SuggestionsService); + const apiary = await prisma.apiary.create({ + data: { name: `Chains e2e ${suffix}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `CHN-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + const user = await prisma.user.create({ + data: { + email: `chains-e2e-${suffix}@example.com`, + passwordHash: 'x', + name: 'Chains e2e', + role: { connect: { key: 'STAFF' } }, + }, + }); + userId = user.id; + }); + + async function cleanUp(): Promise { + await prisma.task.deleteMany({ + where: { + OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }], + }, + }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + await prisma.inspection.deleteMany({ where: { colonyId } }); + aggregateIds = []; + } + + afterEach(async () => { + await cleanUp(); + await prisma.colony.update({ + where: { id: colonyId }, + data: { status: 'ACTIVE' }, + }); + }); + + afterAll(async () => { + await cleanUp(); + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { userId }, + { + entityType: 'Suggestion', + afterData: { path: ['colonyId'], equals: colonyId }, + }, + ], + }, + }); + await prisma.colony.delete({ where: { id: colonyId } }); + await prisma.apiary.delete({ where: { id: apiaryId } }); + await prisma.user.delete({ where: { id: userId } }); + await app.close(); + }); + + it('queen cells -> one PLAN suggestion due +7', async () => { + const inspectionId = await inspect('2026-05-05', { + queenCellsPresent: true, + }); + + const open = await openOf('SWARM_CHECK'); + expect(open).toHaveLength(1); + expect(open[0]).toMatchObject({ + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + source: 'CHAIN', + mode: 'PLAN', + relatedType: 'COLONY', + relatedId: colonyId, + requiredPermission: 'inspections:read', + dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`, + }); + expect(dayOf(open[0].dueDate)).toBe('2026-05-12'); + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0); + }); + + it('an inspection without queen cells proposes nothing', async () => { + await inspect('2026-05-05', { queenCellsPresent: false }); + await inspect('2026-05-06'); + + expect(await chainSuggestions()).toHaveLength(0); + }); + + it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => { + await split('RAISE_OWN'); + + const [suggestion] = await openOf('SPLIT_PLAN'); + expect(suggestion).toMatchObject({ + ruleKey: 'SPLIT_SCHEDULE', + mode: 'PLAN', + requiredPermission: 'colonies:read', + }); + expect(dayOf(suggestion.dueDate)).toBe('2026-05-10'); + + const result = await suggestions.accept(suggestion.id, {}, userId); + + expect( + result.tasks.map((task) => [task.title, dayOf(task.dueDate)]), + ).toEqual([ + ['Nachschaffungszellen prüfen', '2026-05-10'], + ['Begattung prüfen', '2026-05-22'], + ['Stifte prüfen', '2026-05-29'], + ]); + }); + + it('a split without queenSource leaves nothing', async () => { + await split(null); + + expect(await chainSuggestions()).toHaveLength(0); + }); + + it('a queen assignment -> acceptance check +7', async () => { + const historyId = await assignQueen('2026-05-05'); + + const open = await openOf('QUEEN_ACCEPTANCE'); + expect(open).toHaveLength(1); + expect(open[0]).toMatchObject({ + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + source: 'CHAIN', + mode: 'PLAN', + requiredPermission: 'queens:read', + dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`, + }); + expect(dayOf(open[0].dueDate)).toBe('2026-05-12'); + }); + + it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => { + await split('QUEEN'); + await assignQueen('2026-05-01'); + + const open = await openOf('QUEEN_ACCEPTANCE'); + expect(open).toHaveLength(1); + expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE'); + expect(dayOf(open[0].dueDate)).toBe('2026-05-08'); + }); + + it('an inspection on the due day resolves the open swarm check', async () => { + await inspect('2026-05-05', { queenCellsPresent: true }); + + await inspect('2026-05-12', { queenCellsPresent: false }); + + const [resolved] = await chainSuggestions(); + expect(resolved).toMatchObject({ + status: 'RESOLVED', + dedupeKey: null, + decisionNote: 'Mit Durchsicht erledigt', + }); + }); + + it('an inspection before the due day leaves the swarm check open', async () => { + await inspect('2026-05-05', { queenCellsPresent: true }); + + await inspect('2026-05-08', { queenCellsPresent: false }); + + expect(await openOf('SWARM_CHECK')).toHaveLength(1); + }); + + it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => { + await inspect('2026-05-05', { queenCellsPresent: true }); + + const secondId = await inspect('2026-05-08', { queenCellsPresent: true }); + + const all = await chainSuggestions(); + expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']); + expect(all[1].dedupeKey).toBe( + `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`, + ); + expect(dayOf(all[1].dueDate)).toBe('2026-05-15'); + }); + + it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => { + await assignQueen('2026-05-05'); + + await inspect('2026-05-12'); + expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1); + + await inspect('2026-05-13', { eggsPresent: true }); + expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0); + }); + + it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => { + const inspectionId = await inspect('2026-05-05', { + queenCellsPresent: true, + }); + await split('RAISE_OWN'); + const handlers = app.get(ChainEventHandlers); + const events = await prisma.domainEvent.findMany({ + where: { aggregateId: { in: aggregateIds } }, + }); + expect(events).toHaveLength(2); + + // Redelivery after a crash: the handlers run again with the stored events. + for (const event of events) { + await prisma.$transaction(async (tx) => { + const envelope = { ...event, attempts: 1 } as never; + if (event.type === 'InspectionRecorded') { + await handlers.queenCells(envelope, tx); + } else { + await handlers.splitSchedule(envelope, tx); + } + }); + } + + const all = await chainSuggestions(); + expect(all.map((row) => [row.intent, row.status])).toEqual([ + ['SWARM_CHECK', 'OPEN'], + ['SPLIT_PLAN', 'OPEN'], + ]); + expect(all[0].dedupeKey).toContain(inspectionId); + }); + + it('a backdated inspection with queen cells does not replace the newer open check', async () => { + await inspect('2026-05-08', { queenCellsPresent: true }); + + await inspect('2026-05-02', { queenCellsPresent: true }); + + const all = await chainSuggestions(); + expect(all.map((row) => row.status)).toEqual(['OPEN']); + expect(dayOf(all[0].dueDate)).toBe('2026-05-15'); + }); + + it('nothing for an ended colony', async () => { + await prisma.colony.update({ + where: { id: colonyId }, + data: { status: 'DISSOLVED' }, + }); + + await inspect('2026-05-05', { queenCellsPresent: true }); + await assignQueen('2026-05-05'); + await split('QUEEN'); + + expect(await chainSuggestions()).toHaveLength(0); + }); +}); diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index 6de5206..7d409f9 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -86,28 +86,30 @@ describe('Colonies (e2e)', () => { raceColonyId, ...splitColonyIds, ].filter(Boolean); - // Rule tasks are written without a user - their audit entry and - // TaskCreated are not covered by the userId cleanup below. - const ruleTaskIds = ( - await prisma.task.findMany({ - where: { relatedId: { in: colonyIds } }, + // Rule suggestions are written without a user - their audit entry and + // warning are not covered by the userId cleanup below. + const suggestionIds = ( + await prisma.suggestion.findMany({ + where: { colonyId: { in: colonyIds } }, select: { id: true }, }) - ).map((task) => task.id); + ).map((suggestion) => suggestion.id); await prisma.auditLog.deleteMany({ - where: { entityType: 'Task', entityId: { in: ruleTaskIds } }, + where: { entityType: 'Suggestion', entityId: { in: suggestionIds } }, }); await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, + }); + await prisma.notification.deleteMany({ where: { OR: [ - { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, - { aggregateType: 'Task', aggregateId: { in: ruleTaskIds } }, + { relatedId: { in: colonyIds } }, + { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, ], }, }); - await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } }); - await prisma.notification.deleteMany({ - where: { relatedId: { in: colonyIds } }, + await prisma.suggestion.deleteMany({ + where: { id: { in: suggestionIds } }, }); await prisma.colonyLineage.deleteMany({ where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] }, @@ -330,7 +332,7 @@ describe('Colonies (e2e)', () => { expect(colony.status).toBe('WEAK'); }); - it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => { + it('proposes the COLONY_HEALTH_CRITICAL check only after the event is processed, and only on the transition', async () => { const created = await request(app.getHttpServer()) .post('/api/v1/colonies') .set('Authorization', `Bearer ${beekeeperToken}`) @@ -356,15 +358,19 @@ describe('Colonies (e2e)', () => { to: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, }); expect( - await prisma.task.count({ where: { relatedId: healthColonyId } }), + await prisma.suggestion.count({ + where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' }, + }), ).toBe(0); await drainDomainEvents(app, [healthColonyId]); expect( - await prisma.task.count({ where: { relatedId: healthColonyId } }), + await prisma.suggestion.count({ + where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' }, + }), ).toBe(1); - // Already critical: a further edit is no transition and opens no second task. + // Already critical: a further edit is no transition and proposes nothing a second time. await request(app.getHttpServer()) .patch(`/api/v1/colonies/${healthColonyId}`) .set('Authorization', `Bearer ${beekeeperToken}`) @@ -372,11 +378,13 @@ describe('Colonies (e2e)', () => { .expect(200); await drainDomainEvents(app, [healthColonyId]); expect( - await prisma.task.count({ where: { relatedId: healthColonyId } }), + await prisma.suggestion.count({ + where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' }, + }), ).toBe(1); }); - it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => { + it('two concurrent PATCHes into CRITICAL produce one transition event and one suggestion', async () => { const created = await request(app.getHttpServer()) .post('/api/v1/colonies') .set('Authorization', `Bearer ${beekeeperToken}`) @@ -405,7 +413,9 @@ describe('Colonies (e2e)', () => { ).toBe(1); await drainDomainEvents(app, [raceColonyId]); expect( - await prisma.task.count({ where: { relatedId: raceColonyId } }), + await prisma.suggestion.count({ + where: { colonyId: raceColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' }, + }), ).toBe(1); }); }); diff --git a/ApiCore-Backend/test/colony-strength.e2e-spec.ts b/ApiCore-Backend/test/colony-strength.e2e-spec.ts new file mode 100644 index 0000000..4070b7b --- /dev/null +++ b/ApiCore-Backend/test/colony-strength.e2e-spec.ts @@ -0,0 +1,170 @@ +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 ImportReportBody { + created: number; +} + +describe('Colony strength in occupied frames (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}`; + const password = 'SuperSecret123!'; + let adminToken: string; + let adminId: string; + let apiaryId: string; + let apiaryName: string; + let colonyId: string; + + const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const admin = await prisma.user.create({ + data: { + email: `strength-admin-${suffix}@example.com`, + passwordHash: await bcrypt.hash(password, 12), + name: 'Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + adminId = admin.id; + adminToken = (await login(app, admin.email, password)).accessToken; + apiaryName = `Strength ${suffix}`; + apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id; + colonyId = ( + await prisma.colony.create({ + data: { + code: `ST-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + strength: 7, + }, + }) + ).id; + }); + + afterAll(async () => { + const colonies = await prisma.colony.findMany({ + where: { apiaryId }, + select: { id: true }, + }); + const ids = colonies.map((row) => row.id); + const inspections = await prisma.inspection.findMany({ + where: { colonyId: { in: ids } }, + select: { id: true }, + }); + const inspectionIds = inspections.map((row) => row.id); + await prisma.domainEvent.deleteMany({ + where: { + OR: [ + { aggregateType: 'Inspection', aggregateId: { in: inspectionIds } }, + { aggregateType: 'Colony', aggregateId: { in: ids } }, + ], + }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } }, + }); + await prisma.inspection.deleteMany({ where: { colonyId: { in: ids } } }); + await prisma.colony.deleteMany({ where: { id: { in: ids } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: adminId } }); + await prisma.auditLog.deleteMany({ where: { userId: adminId } }); + await prisma.user.deleteMany({ where: { id: adminId } }); + await app.close(); + }); + + it('PATCH occupiedFrames keeps the legacy strength', async () => { + await request(app.getHttpServer()) + .patch(`/api/v1/colonies/${colonyId}`) + .set(auth(adminToken)) + .send({ occupiedFrames: 16 }) + .expect(200); + expect( + await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }), + ).toMatchObject({ occupiedFrames: 16, strength: 7 }); + + await request(app.getHttpServer()) + .patch(`/api/v1/colonies/${colonyId}`) + .set(auth(adminToken)) + .send({ occupiedFrames: null }) + .expect(200); + expect( + (await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } })) + .occupiedFrames, + ).toBeNull(); + + await request(app.getHttpServer()) + .patch(`/api/v1/colonies/${colonyId}`) + .set(auth(adminToken)) + .send({ occupiedFrames: 41 }) + .expect(400); + }); + + it('a takeover from an inspection writes only occupiedFrames', async () => { + await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set(auth(adminToken)) + .send({ + colonyId, + date: '2026-06-01T10:00:00.000Z', + colonyStrength: 12, + applyToColony: ['occupiedFrames'], + }) + .expect(201); + expect( + await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }), + ).toMatchObject({ + occupiedFrames: 12, + strength: 7, + broodStatus: null, + feedStatus: null, + }); + }); + + it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set(auth(adminToken)) + .send({ colonyId, date: '2026-06-02T10:00:00.000Z', colonyStrength: 41 }) + .expect(400); + expect(JSON.stringify(response.body)).toContain( + 'Höchstens 40 besetzte Waben.', + ); + }); + + it('CSV export and import carry strength and occupiedFrames', async () => { + const exported = await request(app.getHttpServer()) + .get('/api/v1/export/colonies.csv') + .set(auth(adminToken)) + .expect(200); + const lines = exported.text.split('\n'); + expect(lines[0].trim().endsWith(',occupiedFrames')).toBe(true); + expect(lines[0]).toContain('strength,healthStatus'); + const row = lines.find((line) => line.includes(`ST-${suffix}`)) ?? ''; + expect(row).toContain(',7,'); + expect(row.trim().endsWith(',12')).toBe(true); + + const code = `ST-IMP-${suffix}`; + const csv = `code,apiaryName,colonyType,strength,occupiedFrames\n${code},${apiaryName},PRODUCTION_COLONY,5,20`; + const response = await request(app.getHttpServer()) + .post('/api/v1/import/colonies') + .set(auth(adminToken)) + .attach('file', Buffer.from(csv), { + filename: 'colonies.csv', + contentType: 'text/csv', + }) + .expect(201); + expect((response.body as ImportReportBody).created).toBe(1); + expect( + await prisma.colony.findUniqueOrThrow({ where: { code } }), + ).toMatchObject({ strength: 5, occupiedFrames: 20 }); + }); +}); diff --git a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts index 45eeeea..4505cd0 100644 --- a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts +++ b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts @@ -1,6 +1,9 @@ import { INestApplicationContext } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Prisma } from '../generated/prisma'; +import { AutopilotStep } from '../src/autopilot/autopilot.step'; +import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step'; +import { AutopilotService } from '../src/autopilot/autopilot.service'; import { JobsModule } from '../src/jobs/jobs.module'; import { TimeEventsStep } from '../src/jobs/steps/time-events.step'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -27,7 +30,11 @@ describe('daily-evaluation (e2e)', () => { let app: INestApplicationContext; let prisma: PrismaService; let step: TimeEventsStep; + let autopilotStep: AutopilotStep; + let expiryStep: SuggestionsExpiryStep; const ids: Record = {}; + /** Colonies of the autopilot tests; the steps only see these. */ + const autopilotColonyIds: string[] = []; const eventsOf = (aggregateId: string) => prisma.domainEvent.findMany({ @@ -49,6 +56,25 @@ describe('daily-evaluation (e2e)', () => { app = await moduleRef.init(); prisma = app.get(PrismaService); step = app.get(TimeEventsStep); + expiryStep = app.get(SuggestionsExpiryStep); + // The real AutopilotStep, but on the colonies of this file only: other + // files share the database and must not be reconciled on a 2001 day. + autopilotStep = new AutopilotStep( + { + colony: { + findMany: (args: Prisma.ColonyFindManyArgs) => + prisma.colony.findMany({ + ...args, + where: { + AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }], + }, + }), + }, + automationRule: prisma.automationRule, + $transaction: prisma.$transaction.bind(prisma) as unknown, + } as unknown as PrismaService, + app.get(AutopilotService), + ); const user = await prisma.user.create({ data: { @@ -155,7 +181,15 @@ describe('daily-evaluation (e2e)', () => { await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, }); - const colonyIds = [ids.living, ids.dead].filter(Boolean); + const colonyIds = [ids.living, ids.dead, ...autopilotColonyIds].filter( + Boolean, + ); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.varroaMeasurement.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); await prisma.treatment.deleteMany({ where: { colonyId: { in: colonyIds } }, }); @@ -305,4 +339,128 @@ describe('daily-evaluation (e2e)', () => { expect(await eventsOf(ids['queen:young'])).toEqual([]); expect(await eventsOf(ids['queen:reserve'])).toEqual([]); }); + + describe('autopilot and suggestions-expiry steps', () => { + /** Living colony, inspected 2001-05-03 and measured 2001-05-10. */ + async function lateColony(name: string): Promise { + const colony = await prisma.colony.create({ + data: { + code: `DE-E2E-${suffix}-ap-${name}`, + apiaryId: ids.apiary, + colonyType: 'PRODUCTION_COLONY', + createdAt: d('2001-04-20'), + }, + }); + autopilotColonyIds.push(colony.id); + await prisma.inspection.create({ + data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') }, + }); + await prisma.varroaMeasurement.create({ + data: { + colonyId: colony.id, + userId: ids.user, + date: d('2001-05-10'), + method: 'NATURAL_MITE_FALL', + }, + }); + return colony.id; + } + + const inspectSuggestions = (colonyId: string) => + prisma.suggestion.findMany({ + where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' }, + orderBy: { createdAt: 'asc' }, + }); + + it('running autopilot and expiry twice yields the same suggestion counts', async () => { + const colonyId = await lateColony('twice'); + + const first = await autopilotStep.run('2001-05-12'); + const firstExpiry = await expiryStep.run('2001-05-12'); + const second = await autopilotStep.run('2001-05-12'); + const secondExpiry = await expiryStep.run('2001-05-12'); + + expect(first).toMatchObject({ + autopilotColonies: 1, + suggestionsOpened: 1, + suggestionsResolved: 0, + }); + expect(second).toMatchObject({ + autopilotColonies: 1, + suggestionsOpened: 0, + suggestionsResolved: 0, + }); + expect(firstExpiry).toEqual({ suggestionsExpired: 0 }); + expect(secondExpiry).toEqual({ suggestionsExpired: 0 }); + expect(await inspectSuggestions(colonyId)).toHaveLength(1); + }); + + it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => { + const colonyId = await lateColony('expires'); + await autopilotStep.run('2001-05-12'); + + // expiresAt = 2001-06-11 + expect(await expiryStep.run('2001-06-11')).toEqual({ + suggestionsExpired: 0, + }); + expect(await expiryStep.run('2001-06-12')).toEqual({ + suggestionsExpired: expect.any(Number) as number, + }); + + const [suggestion] = await inspectSuggestions(colonyId); + expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null }); + }); + + it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => { + const colonyId = await lateColony('again'); + await autopilotStep.run('2001-05-12'); + await expiryStep.run('2001-06-12'); + + const result = await autopilotStep.run('2001-06-12'); + + expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1); + expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual( + ['EXPIRED', 'OPEN'], + ); + }); + + it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => { + const colonyId = await lateColony('overdue20'); + await prisma.inspection.updateMany({ + where: { colonyId }, + data: { date: d('2001-04-10') }, + }); + + await step.run('2001-05-12'); + await autopilotStep.run('2001-05-12'); + + // Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up. + expect( + await prisma.domainEvent.count({ + where: { aggregateId: colonyId, type: 'InspectionOverdue' }, + }), + ).toBe(0); + const [suggestion] = await inspectSuggestions(colonyId); + expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' }); + expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17'); + }); + + it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => { + const colonyId = await lateColony('returns'); + await autopilotStep.run('2001-05-12'); + const fresh = await prisma.inspection.create({ + data: { colonyId, userId: ids.user, date: d('2001-05-12') }, + }); + const resolved = await autopilotStep.run('2001-05-12'); + await prisma.inspection.delete({ where: { id: fresh.id } }); + + const returned = await autopilotStep.run('2001-05-12'); + + expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1); + expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1); + expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual( + ['RESOLVED', 'OPEN'], + ); + }); + }); }); diff --git a/ApiCore-Backend/test/data-integrity.e2e-spec.ts b/ApiCore-Backend/test/data-integrity.e2e-spec.ts index d33f69e..8730a69 100644 --- a/ApiCore-Backend/test/data-integrity.e2e-spec.ts +++ b/ApiCore-Backend/test/data-integrity.e2e-spec.ts @@ -136,11 +136,8 @@ describe('Data integrity (e2e)', () => { await drainDomainEvents(app, [(first.body as { id: string }).id]); // The automation fired once, not once per delivery attempt. expect( - await prisma.task.count({ - where: { - relatedId: colony.id, - sourceAutomationTrigger: { not: null }, - }, + await prisma.suggestion.count({ + where: { colonyId: colony.id, source: 'RULE' }, }), ).toBe(1); }); @@ -261,7 +258,7 @@ describe('Data integrity (e2e)', () => { expect(after.quantity).toBe(5); }); - it('creates only one open reorder task while the item stays below minimum', async () => { + it('proposes only one open reorder while the item stays below minimum', async () => { const item = await prisma.stockItem.create({ data: { name: `Deckel ${suffix}`, @@ -279,11 +276,11 @@ describe('Data integrity (e2e)', () => { .expect(201); } expect( - await prisma.task.count({ + await prisma.suggestion.count({ where: { relatedId: item.id, - sourceAutomationTrigger: 'STOCK_ITEM_LOW', - status: { in: ['OPEN', 'IN_PROGRESS'] }, + ruleKey: 'STOCK_ITEM_LOW', + status: 'OPEN', }, }), ).toBe(1); diff --git a/ApiCore-Backend/test/feedings.e2e-spec.ts b/ApiCore-Backend/test/feedings.e2e-spec.ts index ee2c2f3..c5a084b 100644 --- a/ApiCore-Backend/test/feedings.e2e-spec.ts +++ b/ApiCore-Backend/test/feedings.e2e-spec.ts @@ -92,6 +92,7 @@ describe('Feedings (e2e)', () => { where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } }, }); await prisma.feeding.deleteMany({ where: { colonyId } }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ @@ -254,6 +255,9 @@ describe('Feedings (e2e)', () => { await prisma.feeding.deleteMany({ where: { colonyId: { in: colonyIds } }, }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); }); diff --git a/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts index 7c2d398..2607560 100644 --- a/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts +++ b/ApiCore-Backend/test/follow-up-controls.e2e-spec.ts @@ -95,16 +95,30 @@ describe('Follow-up controls (e2e)', () => { }, data: { active: false, config: {} }, }); - // Follow-up tasks are written without a user: remove their audit entry - // and TaskCreated (deliveries go with the event, ON DELETE CASCADE). - const followUpTaskIds = ( - await prisma.task.findMany({ - where: { relatedId: { in: colonyIds } }, + // Follow-up suggestions are written without a user: remove them with + // their audit entries (deliveries go with the event, ON DELETE CASCADE). + const suggestionIds = ( + await prisma.suggestion.findMany({ + where: { colonyId: { in: colonyIds } }, select: { id: true }, }) - ).map((task) => task.id); + ).map((suggestion) => suggestion.id); await prisma.auditLog.deleteMany({ - where: { entityType: 'Task', entityId: { in: followUpTaskIds } }, + where: { + OR: [ + { entityType: 'Suggestion', entityId: { in: suggestionIds } }, + ...colonyIds.map((id) => ({ + entityType: 'AutomationRule', + afterData: { path: ['relatedId'], equals: id }, + })), + ], + }, + }); + await prisma.notification.deleteMany({ + where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, + }); + await prisma.suggestion.deleteMany({ + where: { id: { in: suggestionIds } }, }); const measureIds = [ ...(await prisma.treatment.findMany({ @@ -122,9 +136,6 @@ describe('Follow-up controls (e2e)', () => { await prisma.auditLog.deleteMany({ where: { entityId: { in: measureIds } }, }); - await prisma.domainEvent.deleteMany({ - where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } }, - }); await app.close(); }); @@ -136,9 +147,9 @@ describe('Follow-up controls (e2e)', () => { const colony = await newColony('OFF'); await treat(colony.id); - expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe( - 0, - ); + expect( + await prisma.suggestion.count({ where: { colonyId: colony.id } }), + ).toBe(0); }); it('suggests a check due the configured days after the treatment ended', async () => { @@ -155,25 +166,30 @@ describe('Follow-up controls (e2e)', () => { endDate: '2026-08-10T00:00:00.000Z', }); - const tasks = await prisma.task.findMany({ - where: { - relatedId: colony.id, - sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP', - }, + const suggestions = await prisma.suggestion.findMany({ + where: { colonyId: colony.id, ruleKey: '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'); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + status: 'OPEN', + intent: 'FOLLOW_UP', + reasonCode: 'TREATMENT_FOLLOW_UP', + requiredPermission: 'treatments:read', + evidence: { colonyId: colony.id, endedOn: '2026-08-10' }, + }); + expect(suggestions[0].dueDate?.toISOString()).toBe( + '2026-08-24T00:00:00.000Z', + ); + // The rule proposes; no task exists before the suggestion is accepted. + expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe( + 0, + ); // 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', - }, + await prisma.suggestion.count({ + where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' }, }), ).toBe(1); }); @@ -199,17 +215,15 @@ describe('Follow-up controls (e2e)', () => { }) .expect(201); // Not before the FeedingRecorded handler ran. - expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe( - 0, - ); + expect( + await prisma.suggestion.count({ where: { colonyId: colony.id } }), + ).toBe(0); await drainDomainEvents(app, [(response.body as { id: string }).id]); - const task = await prisma.task.findFirstOrThrow({ - where: { - relatedId: colony.id, - sourceAutomationTrigger: 'FEEDING_FOLLOW_UP', - }, + const suggestion = await prisma.suggestion.findFirstOrThrow({ + where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' }, }); - expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z'); + expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z'); + expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' }); }); }); diff --git a/ApiCore-Backend/test/inspection-takeover.e2e-spec.ts b/ApiCore-Backend/test/inspection-takeover.e2e-spec.ts new file mode 100644 index 0000000..a2b7169 --- /dev/null +++ b/ApiCore-Backend/test/inspection-takeover.e2e-spec.ts @@ -0,0 +1,272 @@ +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 TakeoverBody { + id: string; + colonyTakeover: { + applied: string[]; + skipped: { field: string; reason: string }[]; + }; +} + +describe('Inspection takeover (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}`; + const password = 'SuperSecret123!'; + let staffToken: string; + let narrowToken: string; + let staffId: string; + let narrowId: string; + let narrowRoleId: string; + let apiaryId: string; + const colonyIds: string[] = []; + // One colony per test: no test depends on what another one wrote. + let colonyId: string; + + const post = (token: string, body: object) => + request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${token}`) + .send({ colonyId, ...body }); + + const colony = () => + prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const passwordHash = await bcrypt.hash(password, 12); + const staff = await prisma.user.create({ + data: { + email: `takeover-staff-${suffix}@example.com`, + passwordHash, + name: 'Admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + staffId = staff.id; + const narrowRole = await prisma.role.create({ + data: { + key: `TAKEOVER_NARROW_${suffix}`, + name: 'Nur Durchsicht', + permissions: { + create: ['inspections:create', 'colonies:read'].map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + narrowRoleId = narrowRole.id; + const narrow = await prisma.user.create({ + data: { + email: `takeover-narrow-${suffix}@example.com`, + passwordHash, + name: 'Narrow', + roleId: narrowRole.id, + }, + }); + narrowId = narrow.id; + staffToken = (await login(app, staff.email, password)).accessToken; + narrowToken = (await login(app, narrow.email, password)).accessToken; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Takeover ${suffix}` } }) + ).id; + }); + + beforeEach(async () => { + const created = await prisma.colony.create({ + data: { + code: `TK-${suffix}-${colonyIds.length}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + strength: 6, + }, + }); + colonyId = created.id; + colonyIds.push(created.id); + }); + + afterAll(async () => { + const ids = ( + await prisma.inspection.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((row) => row.id); + await prisma.domainEvent.deleteMany({ + where: { + OR: [ + { aggregateType: 'Inspection', aggregateId: { in: ids } }, + { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, + ], + }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'INSPECTION', sourceId: { in: ids } }, + }); + await prisma.inspection.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + const userIds = [staffId, narrowId]; + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.role.deleteMany({ where: { id: narrowRoleId } }); + await app.close(); + }); + + it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', async () => { + const response = await post(staffToken, { + date: '2026-06-01T10:00:00.000Z', + feedStatus: 'WEAK', + broodPattern: 4, + colonyStrength: 18, + applyToColony: ['feedStatus', 'broodStatus', 'occupiedFrames'], + }).expect(201); + + const body = response.body as TakeoverBody; + expect(body.colonyTakeover.applied).toEqual([ + 'feedStatus', + 'broodStatus', + 'occupiedFrames', + ]); + expect(body.colonyTakeover.skipped).toEqual([]); + expect(await colony()).toMatchObject({ + feedStatus: 'WEAK', + broodStatus: 'GOOD', + occupiedFrames: 18, + strength: 6, + }); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { + entityType: 'Colony', + entityId: colonyId, + action: 'UPDATE', + userId: staffId, + }, + }); + expect(audit.beforeData).toMatchObject({ + feedStatus: null, + broodStatus: null, + occupiedFrames: null, + }); + expect(audit.afterData).toMatchObject({ + feedStatus: 'WEAK', + broodStatus: 'GOOD', + occupiedFrames: 18, + }); + }); + + it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', async () => { + await post(staffToken, { date: '2026-06-01T10:00:00.000Z' }).expect(201); + + const response = await post(staffToken, { + date: '2026-05-01T10:00:00.000Z', + colonyStrength: 22, + applyToColony: ['occupiedFrames'], + }).expect(201); + + const body = response.body as TakeoverBody; + expect(body.colonyTakeover.applied).toEqual([]); + expect(body.colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NEWER_INSPECTION' }, + ]); + expect((await colony()).occupiedFrames).toBeNull(); + }); + + it('answers 201 and skips with NO_PERMISSION without colonies:update', async () => { + const response = await post(narrowToken, { + date: '2026-07-01T10:00:00.000Z', + colonyStrength: 25, + applyToColony: ['occupiedFrames'], + }).expect(201); + + const body = response.body as TakeoverBody; + expect(body.colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NO_PERMISSION' }, + ]); + expect((await colony()).occupiedFrames).toBeNull(); + expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1); + }); + + it('skips with COLONY_ENDED for a dissolved colony and still saves the inspection', async () => { + await prisma.colony.update({ + where: { id: colonyId }, + data: { status: 'DISSOLVED', deactivatedAt: new Date() }, + }); + + const response = await post(staffToken, { + date: '2026-07-01T10:00:00.000Z', + colonyStrength: 12, + applyToColony: ['occupiedFrames'], + }).expect(201); + + const body = response.body as TakeoverBody; + expect(body.colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'COLONY_ENDED' }, + ]); + expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1); + expect((await colony()).occupiedFrames).toBeNull(); + }); + + it('takes values only from the new inspection, never from an older stored row (even an out-of-range legacy value)', async () => { + await prisma.inspection.create({ + data: { + colonyId, + date: new Date('2026-04-01T10:00:00.000Z'), + colonyStrength: 45, + userId: staffId, + }, + }); + + const response = await post(staffToken, { + date: '2026-06-01T10:00:00.000Z', + applyToColony: ['occupiedFrames'], + }).expect(201); + + expect((response.body as TakeoverBody).colonyTakeover.skipped).toEqual([ + { field: 'occupiedFrames', reason: 'NO_VALUE' }, + ]); + expect((await colony()).occupiedFrames).toBeNull(); + }); + + it('a correction (PATCH) takes nothing over', async () => { + const created = await post(staffToken, { + date: '2026-06-01T10:00:00.000Z', + }).expect(201); + const inspectionId = (created.body as TakeoverBody).id; + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${inspectionId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ colonyStrength: 30 }) + .expect(200); + expect((await colony()).occupiedFrames).toBeNull(); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${inspectionId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ applyToColony: ['occupiedFrames'] }) + .expect(400); + }); + + it.each([ + [['feedStatus', 'broodStatus', 'occupiedFrames', 'feedStatus']], + [['feedStatus', 'feedStatus']], + [['nonsense']], + ])('applyToColony %p answers 400', async (applyToColony) => { + await post(staffToken, { + date: '2026-08-01T10:00:00.000Z', + applyToColony, + }).expect(400); + }); +}); diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index 1cbef10..cc4262f 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -98,6 +98,7 @@ describe('Inspections (e2e)', () => { where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } }, }); await prisma.inspection.deleteMany({ where: { colonyId } }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ @@ -159,7 +160,7 @@ describe('Inspections (e2e)', () => { ]); }); - it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => { + it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => { await drainDomainEvents(app, [inspectionId]); await request(app.getHttpServer()) @@ -188,11 +189,17 @@ describe('Inspections (e2e)', () => { }); await drainDomainEvents(app, [inspectionId]); - // A correction evaluates no automation rule and sends no webhook. + // A correction evaluates no automation rule and sends no webhook; the + // calendar and the autopilot of the colony follow. const deliveries = await prisma.domainEventDelivery.findMany({ where: { eventId: correction.id }, + orderBy: { handler: 'asc' }, }); expect(deliveries).toEqual([ + expect.objectContaining({ + handler: 'autopilot.colony-sync', + status: 'DONE', + }), expect.objectContaining({ handler: 'calendar-sync.inspection', status: 'DONE', diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 730e18a..12cd277 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -150,6 +150,38 @@ describe('Notifications (e2e)', () => { expect(listed).not.toContain(hidden.id); }); + it('a row the user may not see cannot be read, marked or counted', async () => { + const hidden = await seedNotification(ids.bare, { + requiredPermissions: ['colonies:read'], + }); + const open = await seedNotification(ids.bare); + + await request(server()) + .post(`/api/v1/notifications/${hidden.id}/read`) + .set(as('bare')) + .expect(404); + + const response = await request(server()) + .post('/api/v1/notifications/read-all') + .set(as('bare')) + .expect(201); + + expect( + (response.body as { updated: number }).updated, + ).toBeGreaterThanOrEqual(1); + expect( + (await prisma.notification.findUniqueOrThrow({ where: { id: open.id } })) + .read, + ).toBe(true); + expect( + ( + await prisma.notification.findUniqueOrThrow({ + where: { id: hidden.id }, + }) + ).read, + ).toBe(false); + }); + it('reading is per user: marking one copy read leaves the other unread', async () => { const [first, second] = await notifications.recordForUsers( [ids.beekeeper, ids.staff], diff --git a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts index 0c753ae..5750c6d 100644 --- a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts +++ b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts @@ -55,20 +55,31 @@ describe('Post-commit side effects (e2e)', () => { return colony; } - /** Open or not: a rule task that exists at all. */ - const taskCount = (trigger: AutomationTriggerType, relatedId: string) => - prisma.task.count({ - where: { sourceAutomationTrigger: trigger, relatedId }, + /** Open or not: a rule suggestion that exists at all. */ + const suggestionCount = (ruleKey: AutomationTriggerType, relatedId: string) => + prisma.suggestion.count({ where: { ruleKey, relatedId } }); + + const openSuggestionCount = ( + ruleKey: AutomationTriggerType, + relatedId: string, + ) => + prisma.suggestion.count({ + where: { ruleKey, relatedId, status: 'OPEN' }, }); - const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) => - prisma.task.count({ - where: { - sourceAutomationTrigger: trigger, - relatedId, - status: { in: ['OPEN', 'IN_PROGRESS'] }, - }, + /** The notifications written for the suggestions of the given objects. */ + async function suggestionNotifications(relatedIds: string[]) { + const ids = ( + await prisma.suggestion.findMany({ + where: { relatedId: { in: relatedIds } }, + select: { id: true }, + }) + ).map((suggestion) => suggestion.id); + return prisma.notification.findMany({ + where: { relatedType: 'Suggestion', relatedId: { in: ids } }, + select: { userId: true }, }); + } /** Everything this file wrote, scoped by its own ids. */ async function cleanUp(): Promise { @@ -86,13 +97,13 @@ describe('Post-commit side effects (e2e)', () => { .flat() .map((record) => record.id); const relatedIds = [...colonyIds, ...stockItemIds]; - const taskIds = ( - await prisma.task.findMany({ + const suggestionIds = ( + await prisma.suggestion.findMany({ where: { relatedId: { in: relatedIds } }, select: { id: true }, }) - ).map((task) => task.id); - const aggregateIds = [...recordIds, ...taskIds]; + ).map((suggestion) => suggestion.id); + const aggregateIds = recordIds; // Deliveries go with their event (ON DELETE CASCADE). await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, @@ -100,12 +111,12 @@ describe('Post-commit side effects (e2e)', () => { await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceId: { in: aggregateIds } }, }); - // Rule tasks and the rules' own entries are written without a user. + // Rule suggestions and the rules' own entries are written without a user. await prisma.auditLog.deleteMany({ where: { OR: [ { userId }, - { entityType: 'Task', entityId: { in: taskIds } }, + { entityType: 'Suggestion', entityId: { in: suggestionIds } }, ...relatedIds.map((relatedId) => ({ entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: relatedId }, @@ -114,9 +125,11 @@ describe('Post-commit side effects (e2e)', () => { }, }); await prisma.notification.deleteMany({ - where: { relatedId: { in: relatedIds } }, + where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, + }); + await prisma.suggestion.deleteMany({ + where: { id: { in: suggestionIds } }, }); - await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); await prisma.inspection.deleteMany({ where: byColony.where }); await prisma.treatment.deleteMany({ where: byColony.where }); await prisma.varroaMeasurement.deleteMany({ where: byColony.where }); @@ -254,13 +267,11 @@ describe('Post-commit side effects (e2e)', () => { attempts: 0, }), ]); - // No rule ran in the request, not even one that failed: the task + // No rule ran in the request, not even one that failed: the suggestion // follows when the handler runs. expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]); - expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0); - expect( - await prisma.notification.count({ where: { relatedId: colony.id } }), - ).toBe(0); + expect(await suggestionCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0); + expect(await suggestionNotifications([colony.id])).toEqual([]); }); it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => { @@ -286,7 +297,7 @@ describe('Post-commit side effects (e2e)', () => { }); describe('when an automation handler fails', () => { - it('creates exactly one automation task when the handler fails once and is retried', async () => { + it('creates exactly one automation suggestion when the handler fails once and is retried', async () => { jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); const colonyId = (await createColony('PC-RETRY')).id; jest @@ -302,20 +313,16 @@ describe('Post-commit side effects (e2e)', () => { await drainDomainEvents(app, [body.id]); // The warning failed, so the whole evaluation rolled back. - expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0); - expect( - await prisma.notification.count({ where: { relatedId: colonyId } }), - ).toBe(0); + expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0); + expect(await suggestionNotifications([colonyId])).toEqual([]); // Due again now instead of after the 30 s backoff. await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`; await drainDomainEvents(app, [body.id]); - expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1); - // One copy per recipient (everybody who may read tasks), written once. - const warnings = await prisma.notification.findMany({ - where: { relatedId: colonyId }, - select: { userId: true }, - }); + expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1); + // One copy per recipient (everybody who may read tasks and + // inspections), written once. + const warnings = await suggestionNotifications([colonyId]); expect(warnings.length).toBeGreaterThan(0); expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length); @@ -337,11 +344,11 @@ describe('Post-commit side effects (e2e)', () => { }); }); - describe('automation tasks', () => { - it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => { + describe('automation suggestions', () => { + it('leaves one open reorder suggestion when several triggers for the same stock item arrive at once', async () => { const automation = app.get(AutomationService); // The same object crossing the threshold on several pods/requests at - // once: every evaluation sees "no open task yet" unless checking and + // once: every evaluation sees "nothing open yet" unless checking and // creating are serialised. const item = await prisma.stockItem.create({ data: { @@ -358,10 +365,10 @@ describe('Post-commit side effects (e2e)', () => { Array.from({ length: 8 }, () => automation.evaluateStockItem(item)), ); - expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1); + expect(await openSuggestionCount('STOCK_ITEM_LOW', item.id)).toBe(1); }); - it('leaves one open task when several triggers for the same colony arrive at once', async () => { + it('leaves one open suggestion when several triggers for the same colony arrive at once', async () => { const colony = await createColony('PC-RACE'); const measurement = { colonyId: colony.id, @@ -384,7 +391,7 @@ describe('Post-commit side effects (e2e)', () => { ), ); - expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1); + expect(await openSuggestionCount('VARROA_THRESHOLD', colony.id)).toBe(1); }); }); }); diff --git a/ApiCore-Backend/test/raw-materials.e2e-spec.ts b/ApiCore-Backend/test/raw-materials.e2e-spec.ts index ae41aa1..3667219 100644 --- a/ApiCore-Backend/test/raw-materials.e2e-spec.ts +++ b/ApiCore-Backend/test/raw-materials.e2e-spec.ts @@ -59,8 +59,8 @@ describe('Raw materials and production (e2e)', () => { let propolisId: string; let productId: string; let batchId: string; - // Tasks the RAW_MATERIAL_LOW handler created for this file. - const ruleTaskIds: string[] = []; + // Suggestions the RAW_MATERIAL_LOW handler created for this file. + const ruleSuggestionIds: string[] = []; const server = () => app.getHttpServer(); const auth = (req: request.Test) => @@ -133,43 +133,35 @@ describe('Raw materials and production (e2e)', () => { }) ).map((movement) => movement.id); const batchIds = [batchId].filter(Boolean); - // Rule tasks are written without a user: their TaskCreated, audit - // entries, warning and queued deliveries go by task id. - for (const taskId of ruleTaskIds) { - await prisma.auditLog.deleteMany({ - where: { - entityType: 'AutomationRule', - afterData: { path: ['taskId'], equals: taskId }, - }, - }); - await prisma.webhookDelivery.deleteMany({ - where: { event: 'task.created', body: { contains: taskId } }, - }); - } - await prisma.calendarSyncOutboxEntry.deleteMany({ - where: { sourceId: { in: ruleTaskIds } }, + // Rule suggestions are written without a user: their audit entries and + // warnings go by suggestion id. + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } }, + ...ruleSuggestionIds.map((suggestionId) => ({ + entityType: 'AutomationRule', + afterData: { path: ['suggestionId'], equals: suggestionId }, + })), + ], + }, }); - // Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer - // of GENERAL/PROPOLIS warnings, and only this file switches the rule on - // (default: off, no minimum) - so such a warning since this file - // started belongs to the task above. - if (ruleTaskIds.length > 0) { - await prisma.notification.deleteMany({ - where: { - relatedType: 'GENERAL', - relatedId: 'PROPOLIS', - createdAt: { gte: startedAt }, - }, - }); - } - const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds]; + await prisma.notification.deleteMany({ + where: { + relatedType: 'Suggestion', + relatedId: { in: ruleSuggestionIds }, + }, + }); + await prisma.suggestion.deleteMany({ + where: { id: { in: ruleSuggestionIds } }, + }); + const aggregateIds = [...batchIds, ...movementIds]; await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, }); await prisma.auditLog.deleteMany({ where: { entityId: { in: [...lotIds, ...aggregateIds] } }, }); - await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } }); await app.close(); }); @@ -363,22 +355,22 @@ describe('Raw materials and production (e2e)', () => { ).expect(409); }); - it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => { + it('proposes the RAW_MATERIAL_LOW reorder only once its StockChanged event is dispatched', async () => { // The rule is shared by every e2e file: switch it on with a minimum no // test database reaches, and put it back right after. const before = await prisma.automationRule.findUnique({ where: { triggerType: 'RAW_MATERIAL_LOW' }, }); - const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } }; - // One open task per kind: a leftover from an aborted earlier run would - // keep the handler from creating a new one - close it first. - await prisma.task.updateMany({ + const config = { minGrams: { PROPOLIS: 1_000_000_000 } }; + // One open suggestion per kind: a leftover from an aborted earlier run + // would keep the handler from proposing a new one - settle it first. + await prisma.suggestion.updateMany({ where: { - sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + ruleKey: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', - status: { in: ['OPEN', 'IN_PROGRESS'] }, + status: 'OPEN', }, - data: { status: 'DONE', completedAt: new Date() }, + data: { status: 'DISMISSED' }, }); await prisma.automationRule.upsert({ where: { triggerType: 'RAW_MATERIAL_LOW' }, @@ -400,23 +392,35 @@ describe('Raw materials and production (e2e)', () => { .expect(201); // Newest first: the withdrawal just booked. const [movement] = (response.body as StockBody).movements; - const [event] = await prisma.domainEvent.findMany({ - where: { type: 'StockChanged', aggregateId: movement.id }, - select: { correlationId: true }, - }); - const ruleTasksOfThisChange = async () => - ( - await prisma.domainEvent.findMany({ - where: { - type: 'TaskCreated', - correlationId: event.correlationId, - }, - select: { aggregateId: true }, - }) - ).map((created) => created.aggregateId); + const suggestionsOfThisChange = () => + prisma.suggestion.findMany({ + where: { + ruleKey: 'RAW_MATERIAL_LOW', + relatedId: 'PROPOLIS', + createdAt: { gte: startedAt }, + }, + }); - // The request itself creates no task - the handler does. - expect(await ruleTasksOfThisChange()).toEqual([]); + // The request itself proposes nothing - the handler does. + expect(await suggestionsOfThisChange()).toEqual([]); + + await drainDomainEvents(app, [movement.id]); + + const suggestions = await suggestionsOfThisChange(); + ruleSuggestionIds.push(...suggestions.map((found) => found.id)); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + ruleKey: 'RAW_MATERIAL_LOW', + intent: 'STOCK_REORDER', + reasonCode: 'RAW_MATERIAL_LOW', + relatedType: 'GENERAL', + relatedId: 'PROPOLIS', + colonyId: null, + requiredPermission: 'raw-material:read', + status: 'OPEN', + dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`, + }); + // The request creates no task either. expect( await prisma.task.count({ where: { @@ -426,20 +430,6 @@ describe('Raw materials and production (e2e)', () => { }, }), ).toBe(0); - - await drainDomainEvents(app, [movement.id]); - - const taskIds = await ruleTasksOfThisChange(); - ruleTaskIds.push(...taskIds); - expect(taskIds).toHaveLength(1); - expect( - await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }), - ).toMatchObject({ - sourceAutomationTrigger: 'RAW_MATERIAL_LOW', - relatedType: 'GENERAL', - relatedId: 'PROPOLIS', - status: 'OPEN', - }); } finally { // Leave the rule as found: restored, or gone again if this test // created it (ensureDefaults then recreates it with its defaults). diff --git a/ApiCore-Backend/test/suggestions-api.e2e-spec.ts b/ApiCore-Backend/test/suggestions-api.e2e-spec.ts index 19afa20..a0d09dd 100644 --- a/ApiCore-Backend/test/suggestions-api.e2e-spec.ts +++ b/ApiCore-Backend/test/suggestions-api.e2e-spec.ts @@ -245,30 +245,20 @@ describe('Suggestions API (e2e)', () => { expect(other.body).toEqual([]); }); - it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => { - await proposeForTest(app, { + it('a user without varroa:read does not get the varroa suggestion in the list', async () => { + const created = await proposeForTest(app, { ...proposal('v1'), ruleKey: 'VARROA_THRESHOLD', intent: 'VARROA_TREAT', requiredPermission: 'varroa:read', }); - const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`); - const hiddenSummary = await get(tokens.noVarroa, '/summary'); - const seen = await get(tokens.full, `?colonyId=${colonyId}`); - const seenSummary = await get(tokens.full, '/summary'); - - expect(hidden.body).toEqual([]); - expect((hiddenSummary.body as { open: number }).open).toBe(0); - expect(seen.body).toHaveLength(1); - expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual( - 1, - ); - expect( - (seenSummary.body as { byPriority: Record }).byPriority, - ).toEqual( - expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }), - ); + const hidden = (await get(tokens.noVarroa, `?colonyId=${colonyId}`)) + .body as ViewBody[]; + const seen = (await get(tokens.full, `?colonyId=${colonyId}`)) + .body as ViewBody[]; + expect(hidden.map((view) => view.id)).not.toContain(created.id); + expect(seen.map((view) => view.id)).toContain(created.id); }); it('answers 404 to a decision on a suggestion the user may not see', async () => { diff --git a/ApiCore-Backend/test/suggestions.e2e-spec.ts b/ApiCore-Backend/test/suggestions.e2e-spec.ts index d3c4594..7804860 100644 --- a/ApiCore-Backend/test/suggestions.e2e-spec.ts +++ b/ApiCore-Backend/test/suggestions.e2e-spec.ts @@ -1,9 +1,14 @@ 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 { SuggestionsService } from '../src/suggestions/suggestions.service'; import type { ProposeInput } from '../src/suggestions/suggestion.types'; +import { berlinToday } from '../src/common/dates'; +import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; /** * The suggestion core against a real PostgreSQL: the dedupe key, the SQL @@ -17,6 +22,9 @@ describe('Suggestions (e2e)', () => { let apiaryId: string; let colonyId: string; let userId: string; + const roleIds: string[] = []; + const extraUserIds: string[] = []; + const password = 'SuperSecret123!'; const proposal = (anchor: string): ProposeInput => ({ ruleKey: 'SPLIT_SCHEDULE', source: 'CHAIN', @@ -69,23 +77,64 @@ describe('Suggestions (e2e)', () => { }); afterAll(async () => { - await prisma.task.deleteMany({ - where: { sourceSuggestion: { colonyId } }, + await clearSuggestions(); + const inspectionIds = ( + await prisma.inspection.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((inspection) => inspection.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: inspectionIds } }, }); - await prisma.suggestion.deleteMany({ where: { colonyId } }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceId: { in: inspectionIds } }, + }); + await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.colony.delete({ where: { id: colonyId } }); await prisma.apiary.delete({ where: { id: apiaryId } }); - await prisma.auditLog.deleteMany({ where: { userId } }); - await prisma.user.delete({ where: { id: userId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: extraUserIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [userId, ...extraUserIds] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [userId, ...extraUserIds] } }, + }); + await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); - afterEach(async () => { + /** The colony's suggestions with what hangs on them: tasks, notifications, audit. */ + async function clearSuggestions(): Promise { + const ids = ( + await prisma.suggestion.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((suggestion) => suggestion.id); await prisma.task.deleteMany({ where: { sourceSuggestion: { colonyId } }, }); + await prisma.notification.deleteMany({ + where: { relatedType: 'Suggestion', relatedId: { in: ids } }, + }); + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { entityType: 'Suggestion', entityId: { in: ids } }, + { + entityType: 'AutomationRule', + afterData: { path: ['relatedId'], equals: colonyId }, + }, + ], + }, + }); await prisma.suggestion.deleteMany({ where: { colonyId } }); - }); + } + + afterEach(clearSuggestions); it('proposes once per anchor and writes an audit entry', async () => { const created = await propose(proposal('a1')); @@ -412,4 +461,179 @@ describe('Suggestions (e2e)', () => { expect(obsolete.status).toBe('OBSOLETE'); expect(obsolete.dedupeKey).not.toBeNull(); }); + describe('rule suggestions', () => { + let writer: { id: string; token: string }; + let withoutInspections: { id: string }; + let withoutTasks: { id: string }; + + async function createUser( + label: string, + permissions: string[], + ): Promise<{ id: string; token: string }> { + const role = await prisma.role.create({ + data: { + key: `SUG_RULES_${label.toUpperCase()}_${suffix}`, + name: `Suggestions rules ${label}`, + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + roleIds.push(role.id); + const email = `suggestions-rules-${label}-${suffix}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: `Suggestions rules ${label}`, + role: { connect: { id: role.id } }, + }, + }); + extraUserIds.push(user.id); + return { + id: user.id, + token: (await login(app, email, password)).accessToken, + }; + } + + beforeAll(async () => { + writer = await createUser('writer', [ + 'inspections:create', + 'inspections:read', + 'tasks:read', + ]); + withoutInspections = await createUser('noinsp', ['tasks:read']); + withoutTasks = await createUser('notasks', ['inspections:read']); + }); + + /** Records an inspection and runs the handlers of its event. */ + async function inspect(body: object): Promise { + const response = await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${writer.token}`) + .send({ colonyId, date: new Date().toISOString(), ...body }) + .expect(201); + const id = (response.body as { id: string }).id; + await drainDomainEvents(app, [id]); + return id; + } + + const queenless = { queenSeen: false, eggsPresent: false }; + const ruleSuggestions = () => + prisma.suggestion.findMany({ + where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' }, + }); + + it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => { + const inspectionId = await inspect(queenless); + + const suggestions = await ruleSuggestions(); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + status: 'OPEN', + source: 'RULE', + mode: 'PLAN', + intent: 'QUEEN_CHECK', + dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`, + }); + expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( + 0, + ); + }); + + it('a dismissed suggestion is not proposed again for the same inspection', async () => { + const inspectionId = await inspect(queenless); + const [first] = await ruleSuggestions(); + await service.dismiss(first.id, null, userId); + + // The delivery runs again, as after a retry or a catch-up. + await prisma.domainEvent.updateMany({ + where: { aggregateId: inspectionId }, + data: { status: 'PENDING', processedAt: null, claimedAt: null }, + }); + await prisma.domainEventDelivery.updateMany({ + where: { + handler: 'automation.inspection-rules', + event: { aggregateId: inspectionId }, + }, + data: { status: 'PENDING', attempts: 0, completedAt: null }, + }); + await drainDomainEvents(app, [inspectionId]); + + const suggestions = await ruleSuggestions(); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].status).toBe('DISMISSED'); + }); + + it('a later inspection with the same finding proposes again', async () => { + await inspect(queenless); + const [first] = await ruleSuggestions(); + await service.dismiss(first.id, null, userId); + + await inspect(queenless); + + const suggestions = await ruleSuggestions(); + expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual( + ['DISMISSED', 'OPEN'], + ); + }); + + it('accepting a rule suggestion without dueInDays creates a task due today', async () => { + await inspect(queenless); + const [open] = await ruleSuggestions(); + expect(open.dueDate).toBeNull(); + + const result = await service.accept(open.id, {}, userId); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe( + berlinToday(), + ); + expect(result.tasks[0]).toMatchObject({ + sourceSuggestionId: open.id, + sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', + priority: 'HIGH', + }); + }); + + it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => { + const created = (await prisma.$transaction((tx) => + service.proposeIn(tx, { + ...proposal('notify-chain'), + notify: true, + requiredPermission: 'colonies:read', + }), + ))!; + + const rows = await prisma.notification.findMany({ + where: { relatedType: 'Suggestion', relatedId: created.id }, + }); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true); + expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true); + expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe( + true, + ); + }); + + it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => { + await inspect(queenless); + const [open] = await ruleSuggestions(); + + const rows = await prisma.notification.findMany({ + where: { relatedType: 'Suggestion', relatedId: open.id }, + }); + + const recipients = new Set(rows.map((row) => row.userId)); + expect(recipients.has(writer.id)).toBe(true); + expect(recipients.has(withoutInspections.id)).toBe(false); + expect(recipients.has(withoutTasks.id)).toBe(false); + expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true); + expect( + rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)), + ).toBe(true); + }); + }); }); diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index 60b0262..d1d5960 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -89,6 +89,7 @@ describe('Treatments (e2e)', () => { where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } }, }); await prisma.treatment.deleteMany({ where: { colonyId } }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ @@ -482,6 +483,9 @@ describe('Treatments (e2e)', () => { await prisma.treatment.deleteMany({ where: { colonyId: { in: colonyIds } }, }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.deleteMany({ where: { id: secondApiaryId } }); await prisma.idempotencyRecord.deleteMany({ diff --git a/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts b/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts index ce17404..c0cfe38 100644 --- a/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts @@ -5,6 +5,7 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; interface BandBody { yellowFrom: number | null; @@ -41,6 +42,9 @@ describe('Varroa seasonal bands (e2e)', () => { let apiaryId: string; let productionColonyId: string; let youngColonyId: string; + // Colonies of the rule cases: one per case, so no open suggestion of an + // earlier case keeps a later one from appearing. + const ruleColonyIds: string[] = []; const get = (path: string) => request(app.getHttpServer()) @@ -80,7 +84,39 @@ describe('Varroa seasonal bands (e2e)', () => { }); afterAll(async () => { - const colonyIds = [productionColonyId, youngColonyId]; + const colonyIds = [productionColonyId, youngColonyId, ...ruleColonyIds]; + const measurementIds = ( + await prisma.varroaMeasurement.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((measurement) => measurement.id); + const suggestionIds = ( + await prisma.suggestion.findMany({ + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((suggestion) => suggestion.id); + await prisma.notification.deleteMany({ + where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { entityType: 'Suggestion', entityId: { in: suggestionIds } }, + ...colonyIds.map((id) => ({ + entityType: 'AutomationRule', + afterData: { path: ['relatedId'], equals: id }, + })), + ], + }, + }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: measurementIds } }, + }); await prisma.treatment.deleteMany({ where: { colonyId: { in: colonyIds } }, }); @@ -137,6 +173,53 @@ describe('Varroa seasonal bands (e2e)', () => { await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400); }); + describe('range form', () => { + interface SegmentsBody { + segments: { + from: string; + to: string; + bands: Record; + postTreatmentUntil: string | null; + active: boolean; + }[]; + } + const range = (query: string) => + get(`/varroa-measurements/thresholds?${query}`); + + it('returns merged segments split at the season edges', async () => { + const body = ( + await range( + `colonyId=${productionColonyId}&from=2026-10-30&to=2026-11-02`, + ).expect(200) + ).body as SegmentsBody; + + expect(body.segments.map((s) => [s.from, s.to, s.active])).toEqual([ + ['2026-10-30', '2026-10-31', true], + ['2026-11-01', '2026-11-02', true], + ]); + expect(body.segments[0].bands.NATURAL_MITE_FALL).toMatchObject({ + reason: 'NATURAL_FALL_SEASON', + }); + expect(body.segments[1].bands.NATURAL_MITE_FALL).toMatchObject({ + reason: 'NATURAL_FALL_WINTER', + }); + }); + + it('accepts 731 days and rejects 732', async () => { + await range('from=2025-01-01&to=2027-01-01').expect(200); + await range('from=2025-01-01&to=2027-01-02').expect(400); + }); + + it('rejects from after to, a missing bound, a mix with date and a bad day', async () => { + await range('from=2026-06-02&to=2026-06-01').expect(400); + await range('from=2026-06-02').expect(400); + await range('to=2026-06-02').expect(400); + await range('date=2026-06-01&from=2026-06-01&to=2026-06-02').expect(400); + await range('from=2026-02-30&to=2026-03-05').expect(400); + await range('colonyId=unknown&from=2026-06-01&to=2026-06-02').expect(404); + }); + }); + it('the old /threshold route is gone (404)', async () => { await get('/varroa-measurements/threshold').expect(404); }); @@ -221,4 +304,87 @@ describe('Varroa seasonal bands (e2e)', () => { postTreatmentUntil: '2026-08-16', }); }); + describe('the varroa rule', () => { + const newColony = async (): Promise => { + const colony = await prisma.colony.create({ + data: { + code: `VS-R-${suffix}-${ruleColonyIds.length}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + ruleColonyIds.push(colony.id); + return colony.id; + }; + + /** Records a measurement and runs the handlers of its event. */ + const measure = async ( + colonyId: string, + date: string, + miteCount: number, + ): Promise => { + const created = ( + await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${token}`) + .send({ + colonyId, + date, + method: 'NATURAL_MITE_FALL', + miteCount, + // One day on the board: the count is the rate per day. + durationMinutes: 24 * 60, + }) + .expect(201) + ).body as { id: string }; + await drainDomainEvents(app, [created.id]); + return created.id; + }; + + const ruleSuggestions = (colonyId: string) => + prisma.suggestion.findMany({ + where: { colonyId, ruleKey: 'VARROA_THRESHOLD' }, + }); + + it('12 mites/day in August proposes a treatment check', async () => { + const colonyId = await newColony(); + + const id = await measure(colonyId, '2026-08-10T00:00:00.000Z', 12); + + const suggestions = await ruleSuggestions(colonyId); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + intent: 'VARROA_TREAT', + reasonCode: 'VARROA_RED', + status: 'OPEN', + dedupeKey: `VARROA_THRESHOLD:${colonyId}:${id}`, + evidence: { value: 12, redAbove: 10, unit: 'MITES_PER_DAY' }, + }); + }); + + it('12 mites/day five days after treatment end proposes nothing', async () => { + const colonyId = await newColony(); + await prisma.treatment.create({ + data: { + colonyId, + userId, + date: new Date('2026-08-01T00:00:00.000Z'), + endDate: new Date('2026-08-05T00:00:00.000Z'), + product: 'Oxuvar', + }, + }); + + await measure(colonyId, '2026-08-10T00:00:00.000Z', 12); + + expect(await ruleSuggestions(colonyId)).toHaveLength(0); + }); + + it('10.0 mites/day in August proposes nothing (yellow)', async () => { + const colonyId = await newColony(); + + await measure(colonyId, '2026-08-10T00:00:00.000Z', 10); + + expect(await ruleSuggestions(colonyId)).toHaveLength(0); + }); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx index 92b471d..7ed04ed 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.spec.tsx @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ColonyVarroaCard } from "./colony-varroa"; -import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api"; +import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api"; +import type { VarroaBand, VarroaRating, VarroaThresholdSegments } from "@/lib/varroa/bands"; import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types"; vi.mock("@/lib/varroa/api", () => ({ fetchColonyVarroaMeasurements: vi.fn(), - fetchVarroaThreshold: vi.fn(), + fetchVarroaThresholdRange: vi.fn(), })); vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null })); @@ -16,6 +17,7 @@ function measurement( date: string, method: VarroaMethod, calculatedValue: number | null, + rating: VarroaRating | null = null, ): VarroaMeasurement { return { id, @@ -25,13 +27,39 @@ function measurement( method, durationMinutes: 4320, miteCount: 12, + sampleBees: null, calculatedValue, assessment: null, notes: null, createdAt: `${date}T00:00:00.000Z`, + rating, }; } +const SEASON: VarroaBand = { + method: "NATURAL_MITE_FALL", + unit: "MITES_PER_DAY", + yellowFrom: 5, + redAbove: 10, + reason: "NATURAL_FALL_SEASON", + youngColony: false, +}; +const WINTER: VarroaBand = { ...SEASON, yellowFrom: null, redAbove: 0.5, reason: "NATURAL_FALL_WINTER" }; + +/** Winter from 1 November, season before; one segment per side of the change inside the range. */ +function segmentsFor(from: string, to: string): VarroaThresholdSegments { + const part = (start: string, end: string, band: VarroaBand) => ({ + from: start, + to: end, + bands: { NATURAL_MITE_FALL: band }, + postTreatmentUntil: null, + active: true, + }); + if (to < "2026-11-01") return { segments: [part(from, to, SEASON)] }; + if (from >= "2026-11-01") return { segments: [part(from, to, WINTER)] }; + return { segments: [part(from, "2026-10-31", SEASON), part("2026-11-01", to, WINTER)] }; +} + const MEASUREMENTS = [ measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4), measurement("b", "2026-06-08", "POWDERED_SUGAR", 99), @@ -50,7 +78,10 @@ function renderCard() { describe("ColonyVarroaCard trend", () => { beforeEach(() => { vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS); - vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 }); + vi.mocked(fetchVarroaThresholdRange).mockReset(); + vi.mocked(fetchVarroaThresholdRange).mockImplementation(async (_colonyId, from, to) => + segmentsFor(from, to), + ); }); it("the chart ignores powdered sugar counts", async () => { @@ -59,18 +90,62 @@ describe("ColonyVarroaCard trend", () => { expect(container.querySelectorAll("svg circle")).toHaveLength(2); }); - it("draws the threshold line", async () => { + it("the chart draws the band as a monthly step line with an accessible summary", async () => { const { container } = renderCard(); - expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument(); - expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull(); + expect(await screen.findByText("Grenze 10/Tag")).toBeInTheDocument(); + const red = container.querySelector("svg path[data-band='red']"); + expect(red).not.toBeNull(); + expect(container.querySelector("svg path[data-band='yellow']")).not.toBeNull(); + expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-06-01", "2026-06-30"); + + const chart = screen.getByRole("img"); + expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Grenze 10\/Tag/); + expect(chart).toHaveAccessibleDescription(/2 Messungen.*Rote Grenze je Monat: Juni 10/); }); - it("has an accessible summary", async () => { + it("steps down in winter and leaves a gap where there is no yellow stage", async () => { + vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([ + measurement("a", "2026-10-20", "NATURAL_MITE_FALL", 4), + measurement("c", "2026-11-20", "NATURAL_MITE_FALL", 0.2), + ]); + const { container } = renderCard(); + await screen.findByText("Grenze 0,5/Tag"); + const red = container.querySelector("svg path[data-band='red']")?.getAttribute("d") ?? ""; + expect(red).toMatch(/^M[\d.]+,[\d.]+ H[\d.]+ V[\d.]+ H[\d.]+$/); + const yellow = container.querySelector("svg path[data-band='yellow']")?.getAttribute("d") ?? ""; + expect(yellow.match(/M/g)).toHaveLength(1); + expect(yellow).not.toContain("V"); + expect(screen.getByRole("img")).toHaveAccessibleDescription(/Oktober 10, November 0,5/); + }); + + it("asks once for a 24-month trend", async () => { + vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([ + measurement("a", "2024-08-10", "NATURAL_MITE_FALL", 4), + measurement("b", "2026-07-20", "NATURAL_MITE_FALL", 6), + ]); renderCard(); - await screen.findByText("Schwelle 10/Tag"); - const chart = screen.getByRole("img"); - expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/); - expect(chart).toHaveAccessibleDescription(/2 Messungen/); + await screen.findByText("Grenze 10/Tag"); + expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2024-08-01", "2026-07-31"); + }); + + it("rates a late-evening UTC measurement on its Berlin day and month", async () => { + vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([ + { ...measurement("a", "2026-08-31", "NATURAL_MITE_FALL", 4), date: "2026-08-31T22:30:00.000Z" }, + measurement("b", "2026-09-10", "NATURAL_MITE_FALL", 6), + ]); + renderCard(); + await screen.findByText("Grenze 10/Tag"); + expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-09-01", "2026-09-30"); + expect(screen.getByRole("img")).toHaveAccessibleDescription(/2 Messungen von 01.09.2026 bis 10.09.2026/); + }); + + it("draws no band while the thresholds are unknown", async () => { + vi.mocked(fetchVarroaThresholdRange).mockRejectedValue(new Error("offline")); + const { container } = renderCard(); + await screen.findByRole("img"); + expect(container.querySelector("svg path[data-band]")).toBeNull(); + expect(screen.getByRole("img")).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026$/); }); it("shows only the value as text with fewer than two natural-fall points", async () => { @@ -90,4 +165,27 @@ describe("ColonyVarroaCard trend", () => { await screen.findByRole("img"); expect(container.querySelectorAll("svg circle")).toHaveLength(3); }); + + it("names the stored rating of each measurement in words", async () => { + vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([ + measurement("a", "2026-08-01", "NATURAL_MITE_FALL", 12.4, { + light: "red", + reasonCode: "NATURAL_FALL_SEASON", + band: SEASON, + postTreatmentUntil: null, + }), + measurement("b", "2026-08-10", "NATURAL_MITE_FALL", 8, { + light: "POST_TREATMENT", + reasonCode: "POST_TREATMENT", + band: null, + postTreatmentUntil: "2026-08-16", + }), + { ...measurement("c", "2026-08-12", "POWDERED_SUGAR", 2.4), sampleBees: 500 }, + ]); + renderCard(); + expect(await screen.findByText("Kritisch (Grenze 10, LWG)")).toBeInTheDocument(); + expect(screen.getByText("Behandlungseffekt – Nachkontrolle ab 16.08.")).toBeInTheDocument(); + expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument(); + expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument(); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx index 136fd22..0d626da 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-varroa.tsx @@ -4,11 +4,13 @@ import Link from "next/link"; import { useId } from "react"; import { Bug } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api"; +import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api"; +import { VarroaBand, ratingNote, segmentAt, valueText } from "@/lib/varroa/bands"; import { VarroaMeasurement } from "@/lib/varroa/types"; import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format"; -import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold"; +import { berlinCalendarDay } from "@/lib/dates"; import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels"; +import { VarroaNoteLabel } from "@/components/varroa/varroa-rate-light"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/empty-state"; import { QueryState } from "@/components/query-state"; @@ -23,42 +25,147 @@ const PAD_BOTTOM = 12; const MARKER_RADIUS = 4; const LABEL_GAP = 4; +const MAX_BAND_MONTHS = 24; +const ONE_HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + interface TrendPoint { id: string; time: number; rate: number; } +/** The band of the natural mite fall in force during one calendar month. */ +interface MonthLimit { + /** First day of the month, UTC, in ms. */ + start: number; + /** First day of the next month, UTC, in ms. */ + end: number; + band: VarroaBand | null; +} + +interface TimeRange { + from: number; + to: number; +} + +type LimitKey = "redAbove" | "yellowFrom"; + const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", day: "2-digit", month: "2-digit", year: "numeric", }); +const MONTH_FORMAT = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" }); function formatDate(time: number): string { return DAY_FORMAT.format(new Date(time)); } -/** Only the natural mite fall is a rate per day; other methods are not comparable. */ +/** + * Only the natural mite fall is a rate per day; other methods are not comparable. + * A point sits at midnight UTC of its Berlin calendar day, the day the backend rates it on. + */ function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] { return measurements .filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null) - .map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number })) + .map((m) => ({ id: m.id, time: Date.parse(`${berlinCalendarDay(m.date)}T00:00:00Z`), rate: m.calculatedValue as number })) .sort((left, right) => left.time - right.time); } -function summaryOf(last: TrendPoint, threshold: number | null): string { +function nextMonthStart(start: number): number { + const date = new Date(start); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1); +} + +/** The first days of the months the points span, the latest MAX_BAND_MONTHS at most. */ +function monthStarts(points: TrendPoint[]): number[] { + if (points.length === 0) return []; + const first = new Date(points[0].time); + const last = new Date(points[points.length - 1].time); + const stop = Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), 1); + const starts: number[] = []; + let cursor = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1); + while (cursor <= stop) { + starts.push(cursor); + cursor = nextMonthStart(cursor); + } + return starts.slice(-MAX_BAND_MONTHS); +} + +function isoDay(time: number): string { + return new Date(time).toISOString().slice(0, 10); +} + +/** The natural-fall band of each month of the trend: one request for the whole range. */ +function useMonthLimits(colonyId: string, points: TrendPoint[]): MonthLimit[] { + const starts = monthStarts(points); + const from = starts.length > 0 ? isoDay(starts[0]) : ""; + const to = starts.length > 0 ? isoDay(nextMonthStart(starts[starts.length - 1]) - DAY_MS) : ""; + const query = useQuery({ + queryKey: ["varroa", "thresholds", colonyId, from, to], + queryFn: () => fetchVarroaThresholdRange(colonyId, from, to), + enabled: starts.length > 0, + staleTime: ONE_HOUR_MS, + }); + const segments = query.data?.segments ?? []; + return starts.map((start) => ({ + start, + end: nextMonthStart(start), + band: segmentAt(segments, isoDay(start))?.bands.NATURAL_MITE_FALL ?? null, + })); +} + +/** + * A step line through the monthly limits: flat inside a month, a vertical + * step at the month change, a gap where a month has no such limit. + */ +function stepPath( + limits: MonthLimit[], + key: LimitKey, + xOf: (time: number) => number, + yOf: (value: number) => number, + range: TimeRange, +): string { + const parts: string[] = []; + let open = false; + for (const { start, end, band } of limits) { + const value = band ? band[key] : null; + const from = Math.max(start, range.from); + const to = Math.min(end, range.to); + if (value === null || to < from) { + open = false; + continue; + } + const y = yOf(value); + parts.push(open ? `V${y} H${xOf(to)}` : `M${xOf(from)},${y} H${xOf(to)}`); + open = true; + } + return parts.join(" "); +} + +function monthLimitsText(limits: MonthLimit[], range: TimeRange): string { + return limits + .filter(({ band, start, end }) => band && end > range.from && start <= range.to) + .map( + ({ start, band }) => + `${MONTH_FORMAT.format(new Date(start))} ${formatThreshold((band as VarroaBand).redAbove)}`, + ) + .join(", "); +} + +function summaryOf(last: TrendPoint, lastLimit: VarroaBand | null): string { const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`; - return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`; + return lastLimit ? `${base}, Grenze ${formatThreshold(lastLimit.redAbove)}/Tag` : base; } function VarroaTrendChart({ points, - threshold, + limits, }: { points: TrendPoint[]; - threshold: number | null; + limits: MonthLimit[]; }) { const titleId = useId(); const descId = useId(); @@ -66,19 +173,26 @@ function VarroaTrendChart({ if (!last) { return null; } - const summary = summaryOf(last, threshold); + const lastLimit = + limits.find(({ start, end }) => last.time >= start && last.time < end)?.band ?? null; + const summary = summaryOf(last, lastLimit); if (points.length < 2) { return

{summary}

; } const first = points[0]; - const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1); + const range = { from: first.time, to: last.time }; + const limitValues = limits.flatMap(({ band }) => (band ? [band.redAbove] : [])); + const yMax = Math.max(...points.map((p) => p.rate), ...limitValues, 1); const timeRange = last.time - first.time || 1; const usableWidth = CHART_WIDTH - PAD_X * 2; const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM; const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth; const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight; const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" "); + const redPath = stepPath(limits, "redAbove", xOf, yOf, range); + const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range); + const monthly = monthLimitsText(limits, range); return ( {`Milbenfall pro Tag. ${summary}`} - {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`} + {`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${ + monthly ? ` Rote Grenze je Monat: ${monthly}.` : "" + }`} - {threshold !== null ? ( - - - - {`Schwelle ${formatThreshold(threshold)}/Tag`} - + {redPath || yellowPath ? ( + + {yellowPath ? ( + + ) : null} + {redPath ? ( + + ) : null} ) : null} + {lastLimit ? ( + + {`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`} + + ) : null} {points.map((p) => ( +

+ {measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"} + {measurement.sampleBees !== null ? ` · Probe ${measurement.sampleBees} Bienen` : ""} + {measurement.calculatedValue !== null + ? ` · ${valueText(measurement.method, measurement.calculatedValue, formatMitesPerDay)}` + : ""} +

+ {note ? : null} + + ); +} + export function ColonyVarroaCard({ colonyId }: { colonyId: string }) { const measurementsQuery = useQuery({ queryKey: ["colonies", colonyId, "varroa-measurements"], queryFn: () => fetchColonyVarroaMeasurements(colonyId), }); - - const threshold = useVarroaThreshold(); + const limits = useMonthLimits(colonyId, trendPoints(measurementsQuery.data ?? [])); return ( @@ -165,7 +295,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) { > {(measurements) => ( <> - + {measurements.map((measurement) => (
@@ -175,12 +305,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {

-

- {measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"} - {measurement.calculatedValue !== null - ? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag` - : ""} -

+ {measurement.assessment ?

Einschätzung: {measurement.assessment}

: null}
))} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx index 195b58c..b5c9969 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx @@ -3,6 +3,7 @@ import { FormEvent, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { updateTreatment } from "@/lib/treatments/api"; +import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands"; import { Treatment } from "@/lib/treatments/types"; import { emptyToNull } from "@/lib/forms/empty-to-null"; import { errorMessage } from "@/lib/forms/error-message"; @@ -81,6 +82,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) { queryKey: ["colonies", affectedColonyId, "treatments"], }); } + // A treatment moves the post-treatment window of the varroa lights. + void invalidateVarroaBands(queryClient); setOpen(false); }, onError: (error) => diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx index 027d746..a7c0df0 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.spec.tsx @@ -18,6 +18,7 @@ const measurement: VarroaMeasurement = { method: "NATURAL_MITE_FALL", durationMinutes: 4320, miteCount: 12, + sampleBees: null, calculatedValue: 4, assessment: "mittel", notes: "Windel verrutscht", @@ -90,11 +91,46 @@ describe("VarroaEditDialog", () => { expect(input).toMatchObject({ durationMinutes: 2000 }); }); - it("keeps the minutes field for other methods", async () => { - await openDialog({ ...measurement, method: "WASHING_METHOD" }); + it("keeps the minutes field for the drone brood check", async () => { + await openDialog({ ...measurement, method: "DRONE_BROOD_CONTROL" }); expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320); }); + it("the sample methods have no minutes field, a stored value is sent back", async () => { + await openDialog({ ...measurement, method: "WASHING_METHOD", sampleBees: 300 }); + expect(screen.queryByLabelText(/^Zeitraum \(Minuten\)/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ durationMinutes: 4320 }); + }); + + it.each(["0", "2001", "2.5"])("refuses a sample of %s and sends nothing", async (bad) => { + await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300 }); + const field = screen.getByLabelText(/Probe \(Bienen\)/); + await userEvent.clear(field); + await userEvent.type(field, bad); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument(); + expect(updateVarroaMeasurement).not.toHaveBeenCalled(); + }); + + it("a stored sampleBees is sent back unchanged from the edit dialog", async () => { + await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300, calculatedValue: 4 }); + + expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(300); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0]; + expect(input).toMatchObject({ sampleBees: 300, miteCount: 12 }); + }); + + it("shows no sample field for the natural mite fall and sends null", async () => { + await openDialog(); + expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ sampleBees: null }); + }); + it("explains that a manual mites-per-day value takes precedence", async () => { await openDialog(); expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument(); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx index a613fc1..fdc1801 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx @@ -28,6 +28,8 @@ import { } from "./edit-dialog-parts"; import { toApiDate } from "@/lib/dates"; import { VarroaPeriodField } from "@/components/varroa/varroa-period-field"; +import { SampleBeesField } from "@/components/varroa/sample-bees-field"; +import { sampleBeesError, usesSample } from "@/lib/varroa/bands"; export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) { const queryClient = useQueryClient(); @@ -36,6 +38,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme const [date, setDate] = useState(measurement.date.slice(0, 10)); const [method, setMethod] = useState(measurement.method); const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? ""); + const [sampleBees, setSampleBees] = useState(measurement.sampleBees?.toString() ?? ""); const [durationMinutes, setDurationMinutes] = useState( measurement.durationMinutes?.toString() ?? "", ); @@ -45,8 +48,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme const [assessment, setAssessment] = useState(measurement.assessment ?? ""); const [notes, setNotes] = useState(measurement.notes ?? ""); const [dateError, setDateError] = useState(null); + const [sampleError, setSampleError] = useState(null); const [submitError, setSubmitError] = useState(null); + const isNaturalFall = method === "NATURAL_MITE_FALL"; + const hasSample = usesSample(method); + const mutation = useMutation({ mutationFn: () => updateVarroaMeasurement(measurement.id, { @@ -54,6 +61,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme date: toApiDate(date), method, miteCount: emptyToNull(miteCount, Number), + sampleBees: hasSample ? emptyToNull(sampleBees, Number) : null, durationMinutes: emptyToNull(durationMinutes, Number), calculatedValue: emptyToNull(calculatedValue, Number), assessment: emptyToNull(assessment), @@ -73,7 +81,6 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")), }); - const isNaturalFall = method === "NATURAL_MITE_FALL"; function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -82,6 +89,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme setDateError(DATE_REQUIRED_MESSAGE); return; } + const invalidSample = hasSample ? sampleBeesError(sampleBees) : null; + setSampleError(invalidSample); + if (invalidSample) { + document.querySelector('[data-field="sampleBees"]')?.focus(); + return; + } mutation.mutate(); } @@ -147,7 +160,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme /> )} - {isNaturalFall ? null : ( + {isNaturalFall || hasSample ? null : ( {({ id }) => ( )} + {hasSample ? ( + { + setSampleBees(next); + setSampleError(null); + }} + error={sampleError} + /> + ) : null} {({ id }) => ( }) { >