Gateway Function Automation MVP 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: Build a device-profile and endpoint catalog plus a farm-scoped automation MVP that turns normalized telemetry events into safe endpoint-based device commands and exposes the required Super Admin and farm-operator UI.

Architecture: Keep profile/catalog and instance endpoint configuration inside the existing devices vertical slice, because they extend the system device inventory already present in the repo. Build automation as a separate backend/src/automation module that owns rule authoring, validation, execution audit, and the runtime engine, while reusing DevicesModule for device lookups and a new device command-dispatch adapter for MQTT command delivery.

Tech Stack: NestJS, TypeORM, PostgreSQL migrations, Jest, React 19, React Router, TanStack Query, Ant Design, Zod, Vitest, Testing Library, Bun, Biome

Assumptions:

  • The spec’s “farm” maps to the existing Area aggregate in this repo. Persist automation rule scope as automation_rules.farm_id referencing areas.id, and keep UI copy as “Farm” while backend joins Area.
  • Keep legacy devices.type, devices.model, and devices.capabilities for backward compatibility. New automation behavior is driven by devices.deviceProfileId and device_instance_endpoints when present.
  • MVP write dispatch supports writable endpoints whose transportType is rpc. Read endpoints may use mqtt_telemetry or mqtt_attribute.
  • Keep the existing /devices/:id/control 4-IO API unchanged in this phase. The new runtime uses endpoint bindings directly and does not need to first refactor the legacy manual-control surface.
  • Tenant automation permissions are org-scoped in MVP: automation:read:all, automation:create:all, and automation:update:all. Do not introduce assigned-scope automation RBAC until area-assignment semantics are explicitly designed.

File Structure

Create

  • backend/src/devices/enums/device-endpoint-data-type.enum.ts
    Responsibility: canonical endpoint value types shared by profile endpoints, instance endpoints, and automation validation.
  • backend/src/devices/enums/device-endpoint-direction.enum.ts
    Responsibility: shared read/write direction enum.
  • backend/src/devices/enums/device-endpoint-transport-type.enum.ts
    Responsibility: shared transport enum for telemetry, attributes, and RPC.
  • backend/src/devices/entities/device-type.entity.ts
    Responsibility: normalized system catalog for coarse device classification.
  • backend/src/devices/entities/device-profile.entity.ts
    Responsibility: hardware ceiling model for a device family.
  • backend/src/devices/entities/device-profile-endpoint.entity.ts
    Responsibility: normalized profile endpoint catalog and transport binding metadata.
  • backend/src/devices/entities/device-instance-endpoint.entity.ts
    Responsibility: per-device endpoint enablement, aliasing, and writable overrides.
  • backend/src/devices/dto/create-device-profile.dto.ts
    Responsibility: profile create payload validation.
  • backend/src/devices/dto/update-device-profile.dto.ts
    Responsibility: profile update payload validation.
  • backend/src/devices/dto/create-device-profile-endpoint.dto.ts
    Responsibility: endpoint create payload validation.
  • backend/src/devices/dto/update-device-profile-endpoint.dto.ts
    Responsibility: endpoint update payload validation.
  • backend/src/devices/dto/update-device-instance-endpoint.dto.ts
    Responsibility: system device endpoint config update payload validation.
  • backend/src/devices/dto/device-instance-endpoint-response.dto.ts
    Responsibility: Swagger-visible response shape for device endpoint config.
  • backend/src/devices/device-profile-validation.util.ts
    Responsibility: pure validation rules for profile endpoints and transport bindings.
  • backend/src/devices/device-profile-validation.util.spec.ts
    Responsibility: unit coverage for catalog validation rules.
  • backend/src/devices/device-profile.service.ts
    Responsibility: profile CRUD, endpoint CRUD, and catalog query orchestration.
  • backend/src/devices/device-profile.service.spec.ts
    Responsibility: service tests for profile persistence and validation behavior.
  • backend/src/devices/device-endpoint-materializer.service.ts
    Responsibility: create or resync device_instance_endpoints from a selected profile.
  • backend/src/devices/device-endpoint-materializer.service.spec.ts
    Responsibility: unit tests for endpoint materialization and profile-resync behavior.
  • backend/src/devices/device-command-dispatch.service.ts
    Responsibility: translate writable endpoint bindings into MQTT RPC requests and send them safely.
  • backend/src/devices/device-command-dispatch.service.spec.ts
    Responsibility: unit tests for command payload generation and target safety checks.
  • backend/src/devices/controllers/device-profiles.controller.ts
    Responsibility: Super Admin catalog APIs under /device-profiles.
  • backend/src/devices/controllers/device-types.controller.ts
    Responsibility: Super Admin read API for /device-types.
  • backend/src/devices/controllers/system-device-endpoints.controller.ts
    Responsibility: Super Admin device endpoint configuration APIs under /system/devices/:id/endpoints.
  • backend/src/automation/enums/automation-rule-action-type.enum.ts
    Responsibility: canonical action types for automation rules.
  • backend/src/automation/enums/automation-rule-execution-status.enum.ts
    Responsibility: shared execution audit statuses.
  • backend/src/automation/enums/automation-rule-operator.enum.ts
    Responsibility: canonical rule operators.
  • backend/src/automation/enums/automation-rule-status.enum.ts
    Responsibility: rule lifecycle states.
  • backend/src/automation/enums/automation-trigger-mode.enum.ts
    Responsibility: trigger-mode enum with MVP event_driven.
  • backend/src/automation/entities/automation-rule.entity.ts
    Responsibility: farm-scoped rule aggregate root.
  • backend/src/automation/entities/automation-rule-condition.entity.ts
    Responsibility: source endpoint threshold condition row.
  • backend/src/automation/entities/automation-rule-action.entity.ts
    Responsibility: target endpoint action row.
  • backend/src/automation/entities/automation-rule-execution.entity.ts
    Responsibility: runtime evaluation and dispatch audit log.
  • backend/src/automation/dto/create-automation-rule.dto.ts
    Responsibility: create payload validation for single-condition, single-action rules.
  • backend/src/automation/dto/update-automation-rule.dto.ts
    Responsibility: update payload validation for draft/paused rules.
  • backend/src/automation/dto/query-automation-rules.dto.ts
    Responsibility: list query validation for farmId and status.
  • backend/src/automation/dto/automation-rule-response.dto.ts
    Responsibility: response contract for rules list/detail.
  • backend/src/automation/dto/automation-rule-execution-response.dto.ts
    Responsibility: response contract for execution history.
  • backend/src/automation/automation-rule-validation.util.ts
    Responsibility: pure rule-authoring validation against endpoint metadata and data types.
  • backend/src/automation/automation-rule-validation.util.spec.ts
    Responsibility: unit coverage for operator and target-value validation.
  • backend/src/automation/endpoint-event.types.ts
    Responsibility: normalized endpoint-event input contract for the runtime engine.
  • backend/src/automation/endpoint-event-normalizer.service.ts
    Responsibility: map raw telemetry payload fields to logical endpoint events using transport bindings.
  • backend/src/automation/endpoint-event-normalizer.service.spec.ts
    Responsibility: unit tests for telemetry-to-endpoint normalization.
  • backend/src/automation/automation-rules.service.ts
    Responsibility: CRUD, activate/pause, and execution-history queries.
  • backend/src/automation/automation-rules.service.spec.ts
    Responsibility: service tests for scope, validation, and state transitions.
  • backend/src/automation/automation-engine.service.ts
    Responsibility: select matching rules, enforce safety gates, dispatch commands, and write audits.
  • backend/src/automation/automation-engine.service.spec.ts
    Responsibility: unit tests for cooldown, skip, fail, and dispatch behavior.
  • backend/src/automation/controllers/automation-rules.controller.ts
    Responsibility: /automation/rules API contract.
  • backend/src/automation/automation.module.ts
    Responsibility: module wiring for automation APIs and runtime services.
  • backend/test/automation/automation-flow.e2e-spec.ts
    Responsibility: end-to-end coverage for telemetry normalization, rule execution, and audit creation.
  • backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts
    Responsibility: create device catalog, device instance endpoint tables, seed device_types, and extend devices with device_profile_id.
  • backend/src/migrations/1777903600000-CreateAutomationRuleTables.ts
    Responsibility: create automation tables and add org-role permissions for tenant automation.
  • frontend/src/types/device-profile.types.ts
    Responsibility: frontend catalog and instance endpoint types.
  • frontend/src/types/automation.types.ts
    Responsibility: frontend automation rule, execution, and selector types.
  • frontend/src/services/device-profile.service.ts
    Responsibility: API client for profile and endpoint config routes.
  • frontend/src/services/automation-rules.service.ts
    Responsibility: API client for automation rule and execution routes.
  • frontend/src/hooks/useDeviceProfiles.ts
    Responsibility: TanStack Query hooks for profile and endpoint config pages.
  • frontend/src/hooks/useAutomationRules.ts
    Responsibility: TanStack Query hooks for automation rules page.
  • frontend/src/schemas/automation.schema.ts
    Responsibility: Zod validation for automation rule forms.
  • frontend/src/pages/system-devices/DeviceProfilesPage.tsx
    Responsibility: Super Admin UI for profile and endpoint catalog management.
  • frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx
    Responsibility: UI coverage for profile CRUD and endpoint validation feedback.
  • frontend/src/pages/system-devices/SystemDeviceEndpointsPage.tsx
    Responsibility: Super Admin UI for per-device endpoint enablement and aliases.
  • frontend/src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsx
    Responsibility: UI coverage for endpoint enable/disable and alias editing.
  • frontend/src/pages/automation/AutomationRulesPage.tsx
    Responsibility: farm automation authoring, listing, activate/pause, and execution-history UX.
  • frontend/src/pages/automation/AutomationRulesPage.spec.tsx
    Responsibility: UI coverage for the guided rule-creation flow and activation actions.

Modify

  • backend/src/app.module.ts
    Register new entities and import AutomationModule.
  • backend/src/devices/devices.module.ts
    Register new repositories, services, and controllers; import forwardRef(() => MqttModule) for command dispatch.
  • backend/src/devices/entities/device.entity.ts
    Add nullable deviceProfileId relation and instanceEndpoints relation.
  • backend/src/devices/dto/create-system-device.dto.ts
    Require deviceProfileId for new system-managed automation-capable devices.
  • backend/src/devices/dto/update-system-device.dto.ts
    Allow changing deviceProfileId and resyncing instance endpoints.
  • backend/src/devices/dto/system-device-response.dto.ts
    Expose selected profile summary in system inventory responses.
  • backend/src/devices/system-device-inventory.service.ts
    Persist deviceProfileId, validate profile existence, and trigger endpoint materialization on create/update.
  • backend/src/devices/system-device-inventory.service.spec.ts
    Extend existing service tests with profile and endpoint materialization coverage.
  • backend/src/mqtt/mqtt.module.ts
    Export MqttService for device command dispatch and add the forwardRef(() => DevicesModule) import needed by the new dispatch adapter.
  • backend/src/telemetry/telemetry.module.ts
    Import AutomationModule.
  • backend/src/telemetry/telemetry.service.ts
    After telemetry save, normalize endpoint events and trigger automation evaluation.
  • backend/src/telemetry/telemetry.service.spec.ts
    Verify automation hook invocation after telemetry persistence.
  • backend/src/rbac/resource-scope-map.ts
    Add explicit tenant automation resource config.
  • docs/context/rbac-matrix.md
    Document Super Admin catalog permissions and tenant automation permissions.
  • frontend/src/App.tsx
    Add /system/device-profiles, /system/devices/:id/endpoints, and /automation/rules.
  • frontend/src/navigation/menus/super-admin.menu.tsx
    Add device profile catalog entry.
  • frontend/src/navigation/menus/admin.menu.tsx
    Add automation rules entry.
  • frontend/src/navigation/menus/owner.menu.tsx
    Add automation rules entry.
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
    Add profile selection column/form field and endpoint-config action.
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
    Cover the new profile selection and navigation affordance.

