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
Areaaggregate in this repo. Persist automation rule scope asautomation_rules.farm_idreferencingareas.id, and keep UI copy as “Farm” while backend joinsArea. - Keep legacy
devices.type,devices.model, anddevices.capabilitiesfor backward compatibility. New automation behavior is driven bydevices.deviceProfileIdanddevice_instance_endpointswhen present. - MVP write dispatch supports writable endpoints whose
transportTypeisrpc. Read endpoints may usemqtt_telemetryormqtt_attribute. - Keep the existing
/devices/:id/control4-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, andautomation: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 resyncdevice_instance_endpointsfrom 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 MVPevent_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 forfarmIdandstatus.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/rulesAPI 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, seeddevice_types, and extenddeviceswithdevice_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 importAutomationModule.backend/src/devices/devices.module.ts
Register new repositories, services, and controllers; importforwardRef(() => MqttModule)for command dispatch.backend/src/devices/entities/device.entity.ts
Add nullabledeviceProfileIdrelation andinstanceEndpointsrelation.backend/src/devices/dto/create-system-device.dto.ts
RequiredeviceProfileIdfor new system-managed automation-capable devices.backend/src/devices/dto/update-system-device.dto.ts
Allow changingdeviceProfileIdand 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
PersistdeviceProfileId, 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
ExportMqttServicefor device command dispatch and add theforwardRef(() => DevicesModule)import needed by the new dispatch adapter.backend/src/telemetry/telemetry.module.ts
ImportAutomationModule.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.tsCreate:
backend/src/devices/enums/device-endpoint-direction.enum.tsCreate:
backend/src/devices/enums/device-endpoint-transport-type.enum.tsCreate:
backend/src/devices/device-profile-validation.util.tsTest:
backend/src/devices/device-profile-validation.util.spec.tsStep 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.tsCreate:
backend/src/devices/entities/device-profile.entity.tsCreate:
backend/src/devices/entities/device-profile-endpoint.entity.tsCreate:
backend/src/devices/dto/create-device-profile.dto.tsCreate:
backend/src/devices/dto/update-device-profile.dto.tsCreate:
backend/src/devices/dto/create-device-profile-endpoint.dto.tsCreate:
backend/src/devices/dto/update-device-profile-endpoint.dto.tsCreate:
backend/src/devices/device-profile.service.tsCreate:
backend/src/devices/device-profile.service.spec.tsCreate:
backend/src/devices/controllers/device-profiles.controller.tsCreate:
backend/src/devices/controllers/device-types.controller.tsCreate:
backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.tsModify:
backend/src/app.module.tsModify:
backend/src/devices/devices.module.tsModify:
backend/src/devices/entities/device.entity.tsStep 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.tsCreate:
backend/src/devices/dto/update-device-instance-endpoint.dto.tsCreate:
backend/src/devices/dto/device-instance-endpoint-response.dto.tsCreate:
backend/src/devices/device-endpoint-materializer.service.tsCreate:
backend/src/devices/device-endpoint-materializer.service.spec.tsCreate:
backend/src/devices/controllers/system-device-endpoints.controller.tsModify:
backend/src/devices/dto/create-system-device.dto.tsModify:
backend/src/devices/dto/update-system-device.dto.tsModify:
backend/src/devices/dto/system-device-response.dto.tsModify:
backend/src/devices/system-device-inventory.service.tsModify:
backend/src/devices/system-device-inventory.service.spec.tsModify:
backend/src/devices/devices.module.tsModify:
backend/src/migrations/1777900000000-CreateDeviceProfileCatalog.tsStep 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.tsCreate:
backend/src/automation/enums/automation-rule-execution-status.enum.tsCreate:
backend/src/automation/enums/automation-rule-operator.enum.tsCreate:
backend/src/automation/enums/automation-rule-status.enum.tsCreate:
backend/src/automation/enums/automation-trigger-mode.enum.tsCreate:
backend/src/automation/entities/automation-rule.entity.tsCreate:
backend/src/automation/entities/automation-rule-condition.entity.tsCreate:
backend/src/automation/entities/automation-rule-action.entity.tsCreate:
backend/src/automation/entities/automation-rule-execution.entity.tsCreate:
backend/src/automation/dto/create-automation-rule.dto.tsCreate:
backend/src/automation/dto/update-automation-rule.dto.tsCreate:
backend/src/automation/dto/query-automation-rules.dto.tsCreate:
backend/src/automation/dto/automation-rule-response.dto.tsCreate:
backend/src/automation/dto/automation-rule-execution-response.dto.tsCreate:
backend/src/automation/automation-rule-validation.util.tsCreate:
backend/src/automation/automation-rule-validation.util.spec.tsCreate:
backend/src/automation/automation-rules.service.tsCreate:
backend/src/automation/automation-rules.service.spec.tsCreate:
backend/src/automation/controllers/automation-rules.controller.tsCreate:
backend/src/automation/automation.module.tsCreate:
backend/src/migrations/1777903600000-CreateAutomationRuleTables.tsModify:
backend/src/app.module.tsModify:
backend/src/rbac/resource-scope-map.tsStep 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.tsCreate:
backend/src/devices/device-command-dispatch.service.spec.tsCreate:
backend/src/automation/endpoint-event.types.tsCreate:
backend/src/automation/endpoint-event-normalizer.service.tsCreate:
backend/src/automation/endpoint-event-normalizer.service.spec.tsCreate:
backend/src/automation/automation-engine.service.tsCreate:
backend/src/automation/automation-engine.service.spec.tsModify:
backend/src/mqtt/mqtt.module.tsModify:
backend/src/devices/devices.module.tsModify:
backend/src/telemetry/telemetry.module.tsModify:
backend/src/telemetry/telemetry.service.tsModify:
backend/src/telemetry/telemetry.service.spec.tsStep 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.tsCreate:
frontend/src/services/device-profile.service.tsCreate:
frontend/src/hooks/useDeviceProfiles.tsCreate:
frontend/src/pages/system-devices/DeviceProfilesPage.tsxCreate:
frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsxCreate:
frontend/src/pages/system-devices/SystemDeviceEndpointsPage.tsxCreate:
frontend/src/pages/system-devices/SystemDeviceEndpointsPage.spec.tsxModify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsxModify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsxModify:
frontend/src/App.tsxModify:
frontend/src/navigation/menus/super-admin.menu.tsxStep 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.tsCreate:
frontend/src/services/automation-rules.service.tsCreate:
frontend/src/hooks/useAutomationRules.tsCreate:
frontend/src/schemas/automation.schema.tsCreate:
frontend/src/pages/automation/AutomationRulesPage.tsxCreate:
frontend/src/pages/automation/AutomationRulesPage.spec.tsxModify:
frontend/src/App.tsxModify:
frontend/src/navigation/menus/admin.menu.tsxModify:
frontend/src/navigation/menus/owner.menu.tsxStep 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.tsModify:
docs/context/rbac-matrix.mdModify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsxModify:
backend/src/telemetry/telemetry.service.spec.tsStep 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"