Fix Device Profile Catalog create (issue #179)
Date: 2026-07-06
Status: Approved (conversation-level), pending file review gate
1) Goal
Trang /system/device-profiles (thêm ở #175/#178) hoàn toàn không tạo được profile mới — mọi lần submit đều fail. Issue #179 (QA của #178) liệt kê 3 lỗi cụ thể cần fix trong cùng một đợt vì chúng chặn lẫn nhau trên cùng một luồng (fix (1) mà không fix (3) thì vẫn không tạo được profile control):
400 property isActive should not exist— payload create luôn gửiisActive: true, nhưngCreateDeviceProfileDtodùng whitelist validation, không có field này.- Lỗi trên bị nuốt hoàn toàn — dialog không hiển thị toast báo lỗi, người dùng không biết vì sao thao tác thất bại.
- Không có UI để cấu hình endpoint — backend bắt buộc profile có
supportedFunctionschứacontrolphải có ít nhất 1 endpoint ghi (writable) ngay tại thời điểm tạo, nhưng dialog Create không có input nào cho endpoint (endpoints: []hardcode).
Edit, reactivate, soft-delete đã hoạt động đúng — không nằm trong scope này.
2) Current State
frontend/src/pages/system-devices/DeviceProfilesPage.tsx: Drawer/Form với các fieldcode,name,deviceTypeId,manufacturer,model,firmwareFamily,supportedFunctions(multi-selectmonitoring/control).handleFinishcho nhánh create gửiisActive: true, endpoints: []cứng (dòng ~101).frontend/src/hooks/useDeviceProfiles.ts:useCreateDeviceProfile/useUpdateDeviceProfile/useDeleteDeviceProfilechỉ cóonSuccess: invalidateQueries, không cóonError— không có cơ chế nào để lỗi API hiển thị ra UI. Các hook khác trong codebase (useFarms.ts,useFinancial.ts) đã có conventiononError: (error) => message.error(extractApiError(error) || '<fallback>')dùngextractApiErrortừ@/lib/api.backend/src/devices/dto/create-device-profile.dto.ts: không có fieldisActive(whitelist validation ởValidationPipetoàn cục reject property lạ). EntityDeviceProfile.isActiveđã có@Column({ name: 'is_active', default: true })— backend tự settruekhi tạo, không cần client gửi.backend/src/devices/device-profile.service.tscreate()gọivalidateProfileEndpoints({ supportedFunctions: dto.supportedFunctions, endpoints: dto.endpoints... })đồng bộ, ngay trong request tạo profile — không có cách nào “tạo trước, thêm endpoint sau” cho profile cócontrol, dù có API riêngPOST /device-profiles/:id/endpoints(dùng cho việc thêm endpoint vào profile đã tồn tại, không giải quyết được ràng buộc lúc tạo).backend/src/devices/device-profile-validation.util.ts: nếusupportedFunctionschứa'control', phải có ít nhất 1 endpoint vớidirectionlàwrite/read_write; endpoint đó bắt buộctransportType === 'rpc'vàtransportBindingcómethod+paramKeyđều là string.- Codebase đã có 1 tiền lệ
Form.Listcho danh sách lặp lại:frontend/src/pages/cycles/components/TreatmentsFormSection.tsx.
3) Options Considered
Bug 1 & 2: chỉ có một cách hợp lý
Xoá field thừa khỏi payload, và thêm onError vào 3 mutation hook theo đúng convention extractApiError + message.error đã dùng ở useFarms.ts/useFinancial.ts. Không có phương án thay thế đáng cân nhắc — đây là sửa lỗi rõ ràng theo pattern có sẵn.
Bug 3: UI cấu hình endpoint ghi
Option A: Luôn hiển thị section endpoint (mọi profile, kể cả monitoring-only)
Pros: linh hoạt hơn, cho phép thêm cả endpoint đọc ngay từ lúc tạo.
Cons: làm phức tạp hoá luồng tạo profile monitoring-only vốn đang hoạt động tốt (0 endpoint, không cần input gì thêm) — không cần thiết cho việc fix bug đang report, phình UI không phục vụ mục tiêu.
Option B (chọn): Chỉ hiển thị khi supportedFunctions chứa 'control', thu hẹp field xuống mức tối thiểu cần cho ràng buộc writable-endpoint
Section “Cấu hình endpoint ghi (control)” chỉ xuất hiện khi cần. Vì endpoint ghi hợp lệ duy nhất theo validation là transportType: 'rpc', direction thuộc {write, read_write}, ta cố định transportType = 'rpc' và direction = 'write' trong payload dựng từ section này — không hiển thị 2 field đó cho người dùng chọn, giảm số quyết định phải đưa ra mà không mất khả năng thoả mãn ràng buộc backend.
Pros: UI tối thiểu, đúng scope bug-fix, monitoring-only profile không bị ảnh hưởng gì.
Cons: nếu sau này cần tạo endpoint đọc ngay lúc tạo profile (không nằm trong report của #179), sẽ cần mở rộng thêm — chấp nhận vì ngoài scope hiện tại (endpoint đọc vẫn thêm được qua POST /device-profiles/:id/endpoints sau khi tạo).
Người dùng đã chọn Option B.
4) Selected Design
4.1 Bug 1 — Payload create bỏ isActive
DeviceProfilesPage.tsx nhánh !editingProfile trong handleFinish: xoá isActive: true khỏi object gửi lên createProfile. Kiểu tham số của useCreateDeviceProfile’s mutationFn (hiện là Omit<DeviceProfile, 'id'>, vốn kéo theo isActive) thu hẹp lại thành kiểu khớp đúng field mà CreateDeviceProfileDto chấp nhận (code, name, deviceTypeId, manufacturer, model, firmwareFamily, supportedFunctions, endpoints) để TypeScript tự chặn regression tương tự.
4.2 Bug 2 — Toast báo lỗi
Thêm vào useDeviceProfiles.ts, cho cả 3 hook useCreateDeviceProfile, useUpdateDeviceProfile, useDeleteDeviceProfile:
onError: (error: unknown) => {
message.error(extractApiError(error) || '<fallback message>');
},
Fallback message theo từng hook (vd: “Không thể tạo hồ sơ thiết bị”, “Không thể cập nhật hồ sơ thiết bị”, “Không thể xoá hồ sơ thiết bị”), theo đúng convention useFarms.ts. Không thêm message.success — ngoài scope report (chỉ yêu cầu fix “không có toast báo lỗi”).
4.3 Bug 3 — Section cấu hình endpoint ghi
Hiển thị có điều kiện: section “Cấu hình endpoint ghi (control)” chỉ render trong nhánh create (không áp dụng cho edit) khi giá trị hiện tại của field supportedFunctions trong form chứa 'control' — theo dõi bằng Form.useWatch('supportedFunctions', form). Khi không chứa 'control', section ẩn hoàn toàn và payload gửi endpoints: [] như hiện tại.
Cấu trúc: Form.List tên controlEndpoints, khởi tạo với 1 row rỗng khi section được render lần đầu (dùng initialValue: [{}] trên Form.List, phù hợp vì section này chỉ tồn tại trong Drawer create — mount lại từ đầu mỗi lần mở nhờ destroyOnHidden đã có sẵn trên Drawer).
Mỗi row gồm các field (không có direction, không có transportType — cả hai cố định khi map sang payload):
| Field form | Label | Input | Bắt buộc |
|---|---|---|---|
key |
Key | Input, placeholder vd: output.gpio1 |
Có |
defaultLabel |
Nhãn | Input, placeholder vd: GPIO 1 |
Có |
functionKey |
Function | Input, placeholder vd: relay_control |
Có |
dataType |
Loại dữ liệu | Select: number/boolean/enum |
Có |
unit |
Đơn vị | Input | Không |
method |
RPC method | Input, placeholder vd: setIO |
Có |
paramKey |
Param key | Input, placeholder vd: io1 |
Có |
Mỗi row có nút xoá (icon, giống pattern nút xoá trong TreatmentsFormSection.tsx); dưới danh sách có nút “+ Thêm endpoint”.
Validation tối thiểu 1 row: Form.List có rules với 1 validator ở cấp danh sách: nếu supportedFunctions (đọc qua closure/Form.useWatch) chứa 'control' và danh sách rỗng, ném lỗi hiển thị “Vui lòng thêm ít nhất 1 endpoint ghi”. Xoá row cuối cùng được cho phép bình thường (không disable nút xoá) — lỗi chỉ hiện khi submit với danh sách rỗng.
Không validate trùng key phía client. Nếu người dùng nhập 2 row cùng key, backend trả lỗi qua validateProfileEndpoints và hiển thị qua toast (bug 2) — giữ scope fix nhỏ, không xây thêm lớp validate phía client cho trường hợp không nằm trong report gốc.
Map sang payload khi submit: mỗi row trong controlEndpoints được map thành:
{
key: row.key,
direction: 'write',
dataType: row.dataType,
functionKey: row.functionKey,
defaultLabel: row.defaultLabel,
unit: row.unit || undefined,
transportType: 'rpc',
transportBinding: { method: row.method, paramKey: row.paramKey },
}
Khi supportedFunctions không chứa 'control', payload gửi endpoints: [] như hiện tại (bỏ qua controlEndpoints dù còn giá trị stale trong form — không xảy ra thực tế vì section unmount khi bỏ chọn control, Form.List mất giá trị theo hành vi antd mặc định).
5) File-Level Scope
frontend/src/pages/system-devices/DeviceProfilesPage.tsx: bỏisActive: truekhỏi payload create; thêm sectionForm.List“Cấu hình endpoint ghi (control)” hiển thị có điều kiện; mapcontrolEndpoints→endpointskhi submit.frontend/src/hooks/useDeviceProfiles.ts: thêmonErrorchouseCreateDeviceProfile,useUpdateDeviceProfile,useDeleteDeviceProfile; thu hẹp kiểu payload củauseCreateDeviceProfile.frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx: cập nhật/thêm test cho: (a) create không còn gửiisActive; (b) submit lỗi hiển thị toast (nếu hook mock throw); © chọncontrolhiện section endpoint và bắt buộc điền; (d) submit vớicontrolmap đúngendpointspayload.frontend/src/hooks/useDeviceProfiles.spec.ts(nếu chưa có file test cho hook này, tạo mới theo convention test hook khác trong repo): testonErrorgọimessage.errorđúng khi mutation reject.
Không đổi backend — cả 3 bug đều là lỗi ở tầng frontend (payload sai field, thiếu error handling, thiếu UI), backend đã đúng theo spec ban đầu của #175.
6) Acceptance Criteria
- Tạo profile với
supportedFunctions: ['monitoring'](không chọncontrol) thành công, không gửiisActive, không cần nhập endpoint nào. - Tạo profile với
supportedFunctionschứa'control'bắt buộc phải điền ít nhất 1 endpoint ghi trong section mới; submit thành công tạo được profile có endpoint ghi hợp lệ (transportType: rpc,transportBinding: {method, paramKey}). - Xoá hết row endpoint trong section (còn
controlđược chọn) rồi bấm Lưu → hiển thị lỗi validate “Vui lòng thêm ít nhất 1 endpoint ghi”, không gọi API. - Bất kỳ lỗi nào từ API create/update/delete profile (400 whitelist, 404, lỗi validate endpoint từ backend, v.v.) đều hiển thị toast lỗi cụ thể (dùng
extractApiError), không còn thất bại âm thầm. - Luồng edit, reactivate, soft-delete không bị ảnh hưởng bởi thay đổi này (giữ nguyên hành vi đã đúng).
7) Out of Scope
- Thêm UI cấu hình endpoint đọc (
direction: read,transportType: mqtt_telemetry/mqtt_attribute) trong dialog create — không nằm trong bug report; endpoint đọc vẫn có thể thêm sau khi tạo quaPOST /device-profiles/:id/endpoints(API đã tồn tại, không có UI riêng — không nằm trong scope #179). - Cho phép cấu hình endpoint trong luồng edit — bug report chỉ nói edit “hoạt động đúng”, không yêu cầu thêm endpoint editor ở đó.
- Validate trùng
keyphía client trước khi submit. - Thêm
message.successcho các thao tác vốn chưa có (chỉ fix phần thiếu toast lỗi được report).
8) Risks and Mitigations
- Risk:
Form.ListvớiinitialValuekết hợpForm.useWatchđể show/hide section có thể có edge case timing trong antd 6 (giá trị stale khi bật/tắt nhanhcontrol).
Mitigation: vì Drawer đã códestroyOnHiddenvàform.resetFields()gọi khi đóng/mở, section chỉ tồn tại trong 1 lần mở drawer — rủi ro thấp; cover bằng test tương tác bật/tắtcontroltrongDeviceProfilesPage.spec.tsx.