Files
drone-detector/server/services/alertService.js
2025-09-22 10:57:15 +02:00

947 lines
36 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const twilio = require('twilio');
const { AlertRule, AlertLog, User, Device, DroneDetection } = require('../models');
const { Op } = require('sequelize');
const { getDroneTypeInfo } = require('../utils/droneTypes');
const { v4: uuidv4 } = require('uuid');
class AlertService {
constructor() {
this.twilioClient = null;
this.twilioPhone = null;
this.twilioEnabled = false;
this.activeAlerts = new Map(); // Track active alerts for clear notifications
this.initializeTwilio();
}
// RSSI-based threat assessment for security installations
assessThreatLevel(rssi, droneType) {
// RSSI typically ranges from -30 (very close) to -100 (very far)
// For 15km range detection, we need to establish threat zones for sensitive facilities
let threatLevel = 'low';
let estimatedDistance = 0;
let description = '';
let actionRequired = false;
// Convert RSSI to estimated distance (rough calculation)
// Formula: Distance (m) = 10^((RSSI_at_1m - RSSI) / (10 * n))
// Where n = path loss exponent (typically 2-4 for outdoor environments)
const rssiAt1m = -30; // Typical RSSI at 1 meter
const pathLossExponent = 3; // Outdoor environment with obstacles
estimatedDistance = Math.pow(10, (rssiAt1m - rssi) / (10 * pathLossExponent));
// Threat level assessment based on distance zones for sensitive facilities
if (rssi >= -40) {
// Very close: 0-50 meters - CRITICAL THREAT
threatLevel = 'critical';
description = 'IMMEDIATE THREAT: Drone within security perimeter (0-50m)';
actionRequired = true;
} else if (rssi >= -55) {
// Close: 50-200 meters - HIGH THREAT
threatLevel = 'high';
description = 'HIGH THREAT: Drone approaching facility (50-200m)';
actionRequired = true;
} else if (rssi >= -70) {
// Medium: 200-1000 meters - MEDIUM THREAT
threatLevel = 'medium';
description = 'MEDIUM THREAT: Drone in facility vicinity (200m-1km)';
actionRequired = false;
} else if (rssi >= -85) {
// Far: 1-5 kilometers - LOW THREAT
threatLevel = 'low';
description = 'LOW THREAT: Drone detected at distance (1-5km)';
actionRequired = false;
} else {
// Very far: 5-15 kilometers - MONITORING ONLY
threatLevel = 'monitoring';
description = 'MONITORING: Drone detected at long range (5-15km)';
actionRequired = false;
}
// Get drone type information using our comprehensive mapping
const droneTypeInfo = getDroneTypeInfo(droneType);
// Store original distance-based assessment
const distanceBasedThreat = threatLevel;
// Adjust threat level based on drone type and category
if (droneTypeInfo.category.includes('Military') && droneTypeInfo.name !== 'Unknown') {
// Military drones are escalated based on distance
if (droneTypeInfo.name === 'Orlan' || droneTypeInfo.name === 'Zala' || droneTypeInfo.name === 'Eleron') {
// For moderately distant military drones (RSSI between -87 and -80), escalate to critical
if (rssi <= -80 && rssi >= -87) {
threatLevel = 'critical';
description = `CRITICAL THREAT: ${droneTypeInfo.name.toUpperCase()} MILITARY DRONE - IMMEDIATE RESPONSE REQUIRED`;
actionRequired = true;
console.log(`🚨 DISTANT MILITARY DRONE DETECTED: ${droneTypeInfo.name} - Escalated to CRITICAL (RSSI: ${rssi})`);
} else {
// For closer military drones or very distant ones, keep distance-based assessment but enhance description
if (threatLevel === 'critical' && rssi >= -40) {
description = `CRITICAL THREAT: ${droneTypeInfo.name.toUpperCase()} MILITARY DRONE - IMMEDIATE THREAT`;
} else if (threatLevel === 'critical') {
description = description.replace('IMMEDIATE THREAT:', `CRITICAL THREAT: ${droneTypeInfo.name.toUpperCase()} MILITARY DRONE -`);
}
console.log(`🚨 MILITARY DRONE DETECTED: ${droneTypeInfo.name} - Distance-based assessment (RSSI: ${rssi})`);
}
} else {
// Other military drones get escalated one level from distance-based
if (distanceBasedThreat === 'monitoring') threatLevel = 'low';
else if (distanceBasedThreat === 'low') threatLevel = 'medium';
else if (distanceBasedThreat === 'medium') threatLevel = 'high';
else if (distanceBasedThreat === 'high') threatLevel = 'critical';
description = `MILITARY THREAT: ${droneTypeInfo.name.toUpperCase()} DETECTED - ENHANCED RESPONSE REQUIRED`;
actionRequired = (threatLevel !== 'low' && threatLevel !== 'monitoring');
console.log(`🚨 MILITARY DRONE DETECTED: ${droneTypeInfo.name} - Escalated to ${threatLevel} (RSSI: ${rssi})`);
}
} else if (droneTypeInfo.threat_level === 'high' || droneTypeInfo.category.includes('Professional')) {
// Professional/Commercial drone - escalate threat one level from distance-based
if (distanceBasedThreat === 'monitoring') threatLevel = 'low';
else if (distanceBasedThreat === 'low') threatLevel = 'medium';
else if (distanceBasedThreat === 'medium') threatLevel = 'high';
description += ` - ${droneTypeInfo.name.toUpperCase()} DETECTED`;
actionRequired = (threatLevel !== 'low' && threatLevel !== 'monitoring');
} else if (droneTypeInfo.category.includes('Racing')) {
// Racing/Fast drone - escalate if close
if (rssi >= -55) {
if (distanceBasedThreat === 'monitoring') threatLevel = 'low';
else if (distanceBasedThreat === 'low') threatLevel = 'medium';
else if (distanceBasedThreat === 'medium') threatLevel = 'high';
description += ` - HIGH-SPEED ${droneTypeInfo.name.toUpperCase()} DETECTED`;
actionRequired = true;
}
}
return {
level: threatLevel,
estimatedDistance: Math.round(estimatedDistance),
rssi,
droneType: droneTypeInfo.name,
droneCategory: droneTypeInfo.category,
threatLevel: droneTypeInfo.threat_level,
description,
requiresImmediateAction: actionRequired,
priority: threatLevel === 'critical' ? 1 : threatLevel === 'high' ? 2 : threatLevel === 'medium' ? 3 : 4
};
}
initializeTwilio() {
// Check if Twilio credentials are provided
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const phoneNumber = process.env.TWILIO_PHONE_NUMBER;
// If any Twilio credential is missing or empty, disable SMS functionality
if (!accountSid || !authToken || !phoneNumber ||
accountSid.trim() === '' || authToken.trim() === '' || phoneNumber.trim() === '') {
console.log('📱 Twilio credentials not configured - SMS alerts disabled (Development Mode)');
console.log(' To enable SMS alerts, set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER');
this.twilioEnabled = false;
this.twilioClient = null;
return;
}
// Validate Twilio Account SID format (only if provided)
if (accountSid && !accountSid.startsWith('AC')) {
console.log('⚠️ Invalid Twilio Account SID format - SMS alerts disabled (Development Mode)');
console.log(' Account SID must start with "AC"');
this.twilioEnabled = false;
this.twilioClient = null;
return;
}
try {
this.twilioClient = twilio(accountSid, authToken);
this.twilioPhone = phoneNumber;
this.twilioEnabled = true;
console.log('📱 Twilio SMS service initialized successfully');
} catch (error) {
console.error('❌ Failed to initialize Twilio:', error.message);
console.log('📱 SMS alerts disabled due to Twilio initialization error');
this.twilioEnabled = false;
this.twilioClient = null;
}
}
async processAlert(detection) {
try {
// Skip alert processing for drone type 0 (None) - no actual detection
if (detection.drone_type === 0) {
console.log(`🔍 Skipping alert processing for drone type 0 (None) - detection ${detection.id}`);
return;
}
console.log(`🔍 Processing alert for detection ${detection.id}`);
// Assess threat level based on RSSI and drone type
const threatAssessment = this.assessThreatLevel(detection.rssi, detection.drone_type);
console.log('⚠️ Threat assessment:', threatAssessment);
// Update detection with threat assessment
await detection.update({
processed: true,
threat_level: threatAssessment.level,
estimated_distance: threatAssessment.estimatedDistance
});
// Get all active alert rules
const alertRules = await AlertRule.findAll({
where: { is_active: true },
include: [{
model: User,
as: 'user',
where: { is_active: true }
}],
order: [
// Order by priority: critical, high, medium, low
['priority', 'DESC']
]
});
console.log(`📋 Found ${alertRules.length} active alert rules`);
// ENHANCED: Filter rules that match this detection and find the highest priority one
const matchingRules = [];
for (const rule of alertRules) {
if (await this.shouldTriggerAlert(rule, detection, threatAssessment)) {
matchingRules.push(rule);
console.log(`✅ Rule "${rule.name}" matches (priority: ${rule.priority})`);
} else {
console.log(`❌ Rule "${rule.name}" does not match conditions`);
}
}
if (matchingRules.length === 0) {
console.log('📭 No alert rules match this detection');
return;
}
// PRIORITY-BASED DEDUPLICATION: Only trigger the highest priority rule
const highestPriorityRule = matchingRules[0]; // Already sorted by priority DESC
console.log(`🎯 ALERT DEDUPLICATION: ${matchingRules.length} rules matched, triggering only highest priority:`);
console.log(` Selected Rule: "${highestPriorityRule.name}" (priority: ${highestPriorityRule.priority})`);
if (matchingRules.length > 1) {
console.log(` Skipped Rules:`);
matchingRules.slice(1).forEach(rule => {
console.log(` - "${rule.name}" (priority: ${rule.priority})`);
});
}
// Trigger only the highest priority alert
await this.triggerAlert(highestPriorityRule, detection, threatAssessment);
// Track active alert for potential clear notification
const alertKey = `${highestPriorityRule.id}-${detection.device_id}`;
this.activeAlerts.set(alertKey, {
rule: highestPriorityRule,
detection,
threatAssessment,
alertTime: new Date()
});
} catch (error) {
console.error('Error processing alert:', error);
throw error;
}
}
async shouldTriggerAlert(rule, detection, threatAssessment) {
try {
console.log(`🔍 Evaluating rule "${rule.name}" for detection from device ${detection.device_id}`);
// PRIORITY 1: Device-specific filter (most important)
if (rule.device_ids && rule.device_ids.length > 0) {
// Convert both device IDs to strings for consistent comparison
const deviceIdStr = String(detection.device_id);
const allowedDeviceIds = rule.device_ids.map(id => String(id));
console.log(`🔍 DEBUG: Device comparison - Detection device: ${deviceIdStr} (type: ${typeof detection.device_id})`);
console.log(`🔍 DEBUG: Allowed devices: [${allowedDeviceIds.join(', ')}] (types: [${rule.device_ids.map(id => typeof id).join(', ')}])`);
console.log(`🔍 DEBUG: String comparison result: ${allowedDeviceIds.includes(deviceIdStr)}`);
if (!allowedDeviceIds.includes(deviceIdStr)) {
console.log(`❌ Rule "${rule.name}": Device ${detection.device_id} not in allowed devices [${rule.device_ids.join(', ')}]`);
return false;
} else {
console.log(`✅ Rule "${rule.name}": Device ${detection.device_id} is in allowed devices`);
}
} else {
console.log(`✅ Rule "${rule.name}": No device filter (applies to all devices)`);
}
// PRIORITY 2: Cooldown period check (applies even to critical threats for the same device+drone combination)
if (rule.cooldown_period > 0) {
const cooldownStart = new Date(Date.now() - rule.cooldown_period * 1000);
// First, get all recent alert logs for this rule that were successfully sent
const recentAlerts = await AlertLog.findAll({
where: {
alert_rule_id: rule.id,
status: 'sent',
sent_at: {
[Op.gte]: cooldownStart
}
},
include: [{
model: DroneDetection,
as: 'detection',
attributes: ['device_id', 'drone_id'],
required: false // Allow alerts without detection_id (like device offline alerts)
}]
});
// Check if any recent alert was for the same device AND same drone
const deviceDroneAlert = recentAlerts.find(alert => {
// For alerts with detection, check both device_id AND drone_id match
if (alert.detection &&
alert.detection.device_id === detection.device_id &&
alert.detection.drone_id === detection.drone_id) {
return true;
}
// For alerts without detection (like device offline), check device_id field directly
// These don't have drone_id so they follow device-level cooldown
if (!alert.detection && alert.device_id === detection.device_id) {
return true;
}
return false;
});
if (deviceDroneAlert) {
console.log(`❌ Rule "${rule.name}": Still in cooldown period for device ${detection.device_id} + drone ${detection.drone_id} (last alert: ${deviceDroneAlert.sent_at})`);
return false;
} else {
console.log(`✅ Rule "${rule.name}": Cooldown period expired or no recent alerts for device ${detection.device_id} + drone ${detection.drone_id}`);
}
}
// PRIORITY 3: Critical threats and Orlan drones (security overrides)
const isOrlanDrone = detection.drone_type === 1;
const isCriticalThreat = threatAssessment.level === 'critical';
if (isOrlanDrone) {
console.log(`🚨 ORLAN DRONE DETECTED - Rule "${rule.name}" will trigger (security override)`);
return true;
}
if (isCriticalThreat) {
console.log(`🚨 CRITICAL THREAT DETECTED - Rule "${rule.name}" will trigger (security override)`);
return true;
}
// PRIORITY 4: Threat level requirements
if (rule.min_threat_level) {
const threatLevels = { 'monitoring': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4 };
const requiredLevel = threatLevels[rule.min_threat_level] || 0;
const currentLevel = threatLevels[threatAssessment.level] || 0;
if (currentLevel < requiredLevel) {
console.log(`❌ Rule "${rule.name}": Threat level ${threatAssessment.level} below minimum ${rule.min_threat_level}`);
return false;
} else {
console.log(`✅ Rule "${rule.name}": Threat level ${threatAssessment.level} meets minimum ${rule.min_threat_level}`);
}
}
// PRIORITY 5: Drone type filter
if (rule.drone_types && rule.drone_types.length > 0 &&
!rule.drone_types.includes(detection.drone_type)) {
console.log(`❌ Rule "${rule.name}": Drone type ${detection.drone_type} not in allowed types [${rule.drone_types.join(', ')}]`);
return false;
}
// PRIORITY 6: RSSI thresholds (distance-based)
if (rule.min_rssi && detection.rssi < rule.min_rssi) {
console.log(`❌ Rule "${rule.name}": RSSI ${detection.rssi} below minimum ${rule.min_rssi}`);
return false;
}
if (rule.max_rssi && detection.rssi > rule.max_rssi) {
console.log(`❌ Rule "${rule.name}": RSSI ${detection.rssi} above maximum ${rule.max_rssi}`);
return false;
}
// PRIORITY 7: Distance requirements
if (rule.max_distance && threatAssessment.estimatedDistance > rule.max_distance) {
console.log(`❌ Rule "${rule.name}": Distance ${threatAssessment.estimatedDistance}m exceeds maximum ${rule.max_distance}m`);
return false;
}
// PRIORITY 8: Frequency ranges
if (rule.frequency_ranges && rule.frequency_ranges.length > 0) {
const inRange = rule.frequency_ranges.some(range =>
detection.freq >= range.min && detection.freq <= range.max
);
if (!inRange) {
console.log(`❌ Rule "${rule.name}": Frequency ${detection.freq}MHz not in allowed ranges`);
return false;
}
}
// PRIORITY 9: Time window and minimum detections
if (rule.min_detections > 1) {
const timeWindowStart = new Date(Date.now() - rule.time_window * 1000);
const recentDetections = await DroneDetection.count({
where: {
device_id: detection.device_id,
drone_id: detection.drone_id,
server_timestamp: {
[Op.gte]: timeWindowStart
}
}
});
if (recentDetections < rule.min_detections) {
console.log(`❌ Rule "${rule.name}": Only ${recentDetections} detections, need ${rule.min_detections}`);
return false;
}
}
// PRIORITY 10: Active hours and days
if (rule.active_hours || rule.active_days) {
const now = new Date();
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
const currentTime = currentHour * 60 + currentMinute;
const currentDay = now.getDay() || 7; // Convert Sunday from 0 to 7
if (rule.active_days && !rule.active_days.includes(currentDay)) {
console.log(`❌ Rule "${rule.name}": Not active on day ${currentDay}`);
return false;
}
if (rule.active_hours) {
const startTime = this.parseTime(rule.active_hours.start);
const endTime = this.parseTime(rule.active_hours.end);
if (startTime !== null && endTime !== null) {
if (startTime <= endTime) {
// Same day range
if (currentTime < startTime || currentTime > endTime) {
console.log(`❌ Rule "${rule.name}": Outside active hours`);
return false;
}
} else {
// Overnight range
if (currentTime < startTime && currentTime > endTime) {
console.log(`❌ Rule "${rule.name}": Outside active hours (overnight)`);
return false;
}
}
}
}
}
console.log(`✅ Rule "${rule.name}": All conditions met - WILL TRIGGER`);
return true;
} catch (error) {
console.error(`Error evaluating alert rule ${rule.name}:`, error);
return false;
}
}
async triggerAlert(rule, detection, threatAssessment) {
try {
// Generate unique event ID to group related alerts
const alertEventId = uuidv4();
const user = rule.user;
const device = await Device.findByPk(detection.device_id);
// Generate enhanced alert message with threat assessment
const message = this.generateSecurityAlertMessage(detection, device, rule, threatAssessment);
// SECURITY ENHANCEMENT: For critical threats, send to all available channels
const channels = threatAssessment.level === 'critical'
? ['sms', 'email', 'webhook'] // Force all channels for critical threats
: rule.alert_channels || ['sms'];
// Send alerts through configured channels
for (const channel of channels) {
let alertLog = null;
try {
switch (channel) {
case 'sms':
// For critical threats, force SMS even if not in rule channels, otherwise check rule configuration
if ((threatAssessment.level === 'critical' || rule.alert_channels.includes('sms')) && rule.sms_phone_number) {
alertLog = await this.sendSMSAlert(rule.sms_phone_number, message, rule, detection, threatAssessment, alertEventId);
}
break;
case 'email':
// For critical threats, force email even if not in rule channels, otherwise check rule configuration
if ((threatAssessment.level === 'critical' || rule.alert_channels.includes('email')) && (rule.email || user.email)) {
alertLog = await this.sendEmailAlert(rule.email || user.email, message, rule, detection, threatAssessment, alertEventId);
}
break;
case 'webhook':
// For critical threats, force webhook even if not in rule channels, otherwise check rule configuration
if ((threatAssessment.level === 'critical' || rule.alert_channels.includes('webhook')) && rule.webhook_url) {
alertLog = await this.sendWebhookAlert(rule.webhook_url, detection, device, rule, threatAssessment, alertEventId);
}
break;
default:
console.warn(`Unknown alert channel: ${channel}`);
}
if (alertLog) {
console.log(`🚨 ${threatAssessment.level.toUpperCase()} THREAT: Alert sent via ${channel} for detection ${detection.id} (Event: ${alertEventId})`);
}
} catch (channelError) {
console.error(`Error sending ${channel} alert:`, channelError);
// Log failed alert - FIXED: Include alert_event_id for proper grouping
await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: channel,
recipient: channel === 'sms' ? (rule.sms_phone_number || 'SMS_CONFIGURED') :
channel === 'email' ? (rule.email || user.email || 'EMAIL_CONFIGURED') :
(rule.webhook_url || 'WEBHOOK_CONFIGURED'),
message: message,
status: 'failed',
error_message: channelError.message,
priority: rule.priority,
alert_event_id: alertEventId // FIXED: Add missing alert_event_id for grouping
});
}
}
} catch (error) {
console.error('Error triggering alert:', error);
throw error;
}
}
async sendSMSAlert(phoneNumber, message, rule, detection, threatAssessment = null, alertEventId = null) {
// Check if Twilio is enabled
if (!this.twilioEnabled || !this.twilioClient) {
console.log('📱 SMS alert skipped - Twilio not configured');
console.log(`📱 Would have sent to ${phoneNumber}:`);
console.log(`📱 Message: ${message}`);
return await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: 'sms',
recipient: phoneNumber,
message: message,
status: 'failed',
sent_at: new Date(),
external_id: null,
priority: rule.priority,
error_message: 'SMS service not configured',
alert_event_id: alertEventId
});
}
try {
console.log(`📱 Sending SMS alert to ${phoneNumber} for rule: ${rule.name}`);
console.log(`📱 Message: ${message}`);
const twilioMessage = await this.twilioClient.messages.create({
body: message,
from: this.twilioPhone,
to: phoneNumber
});
console.log(`✅ SMS sent successfully to ${phoneNumber}: ${twilioMessage.sid}`);
return await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: 'sms',
recipient: phoneNumber,
message: message,
status: 'sent',
sent_at: new Date(),
external_id: twilioMessage.sid,
priority: rule.priority,
alert_event_id: alertEventId
});
} catch (error) {
console.error(`❌ Failed to send SMS to ${phoneNumber}:`, error.message);
return await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: 'sms',
recipient: phoneNumber,
message: message,
status: 'failed',
sent_at: new Date(),
external_id: null,
priority: rule.priority,
error_message: error.message,
alert_event_id: alertEventId
});
}
}
async sendEmailAlert(email, message, rule, detection, threatAssessment = null, alertEventId = null) {
// Email implementation would go here
// For now, just log the alert
console.log(`Email alert would be sent to ${email}: ${message}`);
return await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: 'email',
recipient: email,
message: message,
status: 'sent',
sent_at: new Date(),
priority: rule.priority,
alert_event_id: alertEventId
});
}
async sendWebhookAlert(webhookUrl, detection, device, rule, threatAssessment = null, alertEventId = null) {
const payload = {
event: 'drone_detection',
timestamp: new Date().toISOString(),
detection: {
id: detection.id,
device_id: detection.device_id,
drone_id: detection.drone_id,
drone_type: detection.drone_type,
rssi: detection.rssi,
freq: detection.freq,
geo_lat: detection.geo_lat,
geo_lon: detection.geo_lon,
server_timestamp: detection.server_timestamp
},
device: {
id: device.id,
name: device.name,
geo_lat: device.geo_lat,
geo_lon: device.geo_lon,
location_description: device.location_description
},
alert_rule: {
id: rule.id,
name: rule.name,
priority: rule.priority
}
};
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'DroneDetectionSystem/1.0'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Webhook failed with status ${response.status}`);
}
return await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: 'webhook',
recipient: webhookUrl,
message: JSON.stringify(payload),
status: 'sent',
sent_at: new Date(),
priority: rule.priority,
alert_event_id: alertEventId
});
}
generateAlertMessage(detection, device, rule) {
const deviceName = device.name || `Device ${device.id}`;
const location = device.location_description ||
`${device.geo_lat}, ${device.geo_lon}` ||
'Unknown location';
return `🚨 DRONE ALERT: Drone detected by ${deviceName} at ${location}. ` +
`Drone ID: ${detection.drone_id}, Frequency: ${detection.freq}MHz, ` +
`RSSI: ${detection.rssi}dBm. Time: ${new Date().toLocaleString()}`;
}
// SECURITY ENHANCEMENT: Enhanced message generation with threat assessment
generateSecurityAlertMessage(detection, device, rule, threatAssessment) {
const timestamp = new Date().toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm' });
const deviceName = device ? device.name : `Device ${detection.device_id}`;
const location = device ? device.location : 'Unknown location';
// Create security-focused alert message
let message = `🚨 SECURITY ALERT 🚨\n`;
message += `THREAT LEVEL: ${threatAssessment.level.toUpperCase()}\n`;
message += `${threatAssessment.description}\n\n`;
message += `📍 LOCATION: ${location}\n`;
message += `🔧 DEVICE: ${deviceName}\n`;
message += `📏 DISTANCE: ~${threatAssessment.estimatedDistance}m\n`;
message += `📶 SIGNAL: ${detection.rssi} dBm\n`;
message += `🚁 DRONE TYPE: ${threatAssessment.droneType}\n`;
message += `⏰ TIME: ${timestamp}\n`;
if (threatAssessment.requiresImmediateAction) {
message += `\n⚠️ IMMEDIATE ACTION REQUIRED\n`;
message += `Security personnel should respond immediately.`;
}
return message;
}
parseTime(timeString) {
if (!timeString) return null;
const match = timeString.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
return null;
}
return hours * 60 + minutes;
}
// Check for cleared alerts (call this periodically)
async checkClearedAlerts() {
try {
const now = new Date();
const clearThreshold = 5 * 60 * 1000; // 5 minutes without detection = cleared
for (const [alertKey, alertData] of this.activeAlerts.entries()) {
const timeSinceAlert = now - alertData.alertTime;
if (timeSinceAlert > clearThreshold) {
// Check if there are any recent detections from this device
const recentDetections = await DroneDetection.count({
where: {
device_id: alertData.detection.device_id,
device_timestamp: {
[Op.gte]: new Date(now - clearThreshold)
}
}
});
if (recentDetections === 0) {
await this.sendClearAlert(alertData);
this.activeAlerts.delete(alertKey);
}
}
}
} catch (error) {
console.error('Error checking cleared alerts:', error);
}
}
async sendClearAlert(alertData) {
const { rule, detection } = alertData;
if (rule.alert_channels.includes('sms') && rule.sms_phone_number) {
const device = await Device.findByPk(detection.device_id);
const clearMessage = this.generateClearMessage(device, rule);
console.log(`🟢 ALERT CLEARED - Sending clear notification for ${device.name} to ${rule.sms_phone_number}`);
await this.sendSMSAlert(rule.sms_phone_number, clearMessage, rule, detection, null, null);
}
}
generateClearMessage(device, rule) {
return `🟢 ALL CLEAR 🟢\n\n` +
`📍 LOCATION: ${device.location_description || device.name}\n` +
`🔧 DEVICE: ${device.name}\n` +
`⏰ TIME: ${new Date().toLocaleString('sv-SE')}\n\n` +
`✅ No drone activity detected for 5+ minutes.\n` +
`🛡️ Area is secure.`;
}
/**
* Check alert rules for a detection and trigger alerts if needed
* @param {Object} detection - The drone detection object
* @returns {Promise<Array>} - Array of triggered alerts
*/
async checkAlertRules(detection) {
try {
// Get the device to determine tenant context
console.log(`🔍 Looking for device with ID: ${detection.device_id} (type: ${typeof detection.device_id})`);
let device = await Device.findByPk(detection.device_id);
if (!device) {
// Try with string conversion if numeric lookup failed
const deviceIdStr = String(detection.device_id);
device = await Device.findByPk(deviceIdStr);
if (!device) {
console.log(`Device ${detection.device_id} not found for detection`);
return [];
}
console.log(`✅ Found device with string conversion: ${deviceIdStr}`);
}
const rules = await AlertRule.findAll({
where: {
tenant_id: device.tenant_id,
is_active: true
}
});
const triggeredAlerts = [];
for (const rule of rules) {
let shouldTrigger = true;
// Check drone type filter
if (rule.drone_type !== null && rule.drone_type !== detection.drone_type) {
shouldTrigger = false;
}
// Check minimum RSSI
if (rule.min_rssi !== null && detection.rssi < rule.min_rssi) {
shouldTrigger = false;
}
// Check maximum distance (if coordinates are available)
if (rule.max_distance !== null && detection.geo_lat && detection.geo_lon) {
// Calculate distance logic would go here
// For now, assume within range
}
if (shouldTrigger) {
triggeredAlerts.push({
rule_id: rule.id,
rule_name: rule.name,
rule: rule,
detection: detection,
triggered_at: new Date()
});
}
}
return triggeredAlerts;
} catch (error) {
console.error('Error checking alert rules:', error);
return [];
}
}
/**
* Log an alert to the database
* @param {Object} rule - The alert rule that was triggered
* @param {Object} detection - The detection that triggered the alert
* @param {string} alertType - Type of alert (sms, email, etc.)
* @param {string} recipient - Alert recipient
* @param {string} alertEventId - UUID to group related alerts from the same event
* @returns {Promise<Object>} - The created alert log entry
*/
async logAlert(rule, detection, alertType = 'sms', recipient = 'test@example.com', alertEventId = null) {
try {
const alertLog = await AlertLog.create({
alert_rule_id: rule.id,
detection_id: detection.id,
alert_type: alertType,
recipient: recipient,
message: `Alert triggered for ${rule.name}`,
status: 'sent',
sent_at: new Date(),
alert_event_id: alertEventId
});
return alertLog;
} catch (error) {
console.error('Error logging alert:', error);
throw error;
}
}
/**
* Process a detection alert workflow
* @param {Object} detection - The drone detection
* @returns {Promise<Array>} - Array of processed alerts
*/
async processDetectionAlert(detection) {
try {
const triggeredRules = await this.checkAlertRules(detection);
const processedAlerts = [];
for (const rule of triggeredRules) {
// Generate unique event ID for this rule/detection combination
const alertEventId = uuidv4();
// Assess threat level
const threat = this.assessThreatLevel(detection.rssi, detection.drone_type);
// Log the alert
const alertLog = await this.logAlert(rule, detection, 'sms', 'test@example.com', alertEventId);
// Send notification if threshold is met
if (threat.threatLevel === 'critical' || threat.threatLevel === 'high') {
// Need phone number for SMS - this might need to be updated based on rule configuration
const phoneNumber = rule.sms_phone_number || 'test-phone';
const message = `THREAT DETECTED: ${threat.description}`;
await this.sendSMSAlert(phoneNumber, message, rule, detection, threat, alertEventId);
}
processedAlerts.push({
rule,
threat,
alertLog
});
}
return processedAlerts;
} catch (error) {
console.error('Error processing detection alert:', error);
return [];
}
}
/**
* Clear expired alerts from active tracking
* @returns {Promise<number>} - Number of alerts cleared
*/
async clearExpiredAlerts() {
try {
const expiredCount = this.activeAlerts.size;
this.activeAlerts.clear();
return expiredCount;
} catch (error) {
console.error('Error clearing expired alerts:', error);
return 0;
}
}
/**
* Cleanup old alerts from the database
* @param {number} maxAge - Maximum age in milliseconds (default: 30 days)
* @returns {Promise<number>} - Number of alerts cleaned up
*/
async cleanupOldAlerts(maxAge = 30 * 24 * 60 * 60 * 1000) {
try {
const cutoffDate = new Date(Date.now() - maxAge);
const deletedCount = await AlertLog.destroy({
where: {
sent_at: {
[Op.lt]: cutoffDate
}
}
});
return deletedCount;
} catch (error) {
console.error('Error cleaning up old alerts:', error);
return 0;
}
}
}
// Export the class directly (not a singleton instance)
module.exports = AlertService;