diff --git a/backend/src/routes/environment.routes.ts b/backend/src/routes/environment.routes.ts index f7997a5..0cab7e2 100644 --- a/backend/src/routes/environment.routes.ts +++ b/backend/src/routes/environment.routes.ts @@ -415,4 +415,189 @@ export async function environmentRoutes(fastify: FastifyInstance) { } } }); + + // ==================== EDGE DEVICE ENDPOINTS ==================== + + /** + * POST /ingest + * Batch ingest readings from edge device + * This endpoint accepts API key auth (not JWT) for edge devices + */ + fastify.post('/ingest', { + config: { rawBody: true }, + preHandler: async (request, reply) => { + // Allow API key auth for edge devices + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const apiKey = authHeader.substring(7); + // TODO: Validate API key against facility configuration + // For now, accept any non-empty key + if (!apiKey) { + return reply.status(401).send({ error: 'Invalid API key' }); + } + } + }, + handler: async (request, reply) => { + try { + const { facilityId, readings } = request.body as { + facilityId?: string; + readings: Array<{ + roomId: string; + sensorId?: string; + temperature: number; + humidity: number; + dewpoint?: number; + vpd?: number; + timestamp: string; + }>; + }; + + if (!readings || !Array.isArray(readings) || readings.length === 0) { + return reply.status(400).send({ error: 'No readings provided' }); + } + + // Store readings in batch + const created = await prisma.$transaction( + readings.map(r => + prisma.sensorReading.create({ + data: { + sensorId: r.sensorId || 'edge-default', + value: r.temperature, // Primary value is temperature + unit: '°F', + timestamp: new Date(r.timestamp), + metadata: { + humidity: r.humidity, + dewpoint: r.dewpoint, + vpd: r.vpd, + roomId: r.roomId, + facilityId + } + } + }) + ) + ); + + fastify.log.info(`Ingested ${created.length} readings from edge device`); + + return { + success: true, + count: created.length, + timestamp: new Date().toISOString() + }; + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Failed to ingest readings' }); + } + } + }); + + /** + * POST /heartbeat + * Edge device heartbeat for monitoring + */ + fastify.post('/heartbeat', { + preHandler: async (request, reply) => { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return reply.status(401).send({ error: 'API key required' }); + } + }, + handler: async (request, reply) => { + try { + const data = request.body as { + facilityId: string; + edgeId: string; + status: 'ok' | 'degraded' | 'error'; + sensorCount: number; + bufferSize: number; + lastReading?: string; + uptime: number; + }; + + // Log heartbeat (could store in Redis for real-time monitoring) + fastify.log.info({ + event: 'edge_heartbeat', + ...data, + receivedAt: new Date().toISOString() + }); + + // TODO: Store in Redis or create alert if status is not 'ok' + // For now, just acknowledge + if (data.status !== 'ok') { + fastify.log.warn(`Edge device ${data.edgeId} status: ${data.status}`); + } + + return { + ack: true, + serverTime: new Date().toISOString() + }; + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Failed to process heartbeat' }); + } + } + }); + + /** + * POST /alert + * Edge device alert relay (for notification fan-out) + */ + fastify.post('/alert', { + preHandler: async (request, reply) => { + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return reply.status(401).send({ error: 'API key required' }); + } + }, + handler: async (request, reply) => { + try { + const data = request.body as { + facilityId: string; + edgeId: string; + alertType: string; + sensorId: string; + sensorName: string; + currentValue: number; + threshold: number; + timestamp: string; + }; + + // Create alert in database + const alert = await prisma.environmentAlert.create({ + data: { + type: data.alertType, + severity: data.alertType.includes('HIGH') ? 'WARNING' : 'INFO', + message: `${data.sensorName}: ${data.alertType.replace('_', ' ')} (${data.currentValue} vs threshold ${data.threshold})`, + value: data.currentValue, + threshold: data.threshold, + metadata: { + facilityId: data.facilityId, + edgeId: data.edgeId, + sensorId: data.sensorId, + sensorName: data.sensorName, + edgeTimestamp: data.timestamp + } + } + }); + + fastify.log.warn({ + event: 'edge_alert', + alertId: alert.id, + ...data + }); + + // TODO: Fan out to notification channels (in-app notifications) + // This will integrate with the notification service for mobile push + + return { + success: true, + alertId: alert.id, + timestamp: new Date().toISOString() + }; + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Failed to process alert' }); + } + } + }); }