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>
This commit is contained in:
commit
503b397dff
38 changed files with 5044 additions and 0 deletions
152
backend/src/routes/assets.js
Normal file
152
backend/src/routes/assets.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue