From 7ce95a31da8e405af1709b3e24270502172fb31c Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 13 Aug 2026 13:51:41 +0000 Subject: [PATCH] hvac: add device delete (retire stale mhi_modbus rows after MQTT switch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to remove a zone_devices mapping — a gap exposed by the Modbus->MQTT switchover, which leaves the old mhi_modbus rows stale beside the freshly auto-registered mhi_mqtt ones. Adds DELETE /api/devices/:id (manage_devices cap, unlinks photo files then deletes; device_photos cascade) and a 'Delete device' button in the expanded device row. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/devices.js | 25 +++++++++++++++++++++++++ frontend/src/api.ts | 3 +++ frontend/src/pages/Devices.tsx | 32 +++++++++++++++++++++++++------- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index d71fce1..9f29330 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -162,6 +162,31 @@ export async function deviceRoutes(app, opts) { return { ok: true } }) + // DELETE /api/devices/:id — remove a device mapping entirely. Main use: retiring + // the old mhi_modbus rows after a gateway is switched to native MQTT (the units + // re-register as fresh mhi_mqtt devices, leaving the Modbus rows stale). Photo + // rows cascade via FK; their files are unlinked here so nothing is orphaned. + app.delete('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => { + const { rows } = await pool.query( + 'SELECT id, discovered_name, external_ref, zone_id FROM zone_devices WHERE id = $1', + [req.params.id] + ) + if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) + const device = rows[0] + + const { rows: photos } = await pool.query('SELECT file_path FROM device_photos WHERE device_id = $1', [device.id]) + for (const p of photos) { + await unlink(join(UPLOADS_DIR, p.file_path)).catch(() => {}) + } + await pool.query('DELETE FROM zone_devices WHERE id = $1', [device.id]) // device_photos cascade + await logActivity(device.zone_id, 'device_command', { + note: `Deleted device ${device.discovered_name || device.external_ref}`, + source: 'manual', + userEmail: req.user.email, + }) + return { ok: true } + }) + // POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration // needs maintenance's own Settings -> API Keys page + POST /api/public/assets first // (see lib/maintenance-client.js). Returns a clear "not implemented" response rather diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e517a4d..c7c6058 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -71,6 +71,9 @@ export function discoverDevices(deviceType: string): Promise<{ ok: boolean; devi export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise { return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) } +export function deleteDevice(id: number): Promise<{ ok: boolean }> { + return request(`/devices/${id}`, { method: 'DELETE' }) +} export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> { return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) }) } diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 202d8ab..35a1c26 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useState } from 'react' -import { RadioTower, ChevronDown, ChevronRight, Wrench } from 'lucide-react' +import { RadioTower, ChevronDown, ChevronRight, Wrench, Trash2 } from 'lucide-react' import { - fetchDevices, discoverDevices, updateDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset, + fetchDevices, discoverDevices, updateDevice, deleteDevice, fetchZones, fetchDevicePhotos, createMaintenanceAsset, } from '../api' import type { Device, Zone, DevicePhoto, DeviceType } from '../types' import { DEVICE_TYPE_LABELS, IMPLEMENTED_DEVICE_TYPES } from '../types' @@ -76,6 +76,17 @@ export default function Devices() { setMsg(res.error || (res.ok ? 'Asset created' : 'Not available yet')) } + async function remove(id: number, label: string) { + if (!confirm(`Delete device "${label}"? This removes its mapping and any photos, and cannot be undone.`)) return + try { + await deleteDevice(id) + setMsg('Device deleted') + load() + } catch (e) { + setMsg(e instanceof Error ? e.message : 'Delete failed') + } + } + if (loading) return

Loading…

return ( @@ -182,11 +193,18 @@ export default function Devices() { /> - {canManage && d.zone_id && ( - - )} +
+ {canManage && d.zone_id && ( + + )} + {canManage && ( + + )} +
)}