Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler
Ports the state machine, retry/backoff, and guest-override detection from the retired homeassistant-newbook-heating-component, without depending on Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following standard stack conventions; LXC 128 (127 was already taken by utilities). MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are later phases/infra, not included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
276c04f8c6
42 changed files with 5461 additions and 0 deletions
27
backend/src/routes/config.js
Normal file
27
backend/src/routes/config.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
|
||||
// Generic key/value config store (hk-planner/maintenance pattern). Zone-specific
|
||||
// settings (excluded_site_ids, etc.) go through their own routes in zones.js so
|
||||
// each has proper validation — this is the catch-all for simple scalar settings
|
||||
// like poll_interval_minutes, default_arrival_time, maintenance_url/api_key.
|
||||
export async function configRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.get('/api/config', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||
})
|
||||
|
||||
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
const { key } = req.params
|
||||
const { value } = req.body || {}
|
||||
if (value === undefined) return reply.status(400).send({ error: 'value required' })
|
||||
await pool.query(
|
||||
`INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
|
||||
[key, JSON.stringify(value)]
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
160
backend/src/routes/devices.js
Normal file
160
backend/src/routes/devices.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { mkdir, unlink, writeFile } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import sharp from 'sharp'
|
||||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, logActivity } from '../db.js'
|
||||
import * as trvDriver from '../lib/drivers/trv.js'
|
||||
import * as haDriver from '../lib/drivers/homeassistant.js'
|
||||
import { createMaintenanceAsset } from '../lib/maintenance-client.js'
|
||||
|
||||
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
const PHOTO_TYPES = ['device', 'serial_plate']
|
||||
|
||||
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 2/3
|
||||
// types (mhi_modbus, midea, daikin) are enum values only — no driver yet.
|
||||
const DRIVERS = {
|
||||
shelly_trv: trvDriver,
|
||||
home_assistant: haDriver,
|
||||
}
|
||||
const NOT_YET_IMPLEMENTED = ['mhi_modbus', 'midea', 'daikin']
|
||||
|
||||
export async function deviceRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/devices — every mapped + unassigned device, with zone name and photo count
|
||||
app.get('/api/devices', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT zd.*, z.name AS zone_name,
|
||||
(SELECT COUNT(*)::int FROM device_photos p WHERE p.device_id = zd.id) AS photo_count
|
||||
FROM zone_devices zd
|
||||
LEFT JOIN zones z ON z.id = zd.zone_id
|
||||
ORDER BY zd.zone_id NULLS FIRST, zd.device_type, zd.discovered_name
|
||||
`)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/devices/discover — { device_type } -> runs that driver's discover(),
|
||||
// and inserts any newly-seen device as an unassigned row (never auto-linked to a zone).
|
||||
app.post('/api/devices/discover', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { device_type } = req.body || {}
|
||||
if (!device_type) return reply.status(400).send({ error: 'device_type required' })
|
||||
|
||||
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
|
||||
return reply.status(200).send({
|
||||
ok: true, devices: [],
|
||||
note: `${device_type} discovery isn't implemented yet — Phase 1 only covers Shelly TRVs. ` +
|
||||
`This device type exists as a column enum value ready for its Phase 2/3 driver.`,
|
||||
})
|
||||
}
|
||||
|
||||
const driver = DRIVERS[device_type]
|
||||
if (!driver) return reply.status(400).send({ error: `Unknown device_type: ${device_type}` })
|
||||
|
||||
const result = await driver.discover()
|
||||
if (!result.ok) return reply.status(200).send(result) // e.g. broker/HA not reachable — not a hard error
|
||||
|
||||
let discoveredCount = 0
|
||||
for (const d of result.devices) {
|
||||
const res = await pool.query(
|
||||
`INSERT INTO zone_devices (device_type, external_ref, discovered_name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (device_type, external_ref) DO UPDATE SET discovered_name = EXCLUDED.discovered_name, last_seen = NOW()
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
[device_type, d.external_ref, d.discovered_name || d.external_ref]
|
||||
)
|
||||
if (res.rows[0].inserted) discoveredCount++
|
||||
}
|
||||
|
||||
return { ok: true, found: result.devices.length, new: discoveredCount }
|
||||
})
|
||||
|
||||
// PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard)
|
||||
app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Device not found' })
|
||||
const d = existing[0]
|
||||
const { zone_id, location } = req.body || {}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE zone_devices SET zone_id = $1, location = $2, updated_at = NOW() WHERE id = $3 RETURNING *`,
|
||||
[zone_id !== undefined ? zone_id : d.zone_id, location !== undefined ? location : d.location, req.params.id]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/devices/:id/photos — multipart: file + photo_type (device | serial_plate)
|
||||
app.post('/api/devices/:id/photos', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const deviceId = parseInt(req.params.id)
|
||||
const { rows } = await pool.query('SELECT id FROM zone_devices WHERE id = $1', [deviceId])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
|
||||
|
||||
let fileData = null, photoType = 'device'
|
||||
for await (const part of req.parts()) {
|
||||
if (part.type === 'file') {
|
||||
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
|
||||
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
|
||||
}
|
||||
const chunks = []
|
||||
for await (const chunk of part.file) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks)
|
||||
|
||||
// Auto-rotate (phone EXIF), resize to 1800px max, re-encode as JPEG — same as maintenance's task_photos.
|
||||
const processed = await sharp(raw)
|
||||
.rotate()
|
||||
.resize(1800, 1800, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 82, progressive: true })
|
||||
.toBuffer()
|
||||
|
||||
const filename = randomUUID() + '.jpg'
|
||||
const dir = join(UPLOADS_DIR, 'devices', String(deviceId))
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, filename), processed)
|
||||
fileData = { originalName: part.filename, savedAs: filename, size: processed.length }
|
||||
} else {
|
||||
const val = await part.value
|
||||
if (part.fieldname === 'photo_type' && PHOTO_TYPES.includes(String(val))) photoType = String(val)
|
||||
}
|
||||
}
|
||||
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
|
||||
|
||||
const filePath = `/devices/${deviceId}/${fileData.savedAs}`
|
||||
const { rows: ins } = await pool.query(
|
||||
`INSERT INTO device_photos (device_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[deviceId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email]
|
||||
)
|
||||
return ins[0]
|
||||
})
|
||||
|
||||
app.get('/api/devices/:id/photos', { preHandler: requireCap('view') }, async (req) => {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM device_photos WHERE device_id = $1 ORDER BY uploaded_at DESC',
|
||||
[req.params.id]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
app.delete('/api/photos/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows } = await pool.query('SELECT * FROM device_photos WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
|
||||
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
|
||||
await pool.query('DELETE FROM device_photos WHERE id = $1', [req.params.id])
|
||||
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
|
||||
// than faking a cross-app call.
|
||||
app.post('/api/devices/:id/create-maintenance-asset', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT zd.*, z.name AS zone_name FROM zone_devices zd LEFT JOIN zones z ON z.id = zd.zone_id WHERE zd.id = $1`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
|
||||
const result = await createMaintenanceAsset(rows[0], { name: rows[0].zone_name })
|
||||
return reply.status(501).send(result)
|
||||
})
|
||||
}
|
||||
41
backend/src/routes/override.js
Normal file
41
backend/src/routes/override.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, logActivity } from '../db.js'
|
||||
import { batchSetZoneTemperature } from '../lib/mqtt.js'
|
||||
|
||||
// Manual force-temperature — the equivalent of the old integration's
|
||||
// newbook.force_room_temperature service. Disables auto mode for the zone so the
|
||||
// scheduler leaves it alone until staff switch auto mode back on (zones.js PATCH).
|
||||
export async function overrideRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.post('/api/zones/:id/override', { preHandler: requireCap('control') }, async (req, reply) => {
|
||||
const { temp_c } = req.body || {}
|
||||
const tempC = Number(temp_c)
|
||||
if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'temp_c must be a number' })
|
||||
|
||||
const { rows } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Zone not found' })
|
||||
const zone = rows[0]
|
||||
|
||||
await pool.query('UPDATE zones SET auto_mode = FALSE WHERE id = $1', [zone.id])
|
||||
|
||||
const { rows: devices } = await pool.query(
|
||||
`SELECT external_ref, device_ip FROM zone_devices WHERE zone_id = $1 AND device_type = 'shelly_trv'`,
|
||||
[zone.id]
|
||||
)
|
||||
if (!devices.length) {
|
||||
return reply.status(200).send({ ok: false, error: 'No TRVs mapped to this zone', auto_mode: false })
|
||||
}
|
||||
|
||||
const results = await batchSetZoneTemperature(devices, tempC)
|
||||
const successful = Object.values(results).filter(Boolean).length
|
||||
|
||||
await logActivity(zone.id, 'override', {
|
||||
note: `Manual override to ${tempC}°C (${successful}/${devices.length} devices, auto mode disabled)`,
|
||||
source: 'manual',
|
||||
userEmail: req.user.email,
|
||||
})
|
||||
|
||||
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
|
||||
})
|
||||
}
|
||||
25
backend/src/routes/settings.js
Normal file
25
backend/src/routes/settings.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { testConnection } from '../lib/newbook.js'
|
||||
import { isConnected as mqttConnected } from '../lib/mqtt.js'
|
||||
|
||||
// Settings-page-backing endpoints that don't fit the generic config.js key/value
|
||||
// store: connection tests and the MQTT broker's live status. The Home Assistant
|
||||
// integration itself is configured centrally in the shared `settings` app, not
|
||||
// here — this just reports whether it's reachable (via the driver's own no-op
|
||||
// behaviour when unconfigured).
|
||||
export async function settingsRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.post('/api/settings/newbook-test', { preHandler: requireCap('settings') }, async () => {
|
||||
return testConnection()
|
||||
})
|
||||
|
||||
app.get('/api/settings/mqtt-status', { preHandler: requireCap('settings') }, async () => {
|
||||
return {
|
||||
connected: mqttConnected(),
|
||||
note: mqttConnected()
|
||||
? 'Connected to the shared MQTT broker.'
|
||||
: 'Not connected — the shared MQTT broker (LXC 104) may not be provisioned yet, or hvac-backend has no credentials in settings.',
|
||||
}
|
||||
})
|
||||
}
|
||||
59
backend/src/routes/status.js
Normal file
59
backend/src/routes/status.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { isConnected as mqttConnected } from '../lib/mqtt.js'
|
||||
|
||||
export async function statusRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/status — live status per zone: room state, target temp, device health/battery
|
||||
app.get('/api/status', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows: zones } = await pool.query(`
|
||||
SELECT z.*, zs.room_state, zs.last_transition_at, zs.last_booking_status
|
||||
FROM zones z
|
||||
LEFT JOIN zone_state zs ON zs.zone_id = z.id
|
||||
WHERE z.active = TRUE
|
||||
ORDER BY z.zone_type, z.name
|
||||
`)
|
||||
|
||||
const { rows: devices } = await pool.query(`
|
||||
SELECT id, zone_id, device_type, external_ref, discovered_name, location,
|
||||
health_state, battery_pct, wifi_rssi, current_target_temp, target_origin, last_seen
|
||||
FROM zone_devices WHERE zone_id IS NOT NULL
|
||||
`)
|
||||
const devicesByZone = new Map()
|
||||
for (const d of devices) {
|
||||
if (!devicesByZone.has(d.zone_id)) devicesByZone.set(d.zone_id, [])
|
||||
devicesByZone.get(d.zone_id).push(d)
|
||||
}
|
||||
|
||||
return {
|
||||
mqtt_connected: mqttConnected(),
|
||||
zones: zones.map(z => ({
|
||||
...z,
|
||||
room_state: z.room_state || 'vacant',
|
||||
target_temp: ['heating_up', 'occupied'].includes(z.room_state) ? z.occupied_temp : z.vacant_temp,
|
||||
devices: devicesByZone.get(z.id) || [],
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/status/activity — recent activity log, optionally filtered by zone
|
||||
app.get('/api/status/activity', { preHandler: requireCap('view') }, async (req) => {
|
||||
const { zone_id, limit } = req.query || {}
|
||||
const lim = Math.min(parseInt(limit) || 100, 500)
|
||||
if (zone_id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id
|
||||
WHERE al.zone_id = $1 ORDER BY al.created_at DESC LIMIT $2`,
|
||||
[zone_id, lim]
|
||||
)
|
||||
return rows
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT al.*, z.name AS zone_name FROM activity_log al LEFT JOIN zones z ON z.id = al.zone_id
|
||||
ORDER BY al.created_at DESC LIMIT $1`,
|
||||
[lim]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
}
|
||||
145
backend/src/routes/zones.js
Normal file
145
backend/src/routes/zones.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, getConfig, setConfig, logActivity } from '../db.js'
|
||||
import { fetchSites, testConnection } from '../lib/newbook.js'
|
||||
|
||||
export async function zoneRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/zones — all zones with device counts
|
||||
app.get('/api/zones', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT z.*, COUNT(zd.id)::int AS device_count
|
||||
FROM zones z
|
||||
LEFT JOIN zone_devices zd ON zd.zone_id = z.id
|
||||
GROUP BY z.id
|
||||
ORDER BY z.zone_type, z.name
|
||||
`)
|
||||
return rows
|
||||
})
|
||||
|
||||
// PATCH /api/zones/:id — per-zone config: temps, offsets, auto_mode, sync_valves, exclude_bathroom
|
||||
app.patch('/api/zones/:id', { preHandler: requireCap('schedule_edit') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Zone not found' })
|
||||
const z = existing[0]
|
||||
const {
|
||||
name, occupied_temp, vacant_temp, heating_offset_min, cooling_offset_min,
|
||||
auto_mode, sync_valves, exclude_bathroom,
|
||||
} = req.body || {}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE zones SET
|
||||
name = $1, occupied_temp = $2, vacant_temp = $3, heating_offset_min = $4,
|
||||
cooling_offset_min = $5, auto_mode = $6, sync_valves = $7, exclude_bathroom = $8
|
||||
WHERE id = $9 RETURNING *`,
|
||||
[
|
||||
name ?? z.name,
|
||||
occupied_temp ?? z.occupied_temp,
|
||||
vacant_temp ?? z.vacant_temp,
|
||||
heating_offset_min ?? z.heating_offset_min,
|
||||
cooling_offset_min ?? z.cooling_offset_min,
|
||||
auto_mode ?? z.auto_mode,
|
||||
sync_valves ?? z.sync_valves,
|
||||
exclude_bathroom ?? z.exclude_bathroom,
|
||||
req.params.id,
|
||||
]
|
||||
)
|
||||
if (auto_mode !== undefined && auto_mode !== z.auto_mode) {
|
||||
await logActivity(z.id, 'override', {
|
||||
note: auto_mode ? 'Auto mode re-enabled' : 'Auto mode disabled',
|
||||
source: 'manual',
|
||||
userEmail: req.user.email,
|
||||
})
|
||||
}
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// POST /api/zones — manual zone creation (public areas, or any non-NewBook device group)
|
||||
app.post('/api/zones', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
const { name, zone_type, occupied_temp, vacant_temp } = req.body || {}
|
||||
if (!name) return reply.status(400).send({ error: 'name required' })
|
||||
if (!['room', 'public_area'].includes(zone_type)) {
|
||||
return reply.status(400).send({ error: "zone_type must be 'room' or 'public_area'" })
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO zones (name, zone_type, source, newbook_site_id, occupied_temp, vacant_temp)
|
||||
VALUES ($1, $2, 'manual', NULL, $3, $4) RETURNING *`,
|
||||
[name, zone_type, occupied_temp || 22.0, vacant_temp || 16.0]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// ── NewBook include/exclude checklist (hk-planner's CategorySettings.tsx pattern) ──
|
||||
|
||||
// GET /api/zones/newbook-sites — live sites list + excluded flag, for the checklist UI
|
||||
app.get('/api/zones/newbook-sites', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
let sites
|
||||
try {
|
||||
sites = await fetchSites()
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
|
||||
}
|
||||
const excluded = (await getConfig('excluded_site_ids', [])) || []
|
||||
return {
|
||||
sites: sites.map(s => ({
|
||||
site_id: String(s.site_id),
|
||||
site_name: s.site_name,
|
||||
category_name: s.category_name || null,
|
||||
excluded: excluded.includes(String(s.site_id)),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/zones/newbook-sites — save the excluded_site_ids list
|
||||
app.put('/api/zones/newbook-sites', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
const { excluded_site_ids } = req.body || {}
|
||||
if (!Array.isArray(excluded_site_ids)) return reply.status(400).send({ error: 'excluded_site_ids must be an array' })
|
||||
await setConfig('excluded_site_ids', excluded_site_ids.map(String))
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/api/zones/newbook-test', { preHandler: requireCap('settings') }, async () => {
|
||||
return testConnection()
|
||||
})
|
||||
|
||||
// POST /api/zones/sync-newbook — upsert a zone per non-excluded site (maintenance's
|
||||
// locations.js sync-newbook pattern): ON CONFLICT (newbook_site_id) DO UPDATE, and
|
||||
// soft-deactivate (never delete) any previously-synced zone whose site disappeared.
|
||||
app.post('/api/zones/sync-newbook', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
let sites
|
||||
try {
|
||||
sites = await fetchSites()
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
|
||||
}
|
||||
|
||||
const excluded = new Set((await getConfig('excluded_site_ids', [])) || [])
|
||||
const included = sites.filter(s => !excluded.has(String(s.site_id)))
|
||||
|
||||
let created = 0, updated = 0
|
||||
for (const site of included) {
|
||||
const siteId = String(site.site_id)
|
||||
const name = site.site_name || `Room ${siteId}`
|
||||
const res = await pool.query(
|
||||
`INSERT INTO zones (name, zone_type, source, newbook_site_id)
|
||||
VALUES ($1, 'room', 'newbook', $2)
|
||||
ON CONFLICT (newbook_site_id) DO UPDATE SET name = EXCLUDED.name, active = TRUE
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
[name, siteId]
|
||||
)
|
||||
res.rows[0].inserted ? created++ : updated++
|
||||
}
|
||||
|
||||
// Zones synced from NewBook whose site is now excluded/gone are deactivated,
|
||||
// never deleted — preserves schedule config + activity history.
|
||||
const includedIds = included.map(s => String(s.site_id))
|
||||
await pool.query(
|
||||
`UPDATE zones SET active = FALSE
|
||||
WHERE source = 'newbook' AND newbook_site_id IS NOT NULL AND NOT (newbook_site_id = ANY($1))`,
|
||||
[includedIds.length ? includedIds : ['']]
|
||||
)
|
||||
|
||||
await logActivity(null, 'sync', { note: `NewBook sync: ${created} created, ${updated} updated`, source: 'sync', userEmail: req.user.email })
|
||||
return { ok: true, created, updated, total: included.length }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue