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)
418 lines
14 KiB
TypeScript
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' });
|
|
}
|
|
}
|
|
});
|
|
}
|