Unified Reaction Automation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the current farm-scoped single-condition automation and telemetry-owned threshold alerts with one organization-scoped reaction engine that supports multi-source conditions, trigger/recovery actions, alert actions, cooldown, runtime state, execution audit, and a real automation authoring UI.

Architecture: Keep backend/src/automation/ as the single owner of rule authoring and runtime behavior, but split the logic into small services: contract validation, latest-snapshot storage, pure evaluation, and action execution. Telemetry ingestion will only persist telemetry, normalize endpoint events, refresh latest endpoint snapshots, and ask automation to reevaluate affected rules; alert-config stops participating in the runtime path and is demoted in the UI.

Tech Stack: NestJS, TypeORM, PostgreSQL, React, Ant Design, TanStack Query, Zod, Jest, Vitest, Bun, Biome


Scope Check

This spec touches backend persistence, runtime evaluation, alert creation, telemetry ingestion, permissions, and frontend authoring, but they are one coupled feature. Splitting them into separate plans would create broken intermediate states where the backend contract, runtime behavior, and UI no longer agree, so this stays as one implementation plan.

File Structure

New backend files

  • backend/src/automation/entities/automation-rule-runtime-state.entity.ts
    Durable edge-trigger state per rule.
  • backend/src/automation/entities/automation-endpoint-latest-value.entity.ts
    Latest endpoint snapshot table used for multi-device evaluation.
  • backend/src/automation/enums/automation-condition-group-operator.enum.ts
    Rule-level AND | OR operator.
  • backend/src/automation/enums/automation-rule-phase.enum.ts
    trigger | recovery.
  • backend/src/automation/enums/automation-runtime-state.enum.ts
    normal | triggered.
  • backend/src/automation/automation-rule-evaluator.service.ts
    Pure rule evaluation, freshness handling, and state transition decisions.
  • backend/src/automation/automation-action-executor.service.ts
    Executes alert and device-command actions sequentially and returns per-action results.
  • backend/src/automation/automation-endpoint-latest-values.service.ts
    Upserts latest endpoint snapshots and loads them for rule evaluation.
  • backend/src/automation/automation-rule-evaluator.service.spec.ts
  • backend/src/automation/automation-action-executor.service.spec.ts
  • backend/src/migrations/1778457600000-CreateUnifiedReactionAutomationTables.ts

Modified backend files

  • backend/src/app.module.ts
    Register new automation entities for TypeORM.
  • backend/src/automation/automation.module.ts
    Provide evaluator, action executor, and latest-value services.
  • backend/src/automation/automation-rule-validation.util.ts
    Validate aggregate rule payloads instead of single source/target pairs.
  • backend/src/automation/automation-rule-validation.util.spec.ts
  • backend/src/automation/automation-rules.service.ts
    Persist aggregate rules, phases, runtime state, and richer list/detail responses.
  • backend/src/automation/automation-rules.service.spec.ts
  • backend/src/automation/automation-engine.service.ts
    Reevaluate only affected rules using latest snapshots and state transitions.
  • backend/src/automation/automation-engine.service.spec.ts
  • backend/src/automation/controllers/automation-rules.controller.ts
    Add GET /:id and split activate/pause permissions.
  • backend/src/automation/dto/create-automation-rule.dto.ts
  • backend/src/automation/dto/update-automation-rule.dto.ts
  • backend/src/automation/dto/query-automation-rules.dto.ts
  • backend/src/automation/dto/automation-rule-response.dto.ts
  • backend/src/automation/dto/automation-rule-execution-response.dto.ts
  • backend/src/automation/entities/automation-rule.entity.ts
  • backend/src/automation/entities/automation-rule-condition.entity.ts
  • backend/src/automation/entities/automation-rule-action.entity.ts
  • backend/src/automation/entities/automation-rule-execution.entity.ts
  • backend/src/automation/enums/automation-rule-action-type.enum.ts
  • backend/src/automation/enums/automation-rule-execution-status.enum.ts
  • backend/src/automation/endpoint-event-normalizer.service.ts
    Carry organizationId instead of farmId.
  • backend/src/automation/endpoint-event.types.ts
  • backend/src/devices/alert.service.ts
    Add a reusable automation alert creation entry point.
  • backend/src/devices/device-command-dispatch.service.ts
    Enforce endpoint/device availability checks required by automation.
  • backend/src/telemetry/telemetry.service.ts
    Remove checkThresholds() from runtime path.
  • backend/src/telemetry/telemetry.service.spec.ts

New frontend files

  • frontend/src/pages/automation/components/AutomationRuleWizard.tsx
    Five-step authoring wizard shell.
  • frontend/src/pages/automation/components/AutomationRuleDetailDrawer.tsx
    Rule detail and execution history container.
  • frontend/src/pages/automation/components/AutomationRuleExecutionHistory.tsx
    Execution history table.
  • frontend/src/schemas/automation.schema.spec.ts

Modified frontend files

  • frontend/src/App.tsx
    Keep /automation/rules; demote /devices/alert-configs.
  • frontend/src/hooks/useAutomationRules.ts
    Add detail/update/execution hooks.
  • frontend/src/pages/automation/AutomationRulesPage.tsx
    Replace placeholder list with workspace UI.
  • frontend/src/pages/automation/AutomationRulesPage.spec.tsx
  • frontend/src/pages/devices/AlertConfigPage.tsx
    Show deprecation CTA to automation instead of remaining a source of truth.
  • frontend/src/schemas/automation.schema.ts
    Match aggregate backend DTO shape.
  • frontend/src/services/automation-rules.service.ts
  • frontend/src/types/automation.types.ts

Task 1: Define the Unified Rule Contract and Validation

Files:

  • Create: backend/src/automation/enums/automation-condition-group-operator.enum.ts

  • Create: backend/src/automation/enums/automation-rule-phase.enum.ts

  • Modify: backend/src/automation/enums/automation-rule-action-type.enum.ts

  • Modify: backend/src/automation/automation-rule-validation.util.ts

  • Modify: backend/src/automation/automation-rule-validation.util.spec.ts

  • Modify: backend/src/automation/dto/create-automation-rule.dto.ts

  • Modify: backend/src/automation/dto/update-automation-rule.dto.ts

  • Step 1: Write the failing validation tests

