fix PowerOutageSummary and optimize ALertService structure
This commit is contained in:
@@ -129,11 +129,12 @@ public class PowerOutageController : ControllerBase
|
|||||||
return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
|
return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all outage events within the date range
|
||||||
var filter = new PowerOutageFilter
|
var filter = new PowerOutageFilter
|
||||||
{
|
{
|
||||||
StartDateUtc = startUtc,
|
StartDateUtc = startUtc,
|
||||||
EndDateUtc = endUtc,
|
EndDateUtc = endUtc,
|
||||||
PageSize = int.MaxValue // Get all records for summary
|
PageSize = int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken);
|
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 outages = result.Items.Where(x => x.CurrentPower == 0).ToList();
|
||||||
var connections = result.Items.Where(x => x.CurrentPower == 1).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
|
// Calculate total downtime by pairing disconnects with subsequent connects
|
||||||
long totalDowntimeSeconds = 0;
|
long totalDowntimeSeconds = 0;
|
||||||
var pairedOutages = new List<PowerOutagePairDto>();
|
var pairedOutages = new List<PowerOutagePairDto>();
|
||||||
@@ -167,7 +188,7 @@ public class PowerOutageController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Still disconnected (no subsequent connect)
|
// Still disconnected (no subsequent connect found anywhere)
|
||||||
pairedOutages.Add(new PowerOutagePairDto
|
pairedOutages.Add(new PowerOutagePairDto
|
||||||
{
|
{
|
||||||
DisconnectedAt = outage.TimestampUtc,
|
DisconnectedAt = outage.TimestampUtc,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAlertService, AlertService>();
|
services.AddScoped<IAlertService, AlertService>();
|
||||||
services.AddScoped<IAlertConditionService, AlertConditionService>();
|
services.AddScoped<IAlertConditionService, AlertConditionService>();
|
||||||
services.AddScoped<IAlertLogService, AlertLogService>();
|
services.AddScoped<IAlertLogService, AlertLogService>();
|
||||||
|
services.AddScoped<AlertConditionEvaluator>();
|
||||||
|
services.AddScoped<AlertMessageBuilder>();
|
||||||
|
services.AddScoped<AlertNotificationSender>();
|
||||||
|
services.AddScoped<AlertPersistenceService>();
|
||||||
|
|
||||||
// Device
|
// Device
|
||||||
services.AddScoped<IDeviceService, DeviceService>();
|
services.AddScoped<IDeviceService, DeviceService>();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using GreenHome.Application;
|
||||||
|
using GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Services.Alert;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates alert conditions against telemetry data.
|
||||||
|
/// Handles time-type filtering (Day/Night/Always) and rule matching.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlertConditionEvaluator
|
||||||
|
{
|
||||||
|
private readonly ISunCalculatorService _sunCalculatorService;
|
||||||
|
|
||||||
|
public AlertConditionEvaluator(ISunCalculatorService sunCalculatorService)
|
||||||
|
{
|
||||||
|
_sunCalculatorService = sunCalculatorService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns conditions whose time-type and all rules match the given telemetry.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AlertCondition> GetMatchingConditions(
|
||||||
|
IEnumerable<AlertCondition> 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<AlertCondition>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a single rule is satisfied by the telemetry data.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using GreenHome.Application;
|
||||||
|
using GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Services.Alert;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds human-readable (Persian) alert messages from alert conditions and telemetry data.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlertMessageBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a descriptive Persian alert message for the given condition and telemetry.
|
||||||
|
/// </summary>
|
||||||
|
public string BuildMessage(AlertCondition condition, string deviceName, TelemetryDto telemetry)
|
||||||
|
{
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
$"هشدار گلخانه {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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles sending alert notifications via SMS or Voice Call,
|
||||||
|
/// including cooldown checks and error handling.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlertNotificationSender
|
||||||
|
{
|
||||||
|
private readonly GreenHomeDbContext _dbContext;
|
||||||
|
private readonly ISmsService _smsService;
|
||||||
|
private readonly IVoiceCallService _voiceCallService;
|
||||||
|
private readonly ILogger<AlertNotificationSender> _logger;
|
||||||
|
|
||||||
|
public AlertNotificationSender(
|
||||||
|
GreenHomeDbContext dbContext,
|
||||||
|
ISmsService smsService,
|
||||||
|
IVoiceCallService voiceCallService,
|
||||||
|
ILogger<AlertNotificationSender> logger)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_smsService = smsService;
|
||||||
|
_voiceCallService = voiceCallService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of sending a single notification.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SendResult(
|
||||||
|
bool IsSent,
|
||||||
|
string? MessageOutboxIds,
|
||||||
|
string? ErrorMessage,
|
||||||
|
long ProcessingTimeMs);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks cooldown and sends an alert notification for a condition to a user.
|
||||||
|
/// Returns null if skipped due to cooldown.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SendResult?> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a power-outage notification (no cooldown check).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SendResult> SendPowerOutageAlertAsync(
|
||||||
|
string mobile,
|
||||||
|
string deviceName,
|
||||||
|
string message,
|
||||||
|
AlertNotificationType notificationType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SendAsync(mobile, deviceName, message, notificationType, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SendResult> 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<SendResult> 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<string, string>
|
||||||
|
{
|
||||||
|
{ "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<string>();
|
||||||
|
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<SendResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists alert notifications and logs to the database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlertPersistenceService
|
||||||
|
{
|
||||||
|
private readonly GreenHomeDbContext _dbContext;
|
||||||
|
|
||||||
|
public AlertPersistenceService(GreenHomeDbContext dbContext)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves an AlertNotification and an AlertLog entry for a condition-based alert.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves an AlertNotification and an AlertLog entry for a power-outage alert.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a simple AlertNotification (used in the old GetAlertMessage flow for non-sent notifications).
|
||||||
|
/// </summary>
|
||||||
|
public async Task SaveNotificationAsync(
|
||||||
|
AlertNotification notification,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_dbContext.AlertNotifications.Add(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists all pending changes to the database.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,53 @@
|
|||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Domain;
|
using GreenHome.Domain;
|
||||||
using GreenHome.Infrastructure.Data;
|
using GreenHome.Infrastructure.Data;
|
||||||
using GreenHome.Sms.Ippanel;
|
|
||||||
using GreenHome.VoiceCall.Avanak;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace GreenHome.Infrastructure.Services.Alert;
|
namespace GreenHome.Infrastructure.Services.Alert;
|
||||||
|
|
||||||
|
// Alias to resolve ambiguity between GreenHome.Domain.Device and the Alert namespace
|
||||||
|
using DomainDevice = GreenHome.Domain.Device;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates alert checking, evaluation, sending, and persistence.
|
||||||
|
/// Delegates specialized work to smaller focused services.
|
||||||
|
/// </summary>
|
||||||
public sealed class AlertService : IAlertService
|
public sealed class AlertService : IAlertService
|
||||||
{
|
{
|
||||||
private readonly GreenHomeDbContext _dbContext;
|
private readonly GreenHomeDbContext _dbContext;
|
||||||
private readonly IDeviceSettingsService _deviceSettingsService;
|
private readonly IDeviceSettingsService _deviceSettingsService;
|
||||||
private readonly ISmsService _smsService;
|
private readonly AlertConditionEvaluator _evaluator;
|
||||||
private readonly IVoiceCallService _voiceCallService;
|
private readonly AlertMessageBuilder _messageBuilder;
|
||||||
private readonly ISunCalculatorService _sunCalculatorService;
|
private readonly AlertNotificationSender _notificationSender;
|
||||||
|
private readonly AlertPersistenceService _persistence;
|
||||||
private readonly IPowerOutageLogService _powerOutageLogService;
|
private readonly IPowerOutageLogService _powerOutageLogService;
|
||||||
private readonly ILogger<AlertService> _logger;
|
private readonly ILogger<AlertService> _logger;
|
||||||
|
|
||||||
public AlertService(
|
public AlertService(
|
||||||
GreenHomeDbContext dbContext,
|
GreenHomeDbContext dbContext,
|
||||||
IDeviceSettingsService deviceSettingsService,
|
IDeviceSettingsService deviceSettingsService,
|
||||||
ISmsService smsService,
|
AlertConditionEvaluator evaluator,
|
||||||
IVoiceCallService voiceCallService,
|
AlertMessageBuilder messageBuilder,
|
||||||
ISunCalculatorService sunCalculatorService,
|
AlertNotificationSender notificationSender,
|
||||||
|
AlertPersistenceService persistence,
|
||||||
IPowerOutageLogService powerOutageLogService,
|
IPowerOutageLogService powerOutageLogService,
|
||||||
ILogger<AlertService> logger)
|
ILogger<AlertService> logger)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_deviceSettingsService = deviceSettingsService;
|
_deviceSettingsService = deviceSettingsService;
|
||||||
_smsService = smsService;
|
_evaluator = evaluator;
|
||||||
_voiceCallService = voiceCallService;
|
_messageBuilder = messageBuilder;
|
||||||
_sunCalculatorService = sunCalculatorService;
|
_notificationSender = notificationSender;
|
||||||
|
_persistence = persistence;
|
||||||
_powerOutageLogService = powerOutageLogService;
|
_powerOutageLogService = powerOutageLogService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AlertCheckResult?> CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
|
public async Task<AlertCheckResult?> CheckAndSendAlertsAsync(
|
||||||
|
int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 1. Load device with users
|
||||||
var device = await _dbContext.Devices
|
var device = await _dbContext.Devices
|
||||||
.Include(d => d.User)
|
.Include(d => d.User)
|
||||||
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
|
.Include(d => d.DeviceUsers.Where(du => du.ReceiveAlerts))
|
||||||
@@ -51,22 +60,18 @@ public sealed class AlertService : IAlertService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Load settings
|
||||||
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
var settings = await _deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken);
|
||||||
|
|
||||||
var recievers = string.Join(",",
|
// 3. Build recipient list
|
||||||
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
|
var recipients = BuildRecipients(device, settings);
|
||||||
.Split(',')
|
if (recipients.Length == 0)
|
||||||
.Select(x => x.Trim())
|
|
||||||
.Where(x => x.Length > 0)
|
|
||||||
.Distinct()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recievers.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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Load enabled conditions
|
||||||
var conditions = await _dbContext.AlertConditions
|
var conditions = await _dbContext.AlertConditions
|
||||||
.Include(c => c.Rules)
|
.Include(c => c.Rules)
|
||||||
.Where(c => c.DeviceId == deviceId && c.IsEnabled)
|
.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);
|
_logger.LogDebug("No enabled alert conditions for device: DeviceId={DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool? isDaytime = null;
|
// 5. Evaluate conditions and send alerts
|
||||||
if (settings?.Latitude != null && settings.Longitude != null)
|
var matchedConditions = _evaluator.GetMatchingConditions(conditions, telemetry, settings);
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
|
||||||
|
foreach (var condition in matchedConditions)
|
||||||
{
|
{
|
||||||
isDaytime = _sunCalculatorService.IsDaytime(DateTime.UtcNow, settings.Latitude.Value, settings.Longitude.Value);
|
var message = _messageBuilder.BuildMessage(condition, device.DeviceName, telemetry);
|
||||||
|
|
||||||
|
// Send to device owner
|
||||||
|
var ownerResult = await _notificationSender.TrySendConditionAlertAsync(
|
||||||
|
condition, device, device.User, message, cancellationToken);
|
||||||
|
|
||||||
|
if (ownerResult != null)
|
||||||
|
{
|
||||||
|
await _persistence.SaveConditionAlertAsync(
|
||||||
|
device, device.User, condition, message,
|
||||||
|
ownerResult.IsSent, ownerResult.MessageOutboxIds,
|
||||||
|
ownerResult.ErrorMessage, ownerResult.ProcessingTimeMs,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
messages.Add(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> messages = [];
|
// Send to additional users who receive alerts
|
||||||
foreach (var condition in conditions)
|
foreach (var deviceUser in device.DeviceUsers)
|
||||||
{
|
{
|
||||||
if (condition.TimeType == Domain.AlertTimeType.Day && isDaytime == false)
|
var userResult = await _notificationSender.TrySendConditionAlertAsync(
|
||||||
continue;
|
condition, device, deviceUser.User, message, cancellationToken);
|
||||||
if (condition.TimeType == Domain.AlertTimeType.Night && isDaytime == true)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var allRulesMatch = condition.Rules.All(rule => CheckRule(rule, telemetry));
|
if (userResult != null)
|
||||||
|
{
|
||||||
|
await _persistence.SaveConditionAlertAsync(
|
||||||
|
device, deviceUser.User, condition, message,
|
||||||
|
userResult.IsSent, userResult.MessageOutboxIds,
|
||||||
|
userResult.ErrorMessage, userResult.ProcessingTimeMs,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
if (allRulesMatch && condition.Rules.Count != 0)
|
messages.Add(message);
|
||||||
{
|
|
||||||
AlertNotification notif = await GetAlertMessage(condition, device, device.User, telemetry, cancellationToken);
|
|
||||||
if (notif != null && !string.IsNullOrEmpty(notif.Message))
|
|
||||||
{
|
|
||||||
messages.Add(notif.Message);
|
|
||||||
await _dbContext.AlertNotifications.AddAsync(notif, cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
|
await _persistence.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 6. Handle power status changes
|
||||||
var result = new AlertCheckResult();
|
var result = new AlertCheckResult();
|
||||||
|
|
||||||
if (telemetry.OldPower == 0 && telemetry.Power == 1)
|
if (telemetry.OldPower == 0 && telemetry.Power == 1)
|
||||||
@@ -112,28 +135,23 @@ public sealed class AlertService : IAlertService
|
|||||||
result.PowerStatus = "1";
|
result.PowerStatus = "1";
|
||||||
messages.Add($"برق دستگاه {device.DeviceName} متصل شد");
|
messages.Add($"برق دستگاه {device.DeviceName} متصل شد");
|
||||||
await _powerOutageLogService.LogPowerChangeAsync(
|
await _powerOutageLogService.LogPowerChangeAsync(
|
||||||
deviceId,
|
deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
|
||||||
telemetry.OldPower.Value,
|
telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
|
||||||
telemetry.Power.Value,
|
|
||||||
telemetry.TimestampUtc ?? DateTime.UtcNow,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
else if (telemetry.OldPower == 1 && telemetry.Power == 0)
|
else if (telemetry.OldPower == 1 && telemetry.Power == 0)
|
||||||
{
|
{
|
||||||
result.PowerStatus = "0";
|
result.PowerStatus = "0";
|
||||||
messages.Add($"برق دستگاه {device.DeviceName} قطع شد");
|
messages.Add($"برق دستگاه {device.DeviceName} قطع شد");
|
||||||
await _powerOutageLogService.LogPowerChangeAsync(
|
await _powerOutageLogService.LogPowerChangeAsync(
|
||||||
deviceId,
|
deviceId, telemetry.OldPower.Value, telemetry.Power.Value,
|
||||||
telemetry.OldPower.Value,
|
telemetry.TimestampUtc ?? DateTime.UtcNow, cancellationToken);
|
||||||
telemetry.Power.Value,
|
|
||||||
telemetry.TimestampUtc ?? DateTime.UtcNow,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Build result
|
||||||
if (messages.Any())
|
if (messages.Any())
|
||||||
{
|
{
|
||||||
result.Messages = messages;
|
result.Messages = messages;
|
||||||
result.Recipients = recievers;
|
result.Recipients = recipients;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,287 +161,6 @@ public sealed class AlertService : IAlertService
|
|||||||
return null;
|
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<AlertNotification> 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<User> 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<string>();
|
|
||||||
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<string, string> {
|
|
||||||
{ "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<string>();
|
|
||||||
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)
|
public async Task SendPowerOutageAlertAsync(int deviceId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var device = await _dbContext.Devices
|
var device = await _dbContext.Devices
|
||||||
@@ -449,82 +186,46 @@ public sealed class AlertService : IAlertService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
|
var message = $"⚠️ هشدار قطع برق! دستگاه {device.DeviceName} از برق قطع شده است.";
|
||||||
var sentAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
foreach (var user in usersToAlert)
|
foreach (var user in usersToAlert)
|
||||||
{
|
{
|
||||||
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.SMS, cancellationToken);
|
// Send SMS
|
||||||
await SendPowerOutageNotificationAsync(device, user, message, sentAt, Domain.AlertNotificationType.Call, cancellationToken);
|
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}",
|
_logger.LogInformation("Power outage alerts sent to {Count} users for device {DeviceId}",
|
||||||
usersToAlert.Count, deviceId);
|
usersToAlert.Count, deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendPowerOutageNotificationAsync(
|
private static string BuildRecipients(DomainDevice device, DeviceSettingsDto? settings)
|
||||||
Domain.Device device,
|
|
||||||
Domain.User user,
|
|
||||||
string message,
|
|
||||||
DateTime sentAt,
|
|
||||||
Domain.AlertNotificationType notificationType,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var startTime = DateTime.UtcNow;
|
return string.Join(",",
|
||||||
string? messageOutboxIds = null;
|
(device.User.Mobile + "," + (settings?.SmsRecievers ?? ""))
|
||||||
string? errorMessage = null;
|
.Split(',')
|
||||||
bool isSent = false;
|
.Select(x => x.Trim())
|
||||||
|
.Where(x => x.Length > 0)
|
||||||
try
|
.Distinct()
|
||||||
{
|
);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user