feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,253 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import type { StockService } from '../stock/stock.service';
|
||||
import { AutomationEventHandlers } from './automation.handlers';
|
||||
import type { AutomationService } from './automation.service';
|
||||
|
||||
type RecordModel =
|
||||
'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony';
|
||||
|
||||
const RECORD_MODELS: readonly RecordModel[] = [
|
||||
'inspection',
|
||||
'varroaMeasurement',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'colony',
|
||||
];
|
||||
|
||||
function envelope<T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: DomainEventEnvelope<T>['payload'],
|
||||
): AnyDomainEventEnvelope<T> {
|
||||
return {
|
||||
id: 'e1',
|
||||
type,
|
||||
aggregateType: 'Inspection',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: 'u1',
|
||||
correlationId: 'corr-1',
|
||||
occurredAt: new Date('2026-10-13T08:00:00.000Z'),
|
||||
attempts: 0,
|
||||
} as unknown as AnyDomainEventEnvelope<T>;
|
||||
}
|
||||
|
||||
describe('AutomationEventHandlers', () => {
|
||||
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
let automation: Record<
|
||||
| 'evaluateInspectionIn'
|
||||
| 'evaluateVarroaMeasurementIn'
|
||||
| 'evaluateTreatmentIn'
|
||||
| 'evaluateFeedingIn'
|
||||
| 'evaluateColonyHealthIn'
|
||||
| 'evaluateRawMaterialLowIn',
|
||||
jest.Mock
|
||||
>;
|
||||
let stock: { remainingByRawMaterialKind: jest.Mock };
|
||||
let handlers: AutomationEventHandlers;
|
||||
|
||||
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||
|
||||
beforeEach(() => {
|
||||
tx = Object.fromEntries(
|
||||
RECORD_MODELS.map((model) => [
|
||||
model,
|
||||
{ findUnique: jest.fn().mockResolvedValue(null) },
|
||||
]),
|
||||
) as Record<RecordModel, { findUnique: jest.Mock }>;
|
||||
automation = {
|
||||
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined),
|
||||
evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
stock = { remainingByRawMaterialKind: jest.fn() };
|
||||
handlers = new AutomationEventHandlers(
|
||||
automation as unknown as AutomationService,
|
||||
stock as unknown as StockService,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers the stable handler ids and event types', () => {
|
||||
const registered = (method: keyof AutomationEventHandlers) =>
|
||||
domainEventHandlerMetadata(
|
||||
Reflect.get(AutomationEventHandlers.prototype, method),
|
||||
);
|
||||
|
||||
expect(registered('inspectionRules')).toEqual([
|
||||
{
|
||||
types: ['InspectionRecorded'],
|
||||
handlerId: 'automation.inspection-rules',
|
||||
},
|
||||
]);
|
||||
expect(registered('varroaThreshold')).toEqual([
|
||||
{
|
||||
types: ['VarroaMeasured', 'VarroaCorrected'],
|
||||
handlerId: 'automation.varroa-threshold',
|
||||
},
|
||||
]);
|
||||
expect(registered('treatmentFollowUp')).toEqual([
|
||||
{
|
||||
types: ['TreatmentRecorded'],
|
||||
handlerId: 'automation.treatment-follow-up',
|
||||
},
|
||||
]);
|
||||
expect(registered('feedingFollowUp')).toEqual([
|
||||
{ types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' },
|
||||
]);
|
||||
expect(registered('colonyHealth')).toEqual([
|
||||
{ types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' },
|
||||
]);
|
||||
expect(registered('rawMaterialLow')).toEqual([
|
||||
{ types: ['StockChanged'], handlerId: 'automation.raw-material-low' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('record handlers', () => {
|
||||
const cases = [
|
||||
[
|
||||
'inspectionRules',
|
||||
'InspectionRecorded',
|
||||
'inspection',
|
||||
'evaluateInspectionIn',
|
||||
],
|
||||
[
|
||||
'varroaThreshold',
|
||||
'VarroaMeasured',
|
||||
'varroaMeasurement',
|
||||
'evaluateVarroaMeasurementIn',
|
||||
],
|
||||
[
|
||||
'varroaThreshold',
|
||||
'VarroaCorrected',
|
||||
'varroaMeasurement',
|
||||
'evaluateVarroaMeasurementIn',
|
||||
],
|
||||
[
|
||||
'treatmentFollowUp',
|
||||
'TreatmentRecorded',
|
||||
'treatment',
|
||||
'evaluateTreatmentIn',
|
||||
],
|
||||
['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'],
|
||||
] as const;
|
||||
|
||||
const run = (
|
||||
method: (typeof cases)[number][0],
|
||||
type: (typeof cases)[number][1],
|
||||
) =>
|
||||
handlers[method](
|
||||
envelope(type, 'record-1', { colonyId: 'colony-1' }) as never,
|
||||
asTx(),
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'handlers load the record via tx and evaluate it: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
});
|
||||
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'handlers load the record via tx and skip a missing one: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
await run(method, type);
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(automation[core]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
|
||||
automation.evaluateInspectionIn.mockRejectedValue(
|
||||
new Error('notifications down'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
run('inspectionRules', 'InspectionRecorded'),
|
||||
).rejects.toThrow('notifications down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('colony-health', () => {
|
||||
const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const;
|
||||
const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const;
|
||||
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' });
|
||||
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
select: { id: 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.
|
||||
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
from,
|
||||
to,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips a colony that no longer exists', async () => {
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw-material-low', () => {
|
||||
const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) =>
|
||||
envelope('StockChanged', 'movement-1', {
|
||||
bottlingBatchIds: [],
|
||||
productIds: [],
|
||||
rawMaterialKinds,
|
||||
});
|
||||
|
||||
it('evaluates every touched kind once with what is left of it', async () => {
|
||||
stock.remainingByRawMaterialKind.mockImplementation((kind: string) =>
|
||||
Promise.resolve(kind === 'BEESWAX' ? 400 : 75),
|
||||
);
|
||||
|
||||
await handlers.rawMaterialLow(
|
||||
stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2);
|
||||
expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([
|
||||
[tx, 'BEESWAX', 400],
|
||||
[tx, 'PROPOLIS', 75],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing for a change without raw material', async () => {
|
||||
await handlers.rawMaterialLow(stockChanged([]), asTx());
|
||||
|
||||
expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled();
|
||||
expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { AutomationService } from './automation.service';
|
||||
|
||||
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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutomationEventHandlers {
|
||||
constructor(
|
||||
private readonly automation: AutomationService,
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
|
||||
async inspectionRules(
|
||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const inspection = await tx.inspection.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (inspection === null) {
|
||||
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.
|
||||
@OnDomainEvent(
|
||||
['VarroaMeasured', 'VarroaCorrected'],
|
||||
'automation.varroa-threshold',
|
||||
)
|
||||
async varroaThreshold(
|
||||
event: AnyDomainEventEnvelope<'VarroaMeasured' | 'VarroaCorrected'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const measurement = await tx.varroaMeasurement.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (measurement === null) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||
}
|
||||
|
||||
@OnDomainEvent('TreatmentRecorded', 'automation.treatment-follow-up')
|
||||
async treatmentFollowUp(
|
||||
event: AnyDomainEventEnvelope<'TreatmentRecorded'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const treatment = await tx.treatment.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (treatment === null) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||
}
|
||||
|
||||
@OnDomainEvent('FeedingRecorded', 'automation.feeding-follow-up')
|
||||
async feedingFollowUp(
|
||||
event: AnyDomainEventEnvelope<'FeedingRecorded'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const feeding = await tx.feeding.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (feeding === null) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||
}
|
||||
|
||||
// Decides on the snapshots the event carries, not on the colony as it is
|
||||
// now: a later edit must not hide the transition into CRITICAL.
|
||||
@OnDomainEvent('ColonyStatusChanged', 'automation.colony-health')
|
||||
async colonyHealth(
|
||||
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (colony === null) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateColonyHealthIn(
|
||||
tx,
|
||||
colony.id,
|
||||
event.payload.from,
|
||||
event.payload.to,
|
||||
);
|
||||
}
|
||||
|
||||
// Per kind the change touched, what is left of it across all its lots.
|
||||
@OnDomainEvent('StockChanged', 'automation.raw-material-low')
|
||||
async rawMaterialLow(
|
||||
event: AnyDomainEventEnvelope<'StockChanged'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
for (const kind of new Set(event.payload.rawMaterialKinds)) {
|
||||
const remaining = await this.stock.remainingByRawMaterialKind(kind);
|
||||
await this.automation.evaluateRawMaterialLowIn(tx, kind, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { TasksModule } from '../tasks/tasks.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.
|
||||
*/
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
imports: [TasksModule, NotificationsModule, StockModule],
|
||||
controllers: [AutomationController],
|
||||
providers: [AutomationService],
|
||||
providers: [AutomationService, AutomationEventHandlers],
|
||||
exports: [AutomationService],
|
||||
})
|
||||
export class AutomationModule {}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,14 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
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 { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
||||
import {
|
||||
AutomationRule,
|
||||
AutomationTriggerType,
|
||||
Colony,
|
||||
Inspection,
|
||||
Feeding,
|
||||
Prisma,
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
import { varroaThresholdOf } from './varroa-threshold';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/** The rules that fill in a batch/lot number instead of creating tasks. */
|
||||
export type NumberFormatTrigger =
|
||||
@@ -171,29 +175,33 @@ export class AutomationService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly tasks: TasksService,
|
||||
) {}
|
||||
|
||||
private async ensureDefaults(): Promise<void> {
|
||||
for (const triggerType of Object.keys(
|
||||
DEFAULT_RULES,
|
||||
) as AutomationTriggerType[]) {
|
||||
const defaults = DEFAULT_RULES[triggerType];
|
||||
await this.prisma.automationRule.upsert({
|
||||
where: { triggerType },
|
||||
update: {},
|
||||
create: {
|
||||
/**
|
||||
* Creates the rules that do not exist yet, on the given client - inside a
|
||||
* handler or legacy transaction too. One INSERT … ON CONFLICT DO NOTHING:
|
||||
* existing rules keep their settings, and their rows are not locked
|
||||
* (an upsert would hold all of them until the evaluation commits).
|
||||
*/
|
||||
private async ensureDefaults(db: DbClient): Promise<void> {
|
||||
const triggerTypes = Object.keys(DEFAULT_RULES) as AutomationTriggerType[];
|
||||
await db.automationRule.createMany({
|
||||
data: triggerTypes.map((triggerType) => {
|
||||
const defaults = DEFAULT_RULES[triggerType];
|
||||
return {
|
||||
triggerType,
|
||||
active: defaults.active ?? true,
|
||||
config: defaults.config as Prisma.InputJsonValue,
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(): Promise<AutomationRuleWithSequences[]> {
|
||||
await this.ensureDefaults();
|
||||
await this.ensureDefaults(this.prisma);
|
||||
return this.prisma.automationRule.findMany({
|
||||
orderBy: { triggerType: 'asc' },
|
||||
include: {
|
||||
@@ -244,141 +252,140 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every evaluate*() runs after the triggering record is committed and is
|
||||
* called by the domain services without a guard - so it never throws (see
|
||||
* runAfterCommit). A failed evaluation is logged, the request succeeds.
|
||||
*/
|
||||
private evaluateAfterCommit(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
evaluate: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action: 'Automation rule evaluation', entityType, entityId },
|
||||
evaluate,
|
||||
);
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
// Rule evaluation
|
||||
//
|
||||
// One core per trigger, on the caller's transaction: the domain event
|
||||
// handlers (AutomationEventHandlers) call it with their delivery
|
||||
// transaction, the legacy wrappers below with a transaction of their own.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async evaluateInspection(inspection: Inspection): Promise<void> {
|
||||
await this.evaluateAfterCommit('Inspection', inspection.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rules = await this.prisma.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.triggerAction(
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
await this.triggerAction(
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
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(
|
||||
tx,
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
await this.triggerActionIn(
|
||||
tx,
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateVarroaMeasurement(
|
||||
async evaluateVarroaMeasurementIn(
|
||||
tx: Tx,
|
||||
measurement: VarroaMeasurement,
|
||||
): Promise<void> {
|
||||
await this.evaluateAfterCommit(
|
||||
'VarroaMeasurement',
|
||||
measurement.id,
|
||||
async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
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') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
},
|
||||
assertTransactionClient(
|
||||
tx,
|
||||
'AutomationService.evaluateVarroaMeasurementIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
});
|
||||
// null for an inactive rule; the one source of the default value.
|
||||
const threshold = varroaThresholdOf(rule);
|
||||
if (!rule || threshold === 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') {
|
||||
return;
|
||||
}
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||
await this.evaluateAfterCommit('SwarmControlCheck', check.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerAction(rule, 'COLONY', check.colonyId);
|
||||
}
|
||||
async evaluateSwarmControlCheckIn(
|
||||
tx: Tx,
|
||||
check: SwarmControlCheck,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(
|
||||
tx,
|
||||
'AutomationService.evaluateSwarmControlCheckIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
|
||||
await this.evaluateAfterCommit('Colony', after.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (
|
||||
after.healthStatus !== 'CRITICAL' ||
|
||||
before.healthStatus === 'CRITICAL'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', after.id);
|
||||
async evaluateColonyHealthIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
from: ColonyStateSnapshot,
|
||||
to: ColonyStateSnapshot,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
||||
// ColonyStatusChanged comes with every status or health edit: decide on
|
||||
// the snapshots first, so most of them cost no statement at all.
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
|
||||
}
|
||||
|
||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||
await this.evaluateAfterCommit('StockItem', item.id, async () => {
|
||||
await this.ensureDefaults();
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
}
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||
// Every movement of the item comes here: only one below its minimum
|
||||
// needs the rule.
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,73 +395,112 @@ export class AutomationService {
|
||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
||||
* keeps one open task per kind.
|
||||
*/
|
||||
async evaluateRawMaterialLow(
|
||||
async evaluateRawMaterialLowIn(
|
||||
tx: Tx,
|
||||
kind: RawMaterialKind,
|
||||
remainingGrams: number,
|
||||
): Promise<void> {
|
||||
await this.evaluateAfterCommit('RawMaterialKind', kind, async () => {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
});
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = 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.`,
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
||||
await this.evaluateAfterCommit('Treatment', treatment.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
treatment.endDate ?? treatment.date,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
||||
),
|
||||
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)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateFeeding(feeding: Feeding): Promise<void> {
|
||||
await this.evaluateAfterCommit('Feeding', feeding.id, () =>
|
||||
this.evaluateFollowUp(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
),
|
||||
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. */
|
||||
private async evaluateFollowUp(
|
||||
/**
|
||||
* 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();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
||||
baseDate: measureDate,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Legacy paths
|
||||
//
|
||||
// Only SWARM_MOOD_HIGH and STOCK_ITEM_LOW remain: the swarm control and
|
||||
// stock modules record no event for them yet. Called after the write is
|
||||
// committed, without a guard - so they never throw (see runAfterCommit): a
|
||||
// failed evaluation is logged, the request succeeds. Each runs the same
|
||||
// core as the handlers, in one transaction of its own.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private evaluateAfterCommit(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
evaluate: (tx: Tx) => Promise<void>,
|
||||
): Promise<void> {
|
||||
return runAfterCommit(
|
||||
this.logger,
|
||||
{ action: 'Automation rule evaluation', entityType, entityId },
|
||||
() => this.prisma.$transaction((tx) => evaluate(tx)),
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
|
||||
await this.evaluateAfterCommit('SwarmControlCheck', check.id, (tx) =>
|
||||
this.evaluateSwarmControlCheckIn(tx, check),
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateStockItem(item: StockItem): Promise<void> {
|
||||
await this.evaluateAfterCommit('StockItem', item.id, (tx) =>
|
||||
this.evaluateStockItemIn(tx, item),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns null (never throws) when the rule is inactive or has no format
|
||||
// configured, so the caller can fall back to requiring manual entry.
|
||||
//
|
||||
@@ -466,7 +512,7 @@ export class AutomationService {
|
||||
triggerType: NumberFormatTrigger,
|
||||
options: { scope?: string; vars?: Record<string, string> } = {},
|
||||
): Promise<string | null> {
|
||||
await this.ensureDefaults();
|
||||
await this.ensureDefaults(this.prisma);
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
@@ -547,75 +593,76 @@ export class AutomationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Check and create run in one transaction under a lock on the rule's row:
|
||||
* two evaluations at the same moment (parallel requests, several pods)
|
||||
* would otherwise both see "no open task" and both create one. The lock
|
||||
* is per rule and held for two short statements; webhook, warning and
|
||||
* audit entry follow after the commit.
|
||||
* 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.
|
||||
*/
|
||||
private async triggerAction(
|
||||
private async triggerActionIn(
|
||||
tx: Tx,
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.prisma.$transaction(async (tx) => {
|
||||
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 },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return null;
|
||||
}
|
||||
return tx.task.create({
|
||||
data: {
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
await this.notifications.record({
|
||||
priority: 'WARNUNG',
|
||||
message: title,
|
||||
relatedType,
|
||||
relatedId,
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'AutomationRule',
|
||||
entityId: rule.id,
|
||||
action: 'CREATE',
|
||||
afterData: {
|
||||
taskId: task.id,
|
||||
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,
|
||||
triggerType: rule.triggerType,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
);
|
||||
await this.notifications.record(
|
||||
{ priority: 'WARNUNG', 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,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user