import { BadRequestException } from '@nestjs/common';
import { validateAutomationRuleShape } from '@/automation/automation-rule-validation.util';

describe('validateAutomationRuleShape', () => {
  it('rejects a rule without conditions', () => {
    expect(() =>
      validateAutomationRuleShape({
        conditionOperator: 'AND',
        conditions: [],
        triggerActions: [{ type: 'alert', phase: 'trigger', isEnabled: true, sequenceOrder: 1 }],
        recoveryActions: [],
      }),
    ).toThrow(new BadRequestException('At least one condition is required'));
  });

  it('rejects a rule without trigger actions', () => {
    expect(() =>
      validateAutomationRuleShape({
        conditionOperator: 'AND',
        conditions: [
          {
            source: { dataType: 'number', direction: 'read', isEnabled: true },
            operator: '<',
            thresholdValue: 4.5,
          },
        ],
        triggerActions: [],
        recoveryActions: [],
      }),
    ).toThrow(new BadRequestException('At least one trigger action is required'));
  });

  it('rejects numeric operators on boolean conditions', () => {
    expect(() =>
      validateAutomationRuleShape({
        conditionOperator: 'AND',
        conditions: [
          {
            source: { dataType: 'boolean', direction: 'read', isEnabled: true },
            operator: '<',
            thresholdValue: true,
          },
        ],
        triggerActions: [{ type: 'alert', phase: 'trigger', isEnabled: true, sequenceOrder: 1 }],
        recoveryActions: [],
      }),
    ).toThrow(new BadRequestException('Boolean conditions only support = and != operators'));
  });

  it('accepts mixed alert and device command actions across trigger and recovery phases', () => {
    expect(() =>
      validateAutomationRuleShape({
        conditionOperator: 'OR',
        conditions: [
          {
            source: { dataType: 'number', direction: 'read', isEnabled: true },
            operator: '<',
            thresholdValue: 4.5,
          },
          {
            source: { dataType: 'number', direction: 'read', isEnabled: true },
            operator: '>',
            thresholdValue: 32,
          },
        ],
        triggerActions: [
          { type: 'alert', phase: 'trigger', isEnabled: true, sequenceOrder: 1 },
          { type: 'device_command', phase: 'trigger', isEnabled: true, sequenceOrder: 2 },
        ],
        recoveryActions: [{ type: 'alert', phase: 'recovery', isEnabled: true, sequenceOrder: 1 }],
      }),
    ).not.toThrow();
  });
});
  • Step 2: Run the validation test and verify it fails

Run from backend/: bun test -- src/automation/automation-rule-validation.util.spec.ts

Expected: FAIL because validateAutomationRuleShape() still expects a single source/target pair and AutomationRuleActionType only supports set_value.

  • Step 3: Implement the aggregate DTO and validation contract
export enum AutomationConditionGroupOperator {
  AND = 'AND',
  OR = 'OR',
}

export enum AutomationRulePhase {
  TRIGGER = 'trigger',
  RECOVERY = 'recovery',
}

export enum AutomationRuleActionType {
  ALERT = 'alert',
  DEVICE_COMMAND = 'device_command',
}

interface RuleConditionContext {
  source: {
    dataType: 'number' | 'boolean' | 'enum';
    direction: 'read' | 'write' | 'read_write';
    isEnabled: boolean;
  };
  operator: '<' | '<=' | '>' | '>=' | '=' | '!=';
  thresholdValue: number | boolean | string;
}

interface RuleActionContext {
  type: 'alert' | 'device_command';
  phase: 'trigger' | 'recovery';
  isEnabled: boolean;
  sequenceOrder: number;
}

export function validateAutomationRuleShape(input: {
  conditionOperator: 'AND' | 'OR';
  conditions: RuleConditionContext[];
  triggerActions: RuleActionContext[];
  recoveryActions: RuleActionContext[];
}): void {
  if (input.conditions.length === 0) {
    throw new BadRequestException('At least one condition is required');
  }

  if (input.triggerActions.length === 0) {
    throw new BadRequestException('At least one trigger action is required');
  }

  for (const condition of input.conditions) {
    if (!condition.source.isEnabled) {
      throw new BadRequestException('Condition source endpoints must be enabled');
    }
    if (condition.source.direction === 'write') {
      throw new BadRequestException('Condition source endpoints must be readable');
    }
    if (condition.source.dataType === 'boolean' && !['=', '!='].includes(condition.operator)) {
      throw new BadRequestException('Boolean conditions only support = and != operators');
    }
    if (condition.source.dataType === 'number' && typeof condition.thresholdValue !== 'number') {
      throw new BadRequestException('Numeric condition thresholds must be numbers');
    }
  }

  for (const action of [...input.triggerActions, ...input.recoveryActions]) {
    if (!action.isEnabled) {
      continue;
    }
    if (action.phase === 'trigger' && action.type !== 'alert' && action.type !== 'device_command') {
      throw new BadRequestException('Unsupported trigger action type');
    }
    if (action.phase === 'recovery' && action.type !== 'alert' && action.type !== 'device_command') {
      throw new BadRequestException('Unsupported recovery action type');
    }
  }
}
  • Step 4: Run the validation test and verify it passes

Run from backend/: bun test -- src/automation/automation-rule-validation.util.spec.ts

Expected: PASS with four green validation cases.

  • Step 5: Commit
git add backend/src/automation/enums/automation-condition-group-operator.enum.ts backend/src/automation/enums/automation-rule-phase.enum.ts backend/src/automation/enums/automation-rule-action-type.enum.ts backend/src/automation/automation-rule-validation.util.ts backend/src/automation/automation-rule-validation.util.spec.ts backend/src/automation/dto/create-automation-rule.dto.ts backend/src/automation/dto/update-automation-rule.dto.ts
git commit -m "feat: define unified reaction rule contract"

Task 2: Reshape Persistence, DTO Responses, and CRUD Endpoints