Task 1: Lock Down Catalog Validation Rules Before Persistence

Files:

  • Create: backend/src/devices/enums/device-endpoint-data-type.enum.ts

  • Create: backend/src/devices/enums/device-endpoint-direction.enum.ts

  • Create: backend/src/devices/enums/device-endpoint-transport-type.enum.ts

  • Create: backend/src/devices/device-profile-validation.util.ts

  • Test: backend/src/devices/device-profile-validation.util.spec.ts

  • Step 1: Write the failing validation utility test

Create backend/src/devices/device-profile-validation.util.spec.ts:

import { BadRequestException } from '@nestjs/common';
import { DeviceEndpointDataType } from '@/devices/enums/device-endpoint-data-type.enum';
import { DeviceEndpointDirection } from '@/devices/enums/device-endpoint-direction.enum';
import { DeviceEndpointTransportType } from '@/devices/enums/device-endpoint-transport-type.enum';
import { validateProfileEndpoints } from '@/devices/device-profile-validation.util';

describe('validateProfileEndpoints', () => {
  it('rejects duplicate endpoint keys inside one profile', () => {
    expect(() =>
      validateProfileEndpoints({
        supportedFunctions: ['monitoring'],
        endpoints: [
          {
            key: 'telemetry.do',
            direction: DeviceEndpointDirection.READ,
            dataType: DeviceEndpointDataType.NUMBER,
            functionKey: 'monitoring',
            defaultLabel: 'DO',
            transportType: DeviceEndpointTransportType.MQTT_TELEMETRY,
            transportBinding: { field: 'do' },
          },
          {
            key: 'telemetry.do',
            direction: DeviceEndpointDirection.READ,
            dataType: DeviceEndpointDataType.NUMBER,
            functionKey: 'monitoring',
            defaultLabel: 'DO Duplicate',
            transportType: DeviceEndpointTransportType.MQTT_TELEMETRY,
            transportBinding: { field: 'do_2' },
          },
        ],
      }),
    ).toThrow(new BadRequestException('Endpoint key telemetry.do already exists in this profile'));
  });

  it('rejects writable RPC endpoints without a method and paramKey binding', () => {
    expect(() =>
      validateProfileEndpoints({
        supportedFunctions: ['control'],
        endpoints: [
          {
            key: 'output.gpio1',
            direction: DeviceEndpointDirection.WRITE,
            dataType: DeviceEndpointDataType.BOOLEAN,
            functionKey: 'control',
            defaultLabel: 'GPIO 1',
            transportType: DeviceEndpointTransportType.RPC,
            transportBinding: { method: 'setIO' },
          },
        ],
      }),
    ).toThrow(
      new BadRequestException(
        'Writable endpoint output.gpio1 must define RPC binding method and paramKey',
      ),
    );
  });

  it('requires at least one writable endpoint when the profile declares control support', () => {
    expect(() =>
      validateProfileEndpoints({
        supportedFunctions: ['monitoring', 'control'],
        endpoints: [
          {
            key: 'telemetry.temperature',
            direction: DeviceEndpointDirection.READ,
            dataType: DeviceEndpointDataType.NUMBER,
            functionKey: 'monitoring',
            defaultLabel: 'Temperature',
            transportType: DeviceEndpointTransportType.MQTT_TELEMETRY,
            transportBinding: { field: 'temperature' },
          },
        ],
      }),
    ).toThrow(
      new BadRequestException('Profiles with control support must expose at least one writable endpoint'),
    );
  });
});
  • Step 2: Run the validation test to verify it fails

Run: cd backend && bun run test -- src/devices/device-profile-validation.util.spec.ts --runInBand

Expected: FAIL because the enums and validateProfileEndpoints() do not exist yet.

  • Step 3: Implement the shared enums and validation utility

Create backend/src/devices/enums/device-endpoint-data-type.enum.ts:

export enum DeviceEndpointDataType {
  NUMBER = 'number',
  BOOLEAN = 'boolean',
  ENUM = 'enum',
}

Create backend/src/devices/enums/device-endpoint-direction.enum.ts:

export enum DeviceEndpointDirection {
  READ = 'read',
  WRITE = 'write',
  READ_WRITE = 'read_write',
}

Create backend/src/devices/enums/device-endpoint-transport-type.enum.ts:

export enum DeviceEndpointTransportType {
  MQTT_TELEMETRY = 'mqtt_telemetry',
  MQTT_ATTRIBUTE = 'mqtt_attribute',
  RPC = 'rpc',
}

Create backend/src/devices/device-profile-validation.util.ts:

import { BadRequestException } from '@nestjs/common';
import { DeviceEndpointDirection } from '@/devices/enums/device-endpoint-direction.enum';
import { DeviceEndpointTransportType } from '@/devices/enums/device-endpoint-transport-type.enum';

interface DraftEndpoint {
  key: string;
  direction: DeviceEndpointDirection;
  functionKey: string;
  defaultLabel: string;
  transportType: DeviceEndpointTransportType;
  transportBinding: Record<string, unknown>;
}

interface ValidateProfileEndpointsInput {
  supportedFunctions: string[];
  endpoints: DraftEndpoint[];
}

export function validateProfileEndpoints(input: ValidateProfileEndpointsInput): void {
  const seenKeys = new Set<string>();

  for (const endpoint of input.endpoints) {
    if (seenKeys.has(endpoint.key)) {
      throw new BadRequestException(
        `Endpoint key ${endpoint.key} already exists in this profile`,
      );
    }
    seenKeys.add(endpoint.key);

    const isWritable =
      endpoint.direction === DeviceEndpointDirection.WRITE ||
      endpoint.direction === DeviceEndpointDirection.READ_WRITE;

    if (isWritable && endpoint.transportType !== DeviceEndpointTransportType.RPC) {
      throw new BadRequestException(
        `Writable endpoint ${endpoint.key} must use rpc transport in MVP`,
      );
    }

    if (
      isWritable &&
      (typeof endpoint.transportBinding.method !== 'string' ||
        typeof endpoint.transportBinding.paramKey !== 'string')
    ) {
      throw new BadRequestException(
        `Writable endpoint ${endpoint.key} must define RPC binding method and paramKey`,
      );
    }

    if (
      endpoint.transportType !== DeviceEndpointTransportType.RPC &&
      typeof endpoint.transportBinding.field !== 'string'
    ) {
      throw new BadRequestException(
        `Readable endpoint ${endpoint.key} must define a telemetry field binding`,
      );
    }
  }

  const hasControl = input.supportedFunctions.includes('control');
  const hasWritableEndpoint = input.endpoints.some(
    (endpoint) =>
      endpoint.direction === DeviceEndpointDirection.WRITE ||
      endpoint.direction === DeviceEndpointDirection.READ_WRITE,
  );

  if (hasControl && !hasWritableEndpoint) {
    throw new BadRequestException(
      'Profiles with control support must expose at least one writable endpoint',
    );
  }
}
  • Step 4: Run the validation test to verify it passes

Run: cd backend && bun run test -- src/devices/device-profile-validation.util.spec.ts --runInBand

Expected: PASS with duplicate key, binding, and control-capability guardrails covered.

  • Step 5: Commit
git add backend/src/devices/enums/device-endpoint-data-type.enum.ts backend/src/devices/enums/device-endpoint-direction.enum.ts backend/src/devices/enums/device-endpoint-transport-type.enum.ts backend/src/devices/device-profile-validation.util.ts backend/src/devices/device-profile-validation.util.spec.ts
git commit -m "test: lock device profile endpoint validation rules"

Task 2: Persist Device Profile Catalog And Expose Super Admin Catalog APIs

Files:

  • Create: backend/src/devices/entities/device-type.entity.ts

  • Create: backend/src/devices/entities/device-profile.entity.ts

  • Create: backend/src/devices/entities/device-profile-endpoint.entity.ts

  • Create: backend/src/devices/dto/create-device-profile.dto.ts

  • Create: backend/src/devices/dto/update-device-profile.dto.ts

  • Create: backend/src/devices/dto/create-device-profile-endpoint.dto.ts

  • Create: backend/src/devices/dto/update-device-profile-endpoint.dto.ts

  • Create: backend/src/devices/device-profile.service.ts

  • Create: backend/src/devices/device-profile.service.spec.ts

  • Create: backend/src/devices/controllers/device-profiles.controller.ts

  • Create: backend/src/devices/controllers/device-types.controller.ts

  • Create: backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts

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

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

  • Modify: backend/src/devices/entities/device.entity.ts

  • Step 1: Write the failing profile service test

Create backend/src/devices/device-profile.service.spec.ts:

import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DeviceProfileService } from '@/devices/device-profile.service';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';
import { DeviceType } from '@/devices/entities/device-type.entity';

describe('DeviceProfileService', () => {
  let service: DeviceProfileService;
  const profileRepository = {
    create: jest.fn((value) => value),
    save: jest.fn(),
    findOne: jest.fn(),
    find: jest.fn(),
  };
  const endpointRepository = {
    create: jest.fn((value) => value),
    save: jest.fn(),
  };
  const typeRepository = {
    findOne: jest.fn(),
  };

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        DeviceProfileService,
        { provide: getRepositoryToken(DeviceProfile), useValue: profileRepository },
        { provide: getRepositoryToken(DeviceProfileEndpoint), useValue: endpointRepository },
        { provide: getRepositoryToken(DeviceType), useValue: typeRepository },
      ],
    }).compile();

    service = module.get(DeviceProfileService);
    jest.clearAllMocks();
  });

  it('creates a profile with normalized endpoints', async () => {
    typeRepository.findOne.mockResolvedValue({ id: 'type-gateway', code: 'gateway' });
    profileRepository.save.mockResolvedValue({
      id: 'profile-1',
      code: 'gateway-4do-2di-v1',
    });
    endpointRepository.save.mockResolvedValue([
      { id: 'endpoint-1', key: 'output.gpio1' },
      { id: 'endpoint-2', key: 'telemetry.do' },
    ]);

    const result = await service.create({
      code: 'gateway-4do-2di-v1',
      name: 'Gateway 4DO 2DI',
      deviceTypeId: 'type-gateway',
      manufacturer: 'HMP',
      model: 'GW-4DO',
      firmwareFamily: 'gw-v1',
      supportedFunctions: ['monitoring', 'control'],
      endpoints: [
        {
          key: 'output.gpio1',
          direction: 'write',
          dataType: 'boolean',
          functionKey: 'control',
          defaultLabel: 'GPIO 1',
          transportType: 'rpc',
          transportBinding: { method: 'setIO', paramKey: 'io1' },
          displayOrder: 1,
        },
        {
          key: 'telemetry.do',
          direction: 'read',
          dataType: 'number',
          functionKey: 'monitoring',
          defaultLabel: 'DO',
          unit: 'mg/L',
          transportType: 'mqtt_telemetry',
          transportBinding: { field: 'do' },
          displayOrder: 2,
        },
      ],
    });

    expect(typeRepository.findOne).toHaveBeenCalledWith({ where: { id: 'type-gateway' } });
    expect(profileRepository.save).toHaveBeenCalled();
    expect(endpointRepository.save).toHaveBeenCalledWith(
      expect.arrayContaining([
        expect.objectContaining({ key: 'output.gpio1' }),
        expect.objectContaining({ key: 'telemetry.do' }),
      ]),
    );
    expect(result.code).toBe('gateway-4do-2di-v1');
  });
});
  • Step 2: Run the profile service test to verify it fails

Run: cd backend && bun run test -- src/devices/device-profile.service.spec.ts --runInBand

Expected: FAIL because the profile entities, service, and DTO contract do not exist.

  • Step 3: Implement catalog entities, service, controller, and migration

Create backend/src/devices/entities/device-type.entity.ts:

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('device_types')
export class DeviceType {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true, length: 50 })
  code: string;

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

  @Column({ name: 'is_active', default: true })
  isActive: boolean;
}

Create backend/src/devices/entities/device-profile.entity.ts:

