Files
drone-detector/server/routes/device.js
2025-09-22 07:25:39 +02:00

598 lines
17 KiB
JavaScript

const express = require('express');
const router = express.Router();
const Joi = require('joi');
const { validateRequest } = require('../middleware/validation');
const { authenticateToken } = require('../middleware/auth');
const MultiTenantAuth = require('../middleware/multi-tenant-auth');
const { Op } = require('sequelize');
// Dynamic model injection for testing
function getModels() {
if (global.__TEST_MODELS__) {
console.log('🔧 DEBUG: Using global test models from models/index.js');
return global.__TEST_MODELS__;
}
return require('../models');
}
// Initialize multi-tenant auth
const multiAuth = new MultiTenantAuth();
// Validation schema for device
const deviceSchema = Joi.object({
id: Joi.string().required().min(1).max(255), // Device ID is required for manual registration - can be string or number
name: Joi.string().max(255).allow('').optional(),
geo_lat: Joi.number().min(-90).max(90).optional(),
geo_lon: Joi.number().min(-180).max(180).optional(),
location_description: Joi.string().allow('').optional(),
heartbeat_interval: Joi.number().integer().min(60).max(3600).optional(),
firmware_version: Joi.string().allow('').optional(),
installation_date: Joi.date().optional(),
notes: Joi.string().allow('').optional()
});
const updateDeviceSchema = Joi.object({
name: Joi.string().max(255).allow('').optional(),
geo_lat: Joi.number().min(-90).max(90).optional(),
geo_lon: Joi.number().min(-180).max(180).optional(),
location_description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
heartbeat_interval: Joi.number().integer().min(60).max(3600).optional(),
firmware_version: Joi.string().allow('').optional(),
installation_date: Joi.date().optional(),
notes: Joi.string().allow('').optional()
});
// GET /api/devices - Get all devices
router.get('/', authenticateToken, async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
const {
include_stats = false,
active_only = false,
limit = 100,
offset = 0
} = req.query;
const whereClause = { tenant_id: tenant.id };
if (active_only === 'true') {
whereClause.is_active = true;
}
const includeOptions = [];
if (include_stats === 'true') {
// Include latest heartbeat and detection count
includeOptions.push({
model: Heartbeat,
as: 'heartbeats',
limit: 1,
order: [['received_at', 'DESC']],
required: false,
attributes: ['received_at']
});
}
const devices = await Device.findAndCountAll({
where: whereClause,
include: includeOptions,
limit: Math.min(parseInt(limit), 1000),
offset: parseInt(offset),
order: [['created_at', 'DESC']]
});
// If stats requested, get detection counts
let devicesWithStats = devices.rows;
if (include_stats === 'true') {
devicesWithStats = await Promise.all(devices.rows.map(async (device) => {
const detectionCount = await DroneDetection.count({
where: {
device_id: device.id,
server_timestamp: {
[Op.gte]: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24 hours
},
drone_type: { [Op.ne]: 0 }
}
});
const now = new Date();
const timeSinceLastHeartbeat = device.last_heartbeat
? (now - new Date(device.last_heartbeat)) / 1000
: null;
const expectedInterval = device.heartbeat_interval || 300;
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < (expectedInterval * 2);
// Debug logging for device status calculation
if (device.id === 1001) { // Stockholm device
console.log(`🔍 DEBUG Device ${device.id} status calculation:`);
console.log(` Now: ${now.toISOString()}`);
console.log(` Last heartbeat: ${device.last_heartbeat}`);
console.log(` Time since last: ${timeSinceLastHeartbeat} seconds`);
console.log(` Expected interval: ${expectedInterval} seconds`);
console.log(` Threshold: ${expectedInterval * 2} seconds`);
console.log(` Is online: ${isOnline}`);
console.log(` Is active: ${device.is_active}`);
console.log(` Final status: ${device.is_active ? (isOnline ? 'online' : 'offline') : 'inactive'}`);
}
return {
...device.toJSON(),
stats: {
detections_24h: detectionCount,
status: device.is_active ? (isOnline ? 'online' : 'offline') : 'inactive',
time_since_last_heartbeat: timeSinceLastHeartbeat
}
};
}));
}
res.json({
success: true,
data: devicesWithStats,
pagination: {
total: devices.count,
limit: parseInt(limit),
offset: parseInt(offset),
pages: Math.ceil(devices.count / parseInt(limit))
}
});
} catch (error) {
console.error('Error fetching devices:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch devices',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// GET /api/devices/map - Get devices with location data for map display
router.get('/map', authenticateToken, async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
// Get active devices for this tenant only
const devices = await Device.findAll({
where: {
is_active: true,
tenant_id: tenant.id
},
attributes: [
'id',
'name',
'geo_lat',
'geo_lon',
'location_description',
'last_heartbeat'
]
});
// Get recent detections for each device and mark coordinate status
const devicesWithDetections = await Promise.all(devices.map(async (device) => {
const recentDetections = await DroneDetection.count({
where: {
device_id: device.id,
server_timestamp: {
[Op.gte]: new Date(Date.now() - 10 * 60 * 1000) // Last 10 minutes
},
drone_type: { [Op.ne]: 0 }
}
});
const now = new Date();
const timeSinceLastHeartbeat = device.last_heartbeat
? (now - new Date(device.last_heartbeat)) / 1000
: null;
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < 600; // 10 minutes
const hasCoordinates = device.geo_lat !== null && device.geo_lon !== null;
return {
...device.toJSON(),
has_recent_detections: recentDetections > 0,
detection_count_10m: recentDetections,
status: isOnline ? 'online' : 'offline',
has_coordinates: hasCoordinates,
coordinate_status: hasCoordinates ? 'complete' : 'incomplete'
};
}));
res.json({
success: true,
data: devicesWithDetections
});
} catch (error) {
console.error('Error fetching devices for map:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch devices for map',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// GET /api/devices/:id - Get specific device
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
const device = await Device.findOne({
where: {
id: req.params.id,
tenant_id: tenant.id
},
include: [
{
model: Heartbeat,
as: 'heartbeats',
limit: 5,
order: [['received_at', 'DESC']]
},
{
model: DroneDetection,
as: 'detections',
limit: 10,
order: [['server_timestamp', 'DESC']]
}
]
});
if (!device) {
return res.status(404).json({
success: false,
message: 'Device not found in your tenant'
});
}
res.json({
success: true,
data: device
});
} catch (error) {
console.error('Error fetching device:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch device',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// POST /api/devices - Create new device (admin only)
router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Check admin role
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Admin role required for device creation'
});
}
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
// Check if device ID already exists in this tenant
const existingDevice = await Device.findOne({
where: {
id: req.body.id,
tenant_id: tenant.id
}
});
if (existingDevice) {
return res.status(409).json({
success: false,
message: 'Device with this ID already exists in your tenant'
});
}
// Create device with tenant association
const deviceData = {
...req.body,
tenant_id: tenant.id,
is_approved: true, // Manually created devices are automatically approved
is_active: true
};
const device = await Device.create(deviceData);
console.log(`✅ Device ${device.id} created in tenant "${tenantId}" by user "${req.user.username}"`);
res.status(201).json({
success: true,
data: device,
message: 'Device created successfully'
});
} catch (error) {
console.error('Error creating device:', error);
if (error.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({
success: false,
message: 'Device with this ID already exists'
});
}
res.status(500).json({
success: false,
message: 'Failed to create device',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// PUT /api/devices/:id - Update device
router.put('/:id', authenticateToken, validateRequest(updateDeviceSchema), async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Check admin role
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Admin role required for device updates'
});
}
const device = await Device.findByPk(req.params.id);
if (!device) {
return res.status(404).json({
success: false,
message: 'Device not found'
});
}
// Check if device belongs to user's tenant
const tenantId = await multiAuth.determineTenant(req);
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (device.tenant_id !== tenant.id) {
return res.status(404).json({
success: false,
message: 'Device not found'
});
}
console.log(`📝 Device ${req.params.id} update requested by user ${req.user.id} (${req.user.username})`);
console.log('Update data:', req.body);
await device.update(req.body);
// Emit real-time update to tenant room only
if (req.io && device.tenant_id) {
req.io.to(`tenant_${device.tenant_id}`).emit('device_updated', device);
}
console.log(`✅ Device ${req.params.id} updated successfully`);
res.json({
success: true,
data: device,
message: 'Device updated successfully'
});
} catch (error) {
console.error('Error updating device:', error);
res.status(500).json({
success: false,
message: 'Failed to update device',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// DELETE /api/devices/:id - Delete device (admin only)
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
// Check admin role
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Admin role required for device deletion'
});
}
const device = await Device.findByPk(req.params.id);
if (!device) {
return res.status(404).json({
success: false,
message: 'Device not found'
});
}
// Check if device belongs to user's tenant
const tenantId = await multiAuth.determineTenant(req);
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (device.tenant_id !== tenant.id) {
return res.status(404).json({
success: false,
message: 'Device not found'
});
}
// Actually delete the device
await device.destroy();
res.json({
success: true,
message: 'Device deactivated successfully'
});
} catch (error) {
console.error('Error deleting device:', error);
res.status(500).json({
success: false,
message: 'Failed to delete device',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// GET /api/devices/pending - List devices pending approval
router.get('/pending', authenticateToken, async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
const pendingDevices = await Device.findAll({
where: { is_approved: false },
attributes: [
'id', 'name', 'geo_lat', 'geo_lon', 'last_heartbeat',
'created_at', 'firmware_version', 'is_approved'
],
order: [['created_at', 'DESC']]
});
res.json({
success: true,
data: pendingDevices,
count: pendingDevices.length
});
} catch (error) {
console.error('Error fetching pending devices:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch pending devices',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
// POST /api/devices/:id/approve - Approve or reject a device
router.post('/:id/approve', async (req, res) => {
try {
const { Device, DroneDetection, Heartbeat, Tenant } = getModels();
const deviceId = parseInt(req.params.id);
const { approved } = req.body;
if (typeof approved !== 'boolean') {
return res.status(400).json({
success: false,
message: 'approved field must be a boolean'
});
}
const device = await Device.findByPk(deviceId);
if (!device) {
return res.status(404).json({
success: false,
message: 'Device not found'
});
}
await device.update({
is_approved: approved,
is_active: approved // Set device as active when approved, inactive when unapproved
});
// Emit real-time notification to tenant room only
const { io } = require('../index');
if (io && device.tenant_id) {
io.to(`tenant_${device.tenant_id}`).emit('device_approval_updated', {
device_id: deviceId,
approved: approved,
timestamp: new Date().toISOString(),
message: approved ?
`Device ${deviceId} has been approved` :
`Device ${deviceId} approval has been revoked`
});
}
console.log(`${approved ? '✅' : '❌'} Device ${deviceId} approval ${approved ? 'granted' : 'revoked'}`);
res.json({
success: true,
data: device,
message: approved ? 'Device approved successfully' : 'Device approval revoked'
});
} catch (error) {
console.error('Error updating device approval:', error);
res.status(500).json({
success: false,
message: 'Failed to update device approval',
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
module.exports = router;