Files:

  • Create: backend/src/automation/entities/automation-rule-runtime-state.entity.ts

  • Create: backend/src/automation/entities/automation-endpoint-latest-value.entity.ts

  • Create: backend/src/automation/enums/automation-runtime-state.enum.ts

  • Create: backend/src/migrations/1778457600000-CreateUnifiedReactionAutomationTables.ts

  • Modify: backend/src/app.module.ts

  • Modify: backend/src/automation/automation.module.ts

  • Modify: backend/src/automation/entities/automation-rule.entity.ts

  • Modify: backend/src/automation/entities/automation-rule-condition.entity.ts

  • Modify: backend/src/automation/entities/automation-rule-action.entity.ts

  • Modify: backend/src/automation/entities/automation-rule-execution.entity.ts

  • Modify: backend/src/automation/enums/automation-rule-execution-status.enum.ts

  • Modify: backend/src/automation/automation-rules.service.ts

  • Modify: backend/src/automation/automation-rules.service.spec.ts

  • Modify: backend/src/automation/controllers/automation-rules.controller.ts

  • Modify: backend/src/automation/dto/automation-rule-response.dto.ts

  • Modify: backend/src/automation/dto/automation-rule-execution-response.dto.ts

  • Modify: backend/src/automation/dto/query-automation-rules.dto.ts

  • Step 1: Write the failing service test for aggregate persistence

it('creates a rule with multiple conditions, trigger actions, recovery actions, and runtime state', async () => {
  const dto = {
    name: 'DO low then react',
    description: 'Turn on aeration and raise alert',
    conditionOperator: 'AND',
    cooldownSeconds: 300,
    conditions: [
      {
        sourceDeviceEndpointId: 'endpoint-src-1',
        operator: '<',
        thresholdValue: 4.5,
        sequenceOrder: 1,
      },
      {
        sourceDeviceEndpointId: 'endpoint-src-2',
        operator: '>',
        thresholdValue: 30,
        sequenceOrder: 2,
      },
    ],
    triggerActions: [
      {
        actionType: 'alert',
        sequenceOrder: 1,
        isEnabled: true,
        payload: {
          severity: 'critical',
          titleTemplate: 'DO low',
          messageTemplate: 'Rule {{ruleName}} triggered',
        },
      },
      {
        actionType: 'device_command',
        sequenceOrder: 2,
        isEnabled: true,
        payload: {
          targetDeviceEndpointId: 'endpoint-target-1',
          targetValue: true,
        },
      },
    ],
    recoveryActions: [
      {
        actionType: 'device_command',
        sequenceOrder: 1,
        isEnabled: true,
        payload: {
          targetDeviceEndpointId: 'endpoint-target-1',
          targetValue: false,
        },
      },
    ],
  };

  await service.create(dto, mockCurrentUser);

  expect(ruleRepo.create).toHaveBeenCalledWith(
    expect.objectContaining({
      organizationId: 'org-1',
      conditionOperator: 'AND',
      cooldownSeconds: 300,
    }),
  );
  expect(actionRepo.save).toHaveBeenCalledWith(
    expect.arrayContaining([
      expect.objectContaining({ phase: 'trigger', actionType: 'alert' }),
      expect.objectContaining({ phase: 'recovery', actionType: 'device_command' }),
    ]),
  );
  expect(runtimeStateRepo.save).toHaveBeenCalledWith(
    expect.objectContaining({ currentState: 'normal', ruleId: 'rule-1' }),
  );
});
  • Step 2: Run the service test and verify it fails

Run from backend/: bun test -- src/automation/automation-rules.service.spec.ts

Expected: FAIL because the current entity shape still requires farmId, top-level target fields, and there is no runtime-state repository.

  • Step 3: Implement the new persistence model and service mapping
@Entity('automation_rules')
export class AutomationRule {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'organization_id', type: 'uuid' })
  organizationId: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string | null;

  @Column({ name: 'condition_operator', type: 'enum', enum: AutomationConditionGroupOperator })
  conditionOperator: AutomationConditionGroupOperator;

  @Column({ type: 'enum', enum: AutomationRuleStatus, default: AutomationRuleStatus.DRAFT })
  status: AutomationRuleStatus;

  @Column({ name: 'cooldown_seconds', type: 'integer', default: 300 })
  cooldownSeconds: number;
}

@Entity('automation_rule_actions')
export class AutomationRuleAction {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'rule_id', type: 'uuid' })
  ruleId: string;

  @Column({ name: 'phase', type: 'enum', enum: AutomationRulePhase })
  phase: AutomationRulePhase;

  @Column({ name: 'action_type', type: 'enum', enum: AutomationRuleActionType })
  actionType: AutomationRuleActionType;

  @Column({ name: 'sequence_order', type: 'integer', default: 1 })
  sequenceOrder: number;

  @Column({ name: 'is_enabled', type: 'boolean', default: true })
  isEnabled: boolean;

  @Column({ type: 'jsonb' })
  payload: Record<string, string | number | boolean>;
}

@Entity('automation_rule_runtime_states')
export class AutomationRuleRuntimeState {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'rule_id', type: 'uuid', unique: true })
  ruleId: string;

  @Column({ name: 'current_state', type: 'enum', enum: AutomationRuntimeState })
  currentState: AutomationRuntimeState;

  @Column({ name: 'last_evaluation_summary', type: 'jsonb', nullable: true })
  lastEvaluationSummary: Record<string, unknown> | null;
}

async create(dto: CreateAutomationRuleDto, currentUser: CurrentUserData) {
  validateAutomationRuleShape({
    conditionOperator: dto.conditionOperator,
    conditions: conditionInputs,
    triggerActions: triggerActionInputs,
    recoveryActions: recoveryActionInputs,
  });

  const rule = await this.ruleRepository.save(
    this.ruleRepository.create({
      organizationId: currentUser.organizationId ?? '',
      name: dto.name,
      description: dto.description ?? null,
      conditionOperator: dto.conditionOperator,
      cooldownSeconds: dto.cooldownSeconds ?? 300,
      createdBy: currentUser.userId,
      updatedBy: currentUser.userId,
      status: AutomationRuleStatus.DRAFT,
    }),
  );

  await this.conditionRepository.save(
    dto.conditions.map((condition) =>
      this.conditionRepository.create({
        ruleId: rule.id,
        sourceDeviceEndpointId: condition.sourceDeviceEndpointId,
        operator: condition.operator,
        thresholdValue: condition.thresholdValue,
        sequenceOrder: condition.sequenceOrder,
      }),
    ),
  );

  await this.actionRepository.save(
    [...dto.triggerActions, ...dto.recoveryActions].map((action) =>
      this.actionRepository.create({
        ruleId: rule.id,
        phase: action.phase,
        actionType: action.actionType,
        sequenceOrder: action.sequenceOrder,
        isEnabled: action.isEnabled,
        payload: action.payload,
      }),
    ),
  );

  await this.runtimeStateRepository.save({
    ruleId: rule.id,
    currentState: AutomationRuntimeState.NORMAL,
    lastEvaluationSummary: null,
  });
}
  • Step 4: Run the service test and migration sanity checks

Run from backend/: bun test -- src/automation/automation-rules.service.spec.ts

Expected: PASS with aggregate rule persistence expectations.

Run from backend/: bun run build

Expected: PASS with the new entities and controller signatures compiling.

  • Step 5: Commit
git add backend/src/app.module.ts backend/src/automation/automation.module.ts backend/src/automation/entities/automation-rule.entity.ts backend/src/automation/entities/automation-rule-condition.entity.ts backend/src/automation/entities/automation-rule-action.entity.ts backend/src/automation/entities/automation-rule-execution.entity.ts backend/src/automation/entities/automation-rule-runtime-state.entity.ts backend/src/automation/entities/automation-endpoint-latest-value.entity.ts backend/src/automation/enums/automation-rule-execution-status.enum.ts backend/src/automation/enums/automation-runtime-state.enum.ts backend/src/automation/automation-rules.service.ts backend/src/automation/automation-rules.service.spec.ts backend/src/automation/controllers/automation-rules.controller.ts backend/src/automation/dto/automation-rule-response.dto.ts backend/src/automation/dto/automation-rule-execution-response.dto.ts backend/src/automation/dto/query-automation-rules.dto.ts backend/src/migrations/1778457600000-CreateUnifiedReactionAutomationTables.ts
git commit -m "feat: reshape automation persistence for reaction rules"

Task 3: Implement Latest Snapshot Loading and Pure Rule Evaluation

Files:

  • Create: backend/src/automation/automation-endpoint-latest-values.service.ts

  • Create: backend/src/automation/automation-rule-evaluator.service.ts

  • Create: backend/src/automation/automation-rule-evaluator.service.spec.ts

  • Modify: backend/src/automation/endpoint-event.types.ts

  • Modify: backend/src/automation/endpoint-event-normalizer.service.ts

  • Modify: backend/src/automation/automation.module.ts

  • Step 1: Write the failing evaluator tests

describe('AutomationRuleEvaluatorService', () => {
  it('evaluates AND rules across multiple fresh endpoint snapshots', async () => {
    const result = service.evaluate({
      now: new Date('2026-05-10T10:00:00.000Z'),
      freshnessSeconds: 300,
      rule: {
        id: 'rule-1',
        conditionOperator: 'AND',
        conditions: [
          { sourceDeviceEndpointId: 'endpoint-1', operator: '<', thresholdValue: 4.5 },
          { sourceDeviceEndpointId: 'endpoint-2', operator: '>', thresholdValue: 30 },
        ],
      },
      snapshots: [
        { deviceInstanceEndpointId: 'endpoint-1', value: 3.9, observedAt: new Date('2026-05-10T09:59:30.000Z') },
        { deviceInstanceEndpointId: 'endpoint-2', value: 31, observedAt: new Date('2026-05-10T09:59:10.000Z') },
      ],
      runtimeState: { currentState: 'normal', lastTriggerExecutionAt: null, lastRecoveryExecutionAt: null },
    });

    expect(result.aggregateResult).toBe(true);
    expect(result.nextState).toBe('triggered');
    expect(result.phaseToRun).toBe('trigger');
  });

  it('treats stale conditions as false for AND rules', async () => {
    const result = service.evaluate({
      now: new Date('2026-05-10T10:00:00.000Z'),
      freshnessSeconds: 300,
      rule: {
        id: 'rule-2',
        conditionOperator: 'AND',
        conditions: [{ sourceDeviceEndpointId: 'endpoint-1', operator: '<', thresholdValue: 4.5 }],
      },
      snapshots: [
        { deviceInstanceEndpointId: 'endpoint-1', value: 3.9, observedAt: new Date('2026-05-10T09:40:00.000Z') },
      ],
      runtimeState: { currentState: 'normal', lastTriggerExecutionAt: null, lastRecoveryExecutionAt: null },
    });

    expect(result.aggregateResult).toBe(false);
    expect(result.summary.staleSources).toEqual(['endpoint-1']);
    expect(result.phaseToRun).toBeNull();
  });

  it('emits a recovery transition when a triggered rule becomes false', async () => {
    const result = service.evaluate({
      now: new Date('2026-05-10T10:05:00.000Z'),
      freshnessSeconds: 300,
      rule: {
        id: 'rule-3',
        conditionOperator: 'OR',
        conditions: [{ sourceDeviceEndpointId: 'endpoint-1', operator: '<', thresholdValue: 4.5 }],
      },
      snapshots: [
        { deviceInstanceEndpointId: 'endpoint-1', value: 6.2, observedAt: new Date('2026-05-10T10:04:30.000Z') },
      ],
      runtimeState: {
        currentState: 'triggered',
        lastTriggerExecutionAt: new Date('2026-05-10T10:00:00.000Z'),
        lastRecoveryExecutionAt: null,
      },
    });

    expect(result.aggregateResult).toBe(false);
    expect(result.nextState).toBe('normal');
    expect(result.phaseToRun).toBe('recovery');
  });
});
  • Step 2: Run the evaluator test and verify it fails

Run from backend/: bun test -- src/automation/automation-rule-evaluator.service.spec.ts

Expected: FAIL because there is no evaluator service, freshness policy, or runtime-state-aware transition logic.

  • Step 3: Implement the latest-value loader and evaluator
const FRESHNESS_SECONDS = 300;

