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 } }) }