import { FastifyInstance } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { z } from 'zod'; const prisma = new PrismaClient(); const createSensorSchema = z.object({ name: z.string().min(2), type: z.enum(['TEMPERATURE', 'HUMIDITY', 'CO2', 'LIGHT_PAR', 'LIGHT_LUX', 'PH', 'EC', 'VPD', 'SOIL_MOISTURE', 'AIR_FLOW']), roomId: z.string().uuid().optional(), location: z.string().optional(), deviceId: z.string().optional(), manufacturer: z.string().optional(), model: z.string().optional(), minThreshold: z.number().optional(), maxThreshold: z.number().optional() }); const readingSchema = z.object({ value: z.number(), unit: z.string(), timestamp: z.string().datetime().optional() }); const profileSchema = z.object({ name: z.string().min(2), stage: z.string().optional(), roomId: z.string().uuid().optional(), tempMinF: z.number().optional(), tempMaxF: z.number().optional(), humidityMin: z.number().optional(), humidityMax: z.number().optional(), co2Min: z.number().optional(), co2Max: z.number().optional(), lightHours: z.number().optional(), vpdMin: z.number().optional(), vpdMax: z.number().optional(), isDefault: z.boolean().optional() }); export async function environmentRoutes(fastify: FastifyInstance) { // Auth middleware fastify.addHook('onRequest', async (request) => { try { await request.jwtVerify(); } catch (err) { throw err; } }); // ==================== SENSORS ==================== /** * GET /sensors * List all sensors */ fastify.get('/sensors', { handler: async (request, reply) => { try { const { roomId, type, active } = request.query as any; const where: any = {}; if (roomId) where.roomId = roomId; if (type) where.type = type; if (active !== undefined) where.isActive = active === 'true'; const sensors = await prisma.sensor.findMany({ where, include: { readings: { orderBy: { timestamp: 'desc' }, take: 1 }, _count: { select: { alerts: true } } }, orderBy: { name: 'asc' } }); return sensors.map(s => ({ ...s, latestReading: s.readings[0] || null, alertCount: s._count.alerts })); } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch sensors' }); } } }); /** * POST /sensors * Create new sensor */ fastify.post('/sensors', { handler: async (request, reply) => { try { const data = createSensorSchema.parse(request.body); const sensor = await prisma.sensor.create({ data }); return sensor; } catch (error: any) { if (error.name === 'ZodError') { return reply.status(400).send({ error: 'Validation failed', details: error.errors }); } fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create sensor' }); } } }); /** * POST /sensors/:id/readings * Submit sensor reading */ fastify.post('/sensors/:id/readings', { handler: async (request, reply) => { try { const { id } = request.params as any; const data = readingSchema.parse(request.body); const sensor = await prisma.sensor.findUnique({ where: { id } }); if (!sensor) { return reply.status(404).send({ error: 'Sensor not found' }); } const reading = await prisma.sensorReading.create({ data: { sensorId: id, value: data.value, unit: data.unit, timestamp: data.timestamp ? new Date(data.timestamp) : new Date() } }); // Check thresholds and create alert if needed if (sensor.minThreshold && data.value < sensor.minThreshold) { await prisma.environmentAlert.create({ data: { sensorId: id, roomId: sensor.roomId, type: `${sensor.type}_LOW`, severity: 'WARNING', message: `${sensor.name} reading ${data.value}${data.unit} below minimum threshold ${sensor.minThreshold}${data.unit}`, value: data.value, threshold: sensor.minThreshold } }); } if (sensor.maxThreshold && data.value > sensor.maxThreshold) { await prisma.environmentAlert.create({ data: { sensorId: id, roomId: sensor.roomId, type: `${sensor.type}_HIGH`, severity: 'WARNING', message: `${sensor.name} reading ${data.value}${data.unit} above maximum threshold ${sensor.maxThreshold}${data.unit}`, value: data.value, threshold: sensor.maxThreshold } }); } return reading; } catch (error: any) { if (error.name === 'ZodError') { return reply.status(400).send({ error: 'Validation failed', details: error.errors }); } fastify.log.error(error); return reply.status(500).send({ error: 'Failed to submit reading' }); } } }); /** * GET /sensors/:id/readings * Get sensor readings history */ fastify.get('/sensors/:id/readings', { handler: async (request, reply) => { try { const { id } = request.params as any; const { hours = 24, limit = 1000 } = request.query as any; const since = new Date(Date.now() - hours * 60 * 60 * 1000); const readings = await prisma.sensorReading.findMany({ where: { sensorId: id, timestamp: { gte: since } }, orderBy: { timestamp: 'asc' }, take: parseInt(limit) }); return { sensorId: id, period: { hours: parseInt(hours), since: since.toISOString() }, count: readings.length, readings }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch readings' }); } } }); // ==================== ALERTS ==================== /** * GET /alerts * Get environment alerts */ fastify.get('/alerts', { handler: async (request, reply) => { try { const { roomId, severity, resolved, limit = 50 } = request.query as any; const where: any = {}; if (roomId) where.roomId = roomId; if (severity) where.severity = severity; if (resolved !== undefined) { where.resolvedAt = resolved === 'true' ? { not: null } : null; } const alerts = await prisma.environmentAlert.findMany({ where, include: { sensor: { select: { id: true, name: true, type: true } } }, orderBy: { createdAt: 'desc' }, take: parseInt(limit) }); return alerts; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch alerts' }); } } }); /** * POST /alerts/:id/acknowledge * Acknowledge an alert */ fastify.post('/alerts/:id/acknowledge', { handler: async (request, reply) => { try { const { id } = request.params as any; const user = request.user as any; const alert = await prisma.environmentAlert.update({ where: { id }, data: { acknowledgedAt: new Date(), acknowledgedBy: user.id } }); return alert; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to acknowledge alert' }); } } }); /** * POST /alerts/:id/resolve * Resolve an alert */ fastify.post('/alerts/:id/resolve', { handler: async (request, reply) => { try { const { id } = request.params as any; const user = request.user as any; const alert = await prisma.environmentAlert.update({ where: { id }, data: { resolvedAt: new Date(), resolvedBy: user.id } }); return alert; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to resolve alert' }); } } }); // ==================== ENVIRONMENT PROFILES ==================== /** * GET /profiles * List environment profiles */ fastify.get('/profiles', { handler: async (request, reply) => { try { const profiles = await prisma.environmentProfile.findMany({ orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] }); return profiles; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch profiles' }); } } }); /** * POST /profiles * Create environment profile */ fastify.post('/profiles', { handler: async (request, reply) => { try { const data = profileSchema.parse(request.body); const profile = await prisma.environmentProfile.create({ data }); return profile; } catch (error: any) { if (error.name === 'ZodError') { return reply.status(400).send({ error: 'Validation failed', details: error.errors }); } fastify.log.error(error); return reply.status(500).send({ error: 'Failed to create profile' }); } } }); // ==================== DASHBOARD ==================== /** * GET /dashboard * Get environment dashboard data */ fastify.get('/dashboard', { handler: async (request, reply) => { try { const { roomId } = request.query as any; const where = roomId ? { roomId } : {}; const sensorWhere = roomId ? { roomId, isActive: true } : { isActive: true }; // Get latest readings by sensor type const sensors = await prisma.sensor.findMany({ where: sensorWhere, include: { readings: { orderBy: { timestamp: 'desc' }, take: 1 } } }); // Group by type with latest value const byType: Record = {}; sensors.forEach(s => { if (s.readings[0]) { if (!byType[s.type]) byType[s.type] = []; byType[s.type].push({ value: s.readings[0].value, unit: s.readings[0].unit, sensor: s.name, timestamp: s.readings[0].timestamp.toISOString() }); } }); // Calculate averages const averages: Record = {}; Object.entries(byType).forEach(([type, readings]) => { averages[type] = readings.reduce((sum, r) => sum + r.value, 0) / readings.length; }); // Get active alerts const activeAlerts = await prisma.environmentAlert.findMany({ where: { ...where, resolvedAt: null }, orderBy: { createdAt: 'desc' }, take: 10 }); // Get active profile const profile = roomId ? await prisma.environmentProfile.findFirst({ where: { roomId } }) : await prisma.environmentProfile.findFirst({ where: { isDefault: true } }); return { sensors: sensors.length, readings: byType, averages, alerts: { active: activeAlerts.length, list: activeAlerts }, profile, timestamp: new Date().toISOString() }; } catch (error) { fastify.log.error(error); return reply.status(500).send({ error: 'Failed to fetch dashboard' }); } } }); }