@Injectable()
export class AutomationRuleEvaluatorService {
  evaluate(input: {
    now: Date;
    freshnessSeconds: number;
    rule: {
      id: string;
      conditionOperator: 'AND' | 'OR';
      conditions: Array<{
        sourceDeviceEndpointId: string;
        operator: '<' | '<=' | '>' | '>=' | '=' | '!=';
        thresholdValue: number | boolean | string;
      }>;
    };
    snapshots: Array<{
      deviceInstanceEndpointId: string;
      value: number | boolean | string;
      observedAt: Date;
    }>;
    runtimeState: {
      currentState: 'normal' | 'triggered';
      lastTriggerExecutionAt: Date | null;
      lastRecoveryExecutionAt: Date | null;
    };
  }) {
    const snapshotMap = new Map(
      input.snapshots.map((snapshot) => [snapshot.deviceInstanceEndpointId, snapshot]),
    );

    const conditionResults = input.rule.conditions.map((condition) => {
      const snapshot = snapshotMap.get(condition.sourceDeviceEndpointId);
      if (!snapshot) {
        return { sourceDeviceEndpointId: condition.sourceDeviceEndpointId, matched: false, stale: true };
      }

      const ageSeconds = (input.now.getTime() - snapshot.observedAt.getTime()) / 1000;
      if (ageSeconds > input.freshnessSeconds) {
        return { sourceDeviceEndpointId: condition.sourceDeviceEndpointId, matched: false, stale: true };
      }

      return {
        sourceDeviceEndpointId: condition.sourceDeviceEndpointId,
        matched: evaluateValue(snapshot.value, condition.operator, condition.thresholdValue),
        stale: false,
      };
    });

    const aggregateResult =
      input.rule.conditionOperator === 'AND'
        ? conditionResults.every((result) => result.matched && !result.stale)
        : conditionResults.some((result) => result.matched && !result.stale);

    const nextState =
      aggregateResult ? 'triggered' : 'normal';

    const phaseToRun =
      input.runtimeState.currentState === 'normal' && nextState === 'triggered'
        ? 'trigger'
        : input.runtimeState.currentState === 'triggered' && nextState === 'normal'
          ? 'recovery'
          : null;

    return {
      aggregateResult,
      nextState,
      phaseToRun,
      summary: {
        staleSources: conditionResults.filter((result) => result.stale).map((result) => result.sourceDeviceEndpointId),
        conditionResults,
      },
    };
  }
}
  • Step 4: Run the evaluator test and verify it passes

Run from backend/: bun test -- src/automation/automation-rule-evaluator.service.spec.ts

Expected: PASS with green coverage for AND, OR, stale handling, and edge-trigger transitions.

  • Step 5: Commit
git add backend/src/automation/automation-endpoint-latest-values.service.ts backend/src/automation/automation-rule-evaluator.service.ts backend/src/automation/automation-rule-evaluator.service.spec.ts backend/src/automation/automation.module.ts backend/src/automation/endpoint-event.types.ts backend/src/automation/endpoint-event-normalizer.service.ts
git commit -m "feat: add snapshot-based automation rule evaluation"

Task 4: Execute Alert and Device Command Reactions with Runtime State

Files:

  • Create: backend/src/automation/automation-action-executor.service.ts

  • Create: backend/src/automation/automation-action-executor.service.spec.ts

  • Modify: backend/src/automation/automation-engine.service.ts

  • Modify: backend/src/automation/automation-engine.service.spec.ts

  • Modify: backend/src/devices/alert.service.ts

  • Modify: backend/src/devices/device-command-dispatch.service.ts

  • Step 1: Write the failing engine and action-executor tests

it('runs trigger actions once on normal -> triggered and records a succeeded execution', async () => {
  runtimeStateRepo.findOne.mockResolvedValue({ ruleId: 'rule-1', currentState: 'normal' });
  evaluator.evaluate.mockReturnValue({
    aggregateResult: true,
    nextState: 'triggered',
    phaseToRun: 'trigger',
    summary: { conditionResults: [], staleSources: [] },
  });
  actionExecutor.executePhase.mockResolvedValue({
    status: 'succeeded',
    actionResults: [
      { sequenceOrder: 1, actionType: 'alert', status: 'succeeded' },
      { sequenceOrder: 2, actionType: 'device_command', status: 'succeeded' },
    ],
  });

  await service.handleEndpointEvent({
    organizationId: 'org-1',
    deviceId: 'device-1',
    deviceInstanceEndpointId: 'source-endpoint-1',
    timestamp: new Date('2026-05-10T10:00:00.000Z'),
    value: 3.8,
    rawPayload: { do: 3.8 },
  });

  expect(actionExecutor.executePhase).toHaveBeenCalledWith(
    expect.objectContaining({ phase: 'trigger' }),
  );
  expect(runtimeStateRepo.save).toHaveBeenCalledWith(
    expect.objectContaining({ currentState: 'triggered' }),
  );
  expect(executionRepository.save).toHaveBeenCalledWith(
    expect.objectContaining({ phase: 'trigger', status: 'succeeded' }),
  );
});

it('runs recovery actions when a triggered rule becomes normal', async () => {
  runtimeStateRepo.findOne.mockResolvedValue({
    ruleId: 'rule-1',
    currentState: 'triggered',
    lastTriggerExecutionAt: new Date('2026-05-10T10:00:00.000Z'),
  });
  evaluator.evaluate.mockReturnValue({
    aggregateResult: false,
    nextState: 'normal',
    phaseToRun: 'recovery',
    summary: { conditionResults: [], staleSources: [] },
  });

  await service.handleEndpointEvent(event);

  expect(actionExecutor.executePhase).toHaveBeenCalledWith(
    expect.objectContaining({ phase: 'recovery' }),
  );
});
  • Step 2: Run the engine test and verify it fails

Run from backend/: bun test -- src/automation/automation-engine.service.spec.ts

Expected: FAIL because the current engine only checks the first condition, dispatches the first target action directly, and has no phase-aware runtime state.

  • Step 3: Implement action execution and state-aware orchestration
@Injectable()
export class AutomationActionExecutorService {
  constructor(
    private readonly alertService: AlertService,
    private readonly deviceCommandDispatchService: DeviceCommandDispatchService,
  ) {}

  async executePhase(input: {
    ruleName: string;
    phase: 'trigger' | 'recovery';
    actions: AutomationRuleAction[];
    evaluationSummary: Record<string, unknown>;
  }) {
    const actionResults: Array<Record<string, unknown>> = [];

    for (const action of input.actions
      .filter((candidate) => candidate.phase === input.phase && candidate.isEnabled)
      .sort((left, right) => left.sequenceOrder - right.sequenceOrder)) {
      try {
        if (action.actionType === AutomationRuleActionType.ALERT) {
          await this.alertService.createAutomationAlert({
            ruleName: input.ruleName,
            phase: input.phase,
            payload: action.payload,
            evaluationSummary: input.evaluationSummary,
          });
        }

        if (action.actionType === AutomationRuleActionType.DEVICE_COMMAND) {
          await this.deviceCommandDispatchService.dispatchEndpointValue({
            targetDeviceEndpointId: String(action.payload.targetDeviceEndpointId),
            targetValue: action.payload.targetValue as boolean | number | string,
          });
        }

        actionResults.push({ sequenceOrder: action.sequenceOrder, actionType: action.actionType, status: 'succeeded' });
      } catch (error) {
        actionResults.push({
          sequenceOrder: action.sequenceOrder,
          actionType: action.actionType,
          status: 'failed',
          message: error instanceof Error ? error.message : 'Unknown action execution error',
        });
      }
    }

    const failedCount = actionResults.filter((result) => result.status === 'failed').length;
    return {
      status: failedCount === 0 ? 'succeeded' : failedCount === actionResults.length ? 'failed' : 'partial_failed',
      actionResults,
    };
  }
}
  • Step 4: Run the engine and action-executor tests

Run from backend/: bun test -- src/automation/automation-action-executor.service.spec.ts src/automation/automation-engine.service.spec.ts

Expected: PASS with trigger, recovery, ordering, and partial-failure coverage.

  • Step 5: Commit
git add backend/src/automation/automation-action-executor.service.ts backend/src/automation/automation-action-executor.service.spec.ts backend/src/automation/automation-engine.service.ts backend/src/automation/automation-engine.service.spec.ts backend/src/devices/alert.service.ts backend/src/devices/device-command-dispatch.service.ts
git commit -m "feat: execute unified automation reactions with runtime state"

Task 5: Route Telemetry Through Latest Snapshots and Remove Threshold Runtime Alerts

Files:

  • Modify: backend/src/telemetry/telemetry.service.ts

  • Modify: backend/src/telemetry/telemetry.service.spec.ts

  • Modify: backend/src/automation/endpoint-event-normalizer.service.ts

  • Modify: backend/src/automation/endpoint-event.types.ts

  • Step 1: Write the failing telemetry integration test

it('stores telemetry, updates latest endpoint snapshots, and reevaluates rules without calling threshold alerts', async () => {
  jest.spyOn(service as unknown as { checkThresholds: () => Promise<void> }, 'checkThresholds');
  endpointEventNormalizerService.normalizeTelemetryEvent.mockResolvedValue([
    {
      organizationId: 'org-1',
      deviceId: 'device-1',
      deviceInstanceEndpointId: 'endpoint-1',
      timestamp: new Date('2026-05-10T10:00:00.000Z'),
      value: 3.8,
      rawPayload: { do: 3.8 },
    },
  ]);

  await service.save({
    deviceId: 'device-1',
    timestamp: new Date('2026-05-10T10:00:00.000Z'),
    data: { do: 3.8 },
  });

  expect(automationEndpointLatestValuesService.upsertFromEvent).toHaveBeenCalledWith(
    expect.objectContaining({ deviceInstanceEndpointId: 'endpoint-1', value: 3.8 }),
  );
  expect(automationEngineService.handleEndpointEvent).toHaveBeenCalledTimes(1);
  expect(
    jest.spyOn(service as unknown as { checkThresholds: () => Promise<void> }, 'checkThresholds'),
  ).not.toHaveBeenCalled();
});
  • Step 2: Run the telemetry test and verify it fails

Run from backend/: bun test -- src/telemetry/telemetry.service.spec.ts

Expected: FAIL because TelemetryService.save() still calls checkThresholds() and does not update automation latest-value storage.

  • Step 3: Implement the new telemetry-to-automation flow
async save(dto: CreateTelemetryDto): Promise<void> {
  const timestamp = dto.timestamp || new Date();

  await this.telemetryRepository.save({
    deviceId: dto.deviceId,
    time: timestamp,
    data: dto.data,
    signalStrength: dto.signalStrength,
    batteryLevel: dto.batteryLevel,
  });

  await this.cacheLatestValues(dto.deviceId, dto.data, timestamp);
  this.websocketGateway.emitTelemetry(dto.deviceId, dto.data);

  const endpointEvents = await this.endpointEventNormalizerService.normalizeTelemetryEvent({
    deviceId: dto.deviceId,
    timestamp,
    rawPayload: dto.data,
  });

  for (const endpointEvent of endpointEvents) {
    await this.automationEndpointLatestValuesService.upsertFromEvent(endpointEvent);
    await this.automationEngineService.handleEndpointEvent(endpointEvent);
  }
}
  • Step 4: Run the telemetry and engine regression tests

Run from backend/: bun test -- src/telemetry/telemetry.service.spec.ts src/automation/automation-engine.service.spec.ts

Expected: PASS with telemetry now feeding latest snapshots and automation reevaluation only.

  • Step 5: Commit
git add backend/src/telemetry/telemetry.service.ts backend/src/telemetry/telemetry.service.spec.ts backend/src/automation/endpoint-event-normalizer.service.ts backend/src/automation/endpoint-event.types.ts
git commit -m "refactor: move threshold reactions out of telemetry runtime path"

Task 6: Align Frontend Types, Service Calls, and Form Schema with the New API

Files:

  • Modify: frontend/src/types/automation.types.ts

  • Modify: frontend/src/services/automation-rules.service.ts

  • Modify: frontend/src/hooks/useAutomationRules.ts

  • Modify: frontend/src/schemas/automation.schema.ts

  • Create: frontend/src/schemas/automation.schema.spec.ts

  • Step 1: Write the failing schema test

