plant/backend/src/routes/assets.js
jtricerolph 503b397dff Scaffold plant app - MQTT monitoring for boiler-room equipment
Read-only monitoring/alerting for boilers, water softener, calorifiers
and pumps via a generic MQTT-topic-prefix asset model, so new
equipment can be onboarded without new ingestion code. Threshold and
stale-data alert rules with email + in-app notification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 21:16:02 +00:00

152 lines
6.5 KiB
JavaScript

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 } from '../db.js'
import { refreshAssetMap } from '../lib/mqtt.js'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const PHOTO_TYPES = ['asset', 'serial_plate']
const ASSET_TYPES = ['boiler', 'water_softener', 'calorifier', 'pump']
const WRITABLE_FIELDS = [
'name', 'asset_type', 'location', 'make_model', 'serial_no',
'install_date', 'notes', 'active', 'mqtt_topic_prefix',
]
export async function assetRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/assets — every asset, with photo count
app.get('/api/assets', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query(`
SELECT a.*, (SELECT COUNT(*)::int FROM asset_photos p WHERE p.asset_id = a.id) AS photo_count
FROM plant_assets a
ORDER BY a.asset_type, a.name
`)
return rows
})
app.get('/api/assets/:id', { preHandler: requireCap('view') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM plant_assets WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Asset not found' })
return rows[0]
})
// POST /api/assets — create
app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const b = req.body || {}
if (!b.name || !String(b.name).trim()) return reply.status(400).send({ error: 'name is required' })
if (!ASSET_TYPES.includes(b.asset_type)) {
return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` })
}
const { rows } = await pool.query(
`INSERT INTO plant_assets
(name, asset_type, location, make_model, serial_no, install_date, notes, active, mqtt_topic_prefix)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING *`,
[
b.name.trim(), b.asset_type, b.location || null, b.make_model || null, b.serial_no || null,
b.install_date || null, b.notes || null, b.active !== undefined ? b.active : true,
b.mqtt_topic_prefix || null,
]
)
await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`))
return reply.status(201).send(rows[0])
})
// PATCH /api/assets/:id — partial update
app.patch('/api/assets/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM plant_assets WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Asset not found' })
const current = existing[0]
const b = req.body || {}
if (b.asset_type !== undefined && !ASSET_TYPES.includes(b.asset_type)) {
return reply.status(400).send({ error: `asset_type must be one of: ${ASSET_TYPES.join(', ')}` })
}
const next = { ...current }
for (const field of WRITABLE_FIELDS) {
if (b[field] !== undefined) next[field] = b[field]
}
const { rows } = await pool.query(
`UPDATE plant_assets SET
name = $1, asset_type = $2, location = $3, make_model = $4, serial_no = $5,
install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9
WHERE id = $10
RETURNING *`,
[
next.name, next.asset_type, next.location, next.make_model, next.serial_no,
next.install_date, next.notes, next.active, next.mqtt_topic_prefix, req.params.id,
]
)
await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`))
return rows[0]
})
// POST /api/assets/:id/photos — multipart: file + photo_type (asset | serial_plate)
app.post('/api/assets/:id/photos', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const assetId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM plant_assets WHERE id = $1', [assetId])
if (!rows.length) return reply.status(404).send({ error: 'Asset not found' })
let fileData = null, photoType = 'asset'
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
// pattern as hvac's device_photos / 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, 'assets', String(assetId))
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 = `/assets/${assetId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO asset_photos (asset_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[assetId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email]
)
return ins[0]
})
app.get('/api/assets/:id/photos', { preHandler: requireCap('view') }, async (req) => {
const { rows } = await pool.query(
'SELECT * FROM asset_photos WHERE asset_id = $1 ORDER BY uploaded_at DESC',
[req.params.id]
)
return rows
})
app.delete('/api/photos/:id', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM asset_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 asset_photos WHERE id = $1', [req.params.id])
return { ok: true }
})
}