Device Create Form Simplification + Device Profile Catalog — 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: Simplify the org-facing “Thêm thiết bị mới” form to only require Loại thiết bị (auto-generating name/serialNumber server-side), and complete the existing-but-unfinished DeviceProfile catalog so “Mô hình” becomes a managed, shared catalog used consistently across the org create/edit flow, the Organization admin device tab, and System Device Inventory.
Architecture: Backend: extend device-identity.util.ts with an org+type-scoped name generator (separate from the existing global generator used by System Device Inventory); rewrite CreateDeviceDto/UpdateDeviceDto to drop free-text name/serialNumber/model in favor of server generation + deviceProfileId; finish DeviceProfileService/DeviceProfilesController with update/soft-delete and a scope-aware (own vs system) read; fix the device_types seed data (pump_controller → inverter) via migration. Frontend: extend the device-profile hooks/service with update/delete, wire a real CRUD UI onto DeviceProfilesPage.tsx, and rewrite the three places that create/edit a Device (DeviceList.tsx, SystemDeviceInventoryPage.tsx, and the Organization admin’s DeviceDrawer.tsx/OrganizationDevicesTab.tsx) to match.
Tech Stack: NestJS 11 + TypeORM 0.3 + class-validator (backend), React 19 + Ant Design 6 + TanStack Query v5 + Zod v4 (frontend), Jest (backend tests), Vitest (frontend tests).
Scope note (file-review-gate finding): The approved spec’s file-level scope (docs/superpowers/specs/2026-07-05-device-create-form-simplification-and-profile-catalog-design.md, section 5) lists only DeviceList.tsx, SystemDeviceInventoryPage.tsx, DeviceProfilesPage.tsx, DeviceDetail.tsx, PondDetail.tsx, hooks, types, and schemas. It misses frontend/src/pages/organizations/components/DeviceDrawer.tsx and OrganizationDevicesTab.tsx — the Organization admin page’s device tab, which calls the same deviceService.createDevice() or updateDevice() with free-text name/serialNumber/model. Since the backend has ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) (backend/src/main.ts:39-40), that flow will start returning 400 Bad Request the moment CreateDeviceDto drops those fields, unless it’s updated too. Task 15 covers this.
Task 1: Org-scoped device name generation util
Files:
- Modify:
backend/src/devices/device-identity.util.ts - Test:
backend/src/devices/device-identity.util.spec.ts
The existing buildGeneratedDeviceIdentity/getNextGeneratedSequence pair is global-scoped and zero-pads (Sensor 001 / SNS-001) — used only by System Device Inventory. We need a separate, org+type-scoped name generator that does not zero-pad (Sensor-3), per spec section 4.1. serialNumber generation stays on the existing global generator (spec 4.2) — it is a separate identity scope and is not changed here.
- Step 1: Write the failing tests
Append to backend/src/devices/device-identity.util.spec.ts:
import {
buildGeneratedDeviceIdentity,
buildOrgScopedDeviceName,
getNextGeneratedSequence,
getNextOrgScopedNameSequence,
parseGeneratedSequence,
parseOrgScopedNameSequence,
} from '@/devices/device-identity.util';
// ... keep the existing describe block, add a new one below it:
describe('org-scoped device name generation', () => {
it('builds an org-scoped device name without zero padding', () => {
expect(buildOrgScopedDeviceName('sensor', 3)).toBe('Sensor-3');
expect(buildOrgScopedDeviceName('gateway', 14)).toBe('Gateway-14');
});
it('parses only org-scoped names matching the same type prefix', () => {
expect(parseOrgScopedNameSequence('sensor', 'Sensor-7')).toBe(7);
expect(parseOrgScopedNameSequence('sensor', 'Gateway-7')).toBeNull();
expect(parseOrgScopedNameSequence('sensor', 'Sensor-CUSTOM')).toBeNull();
expect(parseOrgScopedNameSequence('sensor', 'Sensor 007')).toBeNull(); // the zero-padded, space-separated global format must not match
});
it('computes the next org-scoped sequence ignoring malformed custom names', () => {
expect(getNextOrgScopedNameSequence('sensor', ['Sensor-1', 'Sensor-4', 'Custom Sensor'])).toBe(5);
expect(getNextOrgScopedNameSequence('feeder', [])).toBe(1);
});
});
- Step 2: Run the tests to verify they fail
Run: make backend-test FILE=src/devices/device-identity.util.spec.ts
Expected: FAIL — buildOrgScopedDeviceName is not exported.
- Step 3: Implement the org-scoped generator
In backend/src/devices/device-identity.util.ts, export the internal type alias (currently private) and append the three new functions:
export type DeviceIdentityType = keyof typeof DEVICE_IDENTITY_RULES;
(replace the existing type DeviceIdentityType = keyof typeof DEVICE_IDENTITY_RULES; line with the exported version above), then append at the end of the file:
export function buildOrgScopedDeviceName(type: DeviceIdentityType, sequence: number): string {
const rule = DEVICE_IDENTITY_RULES[type];
return `${rule.namePrefix}-${sequence}`;
}
export function parseOrgScopedNameSequence(type: DeviceIdentityType, name: string): number | null {
const { namePrefix } = DEVICE_IDENTITY_RULES[type];
const match = new RegExp(`^${namePrefix}-(\\d+)$`).exec(name);
return match ? Number(match[1]) : null;
}
export function getNextOrgScopedNameSequence(type: DeviceIdentityType, names: string[]): number {
const maxSequence = names.reduce((max, current) => {
const parsed = parseOrgScopedNameSequence(type, current);
return parsed && parsed > max ? parsed : max;
}, 0);
return maxSequence + 1;
}
- Step 4: Run the tests to verify they pass
Run: make backend-test FILE=src/devices/device-identity.util.spec.ts
Expected: PASS
- Step 5: Commit
git add backend/src/devices/device-identity.util.ts backend/src/devices/device-identity.util.spec.ts
git commit -m "feat(backend): add org-scoped device name generation util"
Task 2: Fix device_types seed data (pump_controller → inverter)
Files:
- Create:
backend/src/migrations/1779200000000-FixDeviceTypesInverter.ts
device_types (seeded in 1777900000000-CreateDeviceProfileCatalog.ts) has gateway, sensor, feeder, pump_controller — it diverges from the 4 Device.type values actually in use (sensor, gateway, inverter, feeder). pump_controller is not referenced anywhere else in the codebase (confirmed via repo-wide grep), so it’s safe to rename in place.
- Step 1: Write the migration
Create backend/src/migrations/1779200000000-FixDeviceTypesInverter.ts:
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class FixDeviceTypesInverter1779200000000 implements MigrationInterface {
name = 'FixDeviceTypesInverter1779200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE device_types SET code = 'inverter', name = 'Inverter' WHERE code = 'pump_controller'
`);
await queryRunner.query(`
INSERT INTO device_types (code, name)
VALUES ('inverter', 'Inverter')
ON CONFLICT (code) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE device_types SET code = 'pump_controller', name = 'Pump Controller' WHERE code = 'inverter'
`);
}
}
The INSERT ... ON CONFLICT DO NOTHING after the UPDATE makes this idempotent against a fresh database where pump_controller was never seeded (e.g. a database created after this migration lands but replaying a stale seed script) — it guarantees inverter exists either way.
- Step 2: Run the migration against the local dev database
Run: cd backend && bun run migration:run
Expected: Migration FixDeviceTypesInverter1779200000000 runs successfully.
- Step 3: Verify manually
Run: cd backend && bun run typeorm -- query "SELECT code, name FROM device_types ORDER BY code" (or connect via psql if that script isn’t available)
Expected: Rows are exactly feeder, gateway, inverter, sensor — no pump_controller.
- Step 4: Commit
git add backend/src/migrations/1779200000000-FixDeviceTypesInverter.ts
git commit -m "fix(backend): reconcile device_types seed with Device.type (pump_controller -> inverter)"
Task 3: Device Profile catalog — service update/soft-delete + scope-aware findAll
Files:
- Modify:
backend/src/devices/dto/update-device-profile.dto.ts - Modify:
backend/src/devices/device-profile.service.ts - Test:
backend/src/devices/device-profile.service.spec.ts
update-device-profile.dto.ts already exists (dormant, unused). DeviceProfileService.findAll() currently returns all profiles unfiltered via .find(). We need: (a) narrow the update DTO to exactly the fields the spec allows editing (name, manufacturer, model, firmwareFamily, supportedFunctions, plus isActive) — not code/deviceTypeId, which stay immutable; (b) findAll gains an activeOnly/deviceTypeCode filter so it can serve both the system-scope admin UI (all profiles) and the org-scope create/edit forms (active only, optionally filtered by type); © update() and softDelete() service methods.
- Step 1: Write the failing tests
Replace the full contents of backend/src/devices/device-profile.service.spec.ts:
import { NotFoundException } from '@nestjs/common';
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 makeMockQb = () => ({
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
});
let mockQb: ReturnType<typeof makeMockQb>;
const profileRepository = {
create: jest.fn((value) => value),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
createQueryBuilder: jest.fn(),
};
const endpointRepository = {
create: jest.fn((value) => value),
save: jest.fn(),
};
const typeRepository = {
findOne: jest.fn(),
};
beforeEach(async () => {
mockQb = makeMockQb();
profileRepository.createQueryBuilder.mockReturnValue(mockQb);
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();
profileRepository.createQueryBuilder.mockReturnValue(mockQb);
});
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' },
]);
profileRepository.findOne.mockResolvedValue({
id: 'profile-1',
code: 'gateway-4do-2di-v1',
endpoints: [],
deviceType: { id: 'type-gateway', code: 'gateway' },
});
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');
});
describe('findAll', () => {
it('does not filter by isActive when activeOnly is false (system scope)', async () => {
await service.findAll({ activeOnly: false });
expect(mockQb.andWhere).not.toHaveBeenCalledWith('profile.isActive = true');
});
it('filters by isActive = true when activeOnly is true (org/own scope)', async () => {
await service.findAll({ activeOnly: true });
expect(mockQb.andWhere).toHaveBeenCalledWith('profile.isActive = true');
});
it('filters by device type code when provided', async () => {
await service.findAll({ activeOnly: true, deviceTypeCode: 'sensor' });
expect(mockQb.andWhere).toHaveBeenCalledWith('deviceType.code = :code', { code: 'sensor' });
});
});
describe('update', () => {
it('throws NotFoundException when the profile does not exist', async () => {
profileRepository.findOne.mockResolvedValue(null);
await expect(service.update('missing-id', { name: 'New name' })).rejects.toThrow(
NotFoundException,
);
});
it('applies only the allowed fields and re-validates endpoints against the new supportedFunctions', async () => {
const existingProfile = {
id: 'profile-1',
code: 'gateway-4do-2di-v1',
name: 'Gateway 4DO 2DI',
manufacturer: 'HMP',
model: 'GW-4DO',
firmwareFamily: 'gw-v1',
supportedFunctions: ['monitoring', 'control'],
isActive: true,
endpoints: [
{
key: 'output.gpio1',
direction: 'write',
dataType: 'boolean',
functionKey: 'control',
defaultLabel: 'GPIO 1',
transportType: 'rpc',
transportBinding: { method: 'setIO', paramKey: 'io1' },
},
],
};
profileRepository.findOne.mockResolvedValue(existingProfile);
profileRepository.save.mockImplementation((value) => Promise.resolve(value));
const result = await service.update('profile-1', { name: 'Gateway 4DO 2DI v2' });
expect(profileRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Gateway 4DO 2DI v2', code: 'gateway-4do-2di-v1' }),
);
expect(result.name).toBe('Gateway 4DO 2DI v2');
});
it('rejects a supportedFunctions change that would leave a control profile without a writable endpoint', async () => {
profileRepository.findOne.mockResolvedValue({
id: 'profile-1',
supportedFunctions: ['monitoring'],
endpoints: [
{
key: 'telemetry.do',
direction: 'read',
dataType: 'number',
functionKey: 'monitoring',
defaultLabel: 'DO',
transportType: 'mqtt_telemetry',
transportBinding: { field: 'do' },
},
],
});
await expect(
service.update('profile-1', { supportedFunctions: ['monitoring', 'control'] }),
).rejects.toThrow('Profiles with control support must expose at least one writable endpoint');
});
});
describe('softDelete', () => {
it('throws NotFoundException when the profile does not exist', async () => {
profileRepository.findOne.mockResolvedValue(null);
await expect(service.softDelete('missing-id')).rejects.toThrow(NotFoundException);
});
it('sets isActive to false without deleting the row', async () => {
const existingProfile = { id: 'profile-1', isActive: true };
profileRepository.findOne.mockResolvedValue(existingProfile);
profileRepository.save.mockImplementation((value) => Promise.resolve(value));
await service.softDelete('profile-1');
expect(profileRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 'profile-1', isActive: false }),
);
});
});
});
- Step 2: Run the tests to verify they fail
Run: make backend-test FILE=src/devices/device-profile.service.spec.ts
Expected: FAIL — service.findAll called with an argument doesn’t match the current no-arg signature; service.update/service.softDelete don’t exist.
- Step 3: Narrow
UpdateDeviceProfileDto
Replace the full contents of backend/src/devices/dto/update-device-profile.dto.ts:
import { PartialType, PickType } from '@nestjs/mapped-types';
import { IsBoolean, IsOptional } from 'class-validator';
import { CreateDeviceProfileDto } from '@/devices/dto/create-device-profile.dto';
export class UpdateDeviceProfileDto extends PartialType(
PickType(CreateDeviceProfileDto, [
'name',
'manufacturer',
'model',
'firmwareFamily',
'supportedFunctions',
] as const),
) {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
- Step 4: Implement
findAllfilter,update, andsoftDelete
In backend/src/devices/device-profile.service.ts, add the import and replace findAll, then add the two new methods after addEndpoint:
import { UpdateDeviceProfileDto } from '@/devices/dto/update-device-profile.dto';
Replace:
async findAll(): Promise<DeviceProfile[]> {
return this.profileRepository.find({
relations: ['deviceType', 'endpoints'],
order: { createdAt: 'DESC', endpoints: { displayOrder: 'ASC' } },
});
}
with:
async findAll(filter: { activeOnly: boolean; deviceTypeCode?: string }): Promise<DeviceProfile[]> {
const qb = this.profileRepository
.createQueryBuilder('profile')
.leftJoinAndSelect('profile.deviceType', 'deviceType')
.leftJoinAndSelect('profile.endpoints', 'endpoints')
.orderBy('profile.createdAt', 'DESC')
.addOrderBy('endpoints.displayOrder', 'ASC');
if (filter.activeOnly) {
qb.andWhere('profile.isActive = true');
}
if (filter.deviceTypeCode) {
qb.andWhere('deviceType.code = :code', { code: filter.deviceTypeCode });
}
return qb.getMany();
}
Add after addEndpoint (before the private toEndpointDraft):
async update(id: string, dto: UpdateDeviceProfileDto): Promise<DeviceProfile> {
const profile = await this.profileRepository.findOne({
where: { id },
relations: ['endpoints', 'deviceType'],
});
if (!profile) {
throw new NotFoundException('Device profile not found');
}
const nextSupportedFunctions = dto.supportedFunctions ?? profile.supportedFunctions;
validateProfileEndpoints({
supportedFunctions: nextSupportedFunctions,
endpoints: profile.endpoints.map((endpoint) => this.toEndpointDraft(endpoint)),
});
Object.assign(profile, {
name: dto.name ?? profile.name,
manufacturer: dto.manufacturer ?? profile.manufacturer,
model: dto.model ?? profile.model,
firmwareFamily: dto.firmwareFamily ?? profile.firmwareFamily,
supportedFunctions: nextSupportedFunctions,
isActive: dto.isActive ?? profile.isActive,
});
return this.profileRepository.save(profile);
}
async softDelete(id: string): Promise<void> {
const profile = await this.profileRepository.findOne({ where: { id } });
if (!profile) {
throw new NotFoundException('Device profile not found');
}
profile.isActive = false;
await this.profileRepository.save(profile);
}
toEndpointDraft currently only accepts CreateDeviceProfileEndpointDto; widen its parameter type to also accept a persisted DeviceProfileEndpoint (both shapes have the same fields the validator needs). Change the private method signature:
private toEndpointDraft(dto: CreateDeviceProfileEndpointDto | DeviceProfileEndpoint) {
return {
key: dto.key,
direction: dto.direction as DeviceEndpointDirection,
dataType: dto.dataType as DeviceEndpointDataType,
functionKey: dto.functionKey,
defaultLabel: dto.defaultLabel,
transportType: dto.transportType as DeviceEndpointTransportType,
transportBinding: dto.transportBinding,
};
}
(This is the same body — only the parameter type widens — since DeviceProfileEndpoint already has every field CreateDeviceProfileEndpointDto has.)
- Step 5: Run the tests to verify they pass
Run: make backend-test FILE=src/devices/device-profile.service.spec.ts
Expected: PASS
- Step 6: Commit
git add backend/src/devices/dto/update-device-profile.dto.ts backend/src/devices/device-profile.service.ts backend/src/devices/device-profile.service.spec.ts
git commit -m "feat(backend): add DeviceProfileService update/softDelete and scope-aware findAll filter"
Task 4: Device Profile catalog — controller (scope-aware GET, PATCH, DELETE)
Files:
- Create:
backend/src/devices/dto/query-device-profile.dto.ts - Modify:
backend/src/devices/controllers/device-profiles.controller.ts - Create:
backend/src/devices/controllers/device-profiles.controller.spec.ts
GET /device-profiles currently requires device:read:system only — org users can’t call it. It must accept device:read:own too, and only return active profiles for own-scope callers (system-scope callers keep seeing everything, active or not, so they can reactivate). resolveEffectiveScope (from AuthorizationPolicyService) is the existing mechanism for telling own from system — see system-device-inventory.service.ts’s assertSystemScope for the established pattern of not trusting the guard’s scope match alone; here we don’t need a hard reject, just a read-visibility difference, so we call resolveEffectiveScope directly in the controller.
- Step 1: Write the failing tests
Create backend/src/devices/controllers/device-profiles.controller.spec.ts:
import { Test } from '@nestjs/testing';
import { DeviceProfilesController } from '@/devices/controllers/device-profiles.controller';
import { DeviceProfileService } from '@/devices/device-profile.service';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';
describe('DeviceProfilesController', () => {
let controller: DeviceProfilesController;
const service = {
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
findEndpoints: jest.fn(),
addEndpoint: jest.fn(),
};
const authorizationPolicyService = {
resolveEffectiveScope: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module = await Test.createTestingModule({
providers: [
DeviceProfilesController,
{ provide: DeviceProfileService, useValue: service },
{ provide: AuthorizationPolicyService, useValue: authorizationPolicyService },
],
}).compile();
controller = module.get(DeviceProfilesController);
});
it('requests only active profiles for an own-scope (org) caller', async () => {
authorizationPolicyService.resolveEffectiveScope.mockReturnValue('own');
const currentUser = { permissions: ['device:read:own'] } as never;
await controller.findAll(currentUser, {});
expect(service.findAll).toHaveBeenCalledWith({ activeOnly: true, deviceTypeCode: undefined });
});
it('requests all profiles (active and inactive) for a system-scope caller', async () => {
authorizationPolicyService.resolveEffectiveScope.mockReturnValue('system');
const currentUser = { permissions: ['device:read:system'] } as never;
await controller.findAll(currentUser, { deviceType: 'sensor' });
expect(service.findAll).toHaveBeenCalledWith({ activeOnly: false, deviceTypeCode: 'sensor' });
});
it('delegates update to the service', async () => {
await controller.update('profile-1', { name: 'New name' });
expect(service.update).toHaveBeenCalledWith('profile-1', { name: 'New name' });
});
it('delegates soft-delete to the service', async () => {
await controller.remove('profile-1');
expect(service.softDelete).toHaveBeenCalledWith('profile-1');
});
});
- Step 2: Run the tests to verify they fail
Run: make backend-test FILE=src/devices/controllers/device-profiles.controller.spec.ts
Expected: FAIL — controller.findAll doesn’t take a currentUser/query pair yet; update/remove don’t exist.
- Step 3: Add the query DTO
Create backend/src/devices/dto/query-device-profile.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
export class QueryDeviceProfileDto {
@ApiProperty({
enum: ['sensor', 'gateway', 'inverter', 'feeder'],
required: false,
description: 'Filter profiles by device type code',
})
@IsOptional()
@IsEnum(['sensor', 'gateway', 'inverter', 'feeder'])
deviceType?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
}
- Step 4: Rewrite the controller
Replace the full contents of backend/src/devices/controllers/device-profiles.controller.ts:
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
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 { DeviceProfileService } from '@/devices/device-profile.service';
import { CreateDeviceProfileDto } from '@/devices/dto/create-device-profile.dto';
import { CreateDeviceProfileEndpointDto } from '@/devices/dto/create-device-profile-endpoint.dto';
import { QueryDeviceProfileDto } from '@/devices/dto/query-device-profile.dto';
import { UpdateDeviceProfileDto } from '@/devices/dto/update-device-profile.dto';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';
@ApiTags('Device Profiles')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('device-profiles')
export class DeviceProfilesController {
constructor(
private readonly service: DeviceProfileService,
private readonly authorizationPolicyService: AuthorizationPolicyService,
) {}
@Get()
@RequirePermissions('device:read:system', 'device:read:own')
findAll(@CurrentUser() currentUser: CurrentUserData, @Query() query: QueryDeviceProfileDto) {
const scope = this.authorizationPolicyService.resolveEffectiveScope(
currentUser,
'device',
'read',
);
return this.service.findAll({
activeOnly: scope !== 'system',
deviceTypeCode: query.deviceType,
});
}
@Post()
@RequirePermissions('device:create:system')
create(@Body() dto: CreateDeviceProfileDto) {
return this.service.create(dto);
}
@Patch(':id')
@RequirePermissions('device:update:system')
update(@Param('id') id: string, @Body() dto: UpdateDeviceProfileDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermissions('device:delete:system')
remove(@Param('id') id: string) {
return this.service.softDelete(id);
}
@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);
}
}
- Step 5: Run the tests to verify they pass
Run: make backend-test FILE=src/devices/controllers/device-profiles.controller.spec.ts
Expected: PASS
- Step 6: Commit
git add backend/src/devices/dto/query-device-profile.dto.ts backend/src/devices/controllers/device-profiles.controller.ts backend/src/devices/controllers/device-profiles.controller.spec.ts
git commit -m "feat(backend): open GET /device-profiles to org users (own scope) and add update/delete routes"
Task 5: Rewrite CreateDeviceDto/UpdateDeviceDto (org-facing)
Files:
- Modify:
backend/src/devices/dto/create-device.dto.ts - Modify:
backend/src/devices/dto/update-device.dto.ts
Per spec 4.6: CreateDeviceDto drops name, serialNumber, model; keeps type (required) + adds deviceProfileId (optional). UpdateDeviceDto currently derives from CreateDeviceDto via PartialType(OmitType(CreateDeviceDto, ['serialNumber'])) — since the field sets now diverge (update keeps name, drops model, and — importantly — must keep type editable, matching current behavior untouched by this spec), it needs to become its own standalone class rather than deriving from CreateDeviceDto.
This task only changes the DTOs; DevicesService/DevicesController are updated in Task 6 to match. There is no isolated test for a DTO with no logic — class-validator behavior is exercised indirectly by Task 6’s service tests and e2e tests, consistent with how create-device.dto.ts has no dedicated spec today.
- Step 1: Rewrite
create-device.dto.ts
Replace the full contents of backend/src/devices/dto/create-device.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsObject, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class CreateDeviceDto {
@ApiProperty({ enum: ['sensor', 'gateway', 'inverter', 'feeder'], example: 'sensor' })
@IsEnum(['sensor', 'gateway', 'inverter', 'feeder'], {
message: 'Type must be sensor, gateway, inverter, or feeder',
})
type: 'sensor' | 'gateway' | 'inverter' | 'feeder';
@ApiProperty({
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
required: false,
description: 'Device profile UUID (catalog "Mô hình")',
})
@IsOptional()
@IsUUID()
deviceProfileId?: string;
@ApiProperty({
example: { ph: true, do: true, temperature: true, salinity: true },
required: false,
description: 'Device capabilities (varies by type)',
})
@IsOptional()
@IsObject()
capabilities?: Record<string, unknown>;
@ApiProperty({ example: 'uuid', required: false, description: 'Assign to pond on creation' })
@IsOptional()
@IsUUID()
pondId?: string;
@ApiProperty({ example: '2024-01-01', required: false, description: 'ISO date string' })
@IsOptional()
@IsString()
installationDate?: string;
@ApiProperty({ example: 12, required: false, description: 'Warranty duration in months' })
@IsOptional()
@IsNumber()
@Min(1, { message: 'Warranty months must be at least 1' })
warrantyMonths?: number;
@ApiProperty({ enum: ['sold', 'rented'], example: 'sold', required: false })
@IsOptional()
@IsEnum(['sold', 'rented'], { message: 'Ownership type must be sold or rented' })
ownershipType?: 'sold' | 'rented';
}
- Step 2: Rewrite
update-device.dto.ts
Replace the full contents of backend/src/devices/dto/update-device.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsObject, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class UpdateDeviceDto {
@ApiProperty({ example: 'Sensor-3', required: false })
@IsOptional()
@IsString()
name?: string;
@ApiProperty({ enum: ['sensor', 'gateway', 'inverter', 'feeder'], required: false })
@IsOptional()
@IsEnum(['sensor', 'gateway', 'inverter', 'feeder'], {
message: 'Type must be sensor, gateway, inverter, or feeder',
})
type?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
@ApiProperty({
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
required: false,
description: 'Device profile UUID (catalog "Mô hình")',
})
@IsOptional()
@IsUUID()
deviceProfileId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsObject()
capabilities?: Record<string, unknown>;
@ApiProperty({ example: 'uuid', required: false })
@IsOptional()
@IsUUID()
pondId?: string;
@ApiProperty({ example: '2024-01-01', required: false })
@IsOptional()
@IsString()
installationDate?: string;
@ApiProperty({ example: 12, required: false })
@IsOptional()
@IsNumber()
@Min(1, { message: 'Warranty months must be at least 1' })
warrantyMonths?: number;
@ApiProperty({ enum: ['sold', 'rented'], required: false })
@IsOptional()
@IsEnum(['sold', 'rented'], { message: 'Ownership type must be sold or rented' })
ownershipType?: 'sold' | 'rented';
}
Note type stays present and editable — spec 4.5 explicitly keeps the edit form’s Loại thiết bị Select “as-is,” and the old UpdateDeviceDto (via OmitType(CreateDeviceDto, ['serialNumber'])) already allowed it. This is not a new capability, just an explicit declaration of one that already existed.
- Step 3: Commit
This task alone won’t compile (DevicesService still references the old DTO shape) — commit together with Task 6 instead of separately. Skip committing here; proceed directly to Task 6.
Task 6: DevicesService — server-generated identity on create, deviceProfileId support
Files:
- Modify:
backend/src/devices/devices.service.ts - Modify:
backend/src/devices/interfaces/device-response.interface.ts - Test:
backend/src/devices/devices.service.spec.ts
This is the core of the feature. create() must ignore any client-supplied name/serialNumber, generate both server-side (org+type-scoped name via Task 1’s util, globally-scoped serial via the existing util), validate deviceProfileId if provided, and retry (up to 5 attempts) if a generated identity collides with a concurrent insert (unique constraint 23505 on serial_number). update() drops model as an editable input (still read-only passthrough of whatever’s on the row) and adds deviceProfileId.
- Step 1: Write the failing tests
In backend/src/devices/devices.service.spec.ts:
- Add imports at the top:
import { ConflictException, NotFoundException } from '@nestjs/common';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';
(merge ConflictException/NotFoundException into the existing import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; line instead of a separate import.)
- In the
providersarray of theTestingModule, add aDeviceProfilerepository mock, and give the existingDevicerepository mock a defaultfindresolved value:
{
provide: getRepositoryToken(Device),
useValue: {
createQueryBuilder: jest.fn().mockReturnValue(mockQb),
findOne: jest.fn().mockResolvedValue(mockDevice),
save: jest.fn().mockResolvedValue(mockDevice),
update: jest.fn(),
find: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue({ affected: 1, raw: undefined }),
} as unknown as jest.Mocked<Repository<Device>>,
},
(only the find line changes, from find: jest.fn(), to find: jest.fn().mockResolvedValue([]),)
and add, alongside the other getRepositoryToken(...) providers:
{
provide: getRepositoryToken(DeviceProfile),
useValue: {
findOne: jest.fn().mockResolvedValue(null),
},
},
- Replace the entire
describe('create', ...)block with:
describe('create', () => {
const currentUser = {
organizationId: 'org-1',
userId: 'user-1',
email: 'user@test.com',
roles: ['Admin'],
permissions: [],
assignedResources: [],
assignedAreaIds: [],
};
beforeEach(() => {
deviceRepository.find.mockResolvedValue([]);
});
it('auto-generates the org-scoped name and the global serial number', async () => {
deviceRepository.find
.mockResolvedValueOnce([{ name: 'Gateway-1' }, { name: 'Gateway-2' }]) // org+type existing names
.mockResolvedValueOnce([{ serialNumber: 'GTW-005' }]); // global existing serials for type
await service.create({ type: 'gateway' } as CreateDeviceDto, currentUser);
expect(mockManager.save).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Gateway-3', serialNumber: 'GTW-006' }),
);
});
it('ignores any name/serialNumber the client tries to send and always generates them', async () => {
const createDto = {
type: 'sensor',
name: 'Client Supplied Name',
serialNumber: 'CLIENT-001',
} as CreateDeviceDto;
await service.create(createDto, currentUser);
expect(mockManager.save).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Sensor-1', serialNumber: 'SNS-001' }),
);
});
it('validates the device profile exists and is active when deviceProfileId is provided', async () => {
const deviceProfileRepository = module.get(getRepositoryToken(DeviceProfile));
(deviceProfileRepository.findOne as jest.Mock).mockResolvedValueOnce(null);
await expect(
service.create(
{ type: 'sensor', deviceProfileId: 'missing-profile' } as CreateDeviceDto,
currentUser,
),
).rejects.toThrow(NotFoundException);
});
it('retries identity generation on a unique constraint conflict and succeeds on the next attempt', async () => {
deviceRepository.find
.mockResolvedValueOnce([]) // attempt 1: org+type names
.mockResolvedValueOnce([]) // attempt 1: global type serials
.mockResolvedValueOnce([{ name: 'Sensor-1' }]) // attempt 2: org+type names
.mockResolvedValueOnce([{ serialNumber: 'SNS-001' }]); // attempt 2: global type serials
mockManager.save
.mockRejectedValueOnce({ driverError: { code: '23505' } })
.mockResolvedValueOnce(mockDevice);
const result = await service.create({ type: 'sensor' } as CreateDeviceDto, currentUser);
expect(result).toEqual(expect.objectContaining({ id: 'device-1' }));
expect(mockThingsBoardClient.deleteDevice).toHaveBeenCalledTimes(1);
expect(mockManager.save).toHaveBeenCalledTimes(2);
});
it('gives up after the max retry attempts and throws ConflictException', async () => {
mockManager.save.mockRejectedValue({ driverError: { code: '23505' } });
await expect(
service.create({ type: 'sensor' } as CreateDeviceDto, currentUser),
).rejects.toThrow(ConflictException);
expect(mockManager.save).toHaveBeenCalledTimes(5);
});
it('rolls back the ThingsBoard device when local persistence fails for a non-conflict error', async () => {
mockManager.save.mockRejectedValueOnce(new Error('db down'));
await expect(
service.create({ type: 'gateway' } as CreateDeviceDto, currentUser),
).rejects.toThrow('db down');
expect(mockThingsBoardClient.deleteDevice).toHaveBeenCalledWith('tb-dev-new');
});
it('throws ServiceUnavailableException when ThingsBoard is unreachable, instead of leaking the raw connection error', async () => {
mockThingsBoardClient.createDevice.mockRejectedValueOnce(
new TypeError('fetch failed: connect ECONNREFUSED 127.0.0.1:8090'),
);
await expect(
service.create({ type: 'gateway' } as CreateDeviceDto, currentUser),
).rejects.toThrow(ServiceUnavailableException);
expect(mockThingsBoardClient.getDeviceCredentials).not.toHaveBeenCalled();
});
});
Note: this replaces the old 3 tests with 7 new ones — the old tests asserted on client-supplied name/serialNumber/model, which is exactly the behavior being removed.
- In the
describe('update', ...)block, add two tests after the existing ones:
it('accepts a deviceProfileId and validates it exists and is active', async () => {
const deviceProfileRepository = module.get(getRepositoryToken(DeviceProfile));
(deviceProfileRepository.findOne as jest.Mock).mockResolvedValueOnce(null);
await expect(
service.update(
'device-1',
{ deviceProfileId: 'missing-profile' },
{
organizationId: 'org-1',
userId: 'user-1',
email: 'user@test.com',
roles: ['Admin'],
permissions: [],
assignedResources: [],
assignedAreaIds: [],
},
),
).rejects.toThrow(NotFoundException);
});
it('still allows changing type, matching pre-existing behavior', async () => {
deviceRepository.save.mockResolvedValue(buildMockDevice({ type: 'gateway' }));
const result = await service.update(
'device-1',
{ type: 'gateway' },
{
organizationId: 'org-1',
userId: 'user-1',
email: 'user@test.com',
roles: ['Admin'],
permissions: [],
assignedResources: [],
assignedAreaIds: [],
},
);
expect(result.type).toBe('gateway');
});
- Capture the
TestingModuleinstance somodule.get(...)is reachable from inside the tests — in the outerbeforeEach, change:
const module: TestingModule = await Test.createTestingModule({
Keep as-is (it’s already named module and already local to beforeEach) — but since tests reference module inside it(...) blocks in a different scope, hoist it to a let module: TestingModule; declared alongside let service: DevicesService; at the top of the outer describe, and drop the const in the assignment (module = await Test.createTestingModule({...).
- Step 2: Run the tests to verify they fail
Run: make backend-test FILE=src/devices/devices.service.spec.ts
Expected: FAIL — create() still reads serialNumber/name/model off the DTO; no retry loop; no deviceProfileId handling.
- Step 3: Update
DeviceResponseinterface
In backend/src/devices/interfaces/device-response.interface.ts, add two fields:
export interface DeviceResponse {
id: string;
serialNumber: string;
name: string | null;
type: 'sensor' | 'gateway' | 'inverter' | 'feeder';
model: string | null;
deviceProfileId: string | null;
deviceProfile: { id: string; code: string; name: string; model: string | null } | null;
capabilities: Record<string, unknown> | null;
pondId: string | null;
pond?: { name: string; code: string };
status: 'online' | 'offline' | 'error';
lastSeenAt: Date | null;
installationDate: Date | null;
warrantyMonths: number;
warrantyExpiryDate: Date | null;
ownershipType: 'sold' | 'rented';
createdAt: Date;
updatedAt: Date;
}
- Step 4: Implement in
devices.service.ts
Update the imports:
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, type Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { User } from '@/auth/entities/user.entity';
import { AssignDeviceDto } from '@/devices/dto/assign-device.dto';
import { CreateDeviceDto } from '@/devices/dto/create-device.dto';
import {
buildGeneratedDeviceIdentity,
buildOrgScopedDeviceName,
getNextGeneratedSequence,
getNextOrgScopedNameSequence,
} from '@/devices/device-identity.util';
import { GatewayControlDto } from '@/devices/dto/gateway-control.dto';
import { QueryDeviceDto } from '@/devices/dto/query-device.dto';
import { TelemetryQueryDto } from '@/devices/dto/telemetry-query.dto';
import type {
TelemetryAggregated,
TelemetryDataPoint,
TelemetryResponseDto,
} from '@/devices/dto/telemetry-response.dto';
import { UpdateDeviceDto } from '@/devices/dto/update-device.dto';
import { Device } from '@/devices/entities/device.entity';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { DeviceProfile } from '@/devices/entities/device-profile.entity';
import { DeviceTelemetry } from '@/devices/entities/device-telemetry.entity';
import type { DeviceResponse } from '@/devices/interfaces/device-response.interface';
import { Pond } from '@/farm/entities/pond.entity';
import { MonitoringStreamService } from '@/monitoring/services/monitoring-stream.service';
import { AuthorizationPolicyService } from '@/rbac/authorization-policy.service';
import { ThingsBoardClientService } from '@/thingsboard/thingsboard-client.service';
Add DeviceProfile repository injection to the constructor:
constructor(
@InjectRepository(Device)
private deviceRepository: Repository<Device>,
@InjectRepository(Pond)
private pondRepository: Repository<Pond>,
@InjectRepository(DeviceTelemetry)
private telemetryRepository: Repository<DeviceTelemetry>,
@InjectRepository(DeviceAlert)
private alertRepository: Repository<DeviceAlert>,
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(DeviceProfile)
private deviceProfileRepository: Repository<DeviceProfile>,
private dataSource: DataSource,
private authorizationPolicyService: AuthorizationPolicyService,
private thingsBoardClientService: ThingsBoardClientService,
private monitoringStreamService: MonitoringStreamService,
) {}
(DeviceProfile is already registered via TypeOrmModule.forFeature([...]) in devices.module.ts — no module change needed.)
Add the retry-attempt constant right after the class opens (alongside private readonly logger):
private readonly logger = new Logger(DevicesService.name);
private static readonly MAX_IDENTITY_GENERATION_ATTEMPTS = 5;
Add .leftJoinAndSelect('device.deviceProfile', 'deviceProfile') to the findAll() query builder chain:
const qb = this.deviceRepository
.createQueryBuilder('device')
.leftJoinAndSelect('device.pond', 'pond')
.leftJoinAndSelect('device.area', 'area')
.leftJoinAndSelect('device.deviceProfile', 'deviceProfile');
Add 'deviceProfile' to findOne()'s relations:
const device = await this.deviceRepository.findOne({
where: { id, deletedAt: IsNull() },
relations: ['pond', 'area', 'deviceProfile'],
});
Replace the entire create() method and add a new private generateOrgScopedIdentity method:
async create(
createDeviceDto: CreateDeviceDto,
currentUser: CurrentUserData,
): Promise<DeviceResponse> {
if (!currentUser.organizationId) {
throw new BadRequestException('User must be associated with an organization');
}
if (createDeviceDto.pondId) {
const pond = await this.pondRepository.findOne({
where: { id: createDeviceDto.pondId },
});
if (!pond) {
throw new NotFoundException('Pond not found');
}
if (pond.organizationId !== currentUser.organizationId) {
throw new BadRequestException('Pond does not belong to your organization');
}
}
let deviceProfile: DeviceProfile | null = null;
if (createDeviceDto.deviceProfileId) {
deviceProfile = await this.deviceProfileRepository.findOne({
where: { id: createDeviceDto.deviceProfileId, isActive: true },
});
if (!deviceProfile) {
throw new NotFoundException('Device profile not found');
}
}
const installationDate = createDeviceDto.installationDate
? new Date(createDeviceDto.installationDate)
: null;
const warrantyMonths = createDeviceDto.warrantyMonths || 12;
const warrantyExpiryDate = this.calculateWarrantyExpiry(installationDate, warrantyMonths);
for (
let attempt = 1;
attempt <= DevicesService.MAX_IDENTITY_GENERATION_ATTEMPTS;
attempt++
) {
const { name, serialNumber } = await this.generateOrgScopedIdentity(
currentUser.organizationId,
createDeviceDto.type,
);
// Provision the device in ThingsBoard (devices publish telemetry directly
// to TB) before opening the DB transaction, so the network call does not
// hold a transaction open. The TB-issued access token is what the device
// firmware authenticates with.
let thingsboardDeviceId: string;
let accessToken: string;
try {
({ thingsboardDeviceId, accessToken } =
await this.provisionThingsBoardDevice(serialNumber));
} catch (error) {
// A raw connection/HTTP error here would otherwise surface as an opaque
// 500, indistinguishable from a permission or code bug (see #160).
this.logger.error(`ThingsBoard device provisioning failed: ${(error as Error).message}`);
throw new ServiceUnavailableException(
'Device provisioning service (ThingsBoard) is unavailable. Please try again later.',
);
}
try {
return await this.dataSource.transaction(async (manager) => {
const device = new Device();
device.serialNumber = serialNumber;
device.name = name;
device.type = createDeviceDto.type;
device.model = null;
device.deviceProfileId = createDeviceDto.deviceProfileId || null;
device.capabilities = createDeviceDto.capabilities || null;
device.pondId = createDeviceDto.pondId || null;
device.areaId = createDeviceDto.pondId ? null : null;
device.installationDate = installationDate;
device.warrantyMonths = warrantyMonths;
device.warrantyExpiryDate = warrantyExpiryDate;
device.ownershipType = createDeviceDto.ownershipType || 'sold';
device.status = 'offline';
device.organizationId = currentUser.organizationId as string;
device.isActive = true;
device.thingsboardDeviceId = thingsboardDeviceId;
device.accessToken = accessToken;
device.secret = this.generateSecret();
const savedDevice = await manager.save(device);
savedDevice.deviceProfile = deviceProfile;
return this.toDeviceResponse(savedDevice);
});
} catch (error) {
// Local persistence failed after the ThingsBoard device was created;
// roll back the TB device so we do not leak an orphan.
await this.rollbackThingsBoardDevice(thingsboardDeviceId);
const driverError = (error as { driverError?: { code?: string } }).driverError;
const isUniqueConflict = driverError?.code === '23505';
if (!isUniqueConflict) {
throw error;
}
if (attempt === DevicesService.MAX_IDENTITY_GENERATION_ATTEMPTS) {
throw new ConflictException(
'Could not generate a unique device identity after multiple attempts',
);
}
// otherwise loop and regenerate a fresh identity for the next attempt
}
}
// Unreachable: the loop above always returns or throws, but TypeScript
// needs an exhaustive return path.
throw new ConflictException(
'Could not generate a unique device identity after multiple attempts',
);
}
/**
* Generates the org+type-scoped `name` (e.g. `Sensor-3`) and the globally
* unique `serialNumber` (e.g. `SNS-004`) for a brand-new device. `name`'s
* sequence resets per organization+type; `serialNumber`'s sequence is
* global, since it is the technical key used to join inbound ThingsBoard
* telemetry back to the right device regardless of which org created it.
*/
private async generateOrgScopedIdentity(
organizationId: string,
type: Device['type'],
): Promise<{ name: string; serialNumber: string }> {
const [orgTypeDevices, allTypeDevices] = await Promise.all([
this.deviceRepository.find({
where: { organizationId, type, deletedAt: IsNull() },
select: { name: true },
}),
this.deviceRepository.find({
where: { type },
select: { serialNumber: true },
}),
]);
const nameSequence = getNextOrgScopedNameSequence(
type,
orgTypeDevices.map((device) => device.name).filter((name): name is string => !!name),
);
const serialSequence = getNextGeneratedSequence(
type,
allTypeDevices.map((device) => device.serialNumber),
);
return {
name: buildOrgScopedDeviceName(type, nameSequence),
serialNumber: buildGeneratedDeviceIdentity(type, serialSequence).serialNumber,
};
}
Replace the update() method:
async update(
id: string,
updateDeviceDto: UpdateDeviceDto,
currentUser: CurrentUserData,
): Promise<DeviceResponse> {
const device = await this.deviceRepository.findOne({
where: { id, deletedAt: IsNull() },
relations: ['deviceProfile'],
});
if (!device || device.organizationId === null) {
throw new NotFoundException('Device not found');
}
const isAccessible = this.authorizationPolicyService.isTenantResourceAccessible(currentUser, {
resource: 'device',
action: 'update',
organizationId: device.organizationId,
resourceId: device.pondId ?? undefined,
});
if (!isAccessible) {
throw new NotFoundException('Device not found');
}
if (updateDeviceDto.pondId && updateDeviceDto.pondId !== device.pondId) {
const pond = await this.pondRepository.findOne({
where: { id: updateDeviceDto.pondId },
});
if (!pond) {
throw new NotFoundException('Pond not found');
}
if (pond.organizationId !== currentUser.organizationId) {
throw new BadRequestException('Pond does not belong to your organization');
}
}
let newDeviceProfile: DeviceProfile | undefined;
if (updateDeviceDto.deviceProfileId) {
const profile = await this.deviceProfileRepository.findOne({
where: { id: updateDeviceDto.deviceProfileId, isActive: true },
});
if (!profile) {
throw new NotFoundException('Device profile not found');
}
newDeviceProfile = profile;
}
const installationDate = updateDeviceDto.installationDate
? new Date(updateDeviceDto.installationDate)
: device.installationDate;
const warrantyMonths = updateDeviceDto.warrantyMonths ?? device.warrantyMonths;
const warrantyExpiryDate = this.calculateWarrantyExpiry(installationDate, warrantyMonths);
Object.assign(device, {
name: updateDeviceDto.name ?? device.name,
type: updateDeviceDto.type ?? device.type,
deviceProfileId: updateDeviceDto.deviceProfileId ?? device.deviceProfileId,
capabilities: updateDeviceDto.capabilities ?? device.capabilities,
pondId: updateDeviceDto.pondId ?? device.pondId,
installationDate,
warrantyMonths,
warrantyExpiryDate,
ownershipType: updateDeviceDto.ownershipType ?? device.ownershipType,
});
if (newDeviceProfile) {
device.deviceProfile = newDeviceProfile;
}
const updatedDevice = await this.deviceRepository.save(device);
return this.toDeviceResponse(updatedDevice);
}
Replace toDeviceResponse:
private toDeviceResponse(device: Device): DeviceResponse {
let calculatedStatus = device.status as 'online' | 'offline' | 'error';
if (device.lastSeenAt) {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
if (device.lastSeenAt < fiveMinutesAgo && device.status === 'online') {
calculatedStatus = 'offline';
}
}
return {
id: device.id,
serialNumber: device.serialNumber,
name: device.name,
type: device.type as 'sensor' | 'gateway' | 'inverter' | 'feeder',
model: device.model,
deviceProfileId: device.deviceProfileId,
deviceProfile: device.deviceProfile
? {
id: device.deviceProfile.id,
code: device.deviceProfile.code,
name: device.deviceProfile.name,
model: device.deviceProfile.model,
}
: null,
capabilities: device.capabilities,
pondId: device.pondId,
pond: device.pond
? {
name: device.pond.name,
code: device.pond.code,
}
: undefined,
status: calculatedStatus,
lastSeenAt: device.lastSeenAt,
installationDate: device.installationDate,
warrantyMonths: device.warrantyMonths,
warrantyExpiryDate: device.warrantyExpiryDate,
ownershipType: device.ownershipType as 'sold' | 'rented',
createdAt: device.createdAt,
updatedAt: device.updatedAt,
};
}
- Step 5: Run the tests to verify they pass
Run: make backend-test FILE=src/devices/devices.service.spec.ts
Expected: PASS
- Step 5b: Fix the now-stale
devices.controller.spec.tsfixtures
backend/src/devices/controllers/devices.controller.spec.ts predates this change: its mockDevice fixture is typed against the old, narrower DeviceResponse (no deviceProfileId/deviceProfile), and its describe('create', ...) tests build CreateDeviceDto object literals with serialNumber/name — fields that no longer exist on the DTO. Both will fail to compile once Task 5/this task’s DeviceResponse and CreateDeviceDto changes land.
Add deviceProfileId: null, and deviceProfile: null, to the mockDevice object (after the model: null, line):
const mockDevice = {
id: 'device-1',
serialNumber: 'DEV-001',
name: 'Test Device',
type: 'sensor' as const,
model: null,
deviceProfileId: null,
deviceProfile: null,
status: 'online' as const,
// ...rest unchanged
In describe('create', ...), replace both createDto object literals (one per test) to match the new CreateDeviceDto shape:
const createDto: CreateDeviceDto = {
type: 'sensor',
};
(replace { serialNumber: 'NEW-DEV', name: 'New Device', type: 'sensor' } and { serialNumber: 'NEW-DEV', name: 'Device', type: 'sensor' } with this in their respective tests.)
Run: make backend-test FILE=src/devices/controllers/devices.controller.spec.ts
Expected: PASS
- Step 6: Run Biome and the full backend test suite
Run: bun run check && cd backend && bun run test
Expected: no lint errors; all backend tests pass.
- Step 7: Commit
git add backend/src/devices/devices.service.ts backend/src/devices/devices.service.spec.ts backend/src/devices/interfaces/device-response.interface.ts backend/src/devices/dto/create-device.dto.ts backend/src/devices/dto/update-device.dto.ts backend/src/devices/controllers/devices.controller.spec.ts
git commit -m "feat(backend): server-generate device name/serial on create; add deviceProfileId to create/update"
Task 7: Org-scoped device name preview endpoint
Files:
- Create:
backend/src/devices/dto/generate-device-identity-query.dto.ts - Modify:
backend/src/devices/devices.service.ts - Modify:
backend/src/devices/controllers/devices.controller.ts - Test:
backend/src/devices/devices.service.spec.ts - Test:
backend/src/devices/controllers/devices.controller.spec.ts
Spec 4.1 requires the Create form to show a read-only, live-updating preview of the name the backend will generate — matching the existing System Device Inventory UX (applyGeneratedIdentity / GET /system/devices/generate-identity). Org users can’t call that endpoint (device:read:system-gated), so we add an org-scoped equivalent under /devices.
- Step 1: Write the failing test for
DevicesService.previewIdentity
In backend/src/devices/devices.service.spec.ts, add a new describe block after describe('create', ...):
describe('previewIdentity', () => {
const currentUser = {
organizationId: 'org-1',
userId: 'user-1',
email: 'user@test.com',
roles: ['Admin'],
permissions: [],
assignedResources: [],
assignedAreaIds: [],
};
it('returns the name that would be generated for the given type, without persisting anything', async () => {
deviceRepository.find.mockResolvedValueOnce([{ name: 'Sensor-1' }]).mockResolvedValueOnce([]);
const result = await service.previewIdentity('sensor', currentUser);
expect(result).toEqual({ type: 'sensor', name: 'Sensor-2' });
expect(mockManager.save).not.toHaveBeenCalled();
});
it('throws BadRequestException when the user has no organization', async () => {
await expect(
service.previewIdentity('sensor', { ...currentUser, organizationId: null }),
).rejects.toThrow(BadRequestException);
});
});
- Step 2: Run the test to verify it fails
Run: make backend-test FILE=src/devices/devices.service.spec.ts
Expected: FAIL — previewIdentity doesn’t exist.
- Step 3: Implement
previewIdentityindevices.service.ts
Add this public method right before previewIdentity’s natural neighbor generateOrgScopedIdentity (i.e. right after create(), before the provisionThingsBoardDevice private method):
async previewIdentity(
type: Device['type'],
currentUser: CurrentUserData,
): Promise<{ type: Device['type']; name: string }> {
if (!currentUser.organizationId) {
throw new BadRequestException('User must be associated with an organization');
}
const { name } = await this.generateOrgScopedIdentity(currentUser.organizationId, type);
return { type, name };
}
- Step 4: Run the test to verify it passes
Run: make backend-test FILE=src/devices/devices.service.spec.ts
Expected: PASS
- Step 5: Add the query DTO
Create backend/src/devices/dto/generate-device-identity-query.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
import type { Device } from '@/devices/entities/device.entity';
export class GenerateDeviceIdentityQueryDto {
@ApiProperty({ enum: ['sensor', 'gateway', 'inverter', 'feeder'], description: 'Device type' })
@IsNotEmpty()
@IsEnum(['sensor', 'gateway', 'inverter', 'feeder'])
type: Device['type'];
}
- Step 6: Write the failing controller test
backend/src/devices/controllers/devices.controller.spec.ts already exists, with a devicesService: jest.Mocked<DevicesService> fixture and a mockUser: CurrentUserData fixture used throughout. Add previewIdentity: jest.fn().mockResolvedValue({ type: 'sensor', name: 'Sensor-2' }), to the providers array’s DevicesService useValue object (alongside the existing findAll/findOne/create/… mocks), then add a new describe block after describe('getWarrantyExpiring', ...):
describe('generateIdentity', () => {
it('delegates to the service and returns the preview', async () => {
devicesService.previewIdentity.mockResolvedValue({ type: 'sensor', name: 'Sensor-2' });
const result = await controller.generateIdentity(mockUser, { type: 'sensor' });
expect(devicesService.previewIdentity).toHaveBeenCalledWith('sensor', mockUser);
expect(result).toEqual({ type: 'sensor', name: 'Sensor-2' });
});
});
- Step 7: Run the controller test to verify it fails
Run: make backend-test FILE=src/devices/controllers/devices.controller.spec.ts
Expected: FAIL — controller.generateIdentity doesn’t exist.
- Step 8: Add the route to
DevicesController
In backend/src/devices/controllers/devices.controller.ts, add the import:
import { GenerateDeviceIdentityQueryDto } from '@/devices/dto/generate-device-identity-query.dto';
Add the route right after getStats and before findOne(':id') (route order matters — a literal segment must be registered before the :id wildcard, matching how stats is already placed):
@Get('generate-identity')
@ApiOperation({ summary: 'Preview the auto-generated device name for a given type' })
@ApiQuery({ name: 'type', required: true, enum: ['sensor', 'gateway', 'inverter', 'feeder'] })
@ApiResponse({ status: 200, description: 'Generated name preview' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden' })
@RequirePermissions('device:create:all', 'device:create:own')
async generateIdentity(
@CurrentUser() currentUser: CurrentUserData,
@Query() query: GenerateDeviceIdentityQueryDto,
) {
return this.devicesService.previewIdentity(query.type, currentUser);
}
- Step 9: Run the controller test to verify it passes
Run: make backend-test FILE=src/devices/controllers/devices.controller.spec.ts
Expected: PASS
- Step 10: Commit
git add backend/src/devices/dto/generate-device-identity-query.dto.ts backend/src/devices/devices.service.ts backend/src/devices/devices.service.spec.ts backend/src/devices/controllers/devices.controller.ts backend/src/devices/controllers/devices.controller.spec.ts
git commit -m "feat(backend): add GET /devices/generate-identity name preview for org users"
Task 8: Drop free-text model from System Device Inventory DTOs
Files:
- Modify:
backend/src/devices/dto/create-system-device.dto.ts - Modify:
backend/src/devices/system-device-inventory.service.ts - Test:
backend/src/devices/system-device-inventory.service.spec.ts
Per spec 4.6: System Device Inventory’s DTOs drop the free-text model field — deviceProfileId is already there and becomes the only “Mô hình” input. name/serialNumber stay editable here (System Device Inventory is Super Admin tooling with its own identity-preview flow, unaffected by this spec).
- Step 1: Write the failing tests
backend/src/devices/system-device-inventory.service.spec.ts uses a systemUser: CurrentUserData fixture and a module-scoped mockManager (with create/save jest mocks, injected via the mocked DataSource.transaction). Add this test inside the existing describe('create', ...) block:
it('does not persist a model field even if present on legacy client payloads', async () => {
const dto = {
type: 'sensor',
name: 'Sensor 001',
serialNumber: 'SNS-001',
} as CreateSystemDeviceDto;
await service.create(dto, systemUser);
expect(mockManager.create).toHaveBeenCalledWith(
Device,
expect.objectContaining({ model: null }),
);
});
- Step 2: Run the test to verify it passes or fails appropriately
Run: make backend-test FILE=src/devices/system-device-inventory.service.spec.ts
Since model is currently optional and this test doesn’t set it, it may already pass (dto.model ?? null evaluates to null when model is undefined). If it already passes, that’s expected — the real behavior change is removing the ability to set model at all, which the DTO change (Step 3) enforces at the validation layer, not the service layer. Proceed regardless.
- Step 3: Remove
modelfromcreate-system-device.dto.ts
In backend/src/devices/dto/create-system-device.dto.ts, delete this block entirely:
@ApiProperty({ example: 'WQ-Pro-v2', required: false })
@IsOptional()
@IsString()
model?: string;
(the empty line separating it from the next @ApiProperty block should be removed as well, so there’s exactly one blank line between fields, matching the rest of the file.)
- Step 4: Remove
modelhandling fromsystem-device-inventory.service.ts
In the create() method, change:
model: dto.model ?? null,
to:
model: null,
In the update() method, remove this line from the Object.assign(device, {...}) call:
model: dto.model ?? device.model,
(UpdateSystemDeviceDto is Partial<Omit<CreateSystemDeviceDto, 'serialNumber'>>, so removing model from CreateSystemDeviceDto automatically removes it from the update DTO too — this is a compile error waiting to happen if the Object.assign line isn’t also removed.)
- Step 5: Run the tests to verify everything passes
Run: make backend-test FILE=src/devices/system-device-inventory.service.spec.ts
Expected: PASS
- Step 6: Run the full backend suite and Biome
Run: cd backend && bun run test && cd .. && bun run check
Expected: all pass — this confirms no other file still references CreateSystemDeviceDto.model/UpdateSystemDeviceDto.model.
- Step 7: Commit
git add backend/src/devices/dto/create-system-device.dto.ts backend/src/devices/system-device-inventory.service.ts backend/src/devices/system-device-inventory.service.spec.ts
git commit -m "feat(backend): drop free-text model input from System Device Inventory DTOs"
Task 9: Frontend — device-profile types, service, hooks (update/delete/filter)
Files:
- Modify:
frontend/src/types/device-profile.types.ts - Modify:
frontend/src/services/device-profile.service.ts - Modify:
frontend/src/hooks/useDeviceProfiles.ts
Extends the existing (partially dormant) device-profile infra with the fields and mutations the new CRUD UI (Task 10) and device forms (Tasks 12-15) need.
- Step 1: Extend the
DeviceProfiletype
In frontend/src/types/device-profile.types.ts, replace the DeviceProfile interface:
export interface DeviceProfile {
id: string;
code: string;
name: string;
deviceTypeId: string;
manufacturer: string | null;
model: string | null;
firmwareFamily: string | null;
supportedFunctions: string[];
isActive: boolean;
endpoints: DeviceProfileEndpoint[];
}
Add a query filter type after DeviceTypeOption:
export interface DeviceProfileQuery {
deviceType?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
}
- Step 2: Add
updateProfile/deleteProfileand a filter param togetProfiles
In frontend/src/services/device-profile.service.ts, add the import:
import type {
DeviceInstanceEndpointRow,
DeviceProfile,
DeviceProfileQuery,
DeviceTypeOption,
} from '@/types/device-profile.types';
Replace getProfiles and add two new methods right after it:
getProfiles(query: DeviceProfileQuery = {}): Promise<DeviceProfile[]> {
return api.get('/device-profiles', { params: query }).then((response) => response.data);
},
updateProfile(
id: string,
payload: Partial<Omit<DeviceProfile, 'id' | 'endpoints' | 'deviceTypeId'>>,
): Promise<DeviceProfile> {
return api.patch(`/device-profiles/${id}`, payload).then((response) => response.data);
},
deleteProfile(id: string): Promise<void> {
return api.delete(`/device-profiles/${id}`).then(() => undefined);
},
- Step 3: Add
useUpdateDeviceProfile/useDeleteDeviceProfile, extenduseDeviceProfileswith a filter
Replace the full contents of frontend/src/hooks/useDeviceProfiles.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { deviceProfileService } from '@/services/device-profile.service';
import type {
DeviceInstanceEndpointRow,
DeviceProfile,
DeviceProfileQuery,
} from '@/types/device-profile.types';
export const useDeviceProfiles = (query: DeviceProfileQuery = {}) =>
useQuery({
queryKey: ['device-profiles', query],
queryFn: () => deviceProfileService.getProfiles(query),
});
export const useDeviceTypes = () =>
useQuery({
queryKey: ['device-types'],
queryFn: () => deviceProfileService.getDeviceTypes(),
});
export const useCreateDeviceProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: Omit<DeviceProfile, 'id' | 'endpoints'>) =>
deviceProfileService.createProfile(payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['device-profiles'] }),
});
};
export const useUpdateDeviceProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: Partial<Omit<DeviceProfile, 'id' | 'endpoints' | 'deviceTypeId'>>;
}) => deviceProfileService.updateProfile(id, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['device-profiles'] }),
});
};
export const useDeleteDeviceProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deviceProfileService.deleteProfile(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['device-profiles'] }),
});
};
export const useDeviceEndpoints = (deviceId: string) =>
useQuery({
queryKey: ['device-endpoints', deviceId],
queryFn: () => deviceProfileService.getDeviceEndpoints(deviceId),
enabled: !!deviceId,
});
export const useUpdateDeviceEndpoint = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
deviceId,
endpointId,
payload,
}: {
deviceId: string;
endpointId: string;
payload: Partial<DeviceInstanceEndpointRow>;
}) => deviceProfileService.updateDeviceEndpoint(deviceId, endpointId, payload),
onSuccess: (_data, variables) =>
queryClient.invalidateQueries({ queryKey: ['device-endpoints', variables.deviceId] }),
});
};
There is no dedicated spec file for this hooks module today (confirmed: only page-level specs mock it), so there’s no existing test to update here. Coverage comes from the page-level tests in Tasks 10/13.
- Step 4: Typecheck
Run: cd frontend && bun run build (or the project’s typecheck script if one exists separately from build)
Expected: no type errors. This will surface any other file still calling useDeviceProfiles() with the old no-arg signature (none currently do besides DeviceProfilesPage.tsx and SystemDeviceInventoryPage.tsx, both updated in Tasks 10/16) or getProfiles() without realizing the new optional param is backward-compatible (it is — query: DeviceProfileQuery = {} keeps getProfiles() callable with no args).
- Step 5: Commit
git add frontend/src/types/device-profile.types.ts frontend/src/services/device-profile.service.ts frontend/src/hooks/useDeviceProfiles.ts
git commit -m "feat(frontend): add update/delete device-profile hooks and deviceType filter"
Task 10: DeviceProfilesPage.tsx — wire real CRUD UI
Files:
- Modify:
frontend/src/pages/system-devices/DeviceProfilesPage.tsx - Modify:
frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx
The page is currently read-only: useCreateDeviceProfile is imported nowhere, and the “Tạo profile” button has no handler. This task wires create/edit/soft-delete via a Drawer + Form, and swaps the raw antd Table import for DataTable (a pre-existing convention violation in this file per frontend/CLAUDE.md, directly touched by this task’s rewrite).
Endpoint management (useDeviceEndpoints/useUpdateDeviceEndpoint) and the initial profile creation’s endpoints/deviceTypeId fields are out of scope here — the spec’s acceptance criteria only require create/edit/soft-delete of the catalog’s own fields (name, manufacturer, model, firmwareFamily, supportedFunctions, isActive) plus a minimal creation form. Editing code/deviceTypeId/endpoints after creation is intentionally not supported (matches the backend’s UpdateDeviceProfileDto, Task 3).
- Step 1: Write the failing test
Replace the full contents of frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { vi } from 'vitest';
import { DeviceProfilesPage } from '@/pages/system-devices/DeviceProfilesPage';
const mockCreate = vi.fn().mockResolvedValue({});
const mockUpdate = vi.fn().mockResolvedValue({});
const mockDelete = vi.fn().mockResolvedValue({});
vi.mock('@/hooks/useDeviceProfiles', () => ({
useDeviceProfiles: () => ({
data: [
{
id: 'profile-1',
code: 'gateway-4do-2di-v1',
name: 'Gateway 4DO 2DI',
deviceTypeId: 'type-gateway',
manufacturer: 'HMP',
model: 'GW-4DO',
firmwareFamily: 'gw-v1',
supportedFunctions: ['monitoring', 'control'],
isActive: true,
endpoints: [
{ id: 'endpoint-1', key: 'telemetry.do', defaultLabel: 'DO' },
{ id: 'endpoint-2', key: 'output.gpio1', defaultLabel: 'GPIO 1' },
],
},
],
isLoading: false,
}),
useDeviceTypes: () => ({
data: [{ id: 'type-gateway', code: 'gateway', name: 'Gateway' }],
}),
useCreateDeviceProfile: () => ({ mutateAsync: mockCreate, isPending: false }),
useUpdateDeviceProfile: () => ({ mutateAsync: mockUpdate, isPending: false }),
useDeleteDeviceProfile: () => ({ mutateAsync: mockDelete, 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();
});
it('opens the create drawer and submits a new profile', async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
<DeviceProfilesPage />
</MemoryRouter>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Tạo profile' }));
expect(await screen.findByText('Tạo hồ sơ thiết bị')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Mã code'), { target: { value: 'sensor-ph-v1' } });
fireEvent.change(screen.getByLabelText('Tên'), { target: { value: 'Sensor pH v1' } });
fireEvent.mouseDown(screen.getByLabelText('Loại thiết bị'));
fireEvent.click(await screen.findByText('Gateway'));
fireEvent.click(screen.getByRole('button', { name: 'Lưu' }));
await waitFor(() =>
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ code: 'sensor-ph-v1', name: 'Sensor pH v1' }),
),
);
});
it('opens the edit drawer prefilled and submits an update', async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
<DeviceProfilesPage />
</MemoryRouter>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'edit-profile-profile-1' }));
expect(await screen.findByText('Chỉnh sửa hồ sơ thiết bị')).toBeInTheDocument();
expect(screen.getByDisplayValue('Gateway 4DO 2DI')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Tên'), { target: { value: 'Gateway 4DO 2DI v2' } });
fireEvent.click(screen.getByRole('button', { name: 'Lưu' }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith({
id: 'profile-1',
payload: expect.objectContaining({ name: 'Gateway 4DO 2DI v2' }),
}),
);
});
it('soft-deletes a profile after confirmation', async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
<DeviceProfilesPage />
</MemoryRouter>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'delete-profile-profile-1' }));
fireEvent.click(await screen.findByText('Vô hiệu hoá'));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('profile-1'));
});
});
- Step 2: Run the tests to verify they fail
Run: make frontend-test FILE=src/pages/system-devices/DeviceProfilesPage.spec.tsx
Expected: FAIL — no “Tạo hồ sơ thiết bị” drawer, no edit/delete buttons exist yet.
- Step 3: Rewrite the page
Replace the full contents of frontend/src/pages/system-devices/DeviceProfilesPage.tsx:
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import {
Button,
Drawer,
Form,
Input,
Popconfirm,
Select,
Space,
Switch,
Tag,
Typography,
} from 'antd';
import type React from 'react';
import { useState } from 'react';
import { DataTable } from '@/components/common/DataTable';
import { PageHeader } from '@/components/common/PageHeader';
import {
useCreateDeviceProfile,
useDeleteDeviceProfile,
useDeviceProfiles,
useDeviceTypes,
useUpdateDeviceProfile,
} from '@/hooks/useDeviceProfiles';
import type { DeviceProfile } from '@/types/device-profile.types';
const SUPPORTED_FUNCTION_OPTIONS = [
{ label: 'monitoring', value: 'monitoring' },
{ label: 'control', value: 'control' },
];
interface ProfileFormValues {
code: string;
name: string;
deviceTypeId: string;
manufacturer?: string;
model?: string;
firmwareFamily?: string;
supportedFunctions: string[];
isActive?: boolean;
}
export const DeviceProfilesPage: React.FC = () => {
const { data = [], isLoading } = useDeviceProfiles();
const { data: deviceTypes = [] } = useDeviceTypes();
const { mutateAsync: createProfile, isPending: isCreating } = useCreateDeviceProfile();
const { mutateAsync: updateProfile, isPending: isUpdating } = useUpdateDeviceProfile();
const { mutateAsync: deleteProfile } = useDeleteDeviceProfile();
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<DeviceProfile | null>(null);
const [form] = Form.useForm<ProfileFormValues>();
const openCreateDrawer = () => {
setEditingProfile(null);
form.resetFields();
setDrawerOpen(true);
};
const openEditDrawer = (profile: DeviceProfile) => {
setEditingProfile(profile);
form.setFieldsValue({
name: profile.name,
manufacturer: profile.manufacturer ?? undefined,
model: profile.model ?? undefined,
firmwareFamily: profile.firmwareFamily ?? undefined,
supportedFunctions: profile.supportedFunctions,
isActive: profile.isActive,
});
setDrawerOpen(true);
};
const closeDrawer = () => {
setDrawerOpen(false);
setEditingProfile(null);
form.resetFields();
};
const handleFinish = async (values: ProfileFormValues) => {
if (editingProfile) {
await updateProfile({
id: editingProfile.id,
payload: {
name: values.name,
manufacturer: values.manufacturer ?? null,
model: values.model ?? null,
firmwareFamily: values.firmwareFamily ?? null,
supportedFunctions: values.supportedFunctions,
isActive: values.isActive,
},
});
} else {
await createProfile({
code: values.code,
name: values.name,
deviceTypeId: values.deviceTypeId,
manufacturer: values.manufacturer ?? null,
model: values.model ?? null,
firmwareFamily: values.firmwareFamily ?? null,
supportedFunctions: values.supportedFunctions,
isActive: true,
});
}
closeDrawer();
};
const handleDelete = async (id: string) => {
await deleteProfile(id);
};
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" icon={<PlusOutlined />} onClick={openCreateDrawer}>
Tạo profile
</Button>
}
/>
<DataTable<DeviceProfile>
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: 'Trạng thái',
render: (_, record) => (
<Tag color={record.isActive ? 'success' : 'default'}>
{record.isActive ? 'Đang dùng' : 'Đã vô hiệu hoá'}
</Tag>
),
},
{
title: 'Functions',
render: (_, record) => (
<Space wrap>
{record.supportedFunctions.map((value) => (
<Tag key={value}>{value}</Tag>
))}
</Space>
),
},
{
title: 'Endpoints',
render: (_, record) => `${record.endpoints.length} endpoints`,
},
{
title: 'Hành động',
render: (_, record) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
aria-label={`edit-profile-${record.id}`}
onClick={() => openEditDrawer(record)}
/>
<Popconfirm
title="Vô hiệu hoá profile?"
description="Thiết bị đã gán profile này vẫn giữ nguyên, nhưng profile sẽ không còn xuất hiện khi tạo/sửa thiết bị mới."
onConfirm={() => handleDelete(record.id)}
okText="Vô hiệu hoá"
cancelText="Hủy"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
aria-label={`delete-profile-${record.id}`}
/>
</Popconfirm>
</Space>
),
},
]}
/>
<Drawer
title={editingProfile ? 'Chỉnh sửa hồ sơ thiết bị' : 'Tạo hồ sơ thiết bị'}
placement="right"
styles={{ wrapper: { width: 480 } }}
open={drawerOpen}
onClose={closeDrawer}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={handleFinish}>
{!editingProfile && (
<>
<Form.Item
label="Mã code"
name="code"
rules={[{ required: true, message: 'Vui lòng nhập mã code' }]}
>
<Input placeholder="vd: gateway-4do-2di-v1" />
</Form.Item>
<Form.Item
label="Loại thiết bị"
name="deviceTypeId"
rules={[{ required: true, message: 'Vui lòng chọn loại thiết bị' }]}
>
<Select
placeholder="Chọn loại thiết bị"
options={deviceTypes.map((type) => ({ label: type.name, value: type.id }))}
/>
</Form.Item>
</>
)}
<Form.Item
label="Tên"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
>
<Input placeholder="vd: Gateway 4DO 2DI" />
</Form.Item>
<Form.Item label="Nhà sản xuất" name="manufacturer">
<Input placeholder="vd: HMP" />
</Form.Item>
<Form.Item label="Model" name="model">
<Input placeholder="vd: GW-4DO" />
</Form.Item>
<Form.Item label="Dòng firmware" name="firmwareFamily">
<Input placeholder="vd: gw-v1" />
</Form.Item>
{!editingProfile && (
<Form.Item
label="Functions hỗ trợ"
name="supportedFunctions"
rules={[{ required: true, message: 'Vui lòng chọn ít nhất một function' }]}
>
<Select mode="multiple" placeholder="Chọn functions" options={SUPPORTED_FUNCTION_OPTIONS} />
</Form.Item>
)}
{editingProfile && (
<>
<Form.Item label="Functions hỗ trợ" name="supportedFunctions">
<Select mode="multiple" placeholder="Chọn functions" options={SUPPORTED_FUNCTION_OPTIONS} />
</Form.Item>
<Form.Item label="Đang sử dụng" name="isActive" valuePropName="checked">
<Switch />
</Form.Item>
</>
)}
<Form.Item>
<Space className="w-full justify-end">
<Button onClick={closeDrawer}>Hủy</Button>
<Button type="primary" htmlType="submit" loading={isCreating || isUpdating}>
Lưu
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
</Space>
);
};
Note: create-device-profile.dto.ts (backend) requires endpoints: CreateDeviceProfileEndpointDto[] as a non-optional array — the create form above does not collect endpoints. Add a default empty array to the payload in handleFinish’s createProfile(...) call:
await createProfile({
code: values.code,
name: values.name,
deviceTypeId: values.deviceTypeId,
manufacturer: values.manufacturer ?? null,
model: values.model ?? null,
firmwareFamily: values.firmwareFamily ?? null,
supportedFunctions: values.supportedFunctions,
isActive: true,
endpoints: [],
});
(This means profiles created through this UI start with zero endpoints; Super Admin adds endpoints through the existing, separate System Device Inventory endpoint-configuration screen (/system/devices/:id/endpoints), which is unaffected by this task and already out of scope per the spec’s “Out of Scope” section.)
- Step 4: Run the tests to verify they pass
Run: make frontend-test FILE=src/pages/system-devices/DeviceProfilesPage.spec.tsx
Expected: PASS
- Step 5: Run Biome
Run: bun run check
Expected: no lint errors (this also confirms the Table → DataTable swap didn’t leave an unused antd Table import).
- Step 6: Commit
git add frontend/src/pages/system-devices/DeviceProfilesPage.tsx frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx
git commit -m "feat(frontend): wire create/edit/soft-delete UI onto DeviceProfilesPage"
Task 11: Frontend — device.types.ts and device.schema.ts sync with new DTO shape
Files:
Modify:
frontend/src/types/device.types.tsModify:
frontend/src/schemas/device.schema.tsStep 1: Update
device.types.ts
In frontend/src/types/device.types.ts, replace the Device, CreateDeviceDto, and UpdateDeviceDto interfaces:
export interface Device {
id: string;
organizationId: string;
serialNumber: string;
name: string;
type: DeviceType;
model: string | null;
deviceProfileId?: string | null;
deviceProfile?: { id: string; code: string; name: string; model: string | null } | null;
capabilities: SensorCapabilities | GatewayCapabilities | Record<string, unknown>;
pondId?: string;
pond?: { id: string; name: string; code: string; };
areaId?: string;
status: DeviceStatus;
lastSeenAt: string;
installationDate: string;
activationDate?: string;
warrantyMonths: number;
warrantyExpiryDate: string;
ownershipType: OwnershipType;
mqttTopic: string;
createdAt: string;
updatedAt: string;
}
export interface CreateDeviceDto {
type: DeviceType;
deviceProfileId?: string;
capabilities?: SensorCapabilities | GatewayCapabilities;
pondId?: string;
installationDate?: string;
warrantyMonths?: number;
ownershipType?: OwnershipType;
}
export interface UpdateDeviceDto {
name?: string;
type?: DeviceType;
deviceProfileId?: string;
capabilities?: SensorCapabilities | GatewayCapabilities;
pondId?: string;
warrantyMonths?: number;
ownershipType?: OwnershipType;
}
(capabilities/installationDate/warrantyMonths become optional on CreateDeviceDto here — they were declared required before but the form that uses this type, DeviceList.tsx, never actually collected them, relying on backend defaults; this just makes the type match the already-optional backend DTO and the already-partial runtime payload.)
- Step 2: Sync the dormant Zod schemas
createDeviceSchema/updateDeviceSchema (and their inferred CreateDeviceInput/UpdateDeviceInput types) are defined in frontend/src/schemas/device.schema.ts but not imported anywhere in the app (confirmed via repo-wide grep) — they’re a second, currently-unused source of truth for the same shape. Since we’re touching this shape, keep them consistent rather than leaving a stale schema behind. In frontend/src/schemas/device.schema.ts, replace createDeviceSchema and updateDeviceSchema:
// Create device schema
export const createDeviceSchema = z.object({
type: deviceTypeSchema,
deviceProfileId: uuidSchema.optional(),
capabilities: deviceCapabilitiesSchema.optional(),
pondId: uuidSchema.optional(),
installationDate: z.string().optional(),
warrantyMonths: z.number().int().min(0, 'Số tháng bảo hành không được âm').optional(),
ownershipType: ownershipTypeSchema.optional(),
});
// Update device schema
export const updateDeviceSchema = z.object({
name: z
.string()
.min(1, 'Vui lòng nhập tên thiết bị')
.max(100, 'Tên không được vượt quá 100 ký tự')
.optional(),
type: deviceTypeSchema.optional(),
deviceProfileId: uuidSchema.optional(),
capabilities: deviceCapabilitiesSchema.optional(),
pondId: uuidSchema.optional(),
warrantyMonths: z.number().int().min(0).optional(),
ownershipType: ownershipTypeSchema.optional(),
});
Also update deviceSchema (the full entity schema) to make model nullable, matching the backend’s model: string | null:
model: z.string().nullable(),
(replace the line model: z.string(), inside deviceSchema).
- Step 3: Typecheck
Run: cd frontend && bun run build
Expected: no type errors (this task’s schema file has zero consumers today, so this step is mainly to confirm the Zod schema itself is internally valid — e.g. uuidSchema/deviceCapabilitiesSchema are used correctly).
- Step 4: Commit
git add frontend/src/types/device.types.ts frontend/src/schemas/device.schema.ts
git commit -m "refactor(frontend): sync Device types/schemas with the simplified create/update DTO shape"
Task 12: Frontend — deviceService.generateIdentity
Files:
Modify:
frontend/src/services/device.service.tsStep 1: Add the method
In frontend/src/services/device.service.ts, add the import of DeviceType:
import type {
AssignDeviceDto,
CreateDeviceDto,
Device,
DeviceAlert,
DeviceListQuery,
DeviceStats,
DeviceTelemetry,
DeviceType,
DeviceWithTelemetry,
GatewayControlRequest,
GatewayControlResponse,
PaginatedResponse,
TelemetryHistoryQuery,
TelemetryHistoryResponse,
UpdateDeviceDto,
} from '@/types/device.types';
Add the method right after createDevice:
async generateIdentity(type: DeviceType): Promise<{ type: DeviceType; name: string }> {
const response = await api.get('/devices/generate-identity', { params: { type } });
return response.data;
},
- Step 2: Typecheck
Run: cd frontend && bun run build
Expected: no type errors.
- Step 3: Commit
git add frontend/src/services/device.service.ts
git commit -m "feat(frontend): add deviceService.generateIdentity for the org-scoped name preview"
Task 13: DeviceList.tsx — rewrite create/edit drawer
Files:
- Modify:
frontend/src/pages/devices/DeviceList.tsx - Modify:
frontend/src/pages/devices/DeviceList.spec.tsx
This is the primary UI change from the spec: the Create form drops Tên thiết bị/Mã serial inputs, shows a read-only generated-name preview, and turns Mô hình into a Select sourced from the device-profile catalog (filtered by the selected type). The Edit form keeps Tên thiết bị editable, turns Mã serial read-only, and also uses the Mô hình Select. Both flows share the existing single Drawer/Form, matching the file’s current structure.
Note: the pre-existing handleEditDevice never called form.setFieldsValue(...) — editing silently opened an empty form. Since this task adds new prefill-dependent fields (deviceProfileId) and a type-dependent name preview, fixing that prefill is a required part of making the Edit flow work at all here, not a drive-by fix.
- Step 1: Write the failing tests
Replace the full contents of frontend/src/pages/devices/DeviceList.spec.tsx:
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DeviceList } from '@/pages/devices/DeviceList';
import { renderWithProviders } from '@/test/renderWithProviders';
const mockCreateDevice = vi.fn().mockResolvedValue({});
const mockUpdateDevice = vi.fn().mockResolvedValue({});
const mockGenerateIdentity = vi.fn().mockResolvedValue({ type: 'sensor', name: 'Sensor-3' });
vi.mock('@/services/device.service', () => ({
deviceService: {
getAllDevices: vi.fn().mockResolvedValue({ data: [] }),
createDevice: mockCreateDevice,
updateDevice: mockUpdateDevice,
deleteDevice: vi.fn(),
generateIdentity: mockGenerateIdentity,
},
}));
vi.mock('@/hooks/useDeviceProfiles', () => ({
useDeviceProfiles: () => ({
data: [{ id: 'profile-1', name: 'Sensor WQ Pro', deviceTypeId: 'type-sensor' }],
}),
}));
vi.mock('@/stores/auth.store', () => ({
useAuthStore: (selector: (state: { permissions: string[] }) => unknown) =>
selector({ permissions: ['device:*:all'] }),
}));
describe('DeviceList', () => {
it('opens the create-device drawer when clicking the empty-state action', async () => {
renderWithProviders(<DeviceList />);
const addButton = await screen.findByRole('button', { name: 'Thêm thiết bị' });
fireEvent.click(addButton);
expect(await screen.findByText('Thêm thiết bị mới')).toBeInTheDocument();
expect(screen.queryByLabelText('Tên thiết bị')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Mã serial')).not.toBeInTheDocument();
});
it('previews the generated name when a device type is selected, and submits type + deviceProfileId only', async () => {
renderWithProviders(<DeviceList />);
fireEvent.click(await screen.findByRole('button', { name: 'Thêm thiết bị' }));
fireEvent.mouseDown(screen.getByLabelText('Loại thiết bị'));
fireEvent.click(await screen.findByText('Cảm biến'));
await waitFor(() => expect(mockGenerateIdentity).toHaveBeenCalledWith('sensor'));
expect(await screen.findByDisplayValue('Sensor-3')).toBeInTheDocument();
fireEvent.mouseDown(screen.getByLabelText('Mô hình'));
fireEvent.click(await screen.findByText('Sensor WQ Pro'));
fireEvent.click(screen.getByRole('button', { name: 'Lưu' }));
await waitFor(() =>
expect(mockCreateDevice).toHaveBeenCalledWith({
type: 'sensor',
deviceProfileId: 'profile-1',
}),
);
});
});
- Step 2: Run the tests to verify they fail
Run: make frontend-test FILE=src/pages/devices/DeviceList.spec.tsx
Expected: FAIL — the current form still has Tên thiết bị/Mã serial inputs and a free-text Mô hình input; no name preview.
- Step 3: Rewrite the component
In frontend/src/pages/devices/DeviceList.tsx:
Add the useDeviceProfiles hook import:
import { useDeviceProfiles } from '@/hooks/useDeviceProfiles';
Add two pieces of state right after const [form] = Form.useForm();:
const [selectedType, setSelectedType] = useState<DeviceType | undefined>();
const [namePreview, setNamePreview] = useState<string | null>(null);
const { data: deviceProfiles = [] } = useDeviceProfiles({ deviceType: selectedType });
Replace handleEditDevice:
const handleEditDevice = (device: Device) => {
setSelectedDevice(device);
setSelectedType(device.type);
setNamePreview(null);
form.setFieldsValue({
name: device.name,
type: device.type,
deviceProfileId: device.deviceProfileId ?? undefined,
});
setDrawerVisible(true);
};
Add a new handler right after it:
const handleOpenCreateDrawer = () => {
setSelectedDevice(null);
setSelectedType(undefined);
setNamePreview(null);
form.resetFields();
setDrawerVisible(true);
};
const handleTypeChange = async (type: DeviceType) => {
setSelectedType(type);
form.setFieldsValue({ deviceProfileId: undefined });
if (selectedDevice) return; // edit mode keeps the existing name; no preview needed
try {
const preview = await deviceService.generateIdentity(type);
setNamePreview(preview.name);
} catch {
setNamePreview(null);
}
};
Replace handleSubmit:
const handleSubmit = async (values: {
type?: DeviceType;
name?: string;
deviceProfileId?: string;
}) => {
setSubmitting(true);
try {
if (selectedDevice) {
const updatePayload: UpdateDeviceDto = {
name: values.name,
deviceProfileId: values.deviceProfileId,
};
await deviceService.updateDevice(selectedDevice.id, updatePayload);
message.success('Đã cập nhật thiết bị thành công');
} else {
const createPayload: CreateDeviceDto = {
type: values.type as DeviceType,
deviceProfileId: values.deviceProfileId,
};
await deviceService.createDevice(createPayload);
message.success('Đã thêm thiết bị thành công');
}
setDrawerVisible(false);
setSelectedDevice(null);
setSelectedType(undefined);
setNamePreview(null);
form.resetFields();
fetchDevices(filters);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Không thể lưu thiết bị';
message.error(errorMessage);
} finally {
setSubmitting(false);
}
};
Replace the three setDrawerVisible(true) call sites used to open the drawer for a new device with handleOpenCreateDrawer:
PageHeader’sextra:
extra={
canCreate && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenCreateDrawer}>
Thêm thiết bị
</Button>
)
}
- The empty-state action:
action={
canCreate
? { label: 'Thêm thiết bị', onClick: handleOpenCreateDrawer }
: undefined
}
DeviceFilters’onAdd:
onAdd={canCreate ? handleOpenCreateDrawer : undefined}
Replace the table’s Mô hình column:
{
title: 'Mô hình',
dataIndex: 'model',
key: 'model',
render: (_: string | null, record: Device) => record.deviceProfile?.model ?? record.model ?? '—',
},
Replace the card view’s model line:
<div>
<span className="text-gray-500">Mô hình:</span>{' '}
<span className="font-medium">{device.deviceProfile?.model ?? device.model ?? '—'}</span>
</div>
Replace the Drawer’s onClose:
onClose={() => {
setDrawerVisible(false);
setSelectedDevice(null);
setSelectedType(undefined);
setNamePreview(null);
}}
Replace the entire <Form> body inside the Drawer:
<Form layout="vertical" form={form} onFinish={handleSubmit}>
<Form.Item
label="Loại thiết bị"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại thiết bị' }]}
>
<Select placeholder="Chọn loại thiết bị" onChange={handleTypeChange}>
<Select.Option value="sensor">Cảm biến</Select.Option>
<Select.Option value="gateway">Cổng kết nối</Select.Option>
<Select.Option value="inverter">Biến tần</Select.Option>
<Select.Option value="feeder">Máy cho ăn</Select.Option>
</Select>
</Form.Item>
{selectedDevice ? (
<>
<Form.Item
label="Tên thiết bị"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên thiết bị' }]}
>
<Input placeholder="Nhập tên thiết bị" />
</Form.Item>
<Form.Item label="Mã serial">
<Input value={selectedDevice.serialNumber} disabled />
</Form.Item>
</>
) : (
<Form.Item label="Tên thiết bị (tự động)">
<Input
value={namePreview ?? ''}
disabled
placeholder="Chọn loại thiết bị để xem trước tên"
/>
</Form.Item>
)}
<Form.Item label="Mô hình" name="deviceProfileId">
<Select
placeholder="Chọn mô hình (không bắt buộc)"
allowClear
options={deviceProfiles.map((profile) => ({ label: profile.name, value: profile.id }))}
/>
</Form.Item>
<Form.Item>
<Space className="w-full justify-end">
<Button onClick={() => setDrawerVisible(false)}>Hủy</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Lưu
</Button>
</Space>
</Form.Item>
</Form>
- Step 4: Run the tests to verify they pass
Run: make frontend-test FILE=src/pages/devices/DeviceList.spec.tsx
Expected: PASS
- Step 5: Run Biome and the full frontend suite
Run: bun run check && cd frontend && bun run test
Expected: all pass.
- Step 6: Manually verify in the browser
Run: cd frontend && bun run dev, navigate to /devices, click “Thêm thiết bị”. Confirm: only Loại thiết bị is required; selecting a type shows a read-only name preview; Mô hình is a dropdown (empty until a DeviceProfile exists in the seeded data — acceptable, since Task 3/10’s profile catalog is what populates it). Then edit an existing device and confirm Mã serial is read-only and prefilled, Tên thiết bị is editable and prefilled.
- Step 7: Commit
git add frontend/src/pages/devices/DeviceList.tsx frontend/src/pages/devices/DeviceList.spec.tsx
git commit -m "feat(frontend): simplify DeviceList create form to type-only with generated-name preview"
Task 14: DeviceDetail.tsx and PondDetail.tsx — model display fallback
Files:
- Modify:
frontend/src/pages/devices/DeviceDetail.tsx - Modify:
frontend/src/pages/farm/PondDetail.tsx
Per spec 4.4, every read-only display of “Mô hình” should prefer the catalog value (device.deviceProfile?.model) and fall back to the legacy free-text device.model for devices created before this change.
- Step 1: Update
DeviceDetail.tsx
Replace line 267:
<Descriptions.Item label="Mô hình">
{device.deviceProfile?.model ?? device.model ?? '—'}
</Descriptions.Item>
- Step 2: Update
PondDetail.tsx
Replace lines 421-424:
<Text type="secondary" style={{ fontSize: 12 }}>
Serial: {device.serialNumber}
{(device.deviceProfile?.model ?? device.model) &&
` · ${device.deviceProfile?.model ?? device.model}`}
</Text>
- Step 3: Typecheck and run any existing specs for these pages
Run: cd frontend && bun run build
Run: find frontend/src/pages/devices -name "DeviceDetail.spec.tsx"; find frontend/src/pages/farm -name "PondDetail.spec.tsx"
If either spec file exists, run it (make frontend-test FILE=<path>) and confirm it still passes — these are display-only changes with a safe fallback, so no existing assertion on device.model text should break as long as tests use fixtures with deviceProfile absent (fallback to .model kicks in automatically).
- Step 4: Commit
git add frontend/src/pages/devices/DeviceDetail.tsx frontend/src/pages/farm/PondDetail.tsx
git commit -m "fix(frontend): prefer device profile catalog model over legacy free-text model in read views"
Task 15: DeviceDrawer.tsx + OrganizationDevicesTab.tsx — the missed 4th flow
Files:
- Modify:
frontend/src/pages/organizations/components/DeviceDrawer.tsx - Modify:
frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx - Modify:
frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx
As flagged at the top of this plan: OrganizationDevicesTab.tsx’s handleSubmit calls deviceService.createDevice({ name, serialNumber, type, model, capabilities, installationDate, warrantyMonths, ownershipType }) — the exact fields CreateDeviceDto (Task 5) removes. With forbidNonWhitelisted: true on the backend, this becomes a hard 400 on every Super-Admin-initiated device creation from an organization’s detail page unless updated to match. This task mirrors DeviceList.tsx’s Task 13 changes onto this second create/edit surface.
- Step 1: Write the failing test
In frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx, add the useDeviceProfiles mock and a new test after the existing ones:
vi.mock('@/hooks/useDeviceProfiles', () => ({
useDeviceProfiles: () => ({ data: [{ id: 'profile-1', name: 'Sensor WQ Pro' }] }),
}));
(add this alongside the existing vi.mock calls at the top of the file)
it('submits type (not free-text name/serial/model) on create', async () => {
mockRole = RoleEnum.SUPER_ADMIN;
mockUseDevices.mockReturnValue({ data: [], isLoading: false });
const { deviceService } = await import('@/services/device.service');
vi.mocked(deviceService.generateIdentity).mockResolvedValue({ type: 'sensor', name: 'Sensor-1' });
renderWithProviders(<OrganizationDevicesTab organization={organization} />, {
queryClient: new QueryClient(),
});
fireEvent.click(screen.getByRole('button', { name: /Thêm thiết bị/ }));
fireEvent.mouseDown(screen.getByLabelText('Loại thiết bị'));
fireEvent.click(await screen.findByText('Sensor'));
fireEvent.click(screen.getByRole('button', { name: 'Lưu' }));
await waitFor(() =>
expect(vi.mocked(deviceService.createDevice)).toHaveBeenCalledWith(
expect.objectContaining({ type: 'sensor' }),
),
);
const [payload] = vi.mocked(deviceService.createDevice).mock.calls[0];
expect(payload).not.toHaveProperty('name');
expect(payload).not.toHaveProperty('serialNumber');
expect(payload).not.toHaveProperty('model');
});
Add generateIdentity: vi.fn() to the existing vi.mock('@/services/device.service', ...) block’s deviceService object.
- Step 2: Run the test to verify it fails
Run: make frontend-test FILE=src/pages/organizations/components/OrganizationDevicesTab.spec.tsx
Expected: FAIL — handleSubmit still builds the old payload shape; the form still has name/serialNumber/model inputs.
- Step 3: Rewrite
DeviceDrawer.tsx
Replace the full contents of frontend/src/pages/organizations/components/DeviceDrawer.tsx:
import { Button, Drawer, Form, Input, Select, Space } from 'antd';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { useDeviceProfiles } from '@/hooks/useDeviceProfiles';
import { deviceService } from '@/services/device.service';
import type { DeviceType } from '@/types/device.types';
const deviceDrawerSchema = z.object({
type: z.enum(['sensor', 'gateway', 'inverter', 'feeder']).optional(),
name: z.string().trim().min(1).optional(),
deviceProfileId: z.string().uuid().optional(),
});
type DeviceDrawerFormValues = z.infer<typeof deviceDrawerSchema>;
type EditableDevice = {
id: string;
name: string;
serialNumber: string;
type: DeviceType;
deviceProfileId?: string | null;
};
interface DeviceDrawerProps {
open: boolean;
onClose: () => void;
onSubmit: (
values: DeviceDrawerFormValues & { organizationId: string },
) => void | Promise<void>;
organizationId: string;
device?: EditableDevice;
}
const deviceTypeOptions = [
{ value: 'sensor', label: 'Sensor' },
{ value: 'gateway', label: 'Gateway' },
{ value: 'inverter', label: 'Inverter' },
{ value: 'feeder', label: 'Feeder' },
] satisfies Array<{ value: DeviceType; label: string }>;
export const DeviceDrawer = ({
open,
onClose,
onSubmit,
organizationId,
device,
}: DeviceDrawerProps) => {
const [form] = Form.useForm<DeviceDrawerFormValues>();
const [selectedType, setSelectedType] = useState<DeviceType | undefined>(device?.type);
const [namePreview, setNamePreview] = useState<string | null>(null);
const { data: deviceProfiles = [] } = useDeviceProfiles({ deviceType: selectedType });
useEffect(() => {
if (!open) return;
setNamePreview(null);
if (device) {
setSelectedType(device.type);
form.setFieldsValue({
type: device.type,
name: device.name,
deviceProfileId: device.deviceProfileId ?? undefined,
});
return;
}
setSelectedType(undefined);
form.resetFields();
}, [device, form, open]);
const handleTypeChange = async (type: DeviceType) => {
setSelectedType(type);
form.setFieldsValue({ deviceProfileId: undefined });
if (device) return; // edit mode keeps the existing name; no preview needed
try {
const preview = await deviceService.generateIdentity(type);
setNamePreview(preview.name);
} catch {
setNamePreview(null);
}
};
const handleFinish = async (values: DeviceDrawerFormValues) => {
const parsed = deviceDrawerSchema.safeParse(values);
if (!parsed.success) return;
await onSubmit({ ...parsed.data, organizationId });
};
return (
<Drawer
title={device ? 'Chỉnh sửa thiết bị' : 'Thêm thiết bị mới'}
open={open}
onClose={onClose}
destroyOnHidden
footer={
<Space>
<Button onClick={onClose}>Hủy</Button>
<Button type="primary" onClick={() => void form.submit()}>
Lưu
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
void handleFinish(values);
}}
>
<Form.Item
label="Loại thiết bị"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại thiết bị' }]}
>
<Select
placeholder="Chọn loại thiết bị"
options={deviceTypeOptions}
onChange={handleTypeChange}
/>
</Form.Item>
{device ? (
<>
<Form.Item
label="Tên thiết bị"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên thiết bị' }]}
>
<Input placeholder="Nhập tên thiết bị" />
</Form.Item>
<Form.Item label="Mã serial">
<Input value={device.serialNumber} disabled />
</Form.Item>
</>
) : (
<Form.Item label="Tên thiết bị (tự động)">
<Input
value={namePreview ?? ''}
disabled
placeholder="Chọn loại thiết bị để xem trước tên"
/>
</Form.Item>
)}
<Form.Item label="Mô hình" name="deviceProfileId">
<Select
placeholder="Chọn mô hình (không bắt buộc)"
allowClear
options={deviceProfiles.map((profile) => ({ label: profile.name, value: profile.id }))}
/>
</Form.Item>
</Form>
</Drawer>
);
};
- Step 4: Rewrite
OrganizationDevicesTab.tsx’shandleSubmitand model column
In frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx, replace handleSubmit:
const handleSubmit = async (payload: {
name?: string;
type?: Device['type'];
deviceProfileId?: string;
organizationId: string;
}) => {
if (selectedDevice) {
const updatePayload: UpdateDeviceDto = {
name: payload.name,
type: payload.type,
deviceProfileId: payload.deviceProfileId,
};
await deviceService.updateDevice(selectedDevice.id, updatePayload);
message.success('Cập nhật thiết bị thành công');
} else {
await deviceService.createDevice({
type: payload.type as Device['type'],
deviceProfileId: payload.deviceProfileId,
capabilities: payload.type === 'gateway' ? { ioCount: 0, ioStates: [] } : {},
installationDate: new Date().toISOString(),
warrantyMonths: 12,
ownershipType: 'sold',
});
message.success('Thêm thiết bị thành công');
}
setDrawerOpen(false);
setSelectedDevice(undefined);
await invalidateDevices();
};
Note: capabilities/installationDate/warrantyMonths/ownershipType are carried over unchanged from the original handleSubmit — only name/serialNumber/model are replaced by type/deviceProfileId. Dropping those defaults would silently regress this flow’s devices to installationDate: null (backend’s default when omitted), changing warranty-expiry calculations for every device Super Admin creates from an organization’s detail page — out of scope for this spec.
Replace the Model column:
{
title: 'Model',
dataIndex: 'model',
key: 'model',
render: (_: string | null, record: Device) => record.deviceProfile?.model ?? record.model ?? '—',
},
Update the <DeviceDrawer> usage to pass deviceProfileId through on device:
<DeviceDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setSelectedDevice(undefined);
}}
onSubmit={handleSubmit}
organizationId={organization.id}
device={
selectedDevice
? {
id: selectedDevice.id,
name: selectedDevice.name,
serialNumber: selectedDevice.serialNumber,
type: selectedDevice.type,
deviceProfileId: selectedDevice.deviceProfileId,
}
: undefined
}
/>
- Step 5: Run the tests to verify they pass
Run: make frontend-test FILE=src/pages/organizations/components/OrganizationDevicesTab.spec.tsx
Expected: PASS (both the pre-existing tests and the new one).
- Step 6: Run Biome and the full frontend suite
Run: bun run check && cd frontend && bun run test
Expected: all pass.
- Step 7: Commit
git add frontend/src/pages/organizations/components/DeviceDrawer.tsx frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx
git commit -m "fix(frontend): update Organization admin device create/edit for the simplified device DTO shape"
Task 16: SystemDeviceInventoryPage.tsx — drop free-text model, filter profile Select by type
Files:
- Modify:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx - Modify:
frontend/src/types/system-device.types.ts - Test:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
Per spec 4.3/4.4: drop the duplicate free-text “Mô hình” input (the page already has a deviceProfileId Select) and filter that Select’s options by the selected Loại thiết bị, via the deviceType query param added to useDeviceProfiles in Task 9.
- Step 1: Update
system-device.types.ts
In frontend/src/types/system-device.types.ts, remove model from CreateSystemDeviceDto and add deviceProfile to SystemDeviceRow:
export interface SystemDeviceRow {
id: string;
organizationId: string | null;
organization?: { id: string; name: string; code: string } | null;
serialNumber: string;
name: string | null;
type: SystemDeviceType;
model: string | null;
deviceProfileId: string | null;
deviceProfile?: { id: string; code: string; name: string } | null;
inventoryStatus: SystemDeviceInventoryStatus;
operationalStatus: SystemDeviceOperationalStatus;
warrantyMonths: number;
ownershipType: 'sold' | 'rented';
assignedAt: string | null;
retiredAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateSystemDeviceDto {
type: SystemDeviceType;
name: string;
serialNumber: string;
capabilities?: Record<string, unknown>;
warrantyMonths?: number;
ownershipType?: 'sold' | 'leased';
deviceProfileId?: string;
}
- Step 2: Write the failing test
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx currently mocks useDeviceProfiles as a plain function returning a fixed value:
vi.mock('@/hooks/useDeviceProfiles', () => ({
useDeviceProfiles: () => ({ data: [], isLoading: false }),
}));
Change it to a vi.fn() wrapper so the test can assert on call arguments (mirroring how generateIdentity/createDevice/etc. are already declared as module-level vi.fn()s in this file):
const useDeviceProfilesMock = vi.fn(() => ({ data: [], isLoading: false }));
vi.mock('@/hooks/useDeviceProfiles', () => ({
useDeviceProfiles: (...args: unknown[]) => useDeviceProfilesMock(...args),
}));
Add useDeviceProfilesMock.mockClear(); to the existing beforeEach (alongside vi.clearAllMocks(), since vi.clearAllMocks() does not reset a vi.fn() created outside a vi.mock() factory unless it was also passed to vi.clearAllMocks’s tracked mocks — vi.fn() declared at module scope like the others here is already tracked, so this line is likely redundant with vi.clearAllMocks(); keep it only if vi.clearAllMocks() alone doesn’t reset call history for this particular mock when run).
Add two new tests after 'prefills name and serial after selecting a device type and preserves manual edits':
it('does not render a free-text Mô hình input', async () => {
renderWithProviders(<SystemDeviceInventoryPage />, { initialEntries: ['/system/devices'] });
fireEvent.click(screen.getByRole('button', { name: /thêm thiết bị/i }));
expect(screen.queryByPlaceholderText('Nhập mô hình thiết bị')).not.toBeInTheDocument();
});
it('filters the device-profile Select by the selected device type', async () => {
renderWithProviders(<SystemDeviceInventoryPage />, { initialEntries: ['/system/devices'] });
fireEvent.click(screen.getByRole('button', { name: /thêm thiết bị/i }));
fireEvent.mouseDown(screen.getByLabelText(/loại thiết bị/i));
fireEvent.click(await screen.findByTitle('sensor'));
await waitFor(() =>
expect(useDeviceProfilesMock).toHaveBeenLastCalledWith({ deviceType: 'sensor' }),
);
});
- Step 3: Run the tests to verify they fail
Run: make frontend-test FILE=src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
Expected: FAIL — the free-text Mô hình input is still present.
- Step 4: Update the component
In frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx, change the useDeviceProfiles() call to filter by the currently-selected type. Since type lives in the antd Form instance (not component state), track it in a small piece of state updated alongside the existing applyGeneratedIdentity flow:
Add state near the other useState calls:
const [selectedType, setSelectedType] = useState<SystemDeviceType | undefined>();
Change:
const { data: profiles = [] } = useDeviceProfiles();
to:
const { data: profiles = [] } = useDeviceProfiles({ deviceType: selectedType });
Update applyGeneratedIdentity to also track the type (add one line at the top of the function body):
const applyGeneratedIdentity = async (type: SystemDeviceType) => {
setSelectedType(type);
try {
const generated = await generateIdentity(type);
Update openDrawerForEdit to also set selectedType so the profile Select is filtered correctly when editing:
const openDrawerForEdit = (device: SystemDeviceRow) => {
setEditingDevice(device);
setSelectedType(device.type);
setLastSuggestedName(null);
setLastSuggestedSerial(null);
form.setFieldsValue({
type: device.type,
name: device.name ?? '',
serialNumber: device.serialNumber,
deviceProfileId: device.deviceProfileId ?? undefined,
});
setDrawerOpen(true);
};
Update openDrawerForNew to reset it:
const openDrawerForNew = () => {
setEditingDevice(null);
setSelectedType(undefined);
setLastSuggestedName(null);
setLastSuggestedSerial(null);
form.resetFields();
setDrawerOpen(true);
};
Update handleFinish to drop model from the create payload:
const handleFinish = async (values: CreateSystemDeviceDto) => {
try {
if (editingDevice) {
await updateDevice({ id: editingDevice.id, dto: values });
message.success('Cập nhật thiết bị thành công');
} else {
const payload: CreateSystemDeviceDto = {
type: values.type,
name: values.name.trim(),
serialNumber: values.serialNumber.trim(),
...(values.deviceProfileId ? { deviceProfileId: values.deviceProfileId } : {}),
};
await createDevice(payload);
message.success('Thêm thiết bị thành công');
}
closeDrawer();
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return; // form validation error
message.error('Không thể lưu thiết bị');
}
};
Delete the free-text Mô hình Form.Item entirely:
<Form.Item label="Mô hình" name="model">
<Input placeholder="Nhập mô hình thiết bị" />
</Form.Item>
- Step 5: Run the tests to verify they pass
Run: make frontend-test FILE=src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
Expected: PASS
- Step 6: Run Biome and the full frontend suite
Run: bun run check && cd frontend && bun run test
Expected: all pass.
- Step 7: Commit
git add frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx frontend/src/types/system-device.types.ts
git commit -m "feat(frontend): drop duplicate free-text model input in System Device Inventory, filter profile select by type"
Final verification (whole-plan)
- Run:
cd backend && bun run migration:run— confirm the new migration (Task 2) is the only pending one and applies cleanly. - Run:
bun run checkfrom the repo root — Biome clean across both workspaces. - Run:
cd backend && bun run test— full backend Jest suite green. - Run:
cd frontend && bun run test— full frontend Vitest suite green. - Run:
cd backend && bun run build && cd ../frontend && bun run build— both workspaces typecheck/build cleanly. - Manually walk all three create flows in the browser (
bun run frontend:dev+bun run backend:dev):/devices(org create), an organization’s detail page → Devices tab (Super Admin create-for-org),/system/device-profiles(catalog CRUD), and/system/devices(System Device Inventory, confirming the model input is gone and the profile Select now filters by type). - Re-check each of the spec’s 10 acceptance criteria (
docs/superpowers/specs/2026-07-05-device-create-form-simplification-and-profile-catalog-design.md, section 6) against the running app.