import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { DeviceType } from '@/devices/entities/device-type.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';

@Entity('device_profiles')
export class DeviceProfile {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true, length: 100 })
  code: string;

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

  @Column({ name: 'device_type_id', type: 'uuid' })
  deviceTypeId: string;

  @Column({ length: 255, nullable: true })
  manufacturer: string | null;

  @Column({ length: 255, nullable: true })
  model: string | null;

  @Column({ name: 'firmware_family', length: 100, nullable: true })
  firmwareFamily: string | null;

  @Column({ name: 'supported_functions', type: 'jsonb', default: () => "'[]'::jsonb" })
  supportedFunctions: string[];

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @ManyToOne(() => DeviceType)
  @JoinColumn({ name: 'device_type_id' })
  deviceType: DeviceType;

  @OneToMany(() => DeviceProfileEndpoint, (endpoint) => endpoint.deviceProfile, { cascade: true })
  endpoints: DeviceProfileEndpoint[];

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}

Create backend/src/devices/entities/device-profile-endpoint.entity.ts:

import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { DeviceEndpointDataType } from '@/devices/enums/device-endpoint-data-type.enum';
import { DeviceEndpointDirection } from '@/devices/enums/device-endpoint-direction.enum';
import { DeviceEndpointTransportType } from '@/devices/enums/device-endpoint-transport-type.enum';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';

@Entity('device_profile_endpoints')
export class DeviceProfileEndpoint {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'device_profile_id', type: 'uuid' })
  deviceProfileId: string;

  @Column({ length: 100 })
  key: string;

  @Column({ type: 'enum', enum: DeviceEndpointDirection })
  direction: DeviceEndpointDirection;

  @Column({ name: 'data_type', type: 'enum', enum: DeviceEndpointDataType })
  dataType: DeviceEndpointDataType;

  @Column({ name: 'function_key', length: 100 })
  functionKey: string;

  @Column({ name: 'default_label', length: 255 })
  defaultLabel: string;

  @Column({ length: 50, nullable: true })
  unit: string | null;

  @Column({ name: 'transport_type', type: 'enum', enum: DeviceEndpointTransportType })
  transportType: DeviceEndpointTransportType;

  @Column({ name: 'transport_binding', type: 'jsonb' })
  transportBinding: Record<string, unknown>;

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

  @Column({ name: 'display_order', type: 'integer', default: 0 })
  displayOrder: number;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @ManyToOne(() => DeviceProfile, (profile) => profile.endpoints, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'device_profile_id' })
  deviceProfile: DeviceProfile;
}

Create backend/src/devices/device-profile.service.ts:

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { validateProfileEndpoints } from '@/devices/device-profile-validation.util';
import { CreateDeviceProfileDto } from '@/devices/dto/create-device-profile.dto';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';
import { DeviceType } from '@/devices/entities/device-type.entity';

@Injectable()
export class DeviceProfileService {
  constructor(
    @InjectRepository(DeviceProfile)
    private profileRepository: Repository<DeviceProfile>,
    @InjectRepository(DeviceProfileEndpoint)
    private endpointRepository: Repository<DeviceProfileEndpoint>,
    @InjectRepository(DeviceType)
    private typeRepository: Repository<DeviceType>,
  ) {}

  async create(dto: CreateDeviceProfileDto): Promise<DeviceProfile> {
    const deviceType = await this.typeRepository.findOne({ where: { id: dto.deviceTypeId } });
    if (!deviceType) {
      throw new NotFoundException('Device type not found');
    }

    validateProfileEndpoints({
      supportedFunctions: dto.supportedFunctions,
      endpoints: dto.endpoints,
    });

    const profile = await this.profileRepository.save(
      this.profileRepository.create({
        code: dto.code,
        name: dto.name,
        deviceTypeId: dto.deviceTypeId,
        manufacturer: dto.manufacturer ?? null,
        model: dto.model ?? null,
        firmwareFamily: dto.firmwareFamily ?? null,
        supportedFunctions: dto.supportedFunctions,
        isActive: true,
      }),
    );

    const endpoints = dto.endpoints.map((endpoint) =>
      this.endpointRepository.create({
        ...endpoint,
        deviceProfileId: profile.id,
        unit: endpoint.unit ?? null,
        constraints: endpoint.constraints ?? null,
        isActive: endpoint.isActive ?? true,
      }),
    );
    await this.endpointRepository.save(endpoints);

    const savedProfile = await this.profileRepository.findOne({
      where: { id: profile.id },
      relations: ['deviceType', 'endpoints'],
    });

    if (!savedProfile) {
      throw new BadRequestException('Failed to reload created profile');
    }

    return savedProfile;
  }

  async findAll(): Promise<DeviceProfile[]> {
    return this.profileRepository.find({
      relations: ['deviceType', 'endpoints'],
      order: { createdAt: 'DESC', endpoints: { displayOrder: 'ASC' } },
    });
  }

  async findDeviceTypes(): Promise<DeviceType[]> {
    return this.typeRepository.find({
      where: { isActive: true },
      order: { name: 'ASC' },
    });
  }

  async findEndpoints(profileId: string): Promise<DeviceProfileEndpoint[]> {
    return this.endpointRepository.find({
      where: { deviceProfileId: profileId },
      order: { displayOrder: 'ASC' },
    });
  }

  async addEndpoint(profileId: string, dto: CreateDeviceProfileEndpointDto) {
    const profile = await this.profileRepository.findOne({
      where: { id: profileId },
      relations: ['endpoints'],
    });
    if (!profile) {
      throw new NotFoundException('Device profile not found');
    }

    validateProfileEndpoints({
      supportedFunctions: profile.supportedFunctions,
      endpoints: [...profile.endpoints, dto],
    });

    return this.endpointRepository.save(
      this.endpointRepository.create({
        ...dto,
        deviceProfileId: profileId,
        unit: dto.unit ?? null,
        constraints: dto.constraints ?? null,
        isActive: dto.isActive ?? true,
      }),
    );
  }
}

Create backend/src/devices/controllers/device-profiles.controller.ts:

import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { DeviceProfileService } from '@/devices/device-profile.service';
import { CreateDeviceProfileDto } from '@/devices/dto/create-device-profile.dto';

@ApiTags('Device Profiles')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('device-profiles')
export class DeviceProfilesController {
  constructor(private readonly service: DeviceProfileService) {}

  @Get()
  @RequirePermissions('device:read:system')
  findAll() {
    return this.service.findAll();
  }

  @Post()
  @RequirePermissions('device:create:system')
  create(@Body() dto: CreateDeviceProfileDto) {
    return this.service.create(dto);
  }

  @Get(':id/endpoints')
  @RequirePermissions('device:read:system')
  findEndpoints(@Param('id') id: string) {
    return this.service.findEndpoints(id);
  }

  @Post(':id/endpoints')
  @RequirePermissions('device:update:system')
  addEndpoint(@Param('id') id: string, @Body() dto: CreateDeviceProfileEndpointDto) {
    return this.service.addEndpoint(id, dto);
  }
}

Create backend/src/devices/controllers/device-types.controller.ts:

import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { DeviceProfileService } from '@/devices/device-profile.service';

@ApiTags('Device Types')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('device-types')
export class DeviceTypesController {
  constructor(private readonly service: DeviceProfileService) {}

  @Get()
  @RequirePermissions('device:read:system')
  findAll() {
    return this.service.findDeviceTypes();
  }
}

Create backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts:

import type { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateDeviceProfileCatalog1777900000000 implements MigrationInterface {
  name = 'CreateDeviceProfileCatalog1777900000000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TYPE device_endpoint_direction_enum AS ENUM ('read', 'write', 'read_write')
    `);
    await queryRunner.query(`
      CREATE TYPE device_endpoint_data_type_enum AS ENUM ('number', 'boolean', 'enum')
    `);
    await queryRunner.query(`
      CREATE TYPE device_endpoint_transport_type_enum AS ENUM ('mqtt_telemetry', 'mqtt_attribute', 'rpc')
    `);
    await queryRunner.query(`
      CREATE TABLE device_types (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        code varchar(50) NOT NULL UNIQUE,
        name varchar(255) NOT NULL,
        is_active boolean NOT NULL DEFAULT true
      )
    `);
    await queryRunner.query(`
      INSERT INTO device_types (code, name)
      VALUES
        ('gateway', 'Gateway'),
        ('sensor', 'Sensor'),
        ('feeder', 'Feeder'),
        ('pump_controller', 'Pump Controller')
      ON CONFLICT (code) DO NOTHING
    `);
    await queryRunner.query(`
      CREATE TABLE device_profiles (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        code varchar(100) NOT NULL UNIQUE,
        name varchar(255) NOT NULL,
        device_type_id uuid NOT NULL REFERENCES device_types(id),
        manufacturer varchar(255),
        model varchar(255),
        firmware_family varchar(100),
        supported_functions jsonb NOT NULL DEFAULT '[]'::jsonb,
        is_active boolean NOT NULL DEFAULT true,
        created_at timestamptz NOT NULL DEFAULT now(),
        updated_at timestamptz NOT NULL DEFAULT now()
      )
    `);
    await queryRunner.query(`
      CREATE TABLE device_profile_endpoints (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        device_profile_id uuid NOT NULL REFERENCES device_profiles(id) ON DELETE CASCADE,
        key varchar(100) NOT NULL,
        direction device_endpoint_direction_enum NOT NULL,
        data_type device_endpoint_data_type_enum NOT NULL,
        function_key varchar(100) NOT NULL,
        default_label varchar(255) NOT NULL,
        unit varchar(50),
        transport_type device_endpoint_transport_type_enum NOT NULL,
        transport_binding jsonb NOT NULL,
        constraints jsonb,
        display_order integer NOT NULL DEFAULT 0,
        is_active boolean NOT NULL DEFAULT true,
        CONSTRAINT uq_profile_endpoint_key UNIQUE (device_profile_id, key)
      )
    `);
    await queryRunner.query(`
      ALTER TABLE devices
      ADD COLUMN device_profile_id uuid NULL REFERENCES device_profiles(id)
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`ALTER TABLE devices DROP COLUMN device_profile_id`);
    await queryRunner.query(`DROP TABLE device_profile_endpoints`);
    await queryRunner.query(`DROP TABLE device_profiles`);
    await queryRunner.query(`DROP TABLE device_types`);
    await queryRunner.query(`DROP TYPE device_endpoint_transport_type_enum`);
    await queryRunner.query(`DROP TYPE device_endpoint_data_type_enum`);
    await queryRunner.query(`DROP TYPE device_endpoint_direction_enum`);
  }
}

Modify backend/src/devices/entities/device.entity.ts:

import { DeviceInstanceEndpoint } from '@/devices/entities/device-instance-endpoint.entity';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';

@Column({ name: 'device_profile_id', type: 'uuid', nullable: true })
deviceProfileId: string | null;

@ManyToOne(() => DeviceProfile, { nullable: true })
@JoinColumn({ name: 'device_profile_id' })
deviceProfile: DeviceProfile | null;

@OneToMany(() => DeviceInstanceEndpoint, (endpoint) => endpoint.device)
instanceEndpoints: DeviceInstanceEndpoint[];
  • Step 4: Run the profile service test to verify it passes

Run: cd backend && bun run test -- src/devices/device-profile.service.spec.ts --runInBand

Expected: PASS with profile creation persisting endpoints after validation succeeds.

  • Step 5: Commit
git add backend/src/devices/entities/device-type.entity.ts backend/src/devices/entities/device-profile.entity.ts backend/src/devices/entities/device-profile-endpoint.entity.ts backend/src/devices/dto/create-device-profile.dto.ts backend/src/devices/dto/update-device-profile.dto.ts backend/src/devices/dto/create-device-profile-endpoint.dto.ts backend/src/devices/dto/update-device-profile-endpoint.dto.ts backend/src/devices/device-profile.service.ts backend/src/devices/device-profile.service.spec.ts backend/src/devices/controllers/device-profiles.controller.ts backend/src/devices/controllers/device-types.controller.ts backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts backend/src/app.module.ts backend/src/devices/devices.module.ts backend/src/devices/entities/device.entity.ts
git commit -m "feat: add device profile catalog backend"

