881 lines
31 KiB
JavaScript
881 lines
31 KiB
JavaScript
const twilio = require('twilio');
|
||
const { AlertRule, AlertLog, User, Device, DroneDetection } = require('../models');
|
||
const { Op } = require('sequelize');
|
||
const { getDroneTypeInfo } = require('../utils/droneTypes');
|
||
|
||
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') {
|
||
// Special handling for known military drones - only escalate if not at extreme distance
|
||
if (rssi >= -85 && (droneTypeInfo.name === 'Orlan' || droneTypeInfo.name === 'Zala' || droneTypeInfo.name === 'Eleron')) {
|
||
// Military drones within reasonable detection range get escalated
|
||
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 {
|
||
// For very distant military drones (RSSI < -85), preserve distance-based assessment
|
||
description += ` - ${droneTypeInfo.name.toUpperCase()} MILITARY DRONE DETECTED (DISTANT)`;
|
||
console.log(`🚨 MILITARY DRONE DETECTED: ${droneTypeInfo.name} - Using distance-based threat level: ${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) {
|
||
if (!rule.device_ids.includes(detection.device_id)) {
|
||
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: 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 3: 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 4: 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 5: 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 6: 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 7: 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 8: 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 9: Cooldown period check
|
||
if (rule.cooldown_period > 0) {
|
||
const cooldownStart = new Date(Date.now() - rule.cooldown_period * 1000);
|
||
const recentAlert = await AlertLog.findOne({
|
||
where: {
|
||
alert_rule_id: rule.id,
|
||
status: 'sent',
|
||
sent_at: {
|
||
[Op.gte]: cooldownStart
|
||
}
|
||
},
|
||
include: [{
|
||
model: DroneDetection,
|
||
as: 'detection',
|
||
where: {
|
||
device_id: detection.device_id,
|
||
drone_id: detection.drone_id
|
||
}
|
||
}]
|
||
});
|
||
|
||
if (recentAlert) {
|
||
console.log(`❌ Rule "${rule.name}": Still in cooldown period`);
|
||
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 {
|
||
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':
|
||
if (rule.alert_channels.includes('sms') && rule.sms_phone_number) {
|
||
alertLog = await this.sendSMSAlert(rule.sms_phone_number, message, rule, detection, threatAssessment);
|
||
}
|
||
break;
|
||
|
||
case 'email':
|
||
if (rule.alert_channels.includes('email') && (rule.email || user.email)) {
|
||
alertLog = await this.sendEmailAlert(rule.email || user.email, message, rule, detection, threatAssessment);
|
||
}
|
||
break;
|
||
|
||
case 'webhook':
|
||
if (rule.alert_channels.includes('webhook') && rule.webhook_url) {
|
||
alertLog = await this.sendWebhookAlert(rule.webhook_url, detection, device, rule, threatAssessment);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
console.warn(`Unknown alert channel: ${channel}`);
|
||
}
|
||
|
||
if (alertLog) {
|
||
console.log(`🚨 ${threatAssessment.level.toUpperCase()} THREAT: Alert sent via ${channel} for detection ${detection.id}`);
|
||
}
|
||
|
||
} catch (channelError) {
|
||
console.error(`Error sending ${channel} alert:`, channelError);
|
||
|
||
// Log failed alert
|
||
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
|
||
});
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error triggering alert:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async sendSMSAlert(phoneNumber, message, rule, detection) {
|
||
// 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'
|
||
});
|
||
}
|
||
|
||
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
|
||
});
|
||
} 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
|
||
});
|
||
}
|
||
}
|
||
|
||
async sendEmailAlert(email, message, rule, detection) {
|
||
// 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
|
||
});
|
||
}
|
||
|
||
async sendWebhookAlert(webhookUrl, detection, device, rule) {
|
||
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
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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
|
||
const device = await Device.findByPk(detection.device_id);
|
||
if (!device) {
|
||
console.log(`Device ${detection.device_id} not found for detection`);
|
||
return [];
|
||
}
|
||
|
||
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
|
||
* @returns {Promise<Object>} - The created alert log entry
|
||
*/
|
||
async logAlert(rule, detection, alertType = 'sms', recipient = 'test@example.com') {
|
||
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()
|
||
});
|
||
|
||
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) {
|
||
// Assess threat level
|
||
const threat = this.assessThreatLevel(detection.rssi, detection.drone_type);
|
||
|
||
// Log the alert
|
||
const alertLog = await this.logAlert(rule, detection);
|
||
|
||
// Send notification if threshold is met
|
||
if (threat.threatLevel === 'critical' || threat.threatLevel === 'high') {
|
||
await this.sendSMSAlert(detection, threat, rule);
|
||
}
|
||
|
||
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;
|