diff --git a/server/migrations/20250922000002-add-alert-event-id.js b/server/migrations/20250922000002-add-alert-event-id.js new file mode 100644 index 0000000..aca01da --- /dev/null +++ b/server/migrations/20250922000002-add-alert-event-id.js @@ -0,0 +1,87 @@ +/** + * Migration: Add alert_event_id to alert_logs table + * This migration adds alert_event_id field to group related alerts (SMS, email, webhook) + * that are part of the same detection event + */ + +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + try { + // Check if alert_logs table exists first + const tables = await queryInterface.showAllTables(); + if (!tables.includes('alert_logs')) { + console.log('⚠️ Alert_logs table does not exist yet, skipping alert event ID migration...'); + return; + } + + // Check if alert_event_id column already exists + const tableDescription = await queryInterface.describeTable('alert_logs'); + + if (!tableDescription.alert_event_id) { + // Add alert_event_id column + await queryInterface.addColumn('alert_logs', 'alert_event_id', { + type: Sequelize.UUID, + allowNull: true, + comment: 'Groups related alerts (SMS, email, webhook) that are part of the same detection event' + }); + console.log('✅ Added alert_event_id column to alert_logs table'); + + // Add index for alert_event_id for better query performance + try { + await queryInterface.addIndex('alert_logs', ['alert_event_id'], { + name: 'alert_logs_alert_event_id_idx' + }); + console.log('✅ Added index on alert_logs.alert_event_id'); + } catch (error) { + if (error.parent?.code === '42P07') { // Index already exists + console.log('⚠️ Index alert_logs_alert_event_id already exists, skipping...'); + } else { + throw error; + } + } + } else { + console.log('⚠️ Column alert_event_id already exists in alert_logs table, skipping...'); + } + + } catch (error) { + console.error('❌ Error in migration 20250922000002-add-alert-event-id:', error); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + try { + // Check if alert_logs table exists + const tables = await queryInterface.showAllTables(); + if (!tables.includes('alert_logs')) { + console.log('⚠️ Alert_logs table does not exist, skipping migration rollback...'); + return; + } + + // Check if alert_event_id column exists + const tableDescription = await queryInterface.describeTable('alert_logs'); + + if (tableDescription.alert_event_id) { + // Remove index first + try { + await queryInterface.removeIndex('alert_logs', 'alert_logs_alert_event_id_idx'); + console.log('✅ Removed index alert_logs_alert_event_id_idx'); + } catch (error) { + console.log('⚠️ Index alert_logs_alert_event_id_idx might not exist, continuing...'); + } + + // Remove column + await queryInterface.removeColumn('alert_logs', 'alert_event_id'); + console.log('✅ Removed alert_event_id column from alert_logs table'); + } else { + console.log('⚠️ Column alert_event_id does not exist in alert_logs table, skipping...'); + } + + } catch (error) { + console.error('❌ Error in migration rollback 20250922000002-add-alert-event-id:', error); + throw error; + } + } +}; \ No newline at end of file diff --git a/server/models/AlertLog.js b/server/models/AlertLog.js index 0401b63..45639e6 100644 --- a/server/models/AlertLog.js +++ b/server/models/AlertLog.js @@ -31,6 +31,11 @@ module.exports = (sequelize) => { key: 'id' } }, + alert_event_id: { + type: DataTypes.UUID, + allowNull: true, + comment: 'Groups related alerts (SMS, email, webhook) that are part of the same detection event' + }, alert_type: { type: DataTypes.ENUM('sms', 'email', 'webhook', 'push'), allowNull: true, // Allow null for testing @@ -111,6 +116,9 @@ module.exports = (sequelize) => { }, { fields: ['alert_type', 'status'] + }, + { + fields: ['alert_event_id'] } ] }); diff --git a/server/services/alertService.js b/server/services/alertService.js index dfa581e..a8d7170 100644 --- a/server/services/alertService.js +++ b/server/services/alertService.js @@ -2,6 +2,7 @@ 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() { @@ -442,6 +443,9 @@ class AlertService { 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); @@ -461,19 +465,19 @@ class AlertService { 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); + alertLog = await this.sendSMSAlert(rule.sms_phone_number, message, rule, detection, threatAssessment, alertEventId); } 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); + alertLog = await this.sendEmailAlert(rule.email || user.email, message, rule, detection, threatAssessment, alertEventId); } break; case 'webhook': if (rule.alert_channels.includes('webhook') && rule.webhook_url) { - alertLog = await this.sendWebhookAlert(rule.webhook_url, detection, device, rule, threatAssessment); + alertLog = await this.sendWebhookAlert(rule.webhook_url, detection, device, rule, threatAssessment, alertEventId); } break; @@ -482,7 +486,7 @@ class AlertService { } if (alertLog) { - console.log(`🚨 ${threatAssessment.level.toUpperCase()} THREAT: Alert sent via ${channel} for detection ${detection.id}`); + console.log(`🚨 ${threatAssessment.level.toUpperCase()} THREAT: Alert sent via ${channel} for detection ${detection.id} (Event: ${alertEventId})`); } } catch (channelError) { @@ -510,7 +514,7 @@ class AlertService { } } - async sendSMSAlert(phoneNumber, message, rule, detection) { + 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'); @@ -527,7 +531,8 @@ class AlertService { sent_at: new Date(), external_id: null, priority: rule.priority, - error_message: 'SMS service not configured' + error_message: 'SMS service not configured', + alert_event_id: alertEventId }); } @@ -552,7 +557,8 @@ class AlertService { status: 'sent', sent_at: new Date(), external_id: twilioMessage.sid, - priority: rule.priority + priority: rule.priority, + alert_event_id: alertEventId }); } catch (error) { console.error(`❌ Failed to send SMS to ${phoneNumber}:`, error.message); @@ -567,12 +573,13 @@ class AlertService { sent_at: new Date(), external_id: null, priority: rule.priority, - error_message: error.message + error_message: error.message, + alert_event_id: alertEventId }); } } - async sendEmailAlert(email, message, rule, detection) { + 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}`); @@ -585,11 +592,12 @@ class AlertService { message: message, status: 'sent', sent_at: new Date(), - priority: rule.priority + priority: rule.priority, + alert_event_id: alertEventId }); } - async sendWebhookAlert(webhookUrl, detection, device, rule) { + async sendWebhookAlert(webhookUrl, detection, device, rule, threatAssessment = null, alertEventId = null) { const payload = { event: 'drone_detection', timestamp: new Date().toISOString(), @@ -639,7 +647,8 @@ class AlertService { message: JSON.stringify(payload), status: 'sent', sent_at: new Date(), - priority: rule.priority + priority: rule.priority, + alert_event_id: alertEventId }); } @@ -735,7 +744,7 @@ class AlertService { 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); + await this.sendSMSAlert(rule.sms_phone_number, clearMessage, rule, detection, null, null); } } @@ -821,9 +830,10 @@ class AlertService { * @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} - The created alert log entry */ - async logAlert(rule, detection, alertType = 'sms', recipient = 'test@example.com') { + async logAlert(rule, detection, alertType = 'sms', recipient = 'test@example.com', alertEventId = null) { try { const alertLog = await AlertLog.create({ alert_rule_id: rule.id, @@ -832,7 +842,8 @@ class AlertService { recipient: recipient, message: `Alert triggered for ${rule.name}`, status: 'sent', - sent_at: new Date() + sent_at: new Date(), + alert_event_id: alertEventId }); return alertLog; @@ -853,15 +864,21 @@ class AlertService { 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); + 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') { - await this.sendSMSAlert(detection, threat, rule); + // 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({