Task 3: Materialize Device Instance Endpoints And Add System Config APIs

Files:

  • Create: backend/src/devices/entities/device-instance-endpoint.entity.ts

  • Create: backend/src/devices/dto/update-device-instance-endpoint.dto.ts

  • Create: backend/src/devices/dto/device-instance-endpoint-response.dto.ts

  • Create: backend/src/devices/device-endpoint-materializer.service.ts

  • Create: backend/src/devices/device-endpoint-materializer.service.spec.ts

  • Create: backend/src/devices/controllers/system-device-endpoints.controller.ts

  • Modify: backend/src/devices/dto/create-system-device.dto.ts

  • Modify: backend/src/devices/dto/update-system-device.dto.ts

  • Modify: backend/src/devices/dto/system-device-response.dto.ts

  • Modify: backend/src/devices/system-device-inventory.service.ts

  • Modify: backend/src/devices/system-device-inventory.service.spec.ts

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

  • Modify: backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts

  • Step 1: Write the failing materializer test

Create backend/src/devices/device-endpoint-materializer.service.spec.ts:

import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DeviceEndpointMaterializerService } from '@/devices/device-endpoint-materializer.service';
import { DeviceInstanceEndpoint } from '@/devices/entities/device-instance-endpoint.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';

describe('DeviceEndpointMaterializerService', () => {
  let service: DeviceEndpointMaterializerService;
  const instanceRepository = {
    find: jest.fn(),
    create: jest.fn((value) => value),
    save: jest.fn(),
  };

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        DeviceEndpointMaterializerService,
        { provide: getRepositoryToken(DeviceInstanceEndpoint), useValue: instanceRepository },
      ],
    }).compile();

    service = module.get(DeviceEndpointMaterializerService);
    jest.clearAllMocks();
  });

  it('creates missing instance endpoints from the selected profile', async () => {
    instanceRepository.find.mockResolvedValue([]);
    instanceRepository.save.mockResolvedValue([]);

    await service.materialize({
      deviceId: 'device-1',
      profileEndpoints: [
        {
          id: 'profile-endpoint-1',
          key: 'telemetry.do',
          direction: 'read',
          defaultLabel: 'DO',
          isActive: true,
        } as DeviceProfileEndpoint,
        {
          id: 'profile-endpoint-2',
          key: 'output.gpio1',
          direction: 'write',
          defaultLabel: 'GPIO 1',
          isActive: true,
        } as DeviceProfileEndpoint,
      ],
    });

    expect(instanceRepository.save).toHaveBeenCalledWith(
      expect.arrayContaining([
        expect.objectContaining({
          deviceId: 'device-1',
          profileEndpointId: 'profile-endpoint-1',
          technicalLabel: 'telemetry.do',
          isEnabled: true,
          isWritable: false,
        }),
        expect.objectContaining({
          deviceId: 'device-1',
          profileEndpointId: 'profile-endpoint-2',
          technicalLabel: 'output.gpio1',
          isEnabled: true,
          isWritable: true,
        }),
      ]),
    );
  });
});
  • Step 2: Run the materializer test to verify it fails

Run: cd backend && bun run test -- src/devices/device-endpoint-materializer.service.spec.ts --runInBand

Expected: FAIL because the instance-endpoint entity and materializer service do not exist.

  • Step 3: Implement instance endpoint persistence, materialization, and config APIs

Create backend/src/devices/entities/device-instance-endpoint.entity.ts:

import {
  Column,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { Device } from '@/devices/entities/device.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';

@Entity('device_instance_endpoints')
export class DeviceInstanceEndpoint {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'device_id', type: 'uuid' })
  deviceId: string;

  @Column({ name: 'profile_endpoint_id', type: 'uuid' })
  profileEndpointId: string;

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

  @Column({ name: 'technical_label', length: 255 })
  technicalLabel: string;

  @Column({ name: 'business_alias', length: 255, nullable: true })
  businessAlias: string | null;

  @Column({ name: 'farm_context_alias', length: 255, nullable: true })
  farmContextAlias: string | null;

  @Column({ name: 'is_writable', default: false })
  isWritable: boolean;

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

  @ManyToOne(() => Device, (device) => device.instanceEndpoints, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'device_id' })
  device: Device;

  @ManyToOne(() => DeviceProfileEndpoint, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'profile_endpoint_id' })
  profileEndpoint: DeviceProfileEndpoint;
}

Create backend/src/devices/device-endpoint-materializer.service.ts:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { DeviceEndpointDirection } from '@/devices/enums/device-endpoint-direction.enum';
import { DeviceInstanceEndpoint } from '@/devices/entities/device-instance-endpoint.entity';
import { DeviceProfileEndpoint } from '@/devices/entities/device-profile-endpoint.entity';

@Injectable()
export class DeviceEndpointMaterializerService {
  constructor(
    @InjectRepository(DeviceInstanceEndpoint)
    private instanceRepository: Repository<DeviceInstanceEndpoint>,
  ) {}

  async materialize(input: {
    deviceId: string;
    profileEndpoints: DeviceProfileEndpoint[];
  }): Promise<DeviceInstanceEndpoint[]> {
    const existing = await this.instanceRepository.find({
      where: { deviceId: input.deviceId },
    });

    const existingByProfileEndpointId = new Map(
      existing.map((endpoint) => [endpoint.profileEndpointId, endpoint]),
    );

    const nextRows = input.profileEndpoints.map((profileEndpoint) => {
      const current = existingByProfileEndpointId.get(profileEndpoint.id);
      const writable =
        profileEndpoint.direction === DeviceEndpointDirection.WRITE ||
        profileEndpoint.direction === DeviceEndpointDirection.READ_WRITE;

      return this.instanceRepository.create({
        id: current?.id,
        deviceId: input.deviceId,
        profileEndpointId: profileEndpoint.id,
        isEnabled: current?.isEnabled ?? profileEndpoint.isActive,
        technicalLabel: current?.technicalLabel ?? profileEndpoint.key,
        businessAlias: current?.businessAlias ?? profileEndpoint.defaultLabel,
        farmContextAlias: current?.farmContextAlias ?? null,
        isWritable: current?.isWritable ?? writable,
        metadata: current?.metadata ?? null,
      });
    });

    return this.instanceRepository.save(nextRows);
  }
}

Create backend/src/devices/controllers/system-device-endpoints.controller.ts:

import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { UpdateDeviceInstanceEndpointDto } from '@/devices/dto/update-device-instance-endpoint.dto';
import { SystemDeviceInventoryService } from '@/devices/system-device-inventory.service';

@ApiTags('System Device Endpoints')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('system/devices/:deviceId/endpoints')
export class SystemDeviceEndpointsController {
  constructor(private readonly service: SystemDeviceInventoryService) {}

  @Get()
  @RequirePermissions('device:read:system')
  findAll(@CurrentUser() currentUser: CurrentUserData, @Param('deviceId') deviceId: string) {
    return this.service.getDeviceEndpoints(deviceId, currentUser);
  }

  @Put(':endpointId')
  @RequirePermissions('device:update:system')
  update(
    @CurrentUser() currentUser: CurrentUserData,
    @Param('deviceId') deviceId: string,
    @Param('endpointId') endpointId: string,
    @Body() dto: UpdateDeviceInstanceEndpointDto,
  ) {
    return this.service.updateDeviceEndpoint(deviceId, endpointId, dto, currentUser);
  }
}

Modify backend/src/devices/dto/create-system-device.dto.ts:

@ApiProperty({ description: 'Selected hardware profile', example: 'profile-id-1' })
@IsUUID()
deviceProfileId: string;

Modify backend/src/devices/system-device-inventory.service.ts in create():

const profile = await this.deviceProfileRepository.findOne({
  where: { id: dto.deviceProfileId },
  relations: ['endpoints'],
});
if (!profile) {
  throw new NotFoundException('Device profile not found');
}

const device = manager.create(Device, {
  serialNumber: dto.serialNumber,
  name: dto.name,
  type: dto.type,
  model: dto.model ?? null,
  deviceProfileId: profile.id,
  capabilities: dto.capabilities ?? null,
  organizationId: null,
  inventoryStatus: DeviceInventoryStatus.IN_INVENTORY,
  status: 'offline',
  pondId: null,
  areaId: null,
  warrantyMonths,
  warrantyExpiryDate,
  ownershipType: dto.ownershipType ?? 'sold',
  accessToken: this.generateAccessToken(),
  secret: this.generateSecret(),
  mqttTopic: `hmpiot/devices/${dto.serialNumber}/telemetry`,
  isActive: true,
});

const saved = await manager.save(device);
await this.deviceEndpointMaterializerService.materialize({
  deviceId: saved.id,
  profileEndpoints: profile.endpoints,
});
return this.findOne(saved.id, currentUser);

Extend backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts:

await queryRunner.query(`
  CREATE TABLE device_instance_endpoints (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    device_id uuid NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    profile_endpoint_id uuid NOT NULL REFERENCES device_profile_endpoints(id) ON DELETE CASCADE,
    is_enabled boolean NOT NULL DEFAULT true,
    technical_label varchar(255) NOT NULL,
    business_alias varchar(255),
    farm_context_alias varchar(255),
    is_writable boolean NOT NULL DEFAULT false,
    metadata jsonb,
    CONSTRAINT uq_device_endpoint_profile UNIQUE (device_id, profile_endpoint_id),
    CONSTRAINT uq_device_business_alias UNIQUE NULLS NOT DISTINCT (device_id, business_alias)
  )
`);
  • Step 4: Run the materializer and updated inventory tests

Run: cd backend && bun run test -- src/devices/device-endpoint-materializer.service.spec.ts src/devices/system-device-inventory.service.spec.ts --runInBand

Expected: PASS with profile selection on system devices creating endpoint rows and config API behavior covered by updated inventory service tests.

  • Step 5: Commit
git add backend/src/devices/entities/device-instance-endpoint.entity.ts backend/src/devices/dto/update-device-instance-endpoint.dto.ts backend/src/devices/dto/device-instance-endpoint-response.dto.ts backend/src/devices/device-endpoint-materializer.service.ts backend/src/devices/device-endpoint-materializer.service.spec.ts backend/src/devices/controllers/system-device-endpoints.controller.ts backend/src/devices/dto/create-system-device.dto.ts backend/src/devices/dto/update-system-device.dto.ts backend/src/devices/dto/system-device-response.dto.ts backend/src/devices/system-device-inventory.service.ts backend/src/devices/system-device-inventory.service.spec.ts backend/src/devices/devices.module.ts backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.ts
git commit -m "feat: materialize configurable device endpoints"

Task 4: Add Automation Rule Persistence, Validation, And APIs

Files:

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

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

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

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

  • Create: backend/src/automation/enums/automation-trigger-mode.enum.ts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Create: backend/src/migrations/1777903600000-CreateAutomationRuleTables.ts

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

  • Modify: backend/src/rbac/resource-scope-map.ts

  • Step 1: Write the failing rule-validation test

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

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

describe('validateAutomationRuleShape', () => {
  it('rejects numeric operators on boolean source endpoints', () => {
    expect(() =>
      validateAutomationRuleShape({
        source: { dataType: 'boolean', direction: 'read', isEnabled: true, farmId: 'farm-1' },
        target: { dataType: 'boolean', direction: 'write', isEnabled: true, farmId: 'farm-1' },
        operator: '<',
        thresholdValue: true,
        targetValue: false,
      }),
    ).toThrow(new BadRequestException('Boolean endpoints only support = and != operators'));
  });

  it('rejects cross-farm source and target endpoints', () => {
    expect(() =>
      validateAutomationRuleShape({
        source: { dataType: 'number', direction: 'read', isEnabled: true, farmId: 'farm-1' },
        target: { dataType: 'boolean', direction: 'write', isEnabled: true, farmId: 'farm-2' },
        operator: '<',
        thresholdValue: 4.5,
        targetValue: true,
      }),
    ).toThrow(new BadRequestException('Source and target endpoints must belong to the same farm'));
  });

  it('accepts a valid number-to-boolean threshold rule', () => {
    expect(() =>
      validateAutomationRuleShape({
        source: { dataType: 'number', direction: 'read', isEnabled: true, farmId: 'farm-1' },
        target: { dataType: 'boolean', direction: 'write', isEnabled: true, farmId: 'farm-1' },
        operator: '<',
        thresholdValue: 4.5,
        targetValue: true,
      }),
    ).not.toThrow();
  });
});
  • Step 2: Run the rule-validation test to verify it fails

