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):

  1. 400 property isActive should not exist — payload create luôn gửi isActive: true, nhưng CreateDeviceProfileDto dùng whitelist validation, không có field này.
  2. 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.
  3. Không có UI để cấu hình endpoint — backend bắt buộc profile có supportedFunctions chứa control phả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 field code, name, deviceTypeId, manufacturer, model, firmwareFamily, supportedFunctions (multi-select monitoring/control). handleFinish cho nhánh create gửi isActive: true, endpoints: [] cứng (dòng ~101).
  • frontend/src/hooks/useDeviceProfiles.ts: useCreateDeviceProfile/useUpdateDeviceProfile/useDeleteDeviceProfile chỉ 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ó convention onError: (error) => message.error(extractApiError(error) || '<fallback>') dùng extractApiError từ @/lib/api.
  • backend/src/devices/dto/create-device-profile.dto.ts: không có field isActive (whitelist validation ở ValidationPipe toàn cục reject property lạ). Entity DeviceProfile.isActive đã có @Column({ name: 'is_active', default: true }) — backend tự set true khi tạo, không cần client gửi.
  • backend/src/devices/device-profile.service.ts create() gọi validateProfileEndpoints({ 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êng POST /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ếu supportedFunctions chứa 'control', phải có ít nhất 1 endpoint với directionwrite/read_write; endpoint đó bắt buộc transportType === 'rpc'transportBindingmethod + paramKey đều là string.
  • Codebase đã có 1 tiền lệ Form.List cho 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'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
defaultLabel Nhãn Input, placeholder vd: GPIO 1
functionKey Function Input, placeholder vd: relay_control
dataType Loại dữ liệu Select: number/boolean/enum
unit Đơn vị Input Không
method RPC method Input, placeholder vd: setIO
paramKey Param key Input, placeholder vd: io1

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.Listrules 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: true khỏi payload create; thêm section Form.List “Cấu hình endpoint ghi (control)” hiển thị có điều kiện; map controlEndpointsendpoints khi submit.
  • frontend/src/hooks/useDeviceProfiles.ts: thêm onError cho useCreateDeviceProfile, useUpdateDeviceProfile, useDeleteDeviceProfile; thu hẹp kiểu payload của useCreateDeviceProfile.
  • frontend/src/pages/system-devices/DeviceProfilesPage.spec.tsx: cập nhật/thêm test cho: (a) create không còn gửi isActive; (b) submit lỗi hiển thị toast (nếu hook mock throw); © chọn control hiện section endpoint và bắt buộc điền; (d) submit với control map đúng endpoints payload.
  • 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): test onError gọi message.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

  1. Tạo profile với supportedFunctions: ['monitoring'] (không chọn control) thành công, không gửi isActive, không cần nhập endpoint nào.
  2. Tạo profile với supportedFunctions chứ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}).
  3. 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.
  4. 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.
  5. 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 qua POST /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 key phía client trước khi submit.
  • Thêm message.success cho 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.List với initialValue kết hợp Form.useWatch để show/hide section có thể có edge case timing trong antd 6 (giá trị stale khi bật/tắt nhanh control).
    Mitigation: vì Drawer đã có destroyOnHiddenform.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ắt control trong DeviceProfilesPage.spec.tsx.