using GreenHome.Application; using GreenHome.Domain; using GreenHome.Sms.Ippanel; using GreenHome.VoiceCall.Avanak; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal; using Microsoft.Extensions.Logging; using System.Globalization; using System.Text.Json; using static GreenHome.Sms.Ippanel.IppanelSmsService; namespace GreenHome.Infrastructure; public sealed class AlertService : IAlertService { private readonly GreenHomeDbContext dbContext; private readonly IDeviceSettingsService deviceSettingsService; private readonly ISmsService smsService; private readonly IVoiceCallService voiceCallService; private readonly ISunCalculatorService sunCalculatorService; private readonly ILogger logger; public AlertService( GreenHomeDbContext dbContext, IDeviceSettingsService deviceSettingsService, ISmsService smsService, IVoiceCallService voiceCallService, ISunCalculatorService sunCalculatorService, ILogger logger) { this.dbContext = dbContext; this.deviceSettingsService = deviceSettingsService; this.smsService = smsService; this.voiceCallService = voiceCallService; this.sunCalculatorService = sunCalculatorService; this.logger = logger; } public async Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken) { // Get device with all users who should receive alerts var device = await dbContext.Devices .Include(d => d.User) .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts)) .ThenInclude(du => du.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) { logger.LogWarning("Device not found: DeviceId={DeviceId}", deviceId); return null; } // Get device settings for location var settings = await deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken); var recievers = device.User.Mobile; recievers += "," + (settings?.SmsRecievers ?? ""); recievers = string.Join(",", recievers .Split(',') .Select(x => x.Trim()) .Where(x => x.Length > 0) ); // Get all users who should receive alerts // var usersToAlert = device.DeviceUsers // .Where(du => du.ReceiveAlerts) // .Select(du => du.User) // .ToList(); if (recievers.Length == 0) { logger.LogInformation("No users with ReceiveAlerts enabled for device: DeviceId={DeviceId}", deviceId); return null; } // Get all enabled alert conditions for this device var conditions = await dbContext.AlertConditions .Include(c => c.Rules) .Where(c => c.DeviceId == deviceId && c.IsEnabled) .ToListAsync(cancellationToken); if (!conditions.Any()) { logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId); //return null; } // Determine if it's daytime or nighttime bool? isDaytime = null; if (settings?.Latitude != null && settings.Longitude != null) { isDaytime = sunCalculatorService.IsDaytime(DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value); } List messages = []; // Check each condition foreach (var condition in conditions) { // Check time type filter if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false) { continue; // This condition is for daytime only, but it's nighttime } if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true) { continue; // This condition is for nighttime only, but it's daytime } // Check if all rules match (AND logic) var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry)); if (allRulesMatch && condition.Rules.Any()) { // All rules passed, send alert if cooldown period has passed // await SendAlertForConditionAsync(condition, device, usersToAlert, telemetry, cancellationToken); string? message = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken); if(message != null && message.Length > 0) messages.Add(message); // return message; } } if(telemetry.OldPower == 0 && telemetry.Power == 1) { messages.Add("برق دستگاه متصل شد"); } else if(telemetry.OldPower == 1 && telemetry.Power == 0) { messages.Add("برق دستگاه قطع شد"); } if(messages.Any()) { return $"tt{recievers}#{string.Join("@", messages)}"; } return null; } private bool CheckRule(Domain.AlertRule rule, TelemetryDto telemetry) { // Get sensor value var sensorValue = rule.SensorType switch { Domain.SensorType.Temperature => telemetry.TemperatureC, Domain.SensorType.Humidity => telemetry.HumidityPercent, Domain.SensorType.Soil => telemetry.SoilPercent, Domain.SensorType.Gas => telemetry.GasPPM, Domain.SensorType.Lux => telemetry.Lux, _ => 0m }; // Check comparison return rule.ComparisonType switch { Domain.ComparisonType.GreaterThan => sensorValue > rule.Value1, Domain.ComparisonType.LessThan => sensorValue < rule.Value1, Domain.ComparisonType.Between => rule.Value2 != null && sensorValue >= rule.Value1 && sensorValue <= rule.Value2.Value, Domain.ComparisonType.OutOfRange => rule.Value2 != null && (sensorValue < rule.Value1 || sensorValue > rule.Value2.Value), _ => false }; } private async Task GetAlertMessage( Domain.AlertCondition condition, Domain.Device device, User user, TelemetryDto telemetry, CancellationToken cancellationToken) { // Determine cooldown based on notification type var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call ? condition.CallCooldownMinutes : condition.SmsCooldownMinutes; // Build alert message once var message = BuildAlertMessage(condition, device.DeviceName, telemetry); var sentAt = DateTime.UtcNow; var cooldownTime = sentAt.AddMinutes(-cooldownMinutes); var recentAlert = await dbContext.AlertNotifications .Where(a => a.DeviceId == device.Id && a.UserId == user.Id && a.AlertConditionId == condition.Id && a.SentAt >= cooldownTime) .FirstOrDefaultAsync(cancellationToken); if (recentAlert != null) { logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}", device.Id, user.Id, condition.Id); return null; } var notification = new Domain.AlertNotification { DeviceId = device.Id, UserId = user.Id, AlertConditionId = condition.Id, NotificationType = condition.NotificationType, Message = message, //MessageOutboxIds = messageOutboxIds, //ErrorMessage = errorMessage, SentAt = sentAt, IsSent = true }; dbContext.AlertNotifications.Add(notification); return message; } private async Task SendAlertForConditionAsync( Domain.AlertCondition condition, Domain.Device device, List usersToAlert, TelemetryDto telemetry, CancellationToken cancellationToken) { // Determine cooldown based on notification type var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call ? condition.CallCooldownMinutes : condition.SmsCooldownMinutes; // Build alert message once var message = BuildAlertMessage(condition, device.DeviceName, telemetry); var sentAt = DateTime.UtcNow; // Send alert to each user foreach (var user in usersToAlert) { // Check if alert was sent recently to this user var cooldownTime = sentAt.AddMinutes(-cooldownMinutes); var recentAlert = await dbContext.AlertNotifications .Where(a => a.DeviceId == device.Id && a.UserId == user.Id && a.AlertConditionId == condition.Id && a.SentAt >= cooldownTime) .FirstOrDefaultAsync(cancellationToken); if (recentAlert != null) { logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}", device.Id, user.Id, condition.Id); continue; } // Send notification var startTime = DateTime.UtcNow; string? messageOutboxIds = null; string? errorMessage = null; bool isSent = false; try { if (condition.NotificationType == Domain.AlertNotificationType.SMS) { (isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken); } else // Call { (isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync(user.Mobile, device.DeviceName, message, cancellationToken); } } catch (Exception ex) { errorMessage = $"Exception: {ex.Message}"; if (ex.InnerException != null) { errorMessage += $" | InnerException: {ex.InnerException.Message}"; } isSent = false; logger.LogError(ex, "Failed to send alert: DeviceId={DeviceId}, UserId={UserId}, ConditionId={ConditionId}", device.Id, user.Id, condition.Id); } var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; // Save notification to database (old table for backwards compatibility) var notification = new Domain.AlertNotification { DeviceId = device.Id, UserId = user.Id, AlertConditionId = condition.Id, NotificationType = condition.NotificationType, Message = message, MessageOutboxIds = messageOutboxIds, ErrorMessage = errorMessage, SentAt = sentAt, IsSent = isSent }; dbContext.AlertNotifications.Add(notification); // Log the alert var alertLog = new Domain.AlertLog { DeviceId = device.Id, UserId = user.Id, AlertConditionId = condition.Id, AlertType = Domain.AlertType.Condition, NotificationType = condition.NotificationType, Message = message, Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed, ErrorMessage = errorMessage, PhoneNumber = user.Mobile, SentAt = sentAt, ProcessingTimeMs = processingTime }; dbContext.AlertLogs.Add(alertLog); } await dbContext.SaveChangesAsync(cancellationToken); } private string BuildAlertMessage(Domain.AlertCondition condition, string deviceName, TelemetryDto telemetry) { var parts = new List(); parts.Add($"هشدار گلخانه {deviceName}:"); foreach (var rule in condition.Rules.OrderBy(r => r.Order)) { var sensorName = rule.SensorType switch { Domain.SensorType.Temperature => "دما", Domain.SensorType.Humidity => "رطوبت", Domain.SensorType.Soil => "رطوبت خاک", Domain.SensorType.Gas => "گاز", Domain.SensorType.Lux => "نور", _ => "سنسور" }; var sensorValue = rule.SensorType switch { Domain.SensorType.Temperature => telemetry.TemperatureC, Domain.SensorType.Humidity => telemetry.HumidityPercent, Domain.SensorType.Soil => telemetry.SoilPercent, Domain.SensorType.Gas => telemetry.GasPPM, Domain.SensorType.Lux => telemetry.Lux, _ => 0m }; var unit = rule.SensorType switch { Domain.SensorType.Temperature => "°C", Domain.SensorType.Humidity => "%", Domain.SensorType.Soil => "%", Domain.SensorType.Gas => "PPM", Domain.SensorType.Lux => "لوکس", _ => "" }; var conditionText = rule.ComparisonType switch { Domain.ComparisonType.GreaterThan => $"{sensorName} ({sensorValue:F1}{unit}) بیشتر از {rule.Value1}{unit}", Domain.ComparisonType.LessThan => $"{sensorName} ({sensorValue:F1}{unit}) کمتر از {rule.Value1}{unit}", Domain.ComparisonType.Between => $"{sensorName} ({sensorValue:F1}{unit}) بین {rule.Value1} و {rule.Value2}{unit}", Domain.ComparisonType.OutOfRange => $"{sensorName} ({sensorValue:F1}{unit}) خارج از محدوده {rule.Value1} تا {rule.Value2}{unit}", _ => $"{sensorName}: {sensorValue:F1}{unit}" }; parts.Add(conditionText); } return string.Join(" و ", parts); } private async Task<(bool isSent, string? messageOutboxIds, string? errorMessage)> SendSmsAlertAsync( string mobile, string deviceName, string message, CancellationToken cancellationToken) { try { var smsResponse = await smsService.SendPatternSmsAsync(new PatternSmsRequest { Recipients = [mobile], PatternCode = "64di3w9kb0fxvif", Variables = new Dictionary { { "name", deviceName }, { "parameter", "شرایط" }, { "value", message }, { "status", "هشدار" } } }, cancellationToken); if (smsResponse != null && smsResponse.Meta.Status && smsResponse.Data?.MessageOutboxIds != null && smsResponse.Data.MessageOutboxIds.Count > 0) { var outboxIds = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds); logger.LogInformation("Alert SMS sent: Mobile={Mobile}, OutboxIds={OutboxIds}", mobile, outboxIds); return (true, outboxIds, null); } else { var errors = new List(); if (!string.IsNullOrWhiteSpace(smsResponse?.Meta.Message)) { errors.Add(smsResponse.Meta.Message); } if (smsResponse?.Meta.Errors != null) { foreach (var error in smsResponse.Meta.Errors) { errors.Add($"{error.Key}: {string.Join(", ", error.Value)}"); } } var errorMsg = errors.Count > 0 ? string.Join(" | ", errors) : "Unknown SMS error"; logger.LogWarning("Alert SMS failed: Mobile={Mobile}, Error={Error}", mobile, errorMsg); return (false, null, errorMsg); } } catch (Exception ex) { var errorMsg = $"Exception: {ex.Message}"; logger.LogError(ex, "Exception sending SMS alert: Mobile={Mobile}", mobile); return (false, null, errorMsg); } } private async Task<(bool isSent, string? callId, string? errorMessage)> SendCallAlertAsync( string mobile, string deviceName, string message, CancellationToken cancellationToken) { try { // TODO: Implement voice call integration // For now, just log and return success logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message); // Placeholder: In real implementation, call voiceCallService here // var callResponse = await voiceCallService.MakeCallAsync(mobile, message, cancellationToken); return (true, null, "Voice call not yet implemented"); } catch (Exception ex) { var errorMsg = $"Exception: {ex.Message}"; logger.LogError(ex, "Exception sending call alert: Mobile={Mobile}", mobile); return (false, null, errorMsg); } } public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken) { // Get device with all users who should receive alerts var device = await dbContext.Devices .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts)) .ThenInclude(du => du.User) .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); if (device == null) { logger.LogWarning("Device not found for power outage alert: DeviceId={DeviceId}", deviceId); throw new InvalidOperationException($"دستگاه با شناسه {deviceId} یافت نشد"); } // Get all users who should receive alerts var usersToAlert = device.DeviceUsers .Where(du => du.ReceiveAlerts) .Select(du => du.User) .ToList(); if (usersToAlert.Count == 0) { logger.LogInformation("No users with ReceiveAlerts enabled for power outage: DeviceId={DeviceId}", deviceId); return; } var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است."; var sentAt = DateTime.UtcNow; // Send to all users (both SMS and Call for power outage - it's critical!) foreach (var user in usersToAlert) { // Send SMS await SendPowerOutageNotificationAsync( device, user, message, sentAt, Domain.AlertNotificationType.SMS, cancellationToken); // Send Call (important alert) await SendPowerOutageNotificationAsync( device, user, message, sentAt, Domain.AlertNotificationType.Call, cancellationToken); } await dbContext.SaveChangesAsync(cancellationToken); logger.LogInformation("Power outage alerts sent to {Count} users for device {DeviceId}", usersToAlert.Count, deviceId); } private async Task SendPowerOutageNotificationAsync( Domain.Device device, Domain.User user, string message, DateTime sentAt, Domain.AlertNotificationType notificationType, CancellationToken cancellationToken) { var startTime = DateTime.UtcNow; string? messageOutboxIds = null; string? errorMessage = null; bool isSent = false; try { if (notificationType == Domain.AlertNotificationType.SMS) { (isSent, messageOutboxIds, errorMessage) = await SendSmsAlertAsync( user.Mobile, device.DeviceName, message, cancellationToken); } else { (isSent, messageOutboxIds, errorMessage) = await SendCallAlertAsync( user.Mobile, device.DeviceName, message, cancellationToken); } } catch (Exception ex) { errorMessage = $"Exception: {ex.Message}"; if (ex.InnerException != null) { errorMessage += $" | InnerException: {ex.InnerException.Message}"; } isSent = false; logger.LogError(ex, "Failed to send power outage alert: DeviceId={DeviceId}, UserId={UserId}, Type={Type}", device.Id, user.Id, notificationType); } var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; // Save notification (old table) var notification = new Domain.AlertNotification { DeviceId = device.Id, UserId = user.Id, AlertConditionId = null, NotificationType = notificationType, Message = message, MessageOutboxIds = messageOutboxIds, ErrorMessage = errorMessage, SentAt = sentAt, IsSent = isSent }; dbContext.AlertNotifications.Add(notification); // Log the alert var alertLog = new Domain.AlertLog { DeviceId = device.Id, UserId = user.Id, AlertConditionId = null, AlertType = Domain.AlertType.PowerOutage, NotificationType = notificationType, Message = message, Status = isSent ? Domain.AlertStatus.Success : Domain.AlertStatus.Failed, ErrorMessage = errorMessage, PhoneNumber = user.Mobile, SentAt = sentAt, ProcessingTimeMs = processingTime }; dbContext.AlertLogs.Add(alertLog); } }