ca-grow-ops-manager/backend/src/routes/environment.routes.ts
fullsizemalt 32fd739ccf
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
feat: Complete Phases 8-13 implementation
Phase 8: Visitor Management
- Visitor/VisitorLog/AccessZone models
- Check-in/out with badge generation
- Zone occupancy tracking
- Kiosk and management pages

Phase 9: Messaging & Communication
- Announcements with priority levels
- Acknowledgement tracking
- Shift notes for team handoffs
- AnnouncementBanner component

Phase 10: Compliance & Audit Trail
- Immutable AuditLog model
- Document versioning and approval workflow
- Acknowledgement tracking for SOPs
- CSV export for audit logs

Phase 11: Accessibility & i18n
- WCAG 2.1 AA compliance utilities
- react-i18next with EN/ES translations
- User preferences context (theme, font size, etc)
- High contrast and reduced motion support

Phase 12: Hardware Integration
- QR code generation for batches/plants/visitors
- Printable label system
- Visitor badge printing

Phase 13: Advanced Features
- Environmental monitoring (sensors, readings, alerts)
- Financial tracking (transactions, P&L reports)
- AI/ML insights (yield predictions, anomaly detection)
2025-12-11 00:26:25 -08:00

418 lines
14 KiB
TypeScript

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<string, { value: number; unit: string; sensor: string; timestamp: string }[]> = {};
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<string, number> = {};
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' });
}
}
});
}