Run: cd backend && bun run test -- src/automation/automation-rule-validation.util.spec.ts --runInBand

Expected: FAIL because the automation module and validation utility do not exist.

  • Step 3: Implement automation entities, validation rules, APIs, and migration

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

import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { User } from '@/auth/entities/user.entity';
import { Area } from '@/farm/entities/area.entity';
import { AutomationRuleStatus } from '@/automation/enums/automation-rule-status.enum';
import { AutomationTriggerMode } from '@/automation/enums/automation-trigger-mode.enum';
import { AutomationRuleAction } from '@/automation/entities/automation-rule-action.entity';
import { AutomationRuleCondition } from '@/automation/entities/automation-rule-condition.entity';
import { AutomationRuleExecution } from '@/automation/entities/automation-rule-execution.entity';

@Entity('automation_rules')
export class AutomationRule {
  @PrimaryGeneratedColumn('uuid')
  id: string;

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

  @Column({ name: 'farm_id', type: 'uuid' })
  farmId: string;

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

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

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

  @Column({
    name: 'trigger_mode',
    type: 'enum',
    enum: AutomationTriggerMode,
    default: AutomationTriggerMode.EVENT_DRIVEN,
  })
  triggerMode: AutomationTriggerMode;

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

  @Column({ name: 'created_by', type: 'uuid' })
  createdBy: string;

  @Column({ name: 'updated_by', type: 'uuid' })
  updatedBy: string;

  @ManyToOne(() => Area)
  @JoinColumn({ name: 'farm_id' })
  farm: Area;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'created_by' })
  createdByUser: User;

  @OneToMany(() => AutomationRuleCondition, (condition) => condition.rule, { cascade: true })
  conditions: AutomationRuleCondition[];

  @OneToMany(() => AutomationRuleAction, (action) => action.rule, { cascade: true })
  actions: AutomationRuleAction[];

  @OneToMany(() => AutomationRuleExecution, (execution) => execution.rule)
  executions: AutomationRuleExecution[];

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}

Create backend/src/automation/automation-rule-validation.util.ts:

import { BadRequestException } from '@nestjs/common';

interface RuleEndpointContext {
  dataType: 'number' | 'boolean' | 'enum';
  direction: 'read' | 'write' | 'read_write';
  isEnabled: boolean;
  farmId: string;
}

interface ValidateAutomationRuleShapeInput {
  source: RuleEndpointContext;
  target: RuleEndpointContext;
  operator: '<' | '<=' | '>' | '>=' | '=' | '!=';
  thresholdValue: number | boolean | string;
  targetValue: number | boolean | string;
}

export function validateAutomationRuleShape(input: ValidateAutomationRuleShapeInput): void {
  if (input.source.farmId !== input.target.farmId) {
    throw new BadRequestException('Source and target endpoints must belong to the same farm');
  }

  if (!input.source.isEnabled || !input.target.isEnabled) {
    throw new BadRequestException('Source and target endpoints must both be enabled');
  }

  if (input.source.direction === 'write') {
    throw new BadRequestException('Source endpoint must be readable');
  }

  if (input.target.direction === 'read') {
    throw new BadRequestException('Target endpoint must be writable');
  }

  if (input.source.dataType === 'boolean' && !['=', '!='].includes(input.operator)) {
    throw new BadRequestException('Boolean endpoints only support = and != operators');
  }

  if (input.source.dataType === 'number' && typeof input.thresholdValue !== 'number') {
    throw new BadRequestException('Numeric source endpoints require numeric threshold values');
  }

  if (input.target.dataType === 'boolean' && typeof input.targetValue !== 'boolean') {
    throw new BadRequestException('Boolean target endpoints require boolean target values');
  }
}

Create backend/src/automation/controllers/automation-rules.controller.ts:

import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { AutomationRulesService } from '@/automation/automation-rules.service';
import { CreateAutomationRuleDto } from '@/automation/dto/create-automation-rule.dto';
import { QueryAutomationRulesDto } from '@/automation/dto/query-automation-rules.dto';
import { UpdateAutomationRuleDto } from '@/automation/dto/update-automation-rule.dto';

@ApiTags('Automation Rules')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('automation/rules')
export class AutomationRulesController {
  constructor(private readonly service: AutomationRulesService) {}

  @Get()
  @RequirePermissions('automation:read:all')
  findAll(@CurrentUser() currentUser: CurrentUserData, @Query() query: QueryAutomationRulesDto) {
    return this.service.findAll(currentUser, query);
  }

  @Post()
  @RequirePermissions('automation:create:all')
  create(@CurrentUser() currentUser: CurrentUserData, @Body() dto: CreateAutomationRuleDto) {
    return this.service.create(currentUser, dto);
  }

  @Put(':id')
  @RequirePermissions('automation:update:all')
  update(
    @CurrentUser() currentUser: CurrentUserData,
    @Param('id') id: string,
    @Body() dto: UpdateAutomationRuleDto,
  ) {
    return this.service.update(currentUser, id, dto);
  }

  @Post(':id/activate')
  @RequirePermissions('automation:update:all')
  activate(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
    return this.service.activate(currentUser, id);
  }

  @Post(':id/pause')
  @RequirePermissions('automation:update:all')
  pause(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
    return this.service.pause(currentUser, id);
  }

  @Get(':id/executions')
  @RequirePermissions('automation:read:all')
  getExecutions(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
    return this.service.getExecutions(currentUser, id);
  }
}

Create backend/src/migrations/1777903600000-CreateAutomationRuleTables.ts:

