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>
152 lines
6.5 KiB
JavaScript
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 }
|
|
})
|
|
}
|