diff --git a/src/GreenHome.Api/Controllers/PowerOutageController.cs b/src/GreenHome.Api/Controllers/PowerOutageController.cs index 5a83b80..4925aa0 100644 --- a/src/GreenHome.Api/Controllers/PowerOutageController.cs +++ b/src/GreenHome.Api/Controllers/PowerOutageController.cs @@ -129,11 +129,12 @@ public class PowerOutageController : ControllerBase return BadRequest(new { error = "شناسه دستگاه نامعتبر است" }); } + // Get all outage events within the date range var filter = new PowerOutageFilter { StartDateUtc = startUtc, EndDateUtc = endUtc, - PageSize = int.MaxValue // Get all records for summary + PageSize = int.MaxValue }; var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken); @@ -141,6 +142,26 @@ public class PowerOutageController : ControllerBase var outages = result.Items.Where(x => x.CurrentPower == 0).ToList(); var connections = result.Items.Where(x => x.CurrentPower == 1).ToList(); + // For outages that don't have a matching connect within the range, + // look beyond the end date to find when power was restored + var unmatchedOutages = outages + .Where(o => !connections.Any(c => c.TimestampUtc > o.TimestampUtc)) + .ToList(); + + if (unmatchedOutages.Count > 0 && endUtc.HasValue) + { + // Fetch any connect events after the range end for unmatched outages + var extendedFilter = new PowerOutageFilter + { + StartDateUtc = endUtc.Value, + PageSize = int.MaxValue + }; + + var extendedResult = await _powerOutageLogService.ListAsync(deviceId, extendedFilter, cancellationToken); + var futureConnections = extendedResult.Items.Where(x => x.CurrentPower == 1).ToList(); + connections.AddRange(futureConnections); + } + // Calculate total downtime by pairing disconnects with subsequent connects long totalDowntimeSeconds = 0; var pairedOutages = new List(); @@ -167,7 +188,7 @@ public class PowerOutageController : ControllerBase } else { - // Still disconnected (no subsequent connect) + // Still disconnected (no subsequent connect found anywhere) pairedOutages.Add(new PowerOutagePairDto { DisconnectedAt = outage.TimestampUtc, diff --git a/src/GreenHome.Infrastructure/DependencyInjection.cs b/src/GreenHome.Infrastructure/DependencyInjection.cs index 1b9e6e2..d2f03bd 100644 --- a/src/GreenHome.Infrastructure/DependencyInjection.cs +++ b/src/GreenHome.Infrastructure/DependencyInjection.cs @@ -38,6 +38,10 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Device services.AddScoped(); diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertConditionEvaluator.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertConditionEvaluator.cs new file mode 100644 index 0000000..84cb38d --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertConditionEvaluator.cs @@ -0,0 +1,81 @@ +using GreenHome.Application; +using GreenHome.Domain; + +namespace GreenHome.Infrastructure.Services.Alert; + +/// +/// Evaluates alert conditions against telemetry data. +/// Handles time-type filtering (Day/Night/Always) and rule matching. +/// +public sealed class AlertConditionEvaluator +{ + private readonly ISunCalculatorService _sunCalculatorService; + + public AlertConditionEvaluator(ISunCalculatorService sunCalculatorService) + { + _sunCalculatorService = sunCalculatorService; + } + + /// + /// Returns conditions whose time-type and all rules match the given telemetry. + /// + public IReadOnlyList GetMatchingConditions( + IEnumerable conditions, + TelemetryDto telemetry, + DeviceSettingsDto? settings) + { + bool? isDaytime = null; + if (settings?.Latitude != null && settings.Longitude != null) + { + isDaytime = _sunCalculatorService.IsDaytime( + DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value); + } + + var matched = new List(); + + foreach (var condition in conditions) + { + if (!condition.IsEnabled) + continue; + + // Filter by time of day + if (condition.TimeType == AlertTimeType.Day && isDaytime == false) + continue; + if (condition.TimeType == AlertTimeType.Night && isDaytime == true) + continue; + + // All rules must match (AND logic) + if (condition.Rules.Count > 0 && condition.Rules.All(rule => CheckRule(rule, telemetry))) + { + matched.Add(condition); + } + } + + return matched; + } + + /// + /// Checks whether a single rule is satisfied by the telemetry data. + /// + public static bool CheckRule(AlertRule rule, TelemetryDto telemetry) + { + var sensorValue = rule.SensorType switch + { + SensorType.Temperature => telemetry.TemperatureC, + SensorType.Humidity => telemetry.HumidityPercent, + SensorType.Soil => telemetry.SoilPercent, + SensorType.Gas => telemetry.GasPPM, + SensorType.Lux => telemetry.Lux, + _ => 0m + }; + + return rule.ComparisonType switch + { + ComparisonType.GreaterThan => sensorValue > rule.Value1, + ComparisonType.LessThan => sensorValue < rule.Value1, + ComparisonType.Between => rule.Value2 != null && sensorValue >= rule.Value1 && sensorValue <= rule.Value2.Value, + ComparisonType.OutOfRange => rule.Value2 != null && (sensorValue < rule.Value1 || sensorValue > rule.Value2.Value), + _ => false + }; + } +} diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertMessageBuilder.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertMessageBuilder.cs new file mode 100644 index 0000000..3d93329 --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertMessageBuilder.cs @@ -0,0 +1,67 @@ +using GreenHome.Application; +using GreenHome.Domain; + +namespace GreenHome.Infrastructure.Services.Alert; + +/// +/// Builds human-readable (Persian) alert messages from alert conditions and telemetry data. +/// +public sealed class AlertMessageBuilder +{ + /// + /// Builds a descriptive Persian alert message for the given condition and telemetry. + /// + public string BuildMessage(AlertCondition condition, string deviceName, TelemetryDto telemetry) + { + var parts = new List + { + $"هشدار گلخانه {deviceName}:" + }; + + foreach (var rule in condition.Rules.OrderBy(r => r.Order)) + { + var sensorName = rule.SensorType switch + { + SensorType.Temperature => "دما", + SensorType.Humidity => "رطوبت", + SensorType.Soil => "رطوبت خاک", + SensorType.Gas => "گاز", + SensorType.Lux => "نور", + _ => "سنسور" + }; + + var sensorValue = rule.SensorType switch + { + SensorType.Temperature => telemetry.TemperatureC, + SensorType.Humidity => telemetry.HumidityPercent, + SensorType.Soil => telemetry.SoilPercent, + SensorType.Gas => telemetry.GasPPM, + SensorType.Lux => telemetry.Lux, + _ => 0m + }; + + var unit = rule.SensorType switch + { + SensorType.Temperature => "°C", + SensorType.Humidity => "%", + SensorType.Soil => "%", + SensorType.Gas => "PPM", + SensorType.Lux => "لوکس", + _ => "" + }; + + var conditionText = rule.ComparisonType switch + { + ComparisonType.GreaterThan => $"{sensorName} ({sensorValue:F1}{unit}) بیشتر از {rule.Value1}{unit}", + ComparisonType.LessThan => $"{sensorName} ({sensorValue:F1}{unit}) کمتر از {rule.Value1}{unit}", + ComparisonType.Between => $"{sensorName} ({sensorValue:F1}{unit}) بین {rule.Value1} و {rule.Value2}{unit}", + ComparisonType.OutOfRange => $"{sensorName} ({sensorValue:F1}{unit}) خارج از محدوده {rule.Value1} تا {rule.Value2}{unit}", + _ => $"{sensorName}: {sensorValue:F1}{unit}" + }; + + parts.Add(conditionText); + } + + return string.Join(" و ", parts); + } +} diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs new file mode 100644 index 0000000..56a8bcd --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertNotificationSender.cs @@ -0,0 +1,200 @@ +using GreenHome.Application; +using GreenHome.Domain; +using GreenHome.Infrastructure.Data; +using GreenHome.Sms.Ippanel; +using GreenHome.VoiceCall.Avanak; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace GreenHome.Infrastructure.Services.Alert; + +// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace +using DomainDevice = GreenHome.Domain.Device; + +/// +/// Handles sending alert notifications via SMS or Voice Call, +/// including cooldown checks and error handling. +/// +public sealed class AlertNotificationSender +{ + private readonly GreenHomeDbContext _dbContext; + private readonly ISmsService _smsService; + private readonly IVoiceCallService _voiceCallService; + private readonly ILogger _logger; + + public AlertNotificationSender( + GreenHomeDbContext dbContext, + ISmsService smsService, + IVoiceCallService voiceCallService, + ILogger logger) + { + _dbContext = dbContext; + _smsService = smsService; + _voiceCallService = voiceCallService; + _logger = logger; + } + + /// + /// Result of sending a single notification. + /// + public sealed record SendResult( + bool IsSent, + string? MessageOutboxIds, + string? ErrorMessage, + long ProcessingTimeMs); + + /// + /// Checks cooldown and sends an alert notification for a condition to a user. + /// Returns null if skipped due to cooldown. + /// + public async Task TrySendConditionAlertAsync( + AlertCondition condition, + DomainDevice device, + User user, + string message, + CancellationToken cancellationToken) + { + // Check cooldown + var cooldownMinutes = condition.NotificationType == AlertNotificationType.Call + ? condition.CallCooldownMinutes + : condition.SmsCooldownMinutes; + + 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; + } + + // Send + return await SendAsync( + user.Mobile, device.DeviceName, message, + condition.NotificationType, cancellationToken); + } + + /// + /// Sends a power-outage notification (no cooldown check). + /// + public async Task SendPowerOutageAlertAsync( + string mobile, + string deviceName, + string message, + AlertNotificationType notificationType, + CancellationToken cancellationToken) + { + return await SendAsync(mobile, deviceName, message, notificationType, cancellationToken); + } + + private async Task SendAsync( + string mobile, + string deviceName, + string message, + AlertNotificationType notificationType, + CancellationToken cancellationToken) + { + var startTime = DateTime.UtcNow; + + try + { + if (notificationType == AlertNotificationType.SMS) + { + return await SendSmsAsync(mobile, deviceName, message, startTime, cancellationToken); + } + else + { + return await SendCallAsync(mobile, deviceName, message, startTime, cancellationToken); + } + } + catch (Exception ex) + { + var errorMessage = $"Exception: {ex.Message}"; + if (ex.InnerException != null) + errorMessage += $" | InnerException: {ex.InnerException.Message}"; + + var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + + _logger.LogError(ex, "Failed to send alert: Mobile={Mobile}, Type={Type}", mobile, notificationType); + + return new SendResult(false, null, errorMessage, processingTime); + } + } + + private async Task SendSmsAsync( + string mobile, + string deviceName, + string message, + DateTime startTime, + CancellationToken cancellationToken) + { + var smsResponse = await _smsService.SendPatternSmsAsync(new PatternSmsRequest + { + Recipients = [mobile], + PatternCode = "64di3w9kb0fxvif", + Variables = new Dictionary + { + { "name", deviceName }, + { "parameter", "شرایط" }, + { "value", message }, + { "status", "هشدار" } + } + }, cancellationToken); + + var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + + 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 new SendResult(true, outboxIds, null, processingTime); + } + + // Build error message from response + 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 new SendResult(false, null, errorMsg, processingTime); + } + + private async Task SendCallAsync( + string mobile, + string deviceName, + string message, + DateTime startTime, + CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message); + var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + return new SendResult(true, null, "Voice call not yet implemented", processingTime); + } + catch (Exception ex) + { + var errorMsg = $"Exception: {ex.Message}"; + var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + _logger.LogError(ex, "Exception sending call alert: Mobile={Mobile}", mobile); + return new SendResult(false, null, errorMsg, processingTime); + } + } +} diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertPersistenceService.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertPersistenceService.cs new file mode 100644 index 0000000..7aca18f --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertPersistenceService.cs @@ -0,0 +1,136 @@ +using GreenHome.Domain; +using GreenHome.Infrastructure.Data; + +namespace GreenHome.Infrastructure.Services.Alert; + +// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace +using DomainDevice = GreenHome.Domain.Device; + +/// +/// Persists alert notifications and logs to the database. +/// +public sealed class AlertPersistenceService +{ + private readonly GreenHomeDbContext _dbContext; + + public AlertPersistenceService(GreenHomeDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// Saves an AlertNotification and an AlertLog entry for a condition-based alert. + /// + public async Task SaveConditionAlertAsync( + DomainDevice device, + User user, + AlertCondition condition, + string message, + bool isSent, + string? messageOutboxIds, + string? errorMessage, + long processingTimeMs, + CancellationToken cancellationToken) + { + var sentAt = DateTime.UtcNow; + + var notification = new 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); + + var alertLog = new AlertLog + { + DeviceId = device.Id, + UserId = user.Id, + AlertConditionId = condition.Id, + AlertType = AlertType.Condition, + NotificationType = condition.NotificationType, + Message = message, + Status = isSent ? AlertStatus.Success : AlertStatus.Failed, + ErrorMessage = errorMessage, + PhoneNumber = user.Mobile, + SentAt = sentAt, + ProcessingTimeMs = processingTimeMs + }; + + _dbContext.AlertLogs.Add(alertLog); + } + + /// + /// Saves an AlertNotification and an AlertLog entry for a power-outage alert. + /// + public async Task SavePowerOutageAlertAsync( + DomainDevice device, + User user, + string message, + AlertNotificationType notificationType, + bool isSent, + string? messageOutboxIds, + string? errorMessage, + long processingTimeMs, + CancellationToken cancellationToken) + { + var sentAt = DateTime.UtcNow; + + var notification = new 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); + + var alertLog = new AlertLog + { + DeviceId = device.Id, + UserId = user.Id, + AlertConditionId = null, + AlertType = AlertType.PowerOutage, + NotificationType = notificationType, + Message = message, + Status = isSent ? AlertStatus.Success : AlertStatus.Failed, + ErrorMessage = errorMessage, + PhoneNumber = user.Mobile, + SentAt = sentAt, + ProcessingTimeMs = processingTimeMs + }; + + _dbContext.AlertLogs.Add(alertLog); + } + + /// + /// Saves a simple AlertNotification (used in the old GetAlertMessage flow for non-sent notifications). + /// + public async Task SaveNotificationAsync( + AlertNotification notification, + CancellationToken cancellationToken) + { + _dbContext.AlertNotifications.Add(notification); + } + + /// + /// Persists all pending changes to the database. + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/GreenHome.Infrastructure/Services/Alert/AlertService.cs b/src/GreenHome.Infrastructure/Services/Alert/AlertService.cs index 250418a..49bee0a 100644 --- a/src/GreenHome.Infrastructure/Services/Alert/AlertService.cs +++ b/src/GreenHome.Infrastructure/Services/Alert/AlertService.cs @@ -1,44 +1,53 @@ using GreenHome.Application; using GreenHome.Domain; using GreenHome.Infrastructure.Data; -using GreenHome.Sms.Ippanel; -using GreenHome.VoiceCall.Avanak; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using System.Text.Json; namespace GreenHome.Infrastructure.Services.Alert; +// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace +using DomainDevice = GreenHome.Domain.Device; + +/// +/// Orchestrates alert checking, evaluation, sending, and persistence. +/// Delegates specialized work to smaller focused services. +/// 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 AlertConditionEvaluator _evaluator; + private readonly AlertMessageBuilder _messageBuilder; + private readonly AlertNotificationSender _notificationSender; + private readonly AlertPersistenceService _persistence; private readonly IPowerOutageLogService _powerOutageLogService; private readonly ILogger _logger; public AlertService( GreenHomeDbContext dbContext, IDeviceSettingsService deviceSettingsService, - ISmsService smsService, - IVoiceCallService voiceCallService, - ISunCalculatorService sunCalculatorService, + AlertConditionEvaluator evaluator, + AlertMessageBuilder messageBuilder, + AlertNotificationSender notificationSender, + AlertPersistenceService persistence, IPowerOutageLogService powerOutageLogService, ILogger logger) { _dbContext = dbContext; _deviceSettingsService = deviceSettingsService; - _smsService = smsService; - _voiceCallService = voiceCallService; - _sunCalculatorService = sunCalculatorService; + _evaluator = evaluator; + _messageBuilder = messageBuilder; + _notificationSender = notificationSender; + _persistence = persistence; _powerOutageLogService = powerOutageLogService; _logger = logger; } - public async Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken) + public async Task CheckAndSendAlertsAsync( + int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken) { + // 1. Load device with users var device = await _dbContext.Devices .Include(d => d.User) .Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts)) @@ -51,22 +60,18 @@ public sealed class AlertService : IAlertService return null; } + // 2. Load settings var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken); - var recievers = string.Join(",", - (device.User.Mobile + "," + (settings?.SmsRecievers ?? "")) - .Split(',') - .Select(x => x.Trim()) - .Where(x => x.Length > 0) - .Distinct() - ); - - if (recievers.Length == 0) + // 3. Build recipient list + var recipients = BuildRecipients(device, settings); + if (recipients.Length == 0) { - _logger.LogInformation("No users with ReceiveAlerts enabled for device: DeviceId={DeviceId}", deviceId); + _logger.LogInformation("No recipients for device: DeviceId={DeviceId}", deviceId); return null; } + // 4. Load enabled conditions var conditions = await _dbContext.AlertConditions .Include(c => c.Rules) .Where(c => c.DeviceId == deviceId && c.IsEnabled) @@ -77,34 +82,52 @@ public sealed class AlertService : IAlertService _logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId); } - bool? isDaytime = null; - if (settings?.Latitude != null && settings.Longitude != null) + // 5. Evaluate conditions and send alerts + var matchedConditions = _evaluator.GetMatchingConditions(conditions, telemetry, settings); + + var messages = new List(); + + foreach (var condition in matchedConditions) { - isDaytime = _sunCalculatorService.IsDaytime(DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value); - } + var message = _messageBuilder.BuildMessage(condition, device.DeviceName, telemetry); - List messages = []; - foreach (var condition in conditions) - { - if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false) - continue; - if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true) - continue; + // Send to device owner + var ownerResult = await _notificationSender.TrySendConditionAlertAsync( + condition, device, device.User, message, cancellationToken); - var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry)); - - if (allRulesMatch && condition.Rules.Count != 0) + if (ownerResult != null) { - AlertNotification notif = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken); - if (notif != null && !string.IsNullOrEmpty(notif.Message)) + await _persistence.SaveConditionAlertAsync( + device, device.User, condition, message, + ownerResult.IsSent, ownerResult.MessageOutboxIds, + ownerResult.ErrorMessage, ownerResult.ProcessingTimeMs, + cancellationToken); + + messages.Add(message); + } + + // Send to additional users who receive alerts + foreach (var deviceUser in device.DeviceUsers) + { + var userResult = await _notificationSender.TrySendConditionAlertAsync( + condition, device, deviceUser.User, message, cancellationToken); + + if (userResult != null) { - messages.Add(notif.Message); - await _dbContext.AlertNotifications.AddAsync(notif, cancellationToken); + await _persistence.SaveConditionAlertAsync( + device, deviceUser.User, condition, message, + userResult.IsSent, userResult.MessageOutboxIds, + userResult.ErrorMessage, userResult.ProcessingTimeMs, + cancellationToken); + + messages.Add(message); } } } - await _dbContext.SaveChangesAsync(cancellationToken); + await _persistence.SaveChangesAsync(cancellationToken); + + // 6. Handle power status changes var result = new AlertCheckResult(); if (telemetry.OldPower == 0 && telemetry.Power == 1) @@ -112,28 +135,23 @@ public sealed class AlertService : IAlertService result.PowerStatus = "1"; messages.Add($"برق دستگاه {device.DeviceName} متصل شد"); await _powerOutageLogService.LogPowerChangeAsync( - deviceId, - telemetry.OldPower.Value, - telemetry.Power.Value, - telemetry.TimestampUtc ?? DateTime.UtcNow, - cancellationToken); + deviceId, telemetry.OldPower.Value, telemetry.Power.Value, + telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken); } else if (telemetry.OldPower == 1 && telemetry.Power == 0) { result.PowerStatus = "0"; messages.Add($"برق دستگاه {device.DeviceName} قطع شد"); await _powerOutageLogService.LogPowerChangeAsync( - deviceId, - telemetry.OldPower.Value, - telemetry.Power.Value, - telemetry.TimestampUtc ?? DateTime.UtcNow, - cancellationToken); + deviceId, telemetry.OldPower.Value, telemetry.Power.Value, + telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken); } + // 7. Build result if (messages.Any()) { result.Messages = messages; - result.Recipients = recievers; + result.Recipients = recipients; return result; } @@ -143,287 +161,6 @@ public sealed class AlertService : IAlertService return null; } - private bool CheckRule(Domain.AlertRule rule, TelemetryDto telemetry) - { - 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 - }; - - 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) - { - var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call - ? condition.CallCooldownMinutes - : condition.SmsCooldownMinutes; - - 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, - SentAt = sentAt, - }; - - return notification; - } - - private async Task SendAlertForConditionAsync( - Domain.AlertCondition condition, - Domain.Device device, - List usersToAlert, - TelemetryDto telemetry, - CancellationToken cancellationToken) - { - var cooldownMinutes = condition.NotificationType == Domain.AlertNotificationType.Call - ? condition.CallCooldownMinutes - : condition.SmsCooldownMinutes; - - var message = BuildAlertMessage(condition, device.DeviceName, telemetry); - var sentAt = DateTime.UtcNow; - - foreach (var user in usersToAlert) - { - 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; - } - - 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 - { - (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; - - 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); - - 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 - { - _logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message); - 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) { var device = await _dbContext.Devices @@ -449,82 +186,46 @@ public sealed class AlertService : IAlertService } var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است."; - var sentAt = DateTime.UtcNow; foreach (var user in usersToAlert) { - await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.SMS, cancellationToken); - await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.Call, cancellationToken); + // Send SMS + var smsResult = await _notificationSender.SendPowerOutageAlertAsync( + user.Mobile, device.DeviceName, message, + AlertNotificationType.SMS, cancellationToken); + + await _persistence.SavePowerOutageAlertAsync( + device, user, message, AlertNotificationType.SMS, + smsResult.IsSent, smsResult.MessageOutboxIds, + smsResult.ErrorMessage, smsResult.ProcessingTimeMs, + cancellationToken); + + // Send Voice Call + var callResult = await _notificationSender.SendPowerOutageAlertAsync( + user.Mobile, device.DeviceName, message, + AlertNotificationType.Call, cancellationToken); + + await _persistence.SavePowerOutageAlertAsync( + device, user, message, AlertNotificationType.Call, + callResult.IsSent, callResult.MessageOutboxIds, + callResult.ErrorMessage, callResult.ProcessingTimeMs, + cancellationToken); } - await _dbContext.SaveChangesAsync(cancellationToken); + await _persistence.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) + private static string BuildRecipients(DomainDevice device, DeviceSettingsDto? settings) { - 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; - - 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); - - 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); + return string.Join(",", + (device.User.Mobile + "," + (settings?.SmsRecievers ?? "")) + .Split(',') + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .Distinct() + ); } }