import { describe, expect, it } from 'vitest';
import { automationRuleFormSchema } from '@/schemas/automation.schema';

describe('automationRuleFormSchema', () => {
  it('accepts a multi-condition rule with trigger and recovery actions', () => {
    const parsed = automationRuleFormSchema.parse({
      name: 'DO low then react',
      description: 'Trigger aeration',
      conditionOperator: 'AND',
      cooldownSeconds: 300,
      conditions: [
        { sourceDeviceEndpointId: '11111111-1111-1111-1111-111111111111', operator: '<', thresholdValue: 4.5, sequenceOrder: 1 },
      ],
      triggerActions: [
        {
          actionType: 'alert',
          phase: 'trigger',
          sequenceOrder: 1,
          isEnabled: true,
          payload: {
            severity: 'critical',
            titleTemplate: 'DO low',
            messageTemplate: 'Rule triggered',
          },
        },
      ],
      recoveryActions: [],
    });

    expect(parsed.triggerActions[0].actionType).toBe('alert');
  });
});
  • Step 2: Run the frontend schema test and verify it fails

Run from frontend/: bun test src/schemas/automation.schema.spec.ts

Expected: FAIL because the current schema still expects farmId, one source endpoint, and one target endpoint.

  • Step 3: Implement the new frontend contract
export interface AutomationRuleConditionInput {
  sourceDeviceEndpointId: string;
  operator: '<' | '<=' | '>' | '>=' | '=' | '!=';
  thresholdValue: number | boolean | string;
  sequenceOrder: number;
}

export interface AutomationRuleActionInput {
  actionType: 'alert' | 'device_command';
  phase: 'trigger' | 'recovery';
  sequenceOrder: number;
  isEnabled: boolean;
  payload:
    | {
        severity: 'info' | 'warning' | 'critical';
        titleTemplate: string;
        messageTemplate: string;
      }
    | {
        targetDeviceEndpointId: string;
        targetValue: number | boolean | string;
      };
}

export const automationRuleFormSchema = z.object({
  name: z.string().min(1, 'Nhập tên rule'),
  description: z.string().optional(),
  conditionOperator: z.enum(['AND', 'OR']),
  cooldownSeconds: z.number().int().min(0).default(300),
  conditions: z.array(
    z.object({
      sourceDeviceEndpointId: z.string().uuid(),
      operator: z.enum(['<', '<=', '>', '>=', '=', '!=']),
      thresholdValue: z.union([z.number(), z.boolean(), z.string()]),
      sequenceOrder: z.number().int().min(1),
    }),
  ).min(1, 'Cần ít nhất một điều kiện'),
  triggerActions: z.array(
    z.object({
      actionType: z.enum(['alert', 'device_command']),
      phase: z.literal('trigger'),
      sequenceOrder: z.number().int().min(1),
      isEnabled: z.boolean(),
      payload: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
    }),
  ).min(1, 'Cần ít nhất một trigger action'),
  recoveryActions: z.array(
    z.object({
      actionType: z.enum(['alert', 'device_command']),
      phase: z.literal('recovery'),
      sequenceOrder: z.number().int().min(1),
      isEnabled: z.boolean(),
      payload: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
    }),
  ),
});
  • Step 4: Run the schema and hook-level tests

Run from frontend/: bun test src/schemas/automation.schema.spec.ts src/pages/automation/AutomationRulesPage.spec.tsx

Expected: PASS for the schema and FAIL only where the UI has not been updated yet.

  • Step 5: Commit
git add frontend/src/types/automation.types.ts frontend/src/services/automation-rules.service.ts frontend/src/hooks/useAutomationRules.ts frontend/src/schemas/automation.schema.ts frontend/src/schemas/automation.schema.spec.ts
git commit -m "feat: align frontend automation contract with reaction api"

Task 7: Build the Automation Workspace UI and Demote Alert Config

Files:

  • Create: frontend/src/pages/automation/components/AutomationRuleWizard.tsx

  • Create: frontend/src/pages/automation/components/AutomationRuleDetailDrawer.tsx

  • Create: frontend/src/pages/automation/components/AutomationRuleExecutionHistory.tsx

  • Modify: frontend/src/pages/automation/AutomationRulesPage.tsx

  • Modify: frontend/src/pages/automation/AutomationRulesPage.spec.tsx

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/pages/devices/AlertConfigPage.tsx

  • Step 1: Write the failing page test for the new workflow

it('opens the wizard, shows rule summary columns, and exposes execution history', async () => {
  render(
    <QueryClientProvider client={new QueryClient()}>
      <MemoryRouter>
        <AutomationRulesPage />
      </MemoryRouter>
    </QueryClientProvider>,
  );

  expect(screen.getByText('Trigger actions')).toBeInTheDocument();
  expect(screen.getByText('Recovery actions')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: 'Tạo rule' })).toBeInTheDocument();

  await userEvent.click(screen.getByRole('button', { name: 'Tạo rule' }));
  expect(screen.getByText('Bước 1: Rule info')).toBeInTheDocument();
  expect(screen.getByText('Bước 5: Review and activate')).toBeInTheDocument();
});
  • Step 2: Run the page test and verify it fails

Run from frontend/: bun test src/pages/automation/AutomationRulesPage.spec.tsx

Expected: FAIL because the current page only renders a basic table with Source and Target columns.

  • Step 3: Implement the new page, wizard, and deprecation flow
