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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
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,
);
}
}