import type { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateAutomationRuleTables1777903600000 implements MigrationInterface {
  name = 'CreateAutomationRuleTables1777903600000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TYPE automation_rule_status_enum AS ENUM ('draft', 'active', 'paused', 'archived')
    `);
    await queryRunner.query(`
      CREATE TYPE automation_trigger_mode_enum AS ENUM ('event_driven')
    `);
    await queryRunner.query(`
      CREATE TYPE automation_rule_operator_enum AS ENUM ('<', '<=', '>', '>=', '=', '!=')
    `);
    await queryRunner.query(`
      CREATE TYPE automation_rule_action_type_enum AS ENUM ('set_value')
    `);
    await queryRunner.query(`
      CREATE TYPE automation_rule_execution_status_enum AS ENUM ('triggered', 'skipped', 'sent', 'succeeded', 'failed')
    `);
    await queryRunner.query(`
      CREATE TABLE automation_rules (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id uuid NOT NULL REFERENCES organizations(id),
        farm_id uuid NOT NULL REFERENCES areas(id),
        name varchar(255) NOT NULL,
        description text,
        status automation_rule_status_enum NOT NULL DEFAULT 'draft',
        trigger_mode automation_trigger_mode_enum NOT NULL DEFAULT 'event_driven',
        cooldown_seconds integer NOT NULL DEFAULT 0,
        created_by uuid NOT NULL REFERENCES users(id),
        updated_by uuid NOT NULL REFERENCES users(id),
        created_at timestamptz NOT NULL DEFAULT now(),
        updated_at timestamptz NOT NULL DEFAULT now()
      )
    `);
    await queryRunner.query(`
      CREATE TABLE automation_rule_conditions (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        rule_id uuid NOT NULL REFERENCES automation_rules(id) ON DELETE CASCADE,
        source_device_endpoint_id uuid NOT NULL REFERENCES device_instance_endpoints(id),
        operator automation_rule_operator_enum NOT NULL,
        threshold_value jsonb NOT NULL,
        sequence_order integer NOT NULL DEFAULT 1
      )
    `);
    await queryRunner.query(`
      CREATE TABLE automation_rule_actions (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        rule_id uuid NOT NULL REFERENCES automation_rules(id) ON DELETE CASCADE,
        target_device_endpoint_id uuid NOT NULL REFERENCES device_instance_endpoints(id),
        action_type automation_rule_action_type_enum NOT NULL DEFAULT 'set_value',
        target_value jsonb NOT NULL,
        sequence_order integer NOT NULL DEFAULT 1
      )
    `);
    await queryRunner.query(`
      CREATE TABLE automation_rule_executions (
        id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
        rule_id uuid NOT NULL REFERENCES automation_rules(id) ON DELETE CASCADE,
        triggered_at timestamptz NOT NULL DEFAULT now(),
        source_telemetry_payload jsonb NOT NULL,
        evaluated_condition_snapshot jsonb NOT NULL,
        action_request jsonb,
        action_result jsonb,
        status automation_rule_execution_status_enum NOT NULL,
        failure_reason text
      )
    `);
    await queryRunner.query(`
      UPDATE roles
      SET permissions = permissions || '[
        {"resource":"automation","action":"read","scope":"all"},
        {"resource":"automation","action":"create","scope":"all"},
        {"resource":"automation","action":"update","scope":"all"}
      ]'::jsonb
      WHERE id IN (
        '00000000-0000-0000-0000-000000000001',
        '00000000-0000-0000-0000-000000000002',
        '00000000-0000-0000-0000-000000000003'
      )
    `);
  }
}

Modify backend/src/rbac/resource-scope-map.ts:

automation: {
  tenantResource: true,
  assignmentMode: 'none',
},
  • Step 4: Run the automation validation and service tests

Run: cd backend && bun run test -- src/automation/automation-rule-validation.util.spec.ts src/automation/automation-rules.service.spec.ts --runInBand

Expected: PASS with rule-authoring validation rejecting cross-farm, wrong-direction, and wrong-data-type combinations before persistence.

  • Step 5: Commit
git add backend/src/automation backend/src/migrations/1777903600000-CreateAutomationRuleTables.ts backend/src/app.module.ts backend/src/rbac/resource-scope-map.ts
git commit -m "feat: add farm automation rule authoring backend"

Task 5: Wire Telemetry Normalization, Safety Gates, And Command Dispatch Runtime

Files:

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

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

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

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

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

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

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

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

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

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

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

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

  • Step 1: Write the failing automation-engine test

Create backend/src/automation/automation-engine.service.spec.ts:

import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AutomationEngineService } from '@/automation/automation-engine.service';
import { AutomationRule } from '@/automation/entities/automation-rule.entity';
import { AutomationRuleExecution } from '@/automation/entities/automation-rule-execution.entity';

describe('AutomationEngineService', () => {
  let service: AutomationEngineService;
  const ruleRepository = {
    find: jest.fn(),
  };
  const executionRepository = {
    findOne: jest.fn(),
    save: jest.fn(),
  };
  const dispatchService = {
    dispatchEndpointValue: jest.fn(),
  };

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AutomationEngineService,
        { provide: getRepositoryToken(AutomationRule), useValue: ruleRepository },
        { provide: getRepositoryToken(AutomationRuleExecution), useValue: executionRepository },
        { provide: 'DeviceCommandDispatchService', useValue: dispatchService },
      ],
    }).compile();

    service = module.get(AutomationEngineService);
    jest.clearAllMocks();
  });

  it('dispatches a command and writes an execution audit when a rule matches', async () => {
    ruleRepository.find.mockResolvedValue([
      {
        id: 'rule-1',
        status: 'active',
        cooldownSeconds: 0,
        conditions: [
          {
            sourceDeviceEndpointId: 'source-endpoint-1',
            operator: '<',
            thresholdValue: 4.5,
          },
        ],
        actions: [
          {
            targetDeviceEndpointId: 'target-endpoint-1',
            targetValue: true,
            targetEndpoint: {
              id: 'target-endpoint-1',
              isEnabled: true,
              isWritable: true,
              device: { areaId: 'farm-1', inventoryStatus: 'assigned' },
            },
          },
        ],
      },
    ]);
    executionRepository.findOne.mockResolvedValue(null);
    dispatchService.dispatchEndpointValue.mockResolvedValue({ ok: true });

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

    expect(dispatchService.dispatchEndpointValue).toHaveBeenCalledWith({
      targetDeviceEndpointId: 'target-endpoint-1',
      targetValue: true,
    });
    expect(executionRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        ruleId: 'rule-1',
        status: 'succeeded',
      }),
    );
  });

  it('writes a skipped execution instead of dispatching when the target is in cooldown', async () => {
    ruleRepository.find.mockResolvedValue([
      {
        id: 'rule-2',
        status: 'active',
        cooldownSeconds: 300,
        conditions: [
          {
            sourceDeviceEndpointId: 'source-endpoint-1',
            operator: '<',
            thresholdValue: 4.5,
          },
        ],
        actions: [
          {
            targetDeviceEndpointId: 'target-endpoint-1',
            targetValue: true,
            targetEndpoint: {
              id: 'target-endpoint-1',
              isEnabled: true,
              isWritable: true,
              device: { areaId: 'farm-1', inventoryStatus: 'assigned' },
            },
          },
        ],
      },
    ]);
    executionRepository.findOne.mockResolvedValue({
      triggeredAt: new Date('2026-05-03T09:59:00.000Z'),
      actionRequest: { targetValue: true },
    });

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

    expect(dispatchService.dispatchEndpointValue).not.toHaveBeenCalled();
    expect(executionRepository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        ruleId: 'rule-2',
        status: 'skipped',
        failureReason: 'Rule cooldown is active',
      }),
    );
  });
});
  • Step 2: Run the engine test to verify it fails

Run: cd backend && bun run test -- src/automation/automation-engine.service.spec.ts --runInBand

Expected: FAIL because the runtime engine, normalizer, and dispatch adapter do not exist.

  • Step 3: Implement normalization, engine, command dispatch, and telemetry hook

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

import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { DeviceInstanceEndpoint } from '@/devices/entities/device-instance-endpoint.entity';
import { MqttService } from '@/mqtt/mqtt.service';

@Injectable()
export class DeviceCommandDispatchService {
  constructor(
    @InjectRepository(DeviceInstanceEndpoint)
    private endpointRepository: Repository<DeviceInstanceEndpoint>,
    @Inject(MqttService)
    private mqttService: MqttService,
  ) {}

  async dispatchEndpointValue(input: {
    targetDeviceEndpointId: string;
    targetValue: boolean | number | string;
  }): Promise<unknown> {
    const endpoint = await this.endpointRepository.findOne({
      where: { id: input.targetDeviceEndpointId },
      relations: ['device', 'profileEndpoint'],
    });

    if (!endpoint || !endpoint.device || !endpoint.profileEndpoint) {
      throw new NotFoundException('Target endpoint not found');
    }

    const binding = endpoint.profileEndpoint.transportBinding as {
      method: string;
      paramKey: string;
    };

    return this.mqttService.sendCommand(endpoint.device.serialNumber, {
      method: binding.method,
      params: { [binding.paramKey]: input.targetValue },
    });
  }
}

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

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type { EndpointEvent } from '@/automation/endpoint-event.types';
import { Device } from '@/devices/entities/device.entity';
import { DeviceInstanceEndpoint } from '@/devices/entities/device-instance-endpoint.entity';

@Injectable()
export class EndpointEventNormalizerService {
  constructor(
    @InjectRepository(Device)
    private deviceRepository: Repository<Device>,
    @InjectRepository(DeviceInstanceEndpoint)
    private endpointRepository: Repository<DeviceInstanceEndpoint>,
  ) {}

  async normalizeTelemetryEvent(input: {
    deviceId: string;
    timestamp: Date;
    rawPayload: Record<string, number | string | boolean>;
  }): Promise<EndpointEvent[]> {
    const device = await this.deviceRepository.findOne({ where: { id: input.deviceId } });
    if (!device?.areaId) {
      return [];
    }

    const endpoints = await this.endpointRepository.find({
      where: { deviceId: input.deviceId, isEnabled: true },
      relations: ['profileEndpoint'],
    });

    return endpoints
      .map((endpoint) => {
        const binding = endpoint.profileEndpoint.transportBinding as { field?: string };
        if (!binding.field) {
          return null;
        }

        const value = input.rawPayload[binding.field];
        if (value === undefined) {
          return null;
        }

        return {
          deviceId: input.deviceId,
          deviceInstanceEndpointId: endpoint.id,
          farmId: device.areaId,
          timestamp: input.timestamp,
          value,
          rawPayload: input.rawPayload,
        } satisfies EndpointEvent;
      })
      .filter((event): event is EndpointEvent => event !== null);
  }
}

Create backend/src/automation/automation-engine.service.ts:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type { EndpointEvent } from '@/automation/endpoint-event.types';
import { AutomationRuleExecutionStatus } from '@/automation/enums/automation-rule-execution-status.enum';
import { AutomationRuleStatus } from '@/automation/enums/automation-rule-status.enum';
import { AutomationRule } from '@/automation/entities/automation-rule.entity';
import { AutomationRuleExecution } from '@/automation/entities/automation-rule-execution.entity';
import { DeviceCommandDispatchService } from '@/devices/device-command-dispatch.service';

@Injectable()
export class AutomationEngineService {
  constructor(
    @InjectRepository(AutomationRule)
    private ruleRepository: Repository<AutomationRule>,
    @InjectRepository(AutomationRuleExecution)
    private executionRepository: Repository<AutomationRuleExecution>,
    private deviceCommandDispatchService: DeviceCommandDispatchService,
  ) {}

  async handleEndpointEvent(event: EndpointEvent): Promise<void> {
    const rules = await this.ruleRepository.find({
      where: { farmId: event.farmId, status: AutomationRuleStatus.ACTIVE },
      relations: ['conditions', 'actions', 'actions.targetEndpoint', 'actions.targetEndpoint.device'],
    });

    for (const rule of rules) {
      const condition = rule.conditions[0];
      const action = rule.actions[0];
      const targetEndpoint = action?.targetEndpoint;
      const targetDevice = targetEndpoint?.device;

      if (!condition || !action) {
        continue;
      }
      if (condition.sourceDeviceEndpointId !== event.deviceInstanceEndpointId) {
        continue;
      }
      if (!(typeof event.value === 'number' && event.value < Number(condition.thresholdValue))) {
        continue;
      }

      const lastExecution = await this.executionRepository.findOne({
        where: { ruleId: rule.id },
        order: { triggeredAt: 'DESC' },
      });

      const withinCooldown =
        !!lastExecution &&
        rule.cooldownSeconds > 0 &&
        event.timestamp.getTime() - new Date(lastExecution.triggeredAt).getTime() <
          rule.cooldownSeconds * 1000;
      const sameCommandAsCooldown =
        withinCooldown && lastExecution.actionRequest?.targetValue === action.targetValue;
      const targetUnavailable =
        !targetEndpoint?.isEnabled ||
        !targetEndpoint?.isWritable ||
        targetDevice?.areaId !== event.farmId ||
        ['maintenance', 'retired'].includes(targetDevice?.inventoryStatus ?? '');

      if (targetUnavailable || sameCommandAsCooldown) {
        await this.executionRepository.save({
          ruleId: rule.id,
          triggeredAt: event.timestamp,
          sourceTelemetryPayload: event.rawPayload,
          evaluatedConditionSnapshot: {
            operator: condition.operator,
            thresholdValue: condition.thresholdValue,
            actualValue: event.value,
          },
          actionRequest: {
            targetDeviceEndpointId: action.targetDeviceEndpointId,
            targetValue: action.targetValue,
          },
          actionResult: null,
          status: AutomationRuleExecutionStatus.SKIPPED,
          failureReason: sameCommandAsCooldown
            ? 'Rule cooldown is active'
            : 'Target endpoint is unavailable for dispatch',
        });
        continue;
      }

      try {
        const actionResult = await this.deviceCommandDispatchService.dispatchEndpointValue({
          targetDeviceEndpointId: action.targetDeviceEndpointId,
          targetValue: action.targetValue as boolean,
        });

        await this.executionRepository.save({
          ruleId: rule.id,
          triggeredAt: event.timestamp,
          sourceTelemetryPayload: event.rawPayload,
          evaluatedConditionSnapshot: {
            operator: condition.operator,
            thresholdValue: condition.thresholdValue,
            actualValue: event.value,
          },
          actionRequest: {
            targetDeviceEndpointId: action.targetDeviceEndpointId,
            targetValue: action.targetValue,
          },
          actionResult,
          status: AutomationRuleExecutionStatus.SUCCEEDED,
          failureReason: null,
        });
      } catch (error) {
        const message = error instanceof Error ? error.message : 'Unknown automation dispatch error';
        await this.executionRepository.save({
          ruleId: rule.id,
          triggeredAt: event.timestamp,
          sourceTelemetryPayload: event.rawPayload,
          evaluatedConditionSnapshot: {
            operator: condition.operator,
            thresholdValue: condition.thresholdValue,
            actualValue: event.value,
          },
          actionRequest: {
            targetDeviceEndpointId: action.targetDeviceEndpointId,
            targetValue: action.targetValue,
          },
          actionResult: null,
          status: AutomationRuleExecutionStatus.FAILED,
          failureReason: message,
        });
      }
    }
  }
}

Modify backend/src/telemetry/telemetry.service.ts inside save() after cacheLatestValues():

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

for (const endpointEvent of endpointEvents) {
  await this.automationEngineService.handleEndpointEvent(endpointEvent);
}

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

const mockEndpointEventNormalizerService = {
  normalizeTelemetryEvent: jest.fn().mockResolvedValue([]),
};

const mockAutomationEngineService = {
  handleEndpointEvent: jest.fn(),
};

expect(mockEndpointEventNormalizerService.normalizeTelemetryEvent).toHaveBeenCalledWith({
  deviceId: dto.deviceId,
  timestamp: expect.any(Date),
  rawPayload: dto.data,
});
  • Step 4: Run runtime and telemetry tests

Run: cd backend && bun run test -- src/devices/device-command-dispatch.service.spec.ts src/automation/endpoint-event-normalizer.service.spec.ts src/automation/automation-engine.service.spec.ts src/telemetry/telemetry.service.spec.ts --runInBand

Expected: PASS with telemetry saves producing normalized endpoint events, matching active rules, dispatching endpoint-based commands, and writing audits.

  • Step 5: Commit
git add backend/src/devices/device-command-dispatch.service.ts backend/src/devices/device-command-dispatch.service.spec.ts backend/src/automation/endpoint-event.types.ts backend/src/automation/endpoint-event-normalizer.service.ts backend/src/automation/endpoint-event-normalizer.service.spec.ts backend/src/automation/automation-engine.service.ts backend/src/automation/automation-engine.service.spec.ts backend/src/mqtt/mqtt.module.ts backend/src/devices/devices.module.ts backend/src/telemetry/telemetry.module.ts backend/src/telemetry/telemetry.service.ts backend/src/telemetry/telemetry.service.spec.ts
git commit -m "feat: execute automation rules from normalized telemetry events"

Task 6: Build Super Admin Profile And Endpoint Configuration UI

Files:

  • Create: frontend/src/types/device-profile.types.ts

  • Create: frontend/src/services/device-profile.service.ts

  • Create: frontend/src/hooks/useDeviceProfiles.ts

  • Create: frontend/src/pages/system-devices/DeviceProfilesPage.tsx

  • Create: frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx

  • Create: frontend/src/pages/system-devices/SystemDeviceEndpointsPage.tsx

  • Create: frontend/src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsx

  • Modify: frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx

  • Modify: frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/navigation/menus/super-admin.menu.tsx

  • Step 1: Write the failing profile page test

Create frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi } from 'vitest';
import { DeviceProfilesPage } from '@/pages/system-devices/DeviceProfilesPage';

vi.mock('@/hooks/useDeviceProfiles', () => ({
  useDeviceProfiles: () => ({
    data: [
      {
        id: 'profile-1',
        code: 'gateway-4do-2di-v1',
        name: 'Gateway 4DO 2DI',
        supportedFunctions: ['monitoring', 'control'],
        endpoints: [
          { id: 'endpoint-1', key: 'telemetry.do', defaultLabel: 'DO' },
          { id: 'endpoint-2', key: 'output.gpio1', defaultLabel: 'GPIO 1' },
        ],
      },
    ],
    isLoading: false,
  }),
  useCreateDeviceProfile: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));

describe('DeviceProfilesPage', () => {
  it('renders profile rows and endpoint counts', () => {
    render(
      <QueryClientProvider client={new QueryClient()}>
        <MemoryRouter>
          <DeviceProfilesPage />
        </MemoryRouter>
      </QueryClientProvider>,
    );

    expect(screen.getByText('Gateway 4DO 2DI')).toBeInTheDocument();
    expect(screen.getByText('gateway-4do-2di-v1')).toBeInTheDocument();
    expect(screen.getByText('2 endpoints')).toBeInTheDocument();
  });
});
  • Step 2: Run the profile UI test to verify it fails

Run: cd frontend && bun run test -- src/pages/system-devices/DeviceProfilesPage.spec.tsx

Expected: FAIL because the page, hook, and types do not exist.

  • Step 3: Implement frontend catalog and endpoint-config pages

Create frontend/src/types/device-profile.types.ts:

export interface DeviceProfileEndpoint {
  id: string;
  key: string;
  direction: 'read' | 'write' | 'read_write';
  dataType: 'number' | 'boolean' | 'enum';
  functionKey: string;
  defaultLabel: string;
  unit?: string | null;
  transportType: 'mqtt_telemetry' | 'mqtt_attribute' | 'rpc';
  transportBinding: Record<string, unknown>;
  displayOrder: number;
  isActive: boolean;
}

export interface DeviceProfile {
  id: string;
  code: string;
  name: string;
  deviceTypeId: string;
  supportedFunctions: string[];
  endpoints: DeviceProfileEndpoint[];
}

export interface DeviceTypeOption {
  id: string;
  code: string;
  name: string;
}

export interface DeviceInstanceEndpointRow {
  id: string;
  profileEndpointId: string;
  technicalLabel: string;
  businessAlias: string | null;
  farmContextAlias: string | null;
  isEnabled: boolean;
  isWritable: boolean;
  profileEndpoint: DeviceProfileEndpoint;
}

Create frontend/src/services/device-profile.service.ts:

import { api } from '@/lib/api';
import type {
  DeviceInstanceEndpointRow,
  DeviceProfile,
  DeviceTypeOption,
} from '@/types/device-profile.types';

export const deviceProfileService = {
  getDeviceTypes(): Promise<DeviceTypeOption[]> {
    return api.get('/device-types').then((response) => response.data);
  },
  getProfiles(): Promise<DeviceProfile[]> {
    return api.get('/device-profiles').then((response) => response.data);
  },
  createProfile(payload: Record<string, unknown>): Promise<DeviceProfile> {
    return api.post('/device-profiles', payload).then((response) => response.data);
  },
  getDeviceEndpoints(deviceId: string): Promise<DeviceInstanceEndpointRow[]> {
    return api.get(`/system/devices/${deviceId}/endpoints`).then((response) => response.data);
  },
  updateDeviceEndpoint(
    deviceId: string,
    endpointId: string,
    payload: Record<string, unknown>,
  ): Promise<DeviceInstanceEndpointRow> {
    return api
      .put(`/system/devices/${deviceId}/endpoints/${endpointId}`, payload)
      .then((response) => response.data);
  },
};

Create frontend/src/pages/system-devices/DeviceProfilesPage.tsx:

import { Button, Card, Space, Table, Tag, Typography } from 'antd';
import type React from 'react';
import { PageHeader } from '@/components/common/PageHeader';
import { useDeviceProfiles } from '@/hooks/useDeviceProfiles';

export const DeviceProfilesPage: React.FC = () => {
  const { data = [], isLoading } = useDeviceProfiles();

  return (
    <Space direction="vertical" size={24} style={{ width: '100%' }}>
      <PageHeader
        title="Catalog hồ sơ thiết bị"
        subtitle="Định nghĩa trần phần cứng và endpoint chuẩn hóa cho automation."
        extra={<Button type="primary">Tạo profile</Button>}
      />
      <Card>
        <Table
          loading={isLoading}
          rowKey="id"
          dataSource={data}
          columns={[
            {
              title: 'Profile',
              render: (_, record) => (
                <Space direction="vertical" size={0}>
                  <Typography.Text strong>{record.name}</Typography.Text>
                  <Typography.Text type="secondary">{record.code}</Typography.Text>
                </Space>
              ),
            },
            {
              title: 'Functions',
              render: (_, record) => (
                <Space wrap>
                  {record.supportedFunctions.map((value) => (
                    <Tag key={value}>{value}</Tag>
                  ))}
                </Space>
              ),
            },
            {
              title: 'Endpoints',
              render: (_, record) => `${record.endpoints.length} endpoints`,
            },
          ]}
        />
      </Card>
    </Space>
  );
};

Modify frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx in the form and row actions:

import { LinkOutlined } from '@ant-design/icons';
import { useDeviceProfiles } from '@/hooks/useDeviceProfiles';

const { data: profiles = [] } = useDeviceProfiles();

<Form.Item
  label="Device profile"
  name="deviceProfileId"
  rules={[{ required: true, message: 'Chọn profile phần cứng' }]}
>
  <Select
    options={profiles.map((profile) => ({
      label: `${profile.name} (${profile.code})`,
      value: profile.id,
    }))}
  />
</Form.Item>

{
  key: 'configure-endpoints',
  icon: <LinkOutlined />,
  label: 'Cấu hình endpoint',
  onClick: () => navigate(`/system/devices/${record.id}/endpoints`),
}

Modify frontend/src/App.tsx:

import { DeviceProfilesPage } from '@/pages/system-devices/DeviceProfilesPage';
import { SystemDeviceEndpointsPage } from '@/pages/system-devices/SystemDeviceEndpointsPage';

<Route path="/system/device-profiles" element={<DeviceProfilesPage />} />
<Route path="/system/devices/:id/endpoints" element={<SystemDeviceEndpointsPage />} />

Modify frontend/src/navigation/menus/super-admin.menu.tsx:

{
  key: 'super-admin-device-profiles',
  icon: <ToolOutlined />,
  label: 'Hồ sơ thiết bị',
  path: '/system/device-profiles',
  requiredPermission: { resource: 'device', action: 'read', scope: 'system' },
},
  • Step 4: Run the Super Admin UI tests

Run: cd frontend && bun run test -- src/pages/system-devices/DeviceProfilesPage.spec.tsx src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsx

Expected: PASS with the system inventory page showing profile selection and a dedicated endpoint-config route, and the profile catalog page rendering endpoint counts from API data.

  • Step 5: Commit
git add frontend/src/types/device-profile.types.ts frontend/src/services/device-profile.service.ts frontend/src/hooks/useDeviceProfiles.ts frontend/src/pages/system-devices/DeviceProfilesPage.tsx frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx frontend/src/pages/system-devices/SystemDeviceEndpointsPage.tsx frontend/src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsx frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx frontend/src/App.tsx frontend/src/navigation/menus/super-admin.menu.tsx
git commit -m "feat: add super admin device profile and endpoint config ui"

Task 7: Build Farm Automation Rules UI For Admin And Owner

Files:

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

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

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

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

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

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

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/navigation/menus/admin.menu.tsx

  • Modify: frontend/src/navigation/menus/owner.menu.tsx

  • Step 1: Write the failing automation page test

Create frontend/src/pages/automation/AutomationRulesPage.spec.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi } from 'vitest';
import { AutomationRulesPage } from '@/pages/automation/AutomationRulesPage';

vi.mock('@/hooks/useAutomationRules', () => ({
  useAutomationRules: () => ({
    data: {
      data: [
        {
          id: 'rule-1',
          name: 'DO thấp thì bật bơm',
          farmId: 'farm-1',
          status: 'active',
          cooldownSeconds: 300,
          sourceEndpointLabel: 'DO Sensor 1 / telemetry.do',
          targetEndpointLabel: 'Gateway 7 / output.gpio1',
        },
      ],
    },
    isLoading: false,
  }),
  useCreateAutomationRule: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));

describe('AutomationRulesPage', () => {
  it('renders rule rows and create action', () => {
    render(
      <QueryClientProvider client={new QueryClient()}>
        <MemoryRouter>
          <AutomationRulesPage />
        </MemoryRouter>
      </QueryClientProvider>,
    );

    expect(screen.getByText('DO thấp thì bật bơm')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Tạo rule' })).toBeInTheDocument();
    expect(screen.getByText('active')).toBeInTheDocument();
  });
});
  • Step 2: Run the automation page test to verify it fails

Run: cd frontend && bun run test -- src/pages/automation/AutomationRulesPage.spec.tsx

Expected: FAIL because the automation types, hooks, and page do not exist yet.

  • Step 3: Implement automation types, hooks, services, schema, page, and routes

Create frontend/src/types/automation.types.ts:

export interface AutomationRuleRow {
  id: string;
  farmId: string;
  name: string;
  description?: string | null;
  status: 'draft' | 'active' | 'paused' | 'archived';
  cooldownSeconds: number;
  sourceDeviceEndpointId: string;
  sourceEndpointLabel: string;
  operator: '<' | '<=' | '>' | '>=' | '=' | '!=';
  thresholdValue: number | boolean | string;
  targetDeviceEndpointId: string;
  targetEndpointLabel: string;
  targetValue: number | boolean | string;
}

export interface AutomationExecutionRow {
  id: string;
  status: 'triggered' | 'skipped' | 'sent' | 'succeeded' | 'failed';
  triggeredAt: string;
  failureReason?: string | null;
}

export interface AutomationRuleListResponse {
  data: AutomationRuleRow[];
  meta: { total: number; page: number; limit: number; totalPages: number };
}

Create frontend/src/services/automation-rules.service.ts:

import { api } from '@/lib/api';
import type { AutomationExecutionRow, AutomationRuleListResponse, AutomationRuleRow } from '@/types/automation.types';

export const automationRulesService = {
  getAll(params: Record<string, unknown>): Promise<AutomationRuleListResponse> {
    return api.get('/automation/rules', { params }).then((response) => response.data);
  },
  create(payload: Record<string, unknown>): Promise<AutomationRuleRow> {
    return api.post('/automation/rules', payload).then((response) => response.data);
  },
  activate(id: string): Promise<AutomationRuleRow> {
    return api.post(`/automation/rules/${id}/activate`).then((response) => response.data);
  },
  pause(id: string): Promise<AutomationRuleRow> {
    return api.post(`/automation/rules/${id}/pause`).then((response) => response.data);
  },
  getExecutions(id: string): Promise<AutomationExecutionRow[]> {
    return api.get(`/automation/rules/${id}/executions`).then((response) => response.data);
  },
};

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

import { z } from 'zod/v4';
import { uuidSchema } from '@/schemas/common.schema';

export const automationRuleFormSchema = z.object({
  farmId: uuidSchema,
  name: z.string().min(1, 'Nhập tên rule'),
  sourceDeviceEndpointId: uuidSchema,
  operator: z.enum(['<', '<=', '>', '>=', '=', '!=']),
  thresholdValue: z.union([z.number(), z.boolean(), z.string()]),
  targetDeviceEndpointId: uuidSchema,
  targetValue: z.union([z.number(), z.boolean(), z.string()]),
  cooldownSeconds: z.number().int().min(0),
});

Create frontend/src/pages/automation/AutomationRulesPage.tsx:

import { Button, Card, Space, Table, Tag } from 'antd';
import type React from 'react';
import { PageHeader } from '@/components/common/PageHeader';
import { useAutomationRules } from '@/hooks/useAutomationRules';

export const AutomationRulesPage: React.FC = () => {
  const { data, isLoading } = useAutomationRules({ page: 1, limit: 20 });

  return (
    <Space direction="vertical" size={24} style={{ width: '100%' }}>
      <PageHeader
        title="Automation Rules"
        subtitle="Tạo rule điều khiển giữa sensor, gateway và actuator trong cùng farm."
        extra={<Button type="primary">Tạo rule</Button>}
      />
      <Card>
        <Table
          loading={isLoading}
          rowKey="id"
          dataSource={data?.data ?? []}
          columns={[
            { title: 'Rule', dataIndex: 'name', key: 'name' },
            { title: 'Source', dataIndex: 'sourceEndpointLabel', key: 'sourceEndpointLabel' },
            { title: 'Target', dataIndex: 'targetEndpointLabel', key: 'targetEndpointLabel' },
            {
              title: 'Status',
              key: 'status',
              render: (_, record) => <Tag>{record.status}</Tag>,
            },
          ]}
        />
      </Card>
    </Space>
  );
};

Modify frontend/src/App.tsx:

import { AutomationRulesPage } from '@/pages/automation/AutomationRulesPage';

<Route element={<PermissionRoute resource="automation" />}>
  <Route path="/automation/rules" element={<AutomationRulesPage />} />
</Route>

Modify frontend/src/navigation/menus/admin.menu.tsx:

{
  key: 'admin-automation-rules',
  icon: <ToolOutlined />,
  label: 'Automation',
  path: '/automation/rules',
  requiredPermission: { resource: 'automation' },
},

Modify frontend/src/navigation/menus/owner.menu.tsx:

{
  key: 'owner-automation-rules',
  icon: <ExperimentOutlined />,
  label: 'Automation',
  path: '/automation/rules',
  requiredPermission: { resource: 'automation' },
},
  • Step 4: Run the automation frontend tests

Run: cd frontend && bun run test -- src/pages/automation/AutomationRulesPage.spec.tsx

Expected: PASS with the route rendering current rules, status tags, and create action scaffolding for the guided authoring flow.

  • 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/pages/automation/AutomationRulesPage.tsx frontend/src/pages/automation/AutomationRulesPage.spec.tsx frontend/src/App.tsx frontend/src/navigation/menus/admin.menu.tsx frontend/src/navigation/menus/owner.menu.tsx
git commit -m "feat: add farm automation rules ui"

Task 8: Prove The End-To-End Flow And Update Shared RBAC Docs

Files:

  • Create: backend/test/automation/automation-flow.e2e-spec.ts

  • Modify: docs/context/rbac-matrix.md

  • Modify: frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

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

  • Step 1: Write the failing automation e2e test

Create backend/test/automation/automation-flow.e2e-spec.ts:

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';

describe('Automation flow (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        transform: true,
        forbidNonWhitelisted: true,
      }),
    );
    await app.init();
  });

  it('creates an execution audit when telemetry crosses the rule threshold', async () => {
    const telemetryResponse = await request(app.getHttpServer())
      .post('/api/v1/telemetry')
      .set('Authorization', 'Bearer TEST_TOKEN')
      .send({
        deviceId: 'sensor-device-id',
        data: { do: 3.8 },
      });

    expect(telemetryResponse.status).toBe(201);

    const executionResponse = await request(app.getHttpServer())
      .get('/api/v1/automation/rules/rule-id/executions')
      .set('Authorization', 'Bearer TEST_TOKEN');

    expect(executionResponse.status).toBe(200);
    expect(executionResponse.body[0]).toEqual(
      expect.objectContaining({
        status: expect.stringMatching(/succeeded|failed|skipped/),
      }),
    );
  });
});
  • Step 2: Run the automation e2e test to verify it fails

Run: cd backend && bun run test:e2e -- automation/automation-flow.e2e-spec.ts

Expected: FAIL because the database fixtures, rule APIs, and telemetry-to-automation runtime path are not fully wired until the previous tasks are complete.

  • Step 3: Fill in the e2e fixtures and update shared docs

Update backend/test/automation/automation-flow.e2e-spec.ts with real seeded setup:

const registerResponse = await request(app.getHttpServer()).post('/api/v1/auth/register').send({
  email: 'automation-admin@example.com',
  password: 'Demo@123456',
  fullName: 'Automation Admin',
  organizationName: 'Automation Farm Co',
});

const loginResponse = await request(app.getHttpServer()).post('/api/v1/auth/login').send({
  email: 'automation-admin@example.com',
  password: 'Demo@123456',
});

const accessToken = loginResponse.body.accessToken;
const organizationId = loginResponse.body.user.organizationId;

const areaResponse = await request(app.getHttpServer())
  .post('/api/v1/farm/areas')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({ name: 'Farm A', code: 'FARM-A' });

const deviceTypes = await app.get(DataSource).query(
  `SELECT id, code FROM device_types WHERE code IN ('gateway', 'sensor') ORDER BY code`,
);
const gatewayTypeId = deviceTypes.find((row: { code: string }) => row.code === 'gateway').id;

const pondResponse = await request(app.getHttpServer())
  .post('/api/v1/farm/ponds')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    areaId: areaResponse.body.id,
    name: 'Pond A1',
    code: 'POND-A1',
    surfaceArea: 1200,
    depth: 2,
    type: 'earthen',
  });

const profileResponse = await request(app.getHttpServer())
  .post('/api/v1/device-profiles')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    code: 'gateway-4do-2di-v1',
    name: 'Gateway 4DO 2DI',
    deviceTypeId: gatewayTypeId,
    supportedFunctions: ['monitoring', 'control'],
    endpoints: [
      {
        key: 'telemetry.do',
        direction: 'read',
        dataType: 'number',
        functionKey: 'monitoring',
        defaultLabel: 'DO',
        unit: 'mg/L',
        transportType: 'mqtt_telemetry',
        transportBinding: { field: 'do' },
        displayOrder: 1,
      },
      {
        key: 'output.gpio1',
        direction: 'write',
        dataType: 'boolean',
        functionKey: 'control',
        defaultLabel: 'GPIO 1',
        transportType: 'rpc',
        transportBinding: { method: 'setIO', paramKey: 'io1' },
        displayOrder: 2,
      },
    ],
  });

const sensorResponse = await request(app.getHttpServer())
  .post('/api/v1/system/devices')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    type: 'sensor',
    name: 'DO Sensor 1',
    serialNumber: 'AUTO-SENSOR-001',
    model: 'DO-RS485',
    deviceProfileId: profileResponse.body.id,
  });

await request(app.getHttpServer())
  .post(`/api/v1/system/devices/${sensorResponse.body.id}/assign-organization`)
  .set('Authorization', `Bearer ${accessToken}`)
  .send({ organizationId });

await request(app.getHttpServer())
  .post(`/api/v1/devices/${sensorResponse.body.id}/assign`)
  .set('Authorization', `Bearer ${accessToken}`)
  .send({ pondId: pondResponse.body.id });

const gatewayResponse = await request(app.getHttpServer())
  .post('/api/v1/system/devices')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    type: 'gateway',
    name: 'Gateway 7',
    serialNumber: 'AUTO-GATEWAY-007',
    model: 'GW-4DO',
    deviceProfileId: profileResponse.body.id,
  });

await request(app.getHttpServer())
  .post(`/api/v1/system/devices/${gatewayResponse.body.id}/assign-organization`)
  .set('Authorization', `Bearer ${accessToken}`)
  .send({ organizationId });

await request(app.getHttpServer())
  .post(`/api/v1/devices/${gatewayResponse.body.id}/assign`)
  .set('Authorization', `Bearer ${accessToken}`)
  .send({ pondId: pondResponse.body.id });

const endpointsResponse = await request(app.getHttpServer())
  .get(`/api/v1/system/devices/${gatewayResponse.body.id}/endpoints`)
  .set('Authorization', `Bearer ${accessToken}`);

const targetEndpoint = endpointsResponse.body.find(
  (endpoint: { profileEndpoint: { key: string } }) => endpoint.profileEndpoint.key === 'output.gpio1',
);

const sourceEndpointsResponse = await request(app.getHttpServer())
  .get(`/api/v1/system/devices/${sensorResponse.body.id}/endpoints`)
  .set('Authorization', `Bearer ${accessToken}`);

const sourceEndpoint = sourceEndpointsResponse.body.find(
  (endpoint: { profileEndpoint: { key: string } }) => endpoint.profileEndpoint.key === 'telemetry.do',
);

const ruleResponse = await request(app.getHttpServer())
  .post('/api/v1/automation/rules')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    farmId: areaResponse.body.id,
    name: 'DO thấp thì bật bơm',
    sourceDeviceEndpointId: sourceEndpoint.id,
    operator: '<',
    thresholdValue: 4.5,
    targetDeviceEndpointId: targetEndpoint.id,
    targetValue: true,
    cooldownSeconds: 300,
  });

await request(app.getHttpServer())
  .post(`/api/v1/automation/rules/${ruleResponse.body.id}/activate`)
  .set('Authorization', `Bearer ${accessToken}`)
  .send();

await request(app.getHttpServer())
  .post('/api/v1/telemetry')
  .set('Authorization', `Bearer ${accessToken}`)
  .send({
    deviceId: sensorResponse.body.id,
    data: { do: 3.8 },
  })
  .expect(201);

const executionResponse = await request(app.getHttpServer())
  .get(`/api/v1/automation/rules/${ruleResponse.body.id}/executions`)
  .set('Authorization', `Bearer ${accessToken}`)
  .expect(200);

expect(executionResponse.body[0]).toEqual(
  expect.objectContaining({
    status: expect.stringMatching(/succeeded|failed|skipped/),
    actionRequest: expect.objectContaining({
      targetDeviceEndpointId: targetEndpoint.id,
      targetValue: true,
    }),
  }),
);

Update docs/context/rbac-matrix.md:

### Device Profiles And Automation

| Action | Super Admin | Admin | Owner | Staff | Technical |
|--------|:-----------:|:-----:|:-----:|:-----:|:---------:|
| View device profiles | ✅ (`device:read:system`) |||||
| Create/update device profiles | ✅ (`device:create|update:system`) |||||
| Configure system device endpoints | ✅ (`device:update:system`) |||||
| View automation rules ||||||
| Create/update/activate automation rules ||||||
  • Step 4: Run focused verification for the full slice

Run: cd backend && bun run test -- src/devices/device-profile-validation.util.spec.ts src/devices/device-profile.service.spec.ts src/devices/device-endpoint-materializer.service.spec.ts src/automation/automation-rule-validation.util.spec.ts src/automation/automation-rules.service.spec.ts src/automation/endpoint-event-normalizer.service.spec.ts src/automation/automation-engine.service.spec.ts src/telemetry/telemetry.service.spec.ts --runInBand

Expected: PASS for backend unit/service coverage across catalog, endpoint config, rule authoring, normalization, and runtime execution.

Run: cd backend && bun run test:e2e -- automation/automation-flow.e2e-spec.ts

Expected: PASS with a real automation execution row created from a telemetry event.

Run: cd frontend && bun run test -- src/pages/system-devices/DeviceProfilesPage.spec.tsx src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsx src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx src/pages/automation/AutomationRulesPage.spec.tsx

Expected: PASS with the new Super Admin and farm-operator flows rendered against mocked APIs.

Run: bun run check

Expected: PASS with Biome formatting and lint checks clean across backend, frontend, and docs.

  • Step 5: Commit
git add backend/test/automation/automation-flow.e2e-spec.ts docs/context/rbac-matrix.md frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx backend/src/telemetry/telemetry.service.spec.ts
git commit -m "test: verify gateway automation mvp end to end"