export const AutomationRulesPage: React.FC = () => {
  const [wizardOpen, setWizardOpen] = useState(false);
  const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
  const { data, isLoading } = useAutomationRules({ page: 1, limit: 20 });

  return (
    <Space direction="vertical" size={24} style={{ width: '100%' }}>
      <PageHeader
        title="Automation Rules"
        subtitle="Quản lý reaction rules đa nguồn telemetry với trigger và recovery actions."
        extra={
          <Button type="primary" onClick={() => setWizardOpen(true)}>
            Tạo rule
          </Button>
        }
      />

      <Card>
        <Table
          loading={isLoading}
          rowKey="id"
          dataSource={data?.data ?? []}
          onRow={(record) => ({ onClick: () => setSelectedRuleId(record.id) })}
          columns={[
            { title: 'Rule', dataIndex: 'name', key: 'name' },
            { title: 'Conditions', dataIndex: 'conditionSummary', key: 'conditionSummary' },
            { title: 'Trigger actions', dataIndex: 'triggerActionCount', key: 'triggerActionCount' },
            { title: 'Recovery actions', dataIndex: 'recoveryActionCount', key: 'recoveryActionCount' },
            { title: 'Last state', dataIndex: 'currentState', key: 'currentState' },
            { title: 'Last execution', dataIndex: 'lastExecutedAt', key: 'lastExecutedAt' },
            {
              title: 'Status',
              key: 'status',
              render: (_, record) => <Tag color={record.status === 'active' ? 'green' : 'default'}>{record.status}</Tag>,
            },
          ]}
        />
      </Card>

      <AutomationRuleWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
      <AutomationRuleDetailDrawer ruleId={selectedRuleId} onClose={() => setSelectedRuleId(null)} />
    </Space>
  );
};
  • Step 4: Run the frontend UI tests

Run from frontend/: bun test src/pages/automation/AutomationRulesPage.spec.tsx

Expected: PASS with the new list columns, wizard CTA, and detail/history flow.

Run from frontend/: bun run build

Expected: PASS with the new page components and route wiring.

  • Step 5: Commit
git add frontend/src/pages/automation/components/AutomationRuleWizard.tsx frontend/src/pages/automation/components/AutomationRuleDetailDrawer.tsx frontend/src/pages/automation/components/AutomationRuleExecutionHistory.tsx frontend/src/pages/automation/AutomationRulesPage.tsx frontend/src/pages/automation/AutomationRulesPage.spec.tsx frontend/src/App.tsx frontend/src/pages/devices/AlertConfigPage.tsx
git commit -m "feat: build automation workspace and demote alert config"

Task 8: Verify Backend and Frontend End-to-End Feature Coverage

Files:

  • Modify: backend/src/automation/automation-rules.service.spec.ts

  • Modify: backend/src/automation/automation-engine.service.spec.ts

  • Modify: backend/src/telemetry/telemetry.service.spec.ts

  • Modify: frontend/src/pages/automation/AutomationRulesPage.spec.tsx

  • Step 1: Add the final missing regression tests

it('keeps last command wins semantics when two rules target the same endpoint', async () => {
  actionExecutor.executePhase
    .mockResolvedValueOnce({
      status: 'succeeded',
      actionResults: [{ sequenceOrder: 1, actionType: 'device_command', status: 'succeeded', payload: { targetValue: true } }],
    })
    .mockResolvedValueOnce({
      status: 'succeeded',
      actionResults: [{ sequenceOrder: 1, actionType: 'device_command', status: 'succeeded', payload: { targetValue: false } }],
    });

  await service.handleEndpointEvent(firstEvent);
  await service.handleEndpointEvent(secondEvent);

  expect(deviceCommandDispatchService.dispatchEndpointValue).toHaveBeenNthCalledWith(2, {
    targetDeviceEndpointId: 'endpoint-target-1',
    targetValue: false,
  });
});
it('serializes trigger and recovery actions into the backend dto shape', async () => {
  renderPage();
  await userEvent.click(screen.getByRole('button', { name: 'Tạo rule' }));
  expect(mockCreateMutation).toHaveBeenCalledWith(
    expect.objectContaining({
      conditionOperator: 'AND',
      triggerActions: expect.any(Array),
      recoveryActions: expect.any(Array),
    }),
  );
});
  • Step 2: Run the focused test suite and verify gaps

Run from backend/: bun test -- src/automation/automation-rules.service.spec.ts src/automation/automation-engine.service.spec.ts src/telemetry/telemetry.service.spec.ts

Expected: PASS with list/detail/runtime/conflict coverage.

Run from frontend/: bun test src/pages/automation/AutomationRulesPage.spec.tsx src/schemas/automation.schema.spec.ts

Expected: PASS with authoring and serialization coverage.

  • Step 3: Run the workspace-wide verification commands

Run from repo root: bun run check

Expected: PASS with Biome covering the touched backend and frontend files.

Run from backend/: bun test -- src/automation/automation-rule-validation.util.spec.ts src/automation/automation-rules.service.spec.ts src/automation/automation-rule-evaluator.service.spec.ts src/automation/automation-action-executor.service.spec.ts src/automation/automation-engine.service.spec.ts src/telemetry/telemetry.service.spec.ts

Expected: PASS with all backend reaction-engine tests green.

Run from frontend/: bun test src/schemas/automation.schema.spec.ts src/pages/automation/AutomationRulesPage.spec.tsx

Expected: PASS with all frontend automation tests green.

  • Step 4: Commit
git add backend/src/automation/automation-rules.service.spec.ts backend/src/automation/automation-engine.service.spec.ts backend/src/telemetry/telemetry.service.spec.ts frontend/src/pages/automation/AutomationRulesPage.spec.tsx frontend/src/schemas/automation.schema.spec.ts
git commit -m "test: verify unified reaction automation end to end"

Self-Review

Spec coverage

  • Goal, scope, and locked decisions: covered by Tasks 1 through 7.
  • Multi-condition AND/OR, multi-device latest snapshots, trigger/recovery phases, cooldown, and edge-trigger state: covered by Tasks 1, 3, 4, and 5.
  • Alert as action type and device command reuse: covered by Task 4.
  • CRUD/list/detail/execution endpoints and organization-scoped model: covered by Task 2.
  • Frontend list, wizard, detail, execution history, and alert-config demotion: covered by Tasks 6 and 7.
  • Permissions split for activate and pause: included in Task 2 controller and migration work.
  • Testing strategy from the spec: covered by Task 8 plus task-local TDD steps throughout.

Placeholder scan

  • No TODO, TBD, or “similar to Task N” references remain.
  • Every task includes exact file paths, concrete test code, concrete implementation code, and exact commands.

Type consistency

  • Backend phases use trigger | recovery consistently across DTOs, entities, runtime, and frontend schema.
  • Rule-level operator uses AND | OR consistently.
  • Action types use alert | device_command consistently.
  • Runtime state uses normal | triggered consistently.