feat: add edge device endpoints (ingest, heartbeat, alert)
Some checks failed
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled

This commit is contained in:
fullsizemalt 2026-01-02 00:30:10 -08:00
parent b520ffc578
commit 2ca6fb01f4

View file

@ -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' });
}
}
});
} }