626 lines
21 KiB
JavaScript
626 lines
21 KiB
JavaScript
const twilio = require('twilio');
|
||
const { AlertRule, AlertLog, User, Device, DroneDetection } = require('../models');
|
||
const { Op } = require('sequelize');
|
||
|
||
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;
|
||
}
|
||
|
||
// Adjust threat level based on drone type (if classified)
|
||
const droneTypes = {
|
||
0: 'Consumer/Hobby',
|
||
1: 'Professional/Military',
|
||
2: 'Racing/High-speed',
|
||
3: 'Unknown/Custom'
|
||
};
|
||
|
||
if (droneType === 1) {
|
||
// Military/Professional drone - escalate threat
|
||
if (threatLevel === 'low') threatLevel = 'medium';
|
||
if (threatLevel === 'medium') threatLevel = 'high';
|
||
if (threatLevel === 'high') threatLevel = 'critical';
|
||
description += ' - PROFESSIONAL/MILITARY DRONE DETECTED';
|
||
actionRequired = true;
|
||
} else if (droneType === 2) {
|
||
// Racing/Fast drone - escalate if close
|
||
if (rssi >= -55 && threatLevel !== 'critical') {
|
||
threatLevel = 'high';
|
||
description += ' - HIGH-SPEED DRONE DETECTED';
|
||
actionRequired = true;
|
||
}
|
||
}
|
||
|
||
return {
|
||
level: threatLevel,
|
||
estimatedDistance: Math.round(estimatedDistance),
|
||
rssi,
|
||
droneType: droneTypes[droneType] || 'Unknown',
|
||
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 {
|
||
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 }
|
||
}]
|
||
});
|
||
|
||
for (const rule of alertRules) {
|
||
if (await this.shouldTriggerAlert(rule, detection, threatAssessment)) {
|
||
await this.triggerAlert(rule, detection, threatAssessment);
|
||
|
||
// Track active alert for potential clear notification
|
||
const alertKey = `${rule.id}-${detection.device_id}`;
|
||
this.activeAlerts.set(alertKey, {
|
||
rule,
|
||
detection,
|
||
threatAssessment,
|
||
alertTime: new Date()
|
||
});
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error processing alert:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async shouldTriggerAlert(rule, detection, threatAssessment) {
|
||
try {
|
||
// SECURITY ENHANCEMENT: Check 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(`Alert rule ${rule.name}: Threat level ${threatAssessment.level} below minimum ${rule.min_threat_level}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// SECURITY ENHANCEMENT: For government/sensitive sites, always alert on critical threats
|
||
if (threatAssessment.level === 'critical') {
|
||
console.log(`🚨 CRITICAL THREAT DETECTED - Force triggering alert for rule ${rule.name}`);
|
||
return true;
|
||
}
|
||
|
||
// Check device filter
|
||
if (rule.device_ids && rule.device_ids.length > 0 &&
|
||
!rule.device_ids.includes(detection.device_id)) {
|
||
return false;
|
||
}
|
||
|
||
// Check drone type filter
|
||
if (rule.drone_types && rule.drone_types.length > 0 &&
|
||
!rule.drone_types.includes(detection.drone_type)) {
|
||
return false;
|
||
}
|
||
|
||
// Check RSSI thresholds (enhanced for security)
|
||
if (rule.min_rssi && detection.rssi < rule.min_rssi) {
|
||
return false;
|
||
}
|
||
|
||
// SECURITY ENHANCEMENT: Check estimated distance
|
||
if (rule.max_distance && threatAssessment.estimatedDistance > rule.max_distance) {
|
||
console.log(`Alert rule ${rule.name}: Distance ${threatAssessment.estimatedDistance}m exceeds maximum ${rule.max_distance}m`);
|
||
return false;
|
||
}
|
||
|
||
// Check 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) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check 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) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check cooldown period
|
||
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) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check 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)) {
|
||
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) {
|
||
return false;
|
||
}
|
||
} else {
|
||
// Overnight range
|
||
if (currentTime < startTime && currentTime > endTime) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
|
||
} catch (error) {
|
||
console.error('Error checking alert conditions:', 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.`;
|
||
}
|
||
}
|
||
|
||
// Export the class directly (not a singleton instance)
|
||
module.exports = AlertService;
|