feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
||||
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<string, { WARNUNG?: number; KRITISCH?: number }>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<RawMaterialKind, string> = {
|
||||
BEESWAX: 'Bienenwachs',
|
||||
PROPOLIS: 'Propolis',
|
||||
};
|
||||
|
||||
/** What {ART} turns into in the raw material number format. */
|
||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Record<RawMaterialKind, unknown>>;
|
||||
} | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Suggestion | null> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<AutomationRule, 'active' | 'config'> | 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AutopilotInput | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<AutopilotRuleId, AutopilotRule>>
|
||||
> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<AutopilotEventHandlers['colonySync']>[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');
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<object>('../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<string, ReturnType<typeof rule>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, SeasonState>;
|
||||
}
|
||||
|
||||
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<AutopilotPreload> {
|
||||
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<AutopilotSyncResult> {
|
||||
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<AutopilotConfigs> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<JobResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<JobResult> {
|
||||
return { suggestionsExpired: await this.suggestions.expireDue(today) };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Colony> {
|
||||
// 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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<number, ColonyCondition> = {
|
||||
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<Colony[K]>;
|
||||
};
|
||||
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 };
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
|
||||
'originText',
|
||||
'colonyType',
|
||||
'strength',
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
|
||||
@@ -115,6 +115,7 @@ const contracts: Record<string, NullContract> = {
|
||||
'hiveType',
|
||||
'originText',
|
||||
'strength',
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
@@ -334,6 +335,7 @@ const contracts: Record<string, NullContract> = {
|
||||
clearable: [
|
||||
'durationMinutes',
|
||||
'miteCount',
|
||||
'sampleBees',
|
||||
'calculatedValue',
|
||||
'assessment',
|
||||
'notes',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -122,6 +122,8 @@ export class ExportService {
|
||||
'strength',
|
||||
'healthStatus',
|
||||
'createdAt',
|
||||
// Last: consumers of the CSV read columns by position.
|
||||
'occupiedFrames',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 },
|
||||
) {}
|
||||
@@ -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],
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<Inspection[]> {
|
||||
@@ -46,10 +60,24 @@ export class InspectionsService {
|
||||
async create(
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): Promise<Inspection> {
|
||||
): Promise<InspectionWithTakeover> {
|
||||
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<ColonyTakeover> {
|
||||
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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<JobResult> {
|
||||
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
||||
const steps: DailyEvaluationStep[] = [
|
||||
this.timeEvents,
|
||||
this.autopilot,
|
||||
this.suggestionsExpiry,
|
||||
];
|
||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||
steps,
|
||||
berlinToday(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<NotificationView> {
|
||||
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 };
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: DomainEventEnvelope<T>['payload'],
|
||||
): AnyDomainEventEnvelope<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<Inspection, 'id' | 'date' | 'createdAt'>,
|
||||
): Promise<string[]> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<number, Intl.NumberFormat>();
|
||||
|
||||
/** A number with a decimal comma: 12.4 as '12,4'. */
|
||||
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
|
||||
let format = numberFormats.get(maxFractionDigits);
|
||||
if (!format) {
|
||||
format = new Intl.NumberFormat('de-DE', {
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
});
|
||||
numberFormats.set(maxFractionDigits, format);
|
||||
}
|
||||
return format.format(value);
|
||||
}
|
||||
export { formatDayDe } from './texts/format-day';
|
||||
export { formatNumberDe } from './texts/format-number';
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<string, unknown> }>(
|
||||
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' },
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string, string | number | null>,
|
||||
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.',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren',
|
||||
MATING_CHECK: 'Begattung kontrollieren',
|
||||
EGGS_CHECK: 'Eier und Stifte kontrollieren',
|
||||
ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren',
|
||||
RAISE_CELLS_CHECK: 'Nachschaffungszellen prüfen',
|
||||
MATING_CHECK: 'Begattung prüfen',
|
||||
EGGS_CHECK: 'Stifte prüfen',
|
||||
ACCEPTANCE_CHECK: 'Annahme der Königin prüfen',
|
||||
};
|
||||
@@ -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}.`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const numberFormats = new Map<number, Intl.NumberFormat>();
|
||||
|
||||
/** A number with a decimal comma: 12.4 as '12,4'. */
|
||||
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
|
||||
let format = numberFormats.get(maxFractionDigits);
|
||||
if (!format) {
|
||||
format = new Intl.NumberFormat('de-DE', {
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
});
|
||||
numberFormats.set(maxFractionDigits, format);
|
||||
}
|
||||
return format.format(value);
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
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')],
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<VarroaThresholdSegments> {
|
||||
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<M extends RatableMeasurement>(
|
||||
colonyId: string,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<App>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<App>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<App>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {};
|
||||
/** 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<string> {
|
||||
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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } } });
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<App>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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<string, number> }).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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 <p className="font-medium">{summary}</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg
|
||||
@@ -90,30 +204,31 @@ function VarroaTrendChart({
|
||||
>
|
||||
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
|
||||
<desc id={descId}>
|
||||
{`${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}.` : ""
|
||||
}`}
|
||||
</desc>
|
||||
{threshold !== null ? (
|
||||
<g className="text-muted-foreground">
|
||||
<line
|
||||
x1={PAD_X}
|
||||
x2={CHART_WIDTH - PAD_X}
|
||||
y1={yOf(threshold)}
|
||||
y2={yOf(threshold)}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 4"
|
||||
/>
|
||||
<text
|
||||
x={CHART_WIDTH - PAD_X}
|
||||
y={yOf(threshold) - LABEL_GAP}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="currentColor"
|
||||
>
|
||||
{`Schwelle ${formatThreshold(threshold)}/Tag`}
|
||||
</text>
|
||||
{redPath || yellowPath ? (
|
||||
<g className="text-muted-foreground" fill="none" stroke="currentColor">
|
||||
{yellowPath ? (
|
||||
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
|
||||
) : null}
|
||||
{redPath ? (
|
||||
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
|
||||
) : null}
|
||||
</g>
|
||||
) : null}
|
||||
{lastLimit ? (
|
||||
<text
|
||||
x={CHART_WIDTH - PAD_X}
|
||||
y={yOf(lastLimit.redAbove) - LABEL_GAP}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
|
||||
</text>
|
||||
) : null}
|
||||
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
|
||||
{points.map((p) => (
|
||||
<circle
|
||||
@@ -130,13 +245,28 @@ function VarroaTrendChart({
|
||||
);
|
||||
}
|
||||
|
||||
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
|
||||
const note = ratingNote(measurement.rating);
|
||||
return (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
||||
{measurement.sampleBees !== null ? ` · Probe ${measurement.sampleBees} Bienen` : ""}
|
||||
{measurement.calculatedValue !== null
|
||||
? ` · ${valueText(measurement.method, measurement.calculatedValue, formatMitesPerDay)}`
|
||||
: ""}
|
||||
</p>
|
||||
{note ? <VarroaNoteLabel note={note} className="mt-2" /> : 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 (
|
||||
<Card>
|
||||
@@ -165,7 +295,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
||||
>
|
||||
{(measurements) => (
|
||||
<>
|
||||
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
|
||||
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
|
||||
{measurements.map((measurement) => (
|
||||
<div key={measurement.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -175,12 +305,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
||||
</p>
|
||||
<VarroaEditDialog measurement={measurement} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
||||
{measurement.calculatedValue !== null
|
||||
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
|
||||
: ""}
|
||||
</p>
|
||||
<MeasurementDetails measurement={measurement} />
|
||||
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<VarroaMethod>(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<string | null>(null);
|
||||
const [sampleError, setSampleError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
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<HTMLElement>('[data-field="sampleBees"]')?.focus();
|
||||
return;
|
||||
}
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
@@ -147,7 +160,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{isNaturalFall ? null : (
|
||||
{isNaturalFall || hasSample ? null : (
|
||||
<Field label="Zeitraum (Minuten)">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
@@ -163,9 +176,19 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{hasSample ? (
|
||||
<SampleBeesField
|
||||
value={sampleBees}
|
||||
onChange={(next) => {
|
||||
setSampleBees(next);
|
||||
setSampleError(null);
|
||||
}}
|
||||
error={sampleError}
|
||||
/>
|
||||
) : null}
|
||||
<Field
|
||||
label="Milben pro Tag"
|
||||
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
|
||||
label={hasSample ? "Befall (%)" : "Milben pro Tag"}
|
||||
hint={`Wird beim Speichern aus Milbenzahl und ${hasSample ? "Probe" : "Zeitraum"} neu berechnet – ein eigener, geänderter Wert hat Vorrang.`}
|
||||
>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import { Menu } from "lucide-react";
|
||||
import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NavCounter } from "@/components/suggestions/nav-counter";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
@@ -70,6 +71,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
>
|
||||
<Icon className="size-6 shrink-0" aria-hidden="true" />
|
||||
{item.label}
|
||||
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -55,4 +55,14 @@ describe("nav-config", () => {
|
||||
expect(hrefs([])).not.toContain("/winter");
|
||||
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
||||
});
|
||||
|
||||
it("„Vorschläge“ needs tasks:read and shows the open count", () => {
|
||||
expect(hrefs([])).not.toContain("/suggestions");
|
||||
const work = visibleNavGroups({ permissions: ["tasks:read"] }).find(
|
||||
(group) => group.label === "Arbeit",
|
||||
);
|
||||
const item = work?.items.find((entry) => entry.href === "/suggestions");
|
||||
expect(item?.label).toBe("Vorschläge");
|
||||
expect(item?.counter).toBe("suggestions");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
History,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
MapPin,
|
||||
@@ -41,6 +42,8 @@ export interface NavItem {
|
||||
// together several previously-separate settings pages, each gated by its
|
||||
// own permission.
|
||||
permission?: string | string[];
|
||||
/** A number shown next to the label; the data comes from the matching hook. */
|
||||
counter?: "suggestions";
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
@@ -79,6 +82,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
label: "Arbeit",
|
||||
items: [
|
||||
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
|
||||
{
|
||||
label: "Vorschläge",
|
||||
href: "/suggestions",
|
||||
icon: Lightbulb,
|
||||
permission: "tasks:read",
|
||||
counter: "suggestions",
|
||||
},
|
||||
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ function rule(
|
||||
};
|
||||
}
|
||||
|
||||
let lastClient: QueryClient;
|
||||
|
||||
function renderTab(rules: AutomationRule[]) {
|
||||
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
|
||||
vi.mocked(updateAutomationRule).mockImplementation(
|
||||
@@ -38,6 +40,7 @@ function renderTab(rules: AutomationRule[]) {
|
||||
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
lastClient = client;
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AutomationRulesTab />
|
||||
@@ -93,6 +96,19 @@ describe("varroa rule form", () => {
|
||||
expect(config.notify).toBe(true);
|
||||
});
|
||||
|
||||
it("saving the varroa rule marks the cached thresholds stale", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTab([VARROA]);
|
||||
lastClient.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], { bands: {} });
|
||||
const view = await card(VARROA_TITLE);
|
||||
|
||||
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastClient.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("a 400 issue appears under its field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTab([VARROA]);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RuleFormProps } from "./rule-forms/rule-form-parts";
|
||||
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
|
||||
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
|
||||
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
|
||||
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
|
||||
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
|
||||
import { WaterContentForm } from "./rule-forms/water-content-form";
|
||||
|
||||
@@ -54,6 +55,9 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
|
||||
onSuccess: () => {
|
||||
setIssues({});
|
||||
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
||||
if (rule.triggerType === "VARROA_THRESHOLD") {
|
||||
void invalidateVarroaBands(queryClient);
|
||||
}
|
||||
toast.success("Regel gespeichert");
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FileClock, Hexagon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NavCounter } from "@/components/suggestions/nav-counter";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
|
||||
|
||||
@@ -28,6 +29,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "sonner";
|
||||
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
acceptSuggestion,
|
||||
bulkAcceptSuggestions,
|
||||
dismissSuggestion,
|
||||
fetchSuggestions,
|
||||
fetchSuggestionSummary,
|
||||
reopenSuggestion,
|
||||
} from "@/lib/suggestions/api";
|
||||
import { readRound } from "@/lib/rounds/round-state";
|
||||
import type { Suggestion } from "@/lib/suggestions/types";
|
||||
import SuggestionsPage from "./page";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
user: { id: "user-1" } as { id: string } | null,
|
||||
permissions: new Set(["tasks:read", "tasks:create", "tasks:update"]),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: auth.user,
|
||||
hasPermission: (permission: string) => auth.permissions.has(permission),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
||||
fetchSuggestions: vi.fn(),
|
||||
fetchSuggestionSummary: vi.fn(),
|
||||
acceptSuggestion: vi.fn(),
|
||||
bulkAcceptSuggestions: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
bulkDismissSuggestions: vi.fn(),
|
||||
reopenSuggestion: vi.fn(),
|
||||
}));
|
||||
|
||||
const OPEN: Suggestion[] = [
|
||||
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }),
|
||||
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }),
|
||||
suggestionFixture({
|
||||
id: "s3",
|
||||
mode: "PLAN",
|
||||
intent: "STOCK_REORDER",
|
||||
apiaryId: null,
|
||||
apiaryName: null,
|
||||
colonyId: null,
|
||||
colonyCode: null,
|
||||
title: "Gläser nachbestellen",
|
||||
reasons: [],
|
||||
}),
|
||||
];
|
||||
const SINGLE = OPEN[2];
|
||||
|
||||
function mockLists(
|
||||
open: Suggestion[],
|
||||
options: { dismissed?: Suggestion[]; pageSize?: number; total?: number } = {},
|
||||
) {
|
||||
const { dismissed = [], pageSize = Number.POSITIVE_INFINITY, total = open.length } = options;
|
||||
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
|
||||
query?.status === "DISMISSED"
|
||||
? dismissed
|
||||
: open.slice(query?.offset ?? 0, (query?.offset ?? 0) + pageSize),
|
||||
);
|
||||
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||
open: total,
|
||||
byPriority: { LOW: 0, NORMAL: total, HIGH: 0, URGENT: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SuggestionsPage />
|
||||
<Toaster />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function manyPlan(count: number): Suggestion[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
suggestionFixture({ id: `m${index}`, colonyId: `c${index}`, colonyCode: `B${index}` }),
|
||||
);
|
||||
}
|
||||
|
||||
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Sonner captures the pointer for swiping a toast.
|
||||
HTMLElement.prototype.setPointerCapture = () => undefined;
|
||||
HTMLElement.prototype.releasePointerCapture = () => undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
toast.dismiss();
|
||||
window.sessionStorage.clear();
|
||||
auth.user = { id: "user-1" };
|
||||
auth.permissions = new Set(["tasks:read", "tasks:create", "tasks:update"]);
|
||||
});
|
||||
|
||||
describe("SuggestionsPage", () => {
|
||||
it("shows „3 Vorschläge“ and the explanation", async () => {
|
||||
mockLists(OPEN);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Hang" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Betrieb" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a row once when two pages overlap", async () => {
|
||||
const rows = manyPlan(201);
|
||||
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
|
||||
query?.offset ? [rows[199], rows[200]] : rows.slice(0, 200),
|
||||
);
|
||||
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||
open: 201,
|
||||
byPriority: { LOW: 0, NORMAL: 201, HIGH: 0, URGENT: 0 },
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
|
||||
|
||||
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer more when the last page was short", async () => {
|
||||
mockLists(OPEN, { total: 5 });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("3 von 5 Vorschlägen")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the count region mounted while loading and when empty", async () => {
|
||||
mockLists([]);
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId("suggestion-count")).toHaveAttribute("role", "status");
|
||||
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("suggestion-count")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts from the summary and loads more pages with an offset", async () => {
|
||||
mockLists(manyPlan(201), { pageSize: 200 });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("200 von 201 Vorschlägen")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mehr laden" }));
|
||||
|
||||
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
|
||||
expect(fetchSuggestions).toHaveBeenCalledWith({ status: "OPEN", limit: 200, offset: 200 });
|
||||
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("„Alle übernehmen“ sends one bulk-accept with the group ids", async () => {
|
||||
mockLists(OPEN);
|
||||
vi.mocked(bulkAcceptSuggestions).mockResolvedValue([]);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
|
||||
|
||||
await waitFor(() => expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toEqual(["s1", "s2"]);
|
||||
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][1]).toEqual({
|
||||
idempotencyKey: expect.any(String),
|
||||
});
|
||||
expect(await screen.findByText("2 Aufgaben angelegt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports how many were created when a later bulk chunk fails", async () => {
|
||||
mockLists(manyPlan(51));
|
||||
vi.mocked(bulkAcceptSuggestions)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("50 von 51 angelegt. Rest nicht übernommen – bitte erneut versuchen."),
|
||||
).toBeInTheDocument();
|
||||
expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toHaveLength(50);
|
||||
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[1][0]).toHaveLength(1);
|
||||
const keys = vi.mocked(bulkAcceptSuggestions).mock.calls.map((call) => call[1]?.idempotencyKey);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it("accepting a single suggestion says „Aufgabe angelegt“ with „Ansehen“", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(await screen.findByText("Aufgabe angelegt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
|
||||
expect(acceptSuggestion).toHaveBeenCalledWith("s3", {}, { idempotencyKey: expect.any(String) });
|
||||
});
|
||||
|
||||
it("the due-date sheet accepts with the chosen day", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Weitere Aktionen" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Termin ändern" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const input = within(dialog).getByLabelText("Termin");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "2026-06-01");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(acceptSuggestion).toHaveBeenCalledWith(
|
||||
"s3",
|
||||
{ dueDate: "2026-06-01" },
|
||||
{ idempotencyKey: expect.any(String) },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("„Verwerfen“ shows „Verworfen“ and the tapped „Rückgängig“ reopens", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||
vi.mocked(reopenSuggestion).mockResolvedValue(SINGLE);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||
|
||||
expect(await screen.findByText("Verworfen")).toBeInTheDocument();
|
||||
expect(dismissSuggestion).toHaveBeenCalledWith("s3", null, {
|
||||
idempotencyKey: expect.any(String),
|
||||
});
|
||||
const undo = screen.getByRole("button", { name: "Rückgängig" });
|
||||
expect(undo.className).toContain("min-h-11");
|
||||
await userEvent.click(undo);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reopenSuggestion).toHaveBeenCalledWith("s3", { idempotencyKey: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("explains it when „Rückgängig“ answers 409", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||
vi.mocked(reopenSuggestion).mockRejectedValue(
|
||||
new ApiError(409, "Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Rückgängig" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a 409 in the server's words and reloads", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(acceptSuggestion).mockRejectedValue(
|
||||
new ApiError(409, "Der Vorschlag ist nicht mehr offen."),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(await screen.findByText("Der Vorschlag ist nicht mehr offen.")).toBeInTheDocument();
|
||||
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it("shows German texts for a 400 array, a 404 and a lost connection", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(acceptSuggestion)
|
||||
.mockRejectedValueOnce(new ApiError(400, "dueDate must match", { message: ["dueDate must match"] }))
|
||||
.mockRejectedValueOnce(new ApiError(404, "Not Found"))
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
renderPage();
|
||||
|
||||
const accept = await screen.findByRole("button", { name: "Übernehmen" });
|
||||
await userEvent.click(accept);
|
||||
expect(await screen.findByText("Die Eingabe ist ungültig.")).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
expect(await screen.findByText("Der Vorschlag ist nicht mehr da.")).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
expect(await screen.findByText("Keine Verbindung – nichts gespeichert.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves focus to the next card after a decision", async () => {
|
||||
mockLists([SINGLE, OPEN[0]]);
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
|
||||
renderPage();
|
||||
|
||||
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||
mockLists([SINGLE]);
|
||||
await userEvent.click(buttons[0]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not move focus after a failed decision, even when the list changes", async () => {
|
||||
mockLists([SINGLE, OPEN[0]]);
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
|
||||
renderPage();
|
||||
|
||||
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||
mockLists([SINGLE]);
|
||||
await userEvent.click(buttons[0]);
|
||||
await screen.findByText("Der Vorschlag ist nicht mehr offen.");
|
||||
await waitFor(() => expect(screen.queryByRole("heading", { name: "Honigraum prüfen" })).not.toBeInTheDocument());
|
||||
|
||||
expect(screen.getByTestId("suggestion-count")).not.toHaveFocus();
|
||||
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("moves focus to the count region when the last card is gone", async () => {
|
||||
mockLists([SINGLE]);
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||
renderPage();
|
||||
|
||||
const dismiss = await screen.findByRole("button", { name: "Verwerfen" });
|
||||
mockLists([]);
|
||||
await userEvent.click(dismiss);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("suggestion-count")).toHaveFocus());
|
||||
});
|
||||
|
||||
it("focuses the restored card after „Rückgängig“", async () => {
|
||||
mockLists([SINGLE, OPEN[0]]);
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
|
||||
vi.mocked(reopenSuggestion).mockResolvedValue(OPEN[0]);
|
||||
renderPage();
|
||||
|
||||
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||
mockLists([SINGLE]);
|
||||
await userEvent.click(buttons[0]);
|
||||
const undo = await screen.findByRole("button", { name: "Rückgängig" });
|
||||
mockLists([SINGLE, OPEN[0]]);
|
||||
await userEvent.click(undo);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("heading", { name: "Honigraum prüfen" })).toHaveFocus(),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists dismissed suggestions with „Wieder öffnen“", async () => {
|
||||
const dismissed = suggestionFixture({
|
||||
id: "d1",
|
||||
status: "DISMISSED",
|
||||
title: "Varroa messen",
|
||||
decidedAt: "2026-05-10T08:00:00.000Z",
|
||||
});
|
||||
mockLists(OPEN, { dismissed: [dismissed] });
|
||||
vi.mocked(reopenSuggestion).mockResolvedValue(dismissed);
|
||||
renderPage();
|
||||
|
||||
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
|
||||
expect(within(list).getByText("Varroa messen")).toBeInTheDocument();
|
||||
await userEvent.click(within(list).getByRole("button", { name: "Wieder öffnen" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reopenSuggestion).toHaveBeenCalledWith("d1", { idempotencyKey: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows „Keine offenen Vorschläge.“ when empty", async () => {
|
||||
mockLists([]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Verworfene (30 Tage)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error with retry instead of an empty list when loading fails", async () => {
|
||||
vi.mocked(fetchSuggestions).mockRejectedValue(new Error("boom"));
|
||||
vi.mocked(fetchSuggestionSummary).mockRejectedValue(new Error("boom"));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Vorschläge konnten nicht geladen werden");
|
||||
expect(screen.queryByText("Keine offenen Vorschläge.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("„Runde starten“ starts a round with the due colonies in code order", async () => {
|
||||
mockLists([
|
||||
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
|
||||
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Runde starten" });
|
||||
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2&round=a1");
|
||||
await userEvent.click(link);
|
||||
|
||||
const round = readRound("user-1");
|
||||
expect(round?.apiaryId).toBe("a1");
|
||||
expect(round?.apiaryName).toBe("Hang");
|
||||
expect(round?.colonies.map((c) => c.code)).toEqual(["A2", "A10"]);
|
||||
});
|
||||
|
||||
it("„Runde starten“ without a user is a plain link to the first inspection", async () => {
|
||||
auth.user = null;
|
||||
mockLists([
|
||||
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
|
||||
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||
]);
|
||||
renderPage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: "Runde starten" });
|
||||
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2");
|
||||
});
|
||||
|
||||
it("hides the buttons the user may not use", async () => {
|
||||
auth.permissions = new Set(["tasks:read"]);
|
||||
mockLists(OPEN);
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("3 Vorschläge");
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user