diff --git a/src/GreenHome.Api/Controllers/AuthController.cs b/src/GreenHome.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..00df733 --- /dev/null +++ b/src/GreenHome.Api/Controllers/AuthController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using GreenHome.Application; + +namespace GreenHome.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthService authService; + + public AuthController(IAuthService authService) + { + this.authService = authService; + } + + [HttpPost("send-code")] + public async Task> SendCode( + [FromBody] SendCodeRequest request, + CancellationToken cancellationToken) + { + var result = await authService.SendVerificationCodeAsync(request, cancellationToken); + + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + + [HttpPost("verify-code")] + public async Task> VerifyCode( + [FromBody] VerifyCodeRequest request, + CancellationToken cancellationToken) + { + var result = await authService.VerifyCodeAsync(request, cancellationToken); + + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + + [HttpGet("can-resend")] + public async Task> CanResend( + [FromQuery] string mobile, + CancellationToken cancellationToken) + { + var canResend = await authService.CanResendCodeAsync(mobile, cancellationToken); + return Ok(new { canResend }); + } +} + diff --git a/src/GreenHome.Api/Controllers/DevicesController.cs b/src/GreenHome.Api/Controllers/DevicesController.cs index efd9a20..1235e60 100644 --- a/src/GreenHome.Api/Controllers/DevicesController.cs +++ b/src/GreenHome.Api/Controllers/DevicesController.cs @@ -35,4 +35,31 @@ public class DevicesController : ControllerBase var id = await deviceService.AddDeviceAsync(dto, cancellationToken); return Ok(id); } + + [HttpGet("user/{userId}")] + public async Task>> GetUserDevices(int userId, CancellationToken cancellationToken) + { + var result = await deviceService.GetUserDevicesAsync(userId, cancellationToken); + return Ok(result); + } + + [HttpGet("filtered")] + public async Task>> GetDevices( + [FromQuery] int userId, + [FromQuery] string? search = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10, + CancellationToken cancellationToken = default) + { + var filter = new DeviceFilter + { + UserId = userId, + Search = search, + Page = page, + PageSize = pageSize + }; + + var result = await deviceService.GetDevicesAsync(filter, cancellationToken); + return Ok(result); + } } diff --git a/src/GreenHome.Api/Controllers/SmsTestController.cs b/src/GreenHome.Api/Controllers/SmsTestController.cs new file mode 100644 index 0000000..7f11355 --- /dev/null +++ b/src/GreenHome.Api/Controllers/SmsTestController.cs @@ -0,0 +1,138 @@ +using GreenHome.Sms.Ippanel; +using Microsoft.AspNetCore.Mvc; + +namespace GreenHome.Api.Controllers; + +/// +/// Controller for testing SMS service +/// +[ApiController] +[Route("api/[controller]")] +public class SmsTestController : ControllerBase +{ + private readonly ISmsService smsService; + private readonly ILogger logger; + + public SmsTestController(ISmsService smsService, ILogger logger) + { + this.smsService = smsService; + this.logger = logger; + } + + /// + /// Test sending Webservice SMS + /// + /// SMS request details + /// SMS response + [HttpPost("webservice")] + public async Task TestWebserviceSms([FromBody] WebserviceSmsRequest request) + { + try + { + logger.LogInformation("Testing Webservice SMS to {Recipient}", request.Recipient); + + var result = await smsService.SendWebserviceSmsAsync(request); + + logger.LogInformation("Webservice SMS sent successfully."); + + return Ok(new + { + success = true, + message = "پیامک با موفقیت ارسال شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending Webservice SMS"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال پیامک", + error = ex.Message + }); + } + } + + /// + /// Test sending Pattern SMS + /// + /// Pattern SMS request details + /// SMS response + [HttpPost("pattern")] + public async Task TestPatternSms([FromBody] PatternSmsRequest request) + { + try + { + logger.LogInformation("Testing Pattern SMS to {Recipient} with pattern {PatternCode}", + request.Recipients, request.PatternCode); + + var result = await smsService.SendPatternSmsAsync(request); + + logger.LogInformation("Pattern SMS sent successfully"); + + return Ok(new + { + success = true, + message = "پیامک با موفقیت ارسال شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending Pattern SMS"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال پیامک", + error = ex.Message + }); + } + } + + /// + /// Quick test with default values (for easy testing) + /// + /// Phone number to send SMS to + /// SMS response + [HttpPost("quick-test/{phoneNumber}")] + public async Task QuickTest(string phoneNumber) + { + try + { + logger.LogInformation("Quick test SMS to {PhoneNumber}", phoneNumber); + + var request = new WebserviceSmsRequest + { + Sender = "10001001", // شما می‌توانید این را از تنظیمات بخوانید + Recipient = phoneNumber, + Message = "این یک پیامک تست از سرویس GreenHome است. " + DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss") + }; + + var result = await smsService.SendWebserviceSmsAsync(request); + + return Ok(new + { + success = true, + message = "پیامک تست با موفقیت ارسال شد", + data = result, + testInfo = new + { + phoneNumber, + sentAt = DateTime.Now + } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in quick test SMS"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال پیامک تست", + error = ex.Message + }); + } + } +} + diff --git a/src/GreenHome.Api/Controllers/TelemetryController.cs b/src/GreenHome.Api/Controllers/TelemetryController.cs index 9866bb2..10e93e3 100644 --- a/src/GreenHome.Api/Controllers/TelemetryController.cs +++ b/src/GreenHome.Api/Controllers/TelemetryController.cs @@ -8,10 +8,12 @@ namespace GreenHome.Api.Controllers; public class TelemetryController : ControllerBase { private readonly ITelemetryService telemetryService; + private readonly IAlertService alertService; - public TelemetryController(ITelemetryService telemetryService) + public TelemetryController(ITelemetryService telemetryService, IAlertService alertService) { this.telemetryService = telemetryService; + this.alertService = alertService; } [HttpGet] @@ -37,6 +39,26 @@ public class TelemetryController : ControllerBase TimestampUtc = DateTime.UtcNow }; var id = await telemetryService.AddAsync(dto, cancellationToken); + + // Check and send alerts if needed (fire and forget) + _ = Task.Run(async () => + { + try + { + // Get deviceId from the saved telemetry record + var deviceId = dto.DeviceId; + if (deviceId > 0) + { + await alertService.CheckAndSendAlertsAsync(deviceId, dto, cancellationToken); + } + } + catch + { + // Log error but don't fail the request + // Errors are logged in AlertService + } + }, cancellationToken); + return Ok(id); } diff --git a/src/GreenHome.Api/Controllers/VoiceCallTestController.cs b/src/GreenHome.Api/Controllers/VoiceCallTestController.cs new file mode 100644 index 0000000..21a3458 --- /dev/null +++ b/src/GreenHome.Api/Controllers/VoiceCallTestController.cs @@ -0,0 +1,270 @@ +using GreenHome.VoiceCall.Avanak; +using Microsoft.AspNetCore.Mvc; + +namespace GreenHome.Api.Controllers; + +/// +/// Controller for testing Voice Call service (Avanak) +/// +[ApiController] +[Route("api/[controller]")] +public class VoiceCallTestController : ControllerBase +{ + private readonly IVoiceCallService voiceCallService; + private readonly ILogger logger; + + public VoiceCallTestController(IVoiceCallService voiceCallService, ILogger logger) + { + this.voiceCallService = voiceCallService; + this.logger = logger; + } + + /// + /// Test getting account status + /// + /// Account status response + [HttpGet("account-status")] + public async Task GetAccountStatus() + { + try + { + logger.LogInformation("Testing AccountStatus"); + + var result = await voiceCallService.GetAccountStatusAsync(); + + logger.LogInformation("AccountStatus retrieved successfully"); + + return Ok(new + { + success = true, + message = "وضعیت حساب با موفقیت دریافت شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting account status"); + return StatusCode(500, new + { + success = false, + message = "خطا در دریافت وضعیت حساب", + error = ex.Message + }); + } + } + + /// + /// Test generating TTS (Text-to-Speech) + /// + /// TTS generation request + /// TTS generation response + [HttpPost("generate-tts")] + public async Task GenerateTTS([FromBody] GenerateTTSRequest request) + { + try + { + logger.LogInformation("Testing GenerateTTS for text: {Text}", request.Text); + + var result = await voiceCallService.GenerateTTSAsync(request); + + logger.LogInformation("TTS generated successfully"); + + return Ok(new + { + success = true, + message = "تولید صوت با موفقیت انجام شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating TTS"); + return StatusCode(500, new + { + success = false, + message = "خطا در تولید صوت", + error = ex.Message + }); + } + } + + /// + /// Test QuickSend (sending voice call with uploaded audio file) + /// + /// QuickSend request + /// QuickSend response + [HttpPost("quick-send")] + public async Task QuickSend([FromBody] QuickSendRequest request) + { + try + { + logger.LogInformation("Testing QuickSend to {Recipient} with MessageId {MessageId}", + request.Recipient, request.MessageId); + + var result = await voiceCallService.QuickSendAsync(request); + + logger.LogInformation("QuickSend completed successfully"); + + return Ok(new + { + success = true, + message = "تماس صوتی با موفقیت ارسال شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending quick voice call"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال تماس صوتی", + error = ex.Message + }); + } + } + + /// + /// Test QuickSendWithTTS (sending voice call with TTS) + /// + /// QuickSendWithTTS request + /// QuickSendWithTTS response + [HttpPost("quick-send-with-tts")] + public async Task QuickSendWithTTS([FromBody] QuickSendWithTTSRequest request) + { + try + { + logger.LogInformation("Testing QuickSendWithTTS to {Number} with text: {Text}", + request.Number, request.Text); + + var result = await voiceCallService.QuickSendWithTTSAsync(request); + + if (result != null && result.IsSuccess) + { + logger.LogInformation("QuickSendWithTTS completed successfully. QuickSendId: {QuickSendId}", + result.QuickSendId); + } + else + { + logger.LogWarning("QuickSendWithTTS failed with ReturnValue: {ReturnValue}", + result?.ReturnValue ?? 0); + } + + return Ok(new + { + success = result?.IsSuccess ?? false, + message = result?.IsSuccess == true + ? "تماس صوتی با موفقیت ارسال شد" + : $"خطا در ارسال تماس صوتی (کد خطا: {result?.ReturnValue})", + data = result, + quickSendId = result?.QuickSendId ?? 0 + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending quick voice call with TTS"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال تماس صوتی", + error = ex.Message + }); + } + } + + /// + /// Test getting QuickSend status + /// + /// GetQuickSend request + /// QuickSend status response + [HttpPost("quick-send-status")] + public async Task GetQuickSendStatus([FromBody] GetQuickSendRequest request) + { + try + { + logger.LogInformation("Testing GetQuickSendStatus for QuickSendId: {QuickSendId}", + request.QuickSendId); + + var result = await voiceCallService.GetQuickSendStatusAsync(request); + + logger.LogInformation("QuickSendStatus retrieved successfully"); + + return Ok(new + { + success = true, + message = "وضعیت تماس صوتی با موفقیت دریافت شد", + data = result + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting quick send status"); + return StatusCode(500, new + { + success = false, + message = "خطا در دریافت وضعیت تماس صوتی", + error = ex.Message + }); + } + } + + /// + /// Quick test with default values (for easy testing) + /// + /// Phone number to send voice call to + /// Text to convert to speech (optional) + /// QuickSendWithTTS response + [HttpPost("quick-test/{phoneNumber}")] + public async Task QuickTest(string phoneNumber, [FromQuery] string? text = null) + { + try + { + var testText = text ?? $"سلام. این یک تماس صوتی تست از سرویس GreenHome است. زمان تست: {DateTime.Now:yyyy/MM/dd HH:mm:ss}"; + + logger.LogInformation("Quick test voice call to {PhoneNumber} with text: {Text}", + phoneNumber, testText); + + var request = new QuickSendWithTTSRequest + { + Number = phoneNumber, + Text = testText, + ServerID = 0 // Default + }; + + var result = await voiceCallService.QuickSendWithTTSAsync(request); + + if (result != null && result.IsSuccess) + { + logger.LogInformation("Quick test voice call sent successfully. QuickSendId: {QuickSendId}", + result.QuickSendId); + } + + return Ok(new + { + success = result?.IsSuccess ?? false, + message = result?.IsSuccess == true + ? "تماس صوتی تست با موفقیت ارسال شد" + : $"خطا در ارسال تماس صوتی تست (کد خطا: {result?.ReturnValue})", + data = result, + quickSendId = result?.QuickSendId ?? 0, + testInfo = new + { + phoneNumber, + text = testText, + sentAt = DateTime.Now + } + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in quick test voice call"); + return StatusCode(500, new + { + success = false, + message = "خطا در ارسال تماس صوتی تست", + error = ex.Message + }); + } + } +} + diff --git a/src/GreenHome.Api/GreenHome.Api.csproj b/src/GreenHome.Api/GreenHome.Api.csproj index b1eb9bf..7c6e1d4 100644 --- a/src/GreenHome.Api/GreenHome.Api.csproj +++ b/src/GreenHome.Api/GreenHome.Api.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/GreenHome.Api/Program.cs b/src/GreenHome.Api/Program.cs index 5c97887..a7ddcbe 100644 --- a/src/GreenHome.Api/Program.cs +++ b/src/GreenHome.Api/Program.cs @@ -1,6 +1,8 @@ using FluentValidation; using GreenHome.Application; using GreenHome.Infrastructure; +using GreenHome.Sms.Ippanel; +using GreenHome.VoiceCall.Avanak; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -42,9 +44,33 @@ builder.Services.AddDbContext(optio builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// SMS Service Configuration +builder.Services.AddIppanelSms(builder.Configuration); + +// Voice Call Service Configuration +builder.Services.AddAvanakVoiceCall(builder.Configuration); var app = builder.Build(); +// Apply pending migrations automatically +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var context = services.GetRequiredService(); + context.Database.Migrate(); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while migrating the database."); + } +} + // Configure the HTTP request pipeline. //if (app.Environment.IsDevelopment()) { diff --git a/src/GreenHome.Api/appsettings.json b/src/GreenHome.Api/appsettings.json index 1f2a9cd..235719b 100644 --- a/src/GreenHome.Api/appsettings.json +++ b/src/GreenHome.Api/appsettings.json @@ -6,7 +6,12 @@ } }, "ConnectionStrings": { - "Default": "Server=.;Database=GreenHomeDb;User Id=sa;Password=qwER12#$110;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + "Default": "Server=.;Database=GreenHomeDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "IppanelSms": { + "BaseUrl": "https://edge.ippanel.com/v1", + "AuthorizationToken": "YTA1Zjk3N2EtNzkwOC00ZTg5LWFjZmYtZGEyZDAyNjNlZWQxM2Q2ZDVjYWE0MTA2Yzc1NDYzZDY1Y2VkMjlhMzcwNjA=", + "DefaultSender": "+983000505" }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/src/GreenHome.Application/Dtos.cs b/src/GreenHome.Application/Dtos.cs index 337dcb7..70b2690 100644 --- a/src/GreenHome.Application/Dtos.cs +++ b/src/GreenHome.Application/Dtos.cs @@ -7,8 +7,10 @@ public sealed class DeviceDto { public int Id { get; set; } public string DeviceName { get; set; } = string.Empty; - public string Owner { get; set; } = string.Empty; - public string Mobile { get; set; } = string.Empty; + public int UserId { get; set; } + public string UserName { get; set; } = string.Empty; + public string UserFamily { get; set; } = string.Empty; + public string UserMobile { get; set; } = string.Empty; public string Location { get; set; } = string.Empty; public string NeshanLocation { get; set; } = string.Empty; } @@ -66,6 +68,49 @@ public sealed class DayCount public int Count { get; set; } } +public sealed class SendCodeRequest +{ + public required string Mobile { get; set; } +} + +public sealed class SendCodeResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public int ResendAfterSeconds { get; set; } = 120; +} + +public sealed class VerifyCodeRequest +{ + public required string Mobile { get; set; } + public required string Code { get; set; } +} + +public sealed class VerifyCodeResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public string? Token { get; set; } + public UserDto? User { get; set; } +} + +public sealed class UserDto +{ + public int Id { get; set; } + public string Mobile { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public Domain.UserRole Role { get; set; } +} + +public sealed class DeviceFilter +{ + public int? UserId { get; set; } + public string? Search { get; set; } // جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، Location + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 10; +} + public sealed class DeviceSettingsDto { public int Id { get; set; } @@ -92,4 +137,4 @@ public sealed class DeviceSettingsDto public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } -} +} \ No newline at end of file diff --git a/src/GreenHome.Application/IAlertService.cs b/src/GreenHome.Application/IAlertService.cs new file mode 100644 index 0000000..30ec9e3 --- /dev/null +++ b/src/GreenHome.Application/IAlertService.cs @@ -0,0 +1,7 @@ +namespace GreenHome.Application; + +public interface IAlertService +{ + Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken); +} + diff --git a/src/GreenHome.Application/IAuthService.cs b/src/GreenHome.Application/IAuthService.cs new file mode 100644 index 0000000..b4c4ff4 --- /dev/null +++ b/src/GreenHome.Application/IAuthService.cs @@ -0,0 +1,9 @@ +namespace GreenHome.Application; + +public interface IAuthService +{ + Task SendVerificationCodeAsync(SendCodeRequest request, CancellationToken cancellationToken); + Task VerifyCodeAsync(VerifyCodeRequest request, CancellationToken cancellationToken); + Task CanResendCodeAsync(string mobile, CancellationToken cancellationToken); +} + diff --git a/src/GreenHome.Application/IDeviceService.cs b/src/GreenHome.Application/IDeviceService.cs index 01a2aa5..334ad17 100644 --- a/src/GreenHome.Application/IDeviceService.cs +++ b/src/GreenHome.Application/IDeviceService.cs @@ -6,4 +6,6 @@ public interface IDeviceService Task AddDeviceAsync(DeviceDto dto, CancellationToken cancellationToken); Task> ListAsync(CancellationToken cancellationToken); Task GetDeviceId(string deviceName, CancellationToken cancellationToken); + Task> GetUserDevicesAsync(int userId, CancellationToken cancellationToken); + Task> GetDevicesAsync(DeviceFilter filter, CancellationToken cancellationToken); } diff --git a/src/GreenHome.Application/MappingProfile.cs b/src/GreenHome.Application/MappingProfile.cs index 6a40566..f733c93 100644 --- a/src/GreenHome.Application/MappingProfile.cs +++ b/src/GreenHome.Application/MappingProfile.cs @@ -7,11 +7,20 @@ public sealed class MappingProfile : Profile { public MappingProfile() { - CreateMap().ReverseMap(); + CreateMap() + .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User.Name)) + .ForMember(dest => dest.UserFamily, opt => opt.MapFrom(src => src.User.Family)) + .ForMember(dest => dest.UserMobile, opt => opt.MapFrom(src => src.User.Mobile)) + .ReverseMap() + .ForMember(dest => dest.User, opt => opt.Ignore()); + CreateMap().ReverseMap(); + CreateMap() .ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName)) .ReverseMap() .ForMember(dest => dest.Device, opt => opt.Ignore()); + + CreateMap().ReverseMap(); } } diff --git a/src/GreenHome.Domain/AlertNotification.cs b/src/GreenHome.Domain/AlertNotification.cs new file mode 100644 index 0000000..2693434 --- /dev/null +++ b/src/GreenHome.Domain/AlertNotification.cs @@ -0,0 +1,17 @@ +namespace GreenHome.Domain; + +public sealed class AlertNotification +{ + public int Id { get; set; } + public int DeviceId { get; set; } + public Device Device { get; set; } = null!; + public int UserId { get; set; } + public User User { get; set; } = null!; + public string AlertType { get; set; } = string.Empty; // Temperature, Humidity, Soil, Gas, Lux + public string Message { get; set; } = string.Empty; + public string? MessageOutboxIds { get; set; } // JSON array of message outbox IDs + public string? ErrorMessage { get; set; } // Error details if sending failed + public DateTime SentAt { get; set; } = DateTime.UtcNow; + public bool IsSent { get; set; } = true; +} + diff --git a/src/GreenHome.Domain/Device.cs b/src/GreenHome.Domain/Device.cs index 6f78491..b8b5cd5 100644 --- a/src/GreenHome.Domain/Device.cs +++ b/src/GreenHome.Domain/Device.cs @@ -4,8 +4,9 @@ public sealed class Device { public int Id { get; set; } public string DeviceName { get; set; } = string.Empty; // varchar(10) - public string Owner { get; set; } = string.Empty; // نام صاحب - public string Mobile { get; set; } = string.Empty; + public int UserId { get; set; } + public User User { get; set; } = null!; public string Location { get; set; } = string.Empty; // varchar(250) public string NeshanLocation { get; set; } = string.Empty; // varchar(80) + public ICollection DeviceUsers { get; set; } = new List(); } diff --git a/src/GreenHome.Domain/DeviceUser.cs b/src/GreenHome.Domain/DeviceUser.cs new file mode 100644 index 0000000..06fb4c1 --- /dev/null +++ b/src/GreenHome.Domain/DeviceUser.cs @@ -0,0 +1,10 @@ +namespace GreenHome.Domain; + +public sealed class DeviceUser +{ + public int DeviceId { get; set; } + public Device Device { get; set; } = null!; + public int UserId { get; set; } + public User User { get; set; } = null!; +} + diff --git a/src/GreenHome.Domain/User.cs b/src/GreenHome.Domain/User.cs new file mode 100644 index 0000000..3ad5bf1 --- /dev/null +++ b/src/GreenHome.Domain/User.cs @@ -0,0 +1,14 @@ +namespace GreenHome.Domain; + +public sealed class User +{ + public int Id { get; set; } + public string Mobile { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Family { get; set; } = string.Empty; + public UserRole Role { get; set; } = UserRole.Normal; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastLoginAt { get; set; } + public ICollection DeviceUsers { get; set; } = new List(); +} + diff --git a/src/GreenHome.Domain/UserRole.cs b/src/GreenHome.Domain/UserRole.cs new file mode 100644 index 0000000..21a2ad9 --- /dev/null +++ b/src/GreenHome.Domain/UserRole.cs @@ -0,0 +1,9 @@ +namespace GreenHome.Domain; + +public enum UserRole +{ + Normal = 0, // کاربر عادی + Admin = 1, // ادمین + Supervisor = 2 // ناظر +} + diff --git a/src/GreenHome.Domain/VerificationCode.cs b/src/GreenHome.Domain/VerificationCode.cs new file mode 100644 index 0000000..4eb5e91 --- /dev/null +++ b/src/GreenHome.Domain/VerificationCode.cs @@ -0,0 +1,13 @@ +namespace GreenHome.Domain; + +public sealed class VerificationCode +{ + public int Id { get; set; } + public string Mobile { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public bool IsUsed { get; set; } = false; + public DateTime? UsedAt { get; set; } +} + diff --git a/src/GreenHome.Infrastructure/AlertService.cs b/src/GreenHome.Infrastructure/AlertService.cs new file mode 100644 index 0000000..1f03b20 --- /dev/null +++ b/src/GreenHome.Infrastructure/AlertService.cs @@ -0,0 +1,319 @@ +using GreenHome.Application; +using GreenHome.Sms.Ippanel; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +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 ILogger logger; + private const int AlertCooldownMinutes = 10; + + private sealed record AlertInfo( + string Type, + string Message, + string ParameterName, + decimal Value, + string Status + ); + + public AlertService( + GreenHomeDbContext dbContext, + IDeviceSettingsService deviceSettingsService, + ISmsService smsService, + ILogger logger) + { + this.dbContext = dbContext; + this.deviceSettingsService = deviceSettingsService; + this.smsService = smsService; + this.logger = logger; + } + + public async Task CheckAndSendAlertsAsync(int deviceId, TelemetryDto telemetry, CancellationToken cancellationToken) + { + var settings = await deviceSettingsService.GetByDeviceIdAsync(deviceId, cancellationToken); + if (settings == null) + { + return; + } + + var device = await dbContext.Devices + .Include(d => d.User) + .FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken); + + if (device == null || device.User == null) + { + return; + } + + var alerts = CollectAlerts(telemetry, settings, device.DeviceName); + + foreach (var alert in alerts) + { + await SendAlertIfNeededAsync(deviceId, device.User.Id, device.DeviceName, alert, cancellationToken); + } + } + + private List CollectAlerts(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName) + { + var alerts = new List(); + + CheckTemperatureAlert(telemetry, settings, deviceName, alerts); + CheckHumidityAlert(telemetry, settings, deviceName, alerts); + CheckSoilAlert(telemetry, deviceName, alerts); + CheckGasAlert(telemetry, settings, deviceName, alerts); + CheckLuxAlert(telemetry, settings, deviceName, alerts); + + return alerts; + } + + private void CheckTemperatureAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List alerts) + { + if (telemetry.TemperatureC > settings.MaxTemperature) + { + alerts.Add(new AlertInfo( + Type: "Temperature", + Message: $"هشدار: دمای گلخانه {deviceName} به {telemetry.TemperatureC} درجه رسیده که از حداکثر مجاز ({settings.MaxTemperature}) بیشتر است.", + ParameterName: "دما", + Value: telemetry.TemperatureC, + Status: "بالاتر" + )); + } + else if (telemetry.TemperatureC < settings.MinTemperature) + { + alerts.Add(new AlertInfo( + Type: "Temperature", + Message: $"هشدار: دمای گلخانه {deviceName} به {telemetry.TemperatureC} درجه رسیده که از حداقل مجاز ({settings.MinTemperature}) کمتر است.", + ParameterName: "دما", + Value: telemetry.TemperatureC, + Status: "پایین‌تر" + )); + } + } + + private void CheckHumidityAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List alerts) + { + if (telemetry.HumidityPercent > settings.MaxHumidityPercent) + { + alerts.Add(new AlertInfo( + Type: "Humidity", + Message: $"هشدار: رطوبت گلخانه {deviceName} به {telemetry.HumidityPercent}% رسیده که از حداکثر مجاز ({settings.MaxHumidityPercent}%) بیشتر است.", + ParameterName: "رطوبت", + Value: telemetry.HumidityPercent, + Status: "بالاتر" + )); + } + else if (telemetry.HumidityPercent < settings.MinHumidityPercent) + { + alerts.Add(new AlertInfo( + Type: "Humidity", + Message: $"هشدار: رطوبت گلخانه {deviceName} به {telemetry.HumidityPercent}% رسیده که از حداقل مجاز ({settings.MinHumidityPercent}%) کمتر است.", + ParameterName: "رطوبت", + Value: telemetry.HumidityPercent, + Status: "پایین‌تر" + )); + } + } + + private void CheckSoilAlert(TelemetryDto telemetry, string deviceName, List alerts) + { + if (telemetry.SoilPercent > 100) + { + alerts.Add(new AlertInfo( + Type: "Soil", + Message: $"هشدار: رطوبت خاک گلخانه {deviceName} مقدار نامعتبر ({telemetry.SoilPercent}%) دارد.", + ParameterName: "رطوبت خاک", + Value: telemetry.SoilPercent, + Status: "بالاتر" + )); + } + else if (telemetry.SoilPercent < 0) + { + alerts.Add(new AlertInfo( + Type: "Soil", + Message: $"هشدار: رطوبت خاک گلخانه {deviceName} مقدار نامعتبر ({telemetry.SoilPercent}%) دارد.", + ParameterName: "رطوبت خاک", + Value: telemetry.SoilPercent, + Status: "پایین‌تر" + )); + } + } + + private void CheckGasAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List alerts) + { + if (telemetry.GasPPM > settings.MaxGasPPM) + { + alerts.Add(new AlertInfo( + Type: "Gas", + Message: $"هشدار: گاز گلخانه {deviceName} به {telemetry.GasPPM} PPM رسیده که از حداکثر مجاز ({settings.MaxGasPPM}) بیشتر است.", + ParameterName: "گاز Co", + Value: telemetry.GasPPM, + Status: "بالاتر" + )); + } + else if (telemetry.GasPPM < settings.MinGasPPM) + { + alerts.Add(new AlertInfo( + Type: "Gas", + Message: $"هشدار: گاز گلخانه {deviceName} به {telemetry.GasPPM} PPM رسیده که از حداقل مجاز ({settings.MinGasPPM}) کمتر است.", + ParameterName: "گاز Co", + Value: telemetry.GasPPM, + Status: "پایین‌تر" + )); + } + } + + private void CheckLuxAlert(TelemetryDto telemetry, DeviceSettingsDto settings, string deviceName, List alerts) + { + if (telemetry.Lux > settings.MaxLux) + { + alerts.Add(new AlertInfo( + Type: "Lux", + Message: $"هشدار: نور گلخانه {deviceName} به {telemetry.Lux} لوکس رسیده که از حداکثر مجاز ({settings.MaxLux}) بیشتر است.", + ParameterName: "نور", + Value: telemetry.Lux, + Status: "بالاتر" + )); + } + else if (telemetry.Lux < settings.MinLux) + { + alerts.Add(new AlertInfo( + Type: "Lux", + Message: $"هشدار: نور گلخانه {deviceName} به {telemetry.Lux} لوکس رسیده که از حداقل مجاز ({settings.MinLux}) کمتر است.", + ParameterName: "نور", + Value: telemetry.Lux, + Status: "پایین‌تر" + )); + } + } + + private async Task SendAlertIfNeededAsync( + int deviceId, + int userId, + string deviceName, + AlertInfo alert, + CancellationToken cancellationToken) + { + // Check if alert was sent in the last 10 minutes + var cooldownTime = DateTime.UtcNow.AddMinutes(-AlertCooldownMinutes); + var recentAlert = await dbContext.AlertNotifications + .Where(a => a.DeviceId == deviceId && + a.UserId == userId && + a.AlertType == alert.Type && + a.SentAt >= cooldownTime) + .FirstOrDefaultAsync(cancellationToken); + + if (recentAlert != null) + { + logger.LogInformation("Alert skipped due to cooldown: DeviceId={DeviceId}, AlertType={AlertType}", deviceId, alert.Type); + return; + } + + // Get user to send SMS + var user = await dbContext.Users + .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + if (user == null || string.IsNullOrWhiteSpace(user.Mobile)) + { + logger.LogWarning("User not found or mobile is empty: UserId={UserId}", userId); + return; + } + + // Send SMS and collect response/errors + string? messageOutboxIdsJson = null; + string? errorMessage = null; + bool isSent = false; + + try + { + var smsResponse = await smsService.SendPatternSmsAsync(new PatternSmsRequest + { + Recipients = [user.Mobile], + PatternCode = "64di3w9kb0fxvif", + Variables = new Dictionary { + { "name", deviceName }, + { "parameter", alert.ParameterName }, + { "value", alert.Value.ToString("F1") }, + { "status", alert.Status }, + } + }, cancellationToken); + + if (smsResponse != null) + { + // Check if SMS was sent successfully + if (smsResponse.Meta.Status && smsResponse.Data != null && smsResponse.Data.MessageOutboxIds != null && smsResponse.Data.MessageOutboxIds.Count > 0) + { + // Success - save message outbox IDs + messageOutboxIdsJson = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds); + isSent = true; + logger.LogInformation("Alert SMS sent: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}, OutboxIds={OutboxIds}", + deviceId, userId, alert.Type, messageOutboxIdsJson); + } + else + { + // Failed - save error from meta + var errors = new List(); + if (!string.IsNullOrWhiteSpace(smsResponse.Meta.Message)) + { + errors.Add(smsResponse.Meta.Message); + } + if (smsResponse.Meta.Errors != null && smsResponse.Meta.Errors.Count > 0) + { + foreach (var error in smsResponse.Meta.Errors) + { + errors.Add($"{error.Key}: {string.Join(", ", error.Value)}"); + } + } + if (errors.Count == 0) + { + errors.Add("SMS sending failed with unknown error"); + } + errorMessage = string.Join(" | ", errors); + isSent = false; + logger.LogWarning("Alert SMS failed: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}, Error={Error}", + deviceId, userId, alert.Type, errorMessage); + } + } + else + { + errorMessage = "SMS service returned null response"; + isSent = false; + logger.LogWarning("Alert SMS returned null: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}", + deviceId, userId, alert.Type); + } + } + 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 SMS: DeviceId={DeviceId}, UserId={UserId}, AlertType={AlertType}", deviceId, userId, alert.Type); + } + + // Save notification to database + var notification = new Domain.AlertNotification + { + DeviceId = deviceId, + UserId = userId, + AlertType = alert.Type, + Message = alert.Message, + MessageOutboxIds = messageOutboxIdsJson, + ErrorMessage = errorMessage, + SentAt = DateTime.UtcNow, + IsSent = isSent + }; + + dbContext.AlertNotifications.Add(notification); + await dbContext.SaveChangesAsync(cancellationToken); + } +} + diff --git a/src/GreenHome.Infrastructure/AuthService.cs b/src/GreenHome.Infrastructure/AuthService.cs new file mode 100644 index 0000000..8580fc4 --- /dev/null +++ b/src/GreenHome.Infrastructure/AuthService.cs @@ -0,0 +1,242 @@ +using GreenHome.Application; +using GreenHome.Sms.Ippanel; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; + +namespace GreenHome.Infrastructure; + +public sealed class AuthService : IAuthService +{ + private readonly GreenHomeDbContext dbContext; + private readonly ISmsService smsService; + private readonly ILogger logger; + private const int CodeExpirationMinutes = 5; + private const int ResendCooldownSeconds = 120; + + public AuthService( + GreenHomeDbContext dbContext, + ISmsService smsService, + ILogger logger) + { + this.dbContext = dbContext; + this.smsService = smsService; + this.logger = logger; + } + + public async Task SendVerificationCodeAsync( + SendCodeRequest request, + CancellationToken cancellationToken) + { + // Normalize mobile number (remove spaces, dashes, etc.) + var mobile = NormalizeMobile(request.Mobile); + + if (string.IsNullOrWhiteSpace(mobile) || mobile.Length != 11 || !mobile.StartsWith("09")) + { + return new SendCodeResponse + { + Success = false, + Message = "شماره موبایل معتبر نیست" + }; + } + + // Check if we can resend (cooldown period) + var canResend = await CanResendCodeAsync(mobile, cancellationToken); + if (!canResend) + { + var lastCode = await dbContext.VerificationCodes + .Where(v => v.Mobile == mobile && !v.IsUsed) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + if (lastCode != null) + { + var secondsRemaining = (int)(ResendCooldownSeconds - (DateTime.UtcNow - lastCode.CreatedAt).TotalSeconds); + return new SendCodeResponse + { + Success = false, + Message = $"لطفاً {secondsRemaining} ثانیه دیگر دوباره تلاش کنید", + ResendAfterSeconds = secondsRemaining > 0 ? secondsRemaining : 0 + }; + } + } + + // Generate 4-digit code + var code = GenerateCode(); + + // Invalidate previous unused codes for this mobile + var previousCodes = await dbContext.VerificationCodes + .Where(v => v.Mobile == mobile && !v.IsUsed) + .ToListAsync(cancellationToken); + + foreach (var prevCode in previousCodes) + { + prevCode.IsUsed = true; + prevCode.UsedAt = DateTime.UtcNow; + } + + // Create new verification code + var verificationCode = new Domain.VerificationCode + { + Mobile = mobile, + Code = code, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddMinutes(CodeExpirationMinutes), + IsUsed = false + }; + + dbContext.VerificationCodes.Add(verificationCode); + await dbContext.SaveChangesAsync(cancellationToken); + + // Send SMS + try + { + await smsService.SendPatternSmsAsync(new PatternSmsRequest + { + Recipients = [mobile], + PatternCode = "ruvpjx7lajne1dx", + Variables = new Dictionary { { "code", code } } + }, cancellationToken); + + logger.LogInformation("Verification code sent to {Mobile}", mobile); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send SMS to {Mobile}", mobile); + // Continue even if SMS fails (for development/testing) + } + + return new SendCodeResponse + { + Success = true, + Message = "کد فعال‌سازی ارسال شد", + ResendAfterSeconds = ResendCooldownSeconds + }; + } + + public async Task VerifyCodeAsync( + VerifyCodeRequest request, + CancellationToken cancellationToken) + { + var mobile = NormalizeMobile(request.Mobile); + var code = request.Code.Trim(); + + if (string.IsNullOrWhiteSpace(mobile) || string.IsNullOrWhiteSpace(code) || code.Length != 4) + { + return new VerifyCodeResponse + { + Success = false, + Message = "اطلاعات وارد شده معتبر نیست" + }; + } + + // Find valid verification code + var verificationCode = await dbContext.VerificationCodes + .Where(v => v.Mobile == mobile && v.Code == code && !v.IsUsed && v.ExpiresAt > DateTime.UtcNow) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + if (verificationCode == null) + { + return new VerifyCodeResponse + { + Success = false, + Message = "کد وارد شده معتبر نیست یا منقضی شده است" + }; + } + + // Mark code as used + verificationCode.IsUsed = true; + verificationCode.UsedAt = DateTime.UtcNow; + + // Find or create user + var user = await dbContext.Users + .FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken); + + if (user == null) + { + user = new Domain.User + { + Mobile = mobile, + CreatedAt = DateTime.UtcNow, + LastLoginAt = DateTime.UtcNow + }; + dbContext.Users.Add(user); + } + else + { + user.LastLoginAt = DateTime.UtcNow; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + // Generate simple token (in production, use JWT) + var token = GenerateToken(user.Id, mobile); + + return new VerifyCodeResponse + { + Success = true, + Message = "ورود موفقیت‌آمیز بود", + Token = token, + User = new UserDto + { + Id = user.Id, + Mobile = user.Mobile, + Name = user.Name, + Family = user.Family, + Role = user.Role + } + }; + } + + public async Task CanResendCodeAsync(string mobile, CancellationToken cancellationToken) + { + var normalizedMobile = NormalizeMobile(mobile); + var lastCode = await dbContext.VerificationCodes + .Where(v => v.Mobile == normalizedMobile && !v.IsUsed) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + if (lastCode == null) + return true; + + var elapsed = (DateTime.UtcNow - lastCode.CreatedAt).TotalSeconds; + return elapsed >= ResendCooldownSeconds; + } + + private static string GenerateCode() + { + var random = new Random(); + return random.Next(1000, 9999).ToString(); + } + + private static string GenerateToken(int userId, string mobile) + { + // Simple token generation (in production, use JWT) + var data = $"{userId}:{mobile}:{DateTime.UtcNow:yyyyMMddHHmmss}"; + var bytes = Encoding.UTF8.GetBytes(data); + var hash = SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } + + private static string NormalizeMobile(string mobile) + { + if (string.IsNullOrWhiteSpace(mobile)) + return string.Empty; + + // Remove all non-digit characters + var normalized = new string(mobile.Where(char.IsDigit).ToArray()); + + // Convert to standard format (09xxxxxxxxx) + if (normalized.StartsWith("9") && normalized.Length == 10) + normalized = "0" + normalized; + else if (normalized.StartsWith("0098") && normalized.Length == 13) + normalized = "0" + normalized.Substring(3); + else if (normalized.StartsWith("98") && normalized.Length == 12) + normalized = "0" + normalized.Substring(2); + + return normalized; + } +} + diff --git a/src/GreenHome.Infrastructure/DeviceService.cs b/src/GreenHome.Infrastructure/DeviceService.cs index d3929a6..5b19760 100644 --- a/src/GreenHome.Infrastructure/DeviceService.cs +++ b/src/GreenHome.Infrastructure/DeviceService.cs @@ -25,13 +25,102 @@ public sealed class DeviceService : IDeviceService public async Task> ListAsync(CancellationToken cancellationToken) { - var items = await dbContext.Devices.AsNoTracking().OrderBy(d => d.DeviceName).ToListAsync(cancellationToken); + var items = await dbContext.Devices + .AsNoTracking() + .Include(d => d.User) + .OrderBy(d => d.DeviceName) + .ToListAsync(cancellationToken); return mapper.Map>(items); } public async Task GetDeviceId(string deviceName,CancellationToken cancellationToken) { - var item = await dbContext.Devices.AsNoTracking().Where(d=>d.DeviceName==deviceName).FirstOrDefaultAsync(cancellationToken); + var item = await dbContext.Devices + .AsNoTracking() + .Include(d => d.User) + .Where(d=>d.DeviceName==deviceName) + .FirstOrDefaultAsync(cancellationToken); return mapper.Map(item); } + + public async Task> GetUserDevicesAsync(int userId, CancellationToken cancellationToken) + { + var items = await dbContext.Devices + .AsNoTracking() + .Include(d => d.User) + .Where(d => d.UserId == userId) + .OrderBy(d => d.DeviceName) + .ToListAsync(cancellationToken); + return mapper.Map>(items); + } + + public async Task> GetDevicesAsync(DeviceFilter filter, CancellationToken cancellationToken) + { + if (!filter.UserId.HasValue) + { + throw new ArgumentException("UserId is required", nameof(filter)); + } + + // Get user and role + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == filter.UserId.Value, cancellationToken); + + if (user == null) + { + return new PagedResult + { + Items = Array.Empty(), + TotalCount = 0, + Page = filter.Page, + PageSize = filter.PageSize + }; + } + + // Build query based on role + IQueryable query = dbContext.Devices + .AsNoTracking() + .Include(d => d.User); + + if (user.Role == Domain.UserRole.Normal) + { + // Normal user: only own devices + query = query.Where(d => d.UserId == user.Id); + } + else if (user.Role == Domain.UserRole.Supervisor) + { + // Supervisor: devices assigned to them + query = query.Where(d => d.DeviceUsers.Any(du => du.UserId == user.Id)); + } + // Admin: all devices (no filter) + + // Apply search filter + if (!string.IsNullOrWhiteSpace(filter.Search)) + { + var searchTerm = filter.Search.Trim().ToLower(); + query = query.Where(d => + d.DeviceName.ToLower().Contains(searchTerm) || + d.User.Name.ToLower().Contains(searchTerm) || + d.User.Family.ToLower().Contains(searchTerm) || + d.Location.ToLower().Contains(searchTerm)); + } + + // Get total count + var totalCount = await query.CountAsync(cancellationToken); + + // Apply pagination + var items = await query + .OrderBy(d => d.DeviceName) + .Skip((filter.Page - 1) * filter.PageSize) + .Take(filter.PageSize) + .ToListAsync(cancellationToken); + + return new PagedResult + { + Items = mapper.Map>(items), + TotalCount = totalCount, + Page = filter.Page, + PageSize = filter.PageSize + }; + } } diff --git a/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj b/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj index ad0f566..9df8de2 100644 --- a/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj +++ b/src/GreenHome.Infrastructure/GreenHome.Infrastructure.csproj @@ -22,6 +22,7 @@ + diff --git a/src/GreenHome.Infrastructure/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/GreenHomeDbContext.cs index 9e5c443..ad00e31 100644 --- a/src/GreenHome.Infrastructure/GreenHomeDbContext.cs +++ b/src/GreenHome.Infrastructure/GreenHomeDbContext.cs @@ -9,6 +9,10 @@ public sealed class GreenHomeDbContext : DbContext public DbSet Devices => Set(); public DbSet TelemetryRecords => Set(); public DbSet DeviceSettings => Set(); + public DbSet Users => Set(); + public DbSet VerificationCodes => Set(); + public DbSet DeviceUsers => Set(); + public DbSet AlertNotifications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -19,10 +23,13 @@ public sealed class GreenHomeDbContext : DbContext b.ToTable("Devices"); b.HasKey(x => x.Id); b.Property(x => x.DeviceName).IsRequired().HasMaxLength(10); - b.Property(x => x.Owner).IsRequired(); - b.Property(x => x.Mobile).IsRequired(false); + b.Property(x => x.UserId).IsRequired(); b.Property(x => x.Location).HasMaxLength(250); b.Property(x => x.NeshanLocation).HasMaxLength(80); + b.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(b => @@ -56,5 +63,58 @@ public sealed class GreenHomeDbContext : DbContext .OnDelete(DeleteBehavior.Cascade); b.HasIndex(x => x.DeviceId).IsUnique(); }); + + modelBuilder.Entity(b => + { + b.ToTable("Users"); + b.HasKey(x => x.Id); + b.Property(x => x.Mobile).IsRequired().HasMaxLength(11); + b.Property(x => x.Name).IsRequired().HasMaxLength(100); + b.Property(x => x.Family).IsRequired().HasMaxLength(100); + b.Property(x => x.Role).IsRequired().HasConversion(); + b.HasIndex(x => x.Mobile).IsUnique(); + }); + + modelBuilder.Entity(b => + { + b.ToTable("DeviceUsers"); + b.HasKey(x => new { x.DeviceId, x.UserId }); + b.HasOne(x => x.Device) + .WithMany(d => d.DeviceUsers) + .HasForeignKey(x => x.DeviceId) + .OnDelete(DeleteBehavior.Cascade); + b.HasOne(x => x.User) + .WithMany(u => u.DeviceUsers) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.ToTable("VerificationCodes"); + b.HasKey(x => x.Id); + b.Property(x => x.Mobile).IsRequired().HasMaxLength(11); + b.Property(x => x.Code).IsRequired().HasMaxLength(4); + b.HasIndex(x => new { x.Mobile, x.Code, x.IsUsed }); + }); + + modelBuilder.Entity(b => + { + b.ToTable("AlertNotifications"); + b.HasKey(x => x.Id); + b.Property(x => x.AlertType).IsRequired().HasMaxLength(50); + b.Property(x => x.Message).IsRequired().HasMaxLength(500); + b.Property(x => x.MessageOutboxIds).HasMaxLength(500); + b.Property(x => x.ErrorMessage).HasMaxLength(1000); + b.HasOne(x => x.Device) + .WithMany() + .HasForeignKey(x => x.DeviceId) + .OnDelete(DeleteBehavior.Restrict); + b.HasOne(x => x.User) + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Restrict); + b.HasIndex(x => new { x.DeviceId, x.UserId, x.AlertType, x.SentAt }); + }); } } diff --git a/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.Designer.cs b/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.Designer.cs new file mode 100644 index 0000000..69dbaf6 --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.Designer.cs @@ -0,0 +1,263 @@ +// +using System; +using GreenHome.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20251118204845_UpdateDeviceUserRelationship")] + partial class UpdateDeviceUserRelationship + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DangerMaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DangerMinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("MaxGasPPM") + .HasColumnType("int"); + + b.Property("MaxHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("MinGasPPM") + .HasColumnType("int"); + + b.Property("MinHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MinLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.cs b/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.cs new file mode 100644 index 0000000..2f6a99b --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251118204845_UpdateDeviceUserRelationship.cs @@ -0,0 +1,124 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + /// + public partial class UpdateDeviceUserRelationship : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Mobile", + table: "Devices"); + + migrationBuilder.DropColumn( + name: "Owner", + table: "Devices"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Devices", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Mobile = table.Column(type: "nvarchar(11)", maxLength: 11, nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Family = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastLoginAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VerificationCodes", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Mobile = table.Column(type: "nvarchar(11)", maxLength: 11, nullable: false), + Code = table.Column(type: "nvarchar(4)", maxLength: 4, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + IsUsed = table.Column(type: "bit", nullable: false), + UsedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VerificationCodes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Devices_UserId", + table: "Devices", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Mobile", + table: "Users", + column: "Mobile", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VerificationCodes_Mobile_Code_IsUsed", + table: "VerificationCodes", + columns: new[] { "Mobile", "Code", "IsUsed" }); + + migrationBuilder.AddForeignKey( + name: "FK_Devices_Users_UserId", + table: "Devices", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Devices_Users_UserId", + table: "Devices"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "VerificationCodes"); + + migrationBuilder.DropIndex( + name: "IX_Devices_UserId", + table: "Devices"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Devices"); + + migrationBuilder.AddColumn( + name: "Mobile", + table: "Devices", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Owner", + table: "Devices", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.Designer.cs b/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.Designer.cs new file mode 100644 index 0000000..2b5e10f --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.Designer.cs @@ -0,0 +1,310 @@ +// +using System; +using GreenHome.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20251119133827_AddUserRolesAndDeviceUsers")] + partial class AddUserRolesAndDeviceUsers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DangerMaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DangerMinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("MaxGasPPM") + .HasColumnType("int"); + + b.Property("MaxHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("MinGasPPM") + .HasColumnType("int"); + + b.Property("MinHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MinLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.cs b/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.cs new file mode 100644 index 0000000..37b0e88 --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119133827_AddUserRolesAndDeviceUsers.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + /// + public partial class AddUserRolesAndDeviceUsers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Role", + table: "Users", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "DeviceUsers", + columns: table => new + { + DeviceId = table.Column(type: "int", nullable: false), + UserId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeviceUsers", x => new { x.DeviceId, x.UserId }); + table.ForeignKey( + name: "FK_DeviceUsers_Devices_DeviceId", + column: x => x.DeviceId, + principalTable: "Devices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_DeviceUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DeviceUsers_UserId", + table: "DeviceUsers", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DeviceUsers"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Users"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.Designer.cs b/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.Designer.cs new file mode 100644 index 0000000..27ec36a --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.Designer.cs @@ -0,0 +1,368 @@ +// +using System; +using GreenHome.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20251119135914_AddAlertNotifications")] + partial class AddAlertNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("IsSent") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "AlertType", "SentAt"); + + b.ToTable("AlertNotifications", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DangerMaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DangerMinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("MaxGasPPM") + .HasColumnType("int"); + + b.Property("MaxHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("MinGasPPM") + .HasColumnType("int"); + + b.Property("MinHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MinLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.cs b/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.cs new file mode 100644 index 0000000..989e347 --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119135914_AddAlertNotifications.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + /// + public partial class AddAlertNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AlertNotifications", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DeviceId = table.Column(type: "int", nullable: false), + UserId = table.Column(type: "int", nullable: false), + AlertType = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Message = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + SentAt = table.Column(type: "datetime2", nullable: false), + IsSent = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlertNotifications", x => x.Id); + table.ForeignKey( + name: "FK_AlertNotifications_Devices_DeviceId", + column: x => x.DeviceId, + principalTable: "Devices", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AlertNotifications_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AlertNotifications_DeviceId_UserId_AlertType_SentAt", + table: "AlertNotifications", + columns: new[] { "DeviceId", "UserId", "AlertType", "SentAt" }); + + migrationBuilder.CreateIndex( + name: "IX_AlertNotifications_UserId", + table: "AlertNotifications", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlertNotifications"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.Designer.cs b/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.Designer.cs new file mode 100644 index 0000000..32bab47 --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.Designer.cs @@ -0,0 +1,376 @@ +// +using System; +using GreenHome.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20251119142729_FixAlertNotifications")] + partial class FixAlertNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsSent") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageOutboxIds") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "AlertType", "SentAt"); + + b.ToTable("AlertNotifications", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DangerMaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DangerMinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("MaxGasPPM") + .HasColumnType("int"); + + b.Property("MaxHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MaxTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("MinGasPPM") + .HasColumnType("int"); + + b.Property("MinHumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("MinLux") + .HasColumnType("decimal(18,2)"); + + b.Property("MinTemperature") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.cs b/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.cs new file mode 100644 index 0000000..ad0b9a0 --- /dev/null +++ b/src/GreenHome.Infrastructure/Migrations/20251119142729_FixAlertNotifications.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Migrations +{ + /// + public partial class FixAlertNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ErrorMessage", + table: "AlertNotifications", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "MessageOutboxIds", + table: "AlertNotifications", + type: "nvarchar(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ErrorMessage", + table: "AlertNotifications"); + + migrationBuilder.DropColumn( + name: "MessageOutboxIds", + table: "AlertNotifications"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Migrations/GreenHomeDbContextModelSnapshot.cs b/src/GreenHome.Infrastructure/Migrations/GreenHomeDbContextModelSnapshot.cs index 2d09ced..8818e41 100644 --- a/src/GreenHome.Infrastructure/Migrations/GreenHomeDbContextModelSnapshot.cs +++ b/src/GreenHome.Infrastructure/Migrations/GreenHomeDbContextModelSnapshot.cs @@ -22,6 +22,53 @@ namespace GreenHome.Infrastructure.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsSent") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageOutboxIds") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "AlertType", "SentAt"); + + b.ToTable("AlertNotifications", (string)null); + }); + modelBuilder.Entity("GreenHome.Domain.Device", b => { b.Property("Id") @@ -40,20 +87,18 @@ namespace GreenHome.Infrastructure.Migrations .HasMaxLength(250) .HasColumnType("nvarchar(250)"); - b.Property("Mobile") - .HasColumnType("nvarchar(max)"); - b.Property("NeshanLocation") .IsRequired() .HasMaxLength(80) .HasColumnType("nvarchar(80)"); - b.Property("Owner") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("UserId") + .HasColumnType("int"); b.HasKey("Id"); + b.HasIndex("UserId"); + b.ToTable("Devices", (string)null); }); @@ -112,6 +157,21 @@ namespace GreenHome.Infrastructure.Migrations b.ToTable("DeviceSettings", (string)null); }); + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => { b.Property("Id") @@ -161,6 +221,113 @@ namespace GreenHome.Infrastructure.Migrations b.ToTable("Telemetry", (string)null); }); + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => { b.HasOne("GreenHome.Domain.Device", "Device") @@ -171,6 +338,35 @@ namespace GreenHome.Infrastructure.Migrations b.Navigation("Device"); }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); #pragma warning restore 612, 618 } } diff --git a/src/GreenHome.Infrastructure/TelemetryService.cs b/src/GreenHome.Infrastructure/TelemetryService.cs index 0c77b2f..bd0fb33 100644 --- a/src/GreenHome.Infrastructure/TelemetryService.cs +++ b/src/GreenHome.Infrastructure/TelemetryService.cs @@ -22,7 +22,12 @@ public sealed class TelemetryService : ITelemetryService var entity = mapper.Map(dto); if (!string.IsNullOrEmpty(dto.DeviceName)) { - entity.DeviceId = dbContext.Devices.First(d => d.DeviceName == dto.DeviceName).Id; + var device = await dbContext.Devices.FirstOrDefaultAsync(d => d.DeviceName == dto.DeviceName, cancellationToken); + if (device != null) + { + entity.DeviceId = device.Id; + dto.DeviceId = device.Id; // Update DTO for alert service + } } var dt = dto.TimestampUtc; var py = PersianCalendar.GetYear(dt); diff --git a/src/GreenHome.Infrastructure/VoiceCallService.cs b/src/GreenHome.Infrastructure/VoiceCallService.cs new file mode 100644 index 0000000..1b803df --- /dev/null +++ b/src/GreenHome.Infrastructure/VoiceCallService.cs @@ -0,0 +1,4 @@ +public class VoiceCallService +{ + +} \ No newline at end of file diff --git a/src/GreenHome.Sms.Ippanel/GreenHome.Sms.Ippanel.csproj b/src/GreenHome.Sms.Ippanel/GreenHome.Sms.Ippanel.csproj new file mode 100644 index 0000000..6513d84 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/GreenHome.Sms.Ippanel.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/src/GreenHome.Sms.Ippanel/ISmsService.cs b/src/GreenHome.Sms.Ippanel/ISmsService.cs new file mode 100644 index 0000000..b85e183 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/ISmsService.cs @@ -0,0 +1,26 @@ +using static GreenHome.Sms.Ippanel.IppanelSmsService; + +namespace GreenHome.Sms.Ippanel; + +/// +/// Interface for SMS sending service +/// +public interface ISmsService +{ + /// + /// Sends a webservice SMS message + /// + /// SMS request details + /// Cancellation token + /// SMS response with bulk ID + Task?> SendWebserviceSmsAsync(WebserviceSmsRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends a pattern SMS message + /// + /// Pattern SMS request details + /// Cancellation token + /// SMS response with bulk ID + Task?> SendPatternSmsAsync(PatternSmsRequest request, CancellationToken cancellationToken = default); +} + diff --git a/src/GreenHome.Sms.Ippanel/IppanelSmsOptions.cs b/src/GreenHome.Sms.Ippanel/IppanelSmsOptions.cs new file mode 100644 index 0000000..ba52a17 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/IppanelSmsOptions.cs @@ -0,0 +1,23 @@ +namespace GreenHome.Sms.Ippanel; + +/// +/// Configuration options for IPPanel SMS service +/// +public sealed class IppanelSmsOptions +{ + /// + /// IPPanel API base URL + /// + public string BaseUrl { get; set; } = "https://edge.ippanel.com/v1"; + + /// + /// IPPanel authorization token + /// + public required string AuthorizationToken { get; set; } + + /// + /// Default sender number for webservice SMS + /// + public string? DefaultSender { get; set; } +} + diff --git a/src/GreenHome.Sms.Ippanel/IppanelSmsService.cs b/src/GreenHome.Sms.Ippanel/IppanelSmsService.cs new file mode 100644 index 0000000..4d8e321 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/IppanelSmsService.cs @@ -0,0 +1,160 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace GreenHome.Sms.Ippanel; + +/// +/// IPPanel SMS service implementation +/// +public sealed class IppanelSmsService : ISmsService +{ + private readonly HttpClient httpClient; + private readonly IppanelSmsOptions options; + private readonly ILogger logger; + + public IppanelSmsService( + HttpClient httpClient, + IppanelSmsOptions options, + ILogger logger) + { + this.httpClient = httpClient; + this.options = options; + this.logger = logger; + } + + public async Task?> SendWebserviceSmsAsync( + WebserviceSmsRequest request, + CancellationToken cancellationToken = default) + { + try + { + // Split recipients by comma and create array + var recipients = request.Recipient + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + var payload = new + { + sending_type = "webservice", + from_number = request.Sender ?? options.DefaultSender, + @params = new + { + recipients + }, + message = request.Message + }; + + var response = await httpClient.PostAsJsonAsync( + "api/send", + payload, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync>( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error sending webservice SMS to {Recipient}", request.Recipient); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error sending webservice SMS"); + throw; + } + } + + public async Task?> SendPatternSmsAsync( + PatternSmsRequest request, + CancellationToken cancellationToken = default) + { + try + { + + var payload = new + { + sending_type = "pattern", + code = request.PatternCode, + from_number = request.Originator ?? options.DefaultSender ?? string.Empty, + recipients = request.Recipients, + @params = ToAnonymousObject(request.Variables) + }; + + var response = await httpClient.PostAsJsonAsync( + "api/send", + payload, + cancellationToken); + + //response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync>( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error sending pattern SMS to {Recipient}", request.Recipients); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error sending pattern SMS"); + throw; + } + } + + public object ToAnonymousObject(Dictionary dictionary) + { + var type = dictionary.GetType(); + var obj = new System.Dynamic.ExpandoObject(); + var dict = obj as IDictionary; + + foreach (var item in dictionary) + { + dict.Add(item.Key, item.Value); + } + + return obj; + } + public class IppanelApiResponse + { + [JsonPropertyName("data")] + public T? Data { get; set; } + + [JsonPropertyName("meta")] + public IppanelMeta Meta { get; set; } + } + + public class IppanelMeta + { + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_parameters")] + public List MessageParameters { get; set; } = new List(); + + [JsonPropertyName("message_code")] + public string? MessageCode { get; set; } + + [JsonPropertyName("errors")] + public Dictionary> Errors { get; set; } = new Dictionary>(); + } + + // Specific data models for different response types + public class IppanelData + { + [JsonPropertyName("message_outbox_ids")] + public List MessageOutboxIds { get; set; } = new List(); + } +} + diff --git a/src/GreenHome.Sms.Ippanel/Models.cs b/src/GreenHome.Sms.Ippanel/Models.cs new file mode 100644 index 0000000..b480474 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/Models.cs @@ -0,0 +1,63 @@ +namespace GreenHome.Sms.Ippanel; + +// SMS DTOs +public sealed class WebserviceSmsRequest +{ + /// + /// Sender number (must be registered in IPPanel) + /// + public string? Sender { get; set; } + + /// + /// Recipient phone number(s) - comma separated for multiple recipients + /// + public required string Recipient { get; set; } + + /// + /// SMS message content + /// + public required string Message { get; set; } +} + +public sealed class PatternSmsRequest +{ + /// + /// Pattern code (must be registered in IPPanel) + /// + public required string PatternCode { get; set; } + + /// + /// Recipient phone number(s) - comma separated for multiple recipients + /// Note: Pattern SMS typically supports single recipient per request + /// + public required List Recipients { get; set; } + + /// + /// Pattern variables as key-value pairs + /// + public required Dictionary Variables { get; set; } + + /// + /// Optional sender/originator number (if not provided, uses default from configuration) + /// + public string? Originator { get; set; } +} + +public sealed class SmsResponse +{ + /// + /// Bulk ID for tracking the SMS + /// + public required List BulkId { get; set; } + + /// + /// Response status + /// + public required string Status { get; set; } + + /// + /// Response message + /// + public string? Message { get; set; } +} + diff --git a/src/GreenHome.Sms.Ippanel/README.md b/src/GreenHome.Sms.Ippanel/README.md new file mode 100644 index 0000000..4202fc9 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/README.md @@ -0,0 +1,130 @@ +# GreenHome.Sms.Ippanel + +سرویس ارسال پیامک IPPanel برای پروژه GreenHome + +## نصب و راه‌اندازی + +### 1. اضافه کردن Reference به پروژه + +در فایل `.csproj` پروژه خود: + +```xml + + + +``` + +### 2. ثبت سرویس در Program.cs + +```csharp +using GreenHome.Sms.Ippanel; + +// روش 1: استفاده از Configuration (پیشنهادی) +builder.Services.AddIppanelSms(builder.Configuration); + +// روش 2: استفاده از Configuration Section +builder.Services.AddIppanelSms(builder.Configuration.GetSection("IppanelSms")); + +// روش 3: تنظیم دستی +builder.Services.AddIppanelSms(options => +{ + options.BaseUrl = "https://edge.ippanel.com/v1"; + options.AuthorizationToken = "YOUR_TOKEN_HERE"; + options.DefaultSender = "10001001"; // اختیاری +}); +``` + +### 3. تنظیمات در appsettings.json + +```json +{ + "IppanelSms": { + "BaseUrl": "https://edge.ippanel.com/v1", + "AuthorizationToken": "YOUR_IPPANEL_TOKEN_HERE", + "DefaultSender": "10001001" + } +} +``` + +## استفاده + +### تزریق سرویس + +```csharp +public class MyController : ControllerBase +{ + private readonly ISmsService smsService; + + public MyController(ISmsService smsService) + { + this.smsService = smsService; + } +} +``` + +### ارسال Webservice SMS + +```csharp +var request = new WebserviceSmsRequest +{ + Sender = "10001001", + Recipient = "09123456789", // یا چند شماره با کاما: "09123456789,09187654321" + Message = "سلام، این یک پیامک تست است" +}; + +var result = await smsService.SendWebserviceSmsAsync(request); +// result.BulkId برای ردیابی پیامک استفاده می‌شود +``` + +### ارسال Pattern SMS + +```csharp +var request = new PatternSmsRequest +{ + PatternCode = "your-pattern-code", + Recipient = "09123456789", + Variables = new Dictionary + { + { "code", "12345" }, + { "name", "کاربر" } + }, + Originator = "10001001" // اختیاری، اگر نباشد از DefaultSender استفاده می‌شود +}; + +var result = await smsService.SendPatternSmsAsync(request); +``` + +## ساختار پروژه + +``` +GreenHome.Sms.Ippanel/ +├── IppanelSmsService.cs # پیاده‌سازی سرویس +├── IppanelSmsOptions.cs # تنظیمات +└── ServiceCollectionExtensions.cs # Extension Methods برای ثبت سرویس +``` + +## نکات مهم + +1. **توکن IPPanel**: حتماً توکن معتبر خود را از پنل IPPanel دریافت و در تنظیمات قرار دهید +2. **Webservice SMS**: می‌توانید چند گیرنده را با کاما جدا کنید +3. **Pattern SMS**: معمولاً یک گیرنده در هر درخواست پشتیبانی می‌شود +4. **خطاها**: تمام خطاها لاگ می‌شوند و به صورت Exception پرتاب می‌شوند + +## استفاده در پروژه‌های دیگر + +این پروژه مستقل است و می‌توانید آن را در هر پروژه .NET دیگری استفاده کنید: + +1. پروژه را به Solution خود اضافه کنید +2. Reference را اضافه کنید +3. `GreenHome.Application` را نیز Reference کنید (برای Interface و DTOs) +4. سرویس را ثبت کنید + +## تغییر سرویس SMS + +اگر بخواهید از سرویس SMS دیگری استفاده کنید: + +1. یک پیاده‌سازی جدید از `ISmsService` ایجاد کنید +2. در `Program.cs` به جای `AddIppanelSms` از متد ثبت سرویس جدید استفاده کنید + +Interface و DTOs در `GreenHome.Application` باقی می‌مانند و نیازی به تغییر کدهای استفاده‌کننده نیست. + diff --git a/src/GreenHome.Sms.Ippanel/ServiceCollectionExtensions.cs b/src/GreenHome.Sms.Ippanel/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c0f1ac5 --- /dev/null +++ b/src/GreenHome.Sms.Ippanel/ServiceCollectionExtensions.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace GreenHome.Sms.Ippanel; + +/// +/// Extension methods for registering IPPanel SMS service +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds IPPanel SMS service to the service collection + /// + /// The service collection + /// Configuration section for IppanelSms + /// The service collection for chaining + public static IServiceCollection AddIppanelSms( + this IServiceCollection services, + IConfiguration configuration) + { + return services.AddIppanelSms(configuration.GetSection("IppanelSms")); + } + + /// + /// Adds IPPanel SMS service to the service collection + /// + /// The service collection + /// Configuration section for IppanelSms + /// The service collection for chaining + public static IServiceCollection AddIppanelSms( + this IServiceCollection services, + IConfigurationSection? configurationSection = null) + { + // Configure options + IppanelSmsOptions? options = null; + if (configurationSection != null) + { + options = configurationSection.Get(); + } + + if (options == null) + { + throw new InvalidOperationException( + "IppanelSms configuration section is missing. " + + "Please add 'IppanelSms' section to your appsettings.json with 'AuthorizationToken' property."); + } + + // Register options as singleton + services.AddSingleton(options); + + // Register HttpClient and service + services.AddHttpClient(client => + { + // Ensure BaseUrl ends with / for proper relative path handling + var baseUrl = options.BaseUrl.TrimEnd('/') + "/"; + client.BaseAddress = new Uri(baseUrl); + client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json"); + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", options.AuthorizationToken); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + return services; + } + + /// + /// Adds IPPanel SMS service to the service collection with explicit options + /// + /// The service collection + /// Action to configure options + /// The service collection for chaining + public static IServiceCollection AddIppanelSms( + this IServiceCollection services, + Action configureOptions) + { + var options = new IppanelSmsOptions + { + AuthorizationToken = string.Empty // Will be set by configureOptions + }; + configureOptions(options); + + if (string.IsNullOrWhiteSpace(options.AuthorizationToken)) + { + throw new InvalidOperationException( + "IppanelSms AuthorizationToken is required."); + } + + // Register options as singleton + services.AddSingleton(options); + + // Register HttpClient and service + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(options.BaseUrl); + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", options.AuthorizationToken); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + return services; + } +} + diff --git a/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallOptions.cs b/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallOptions.cs new file mode 100644 index 0000000..82fa8f9 --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallOptions.cs @@ -0,0 +1,18 @@ +namespace GreenHome.VoiceCall.Avanak; + +/// +/// Configuration options for Avanak Voice Call service +/// +public sealed class AvanakVoiceCallOptions +{ + /// + /// Avanak API base URL + /// + public string BaseUrl { get; set; } = "https://portal.avanak.ir/Rest"; + + /// + /// Avanak authorization token + /// + public required string Token { get; set; } +} + diff --git a/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallService.cs b/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallService.cs new file mode 100644 index 0000000..a083e7b --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/AvanakVoiceCallService.cs @@ -0,0 +1,220 @@ +using System.Net.Http.Json; +using System.Net.Http; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace GreenHome.VoiceCall.Avanak; + +/// +/// Avanak Voice Call service implementation +/// +public sealed class AvanakVoiceCallService : IVoiceCallService +{ + private readonly HttpClient httpClient; + private readonly AvanakVoiceCallOptions options; + private readonly ILogger logger; + + public AvanakVoiceCallService( + HttpClient httpClient, + AvanakVoiceCallOptions options, + ILogger logger) + { + this.httpClient = httpClient; + this.options = options; + this.logger = logger; + } + + /// + public async Task GetAccountStatusAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PostAsync( + "AccountStatus", + null, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error getting account status"); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error getting account status"); + throw; + } + } + + /// + public async Task GenerateTTSAsync(GenerateTTSRequest request, CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PostAsJsonAsync( + "GenerateTTS", + request, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error generating TTS for text: {Text}", request.Text); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error generating TTS"); + throw; + } + } + + /// + public async Task QuickSendAsync(QuickSendRequest request, CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PostAsJsonAsync( + "QuickSend", + request, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error sending quick voice call to {Recipient}", request.Recipient); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error sending quick voice call"); + throw; + } + } + + /// + public async Task QuickSendWithTTSAsync(QuickSendWithTTSRequest request, CancellationToken cancellationToken = default) + { + try + { + // Build form data according to Avanak API documentation + var formData = new List> + { + new("Text", request.Text), + new("Number", request.Number) + }; + + if (request.Vote.HasValue) + { + formData.Add(new KeyValuePair("Vote", request.Vote.Value.ToString().ToLower())); + } + + if (request.ServerID.HasValue) + { + formData.Add(new KeyValuePair("ServerID", request.ServerID.Value.ToString())); + } + + if (!string.IsNullOrWhiteSpace(request.CallFromMobile)) + { + formData.Add(new KeyValuePair("CallFromMobile", request.CallFromMobile)); + } + + if (request.RecordVoice.HasValue) + { + formData.Add(new KeyValuePair("RecordVoice", request.RecordVoice.Value.ToString().ToLower())); + } + + if (request.RecordVoiceDuration.HasValue) + { + formData.Add(new KeyValuePair("RecordVoiceDuration", request.RecordVoiceDuration.Value.ToString())); + } + + var content = new FormUrlEncodedContent(formData); + + var response = await httpClient.PostAsync( + "QuickSendWithTTS", + content, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var jsonString = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (result != null && !result.IsSuccess) + { + logger.LogWarning( + "QuickSendWithTTS failed with ReturnValue={ReturnValue} for Number={Number}. " + + "Error codes: -25=QuickSend disabled, 0=No permission/demo user, -2=Wrong number, " + + "-3=Insufficient credit, -5=Text too long (>1000 chars), -6=Invalid send time, " + + "-7/-9=TTS generation error, -8=Text too short (<3 sec), -10=Empty text, " + + "-11=Duplicate send limit, -71=Invalid record duration, -72=No record permission, -111=Too Many Requests", + result.ReturnValue, request.Number); + } + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error sending quick voice call with TTS to {Number}", request.Number); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error sending quick voice call with TTS"); + throw; + } + } + + /// + public async Task GetQuickSendStatusAsync(GetQuickSendRequest request, CancellationToken cancellationToken = default) + { + try + { + var response = await httpClient.PostAsJsonAsync( + "GetQuickSend", + request, + cancellationToken); + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Error getting quick send status for {QuickSendId}", request.QuickSendId); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error getting quick send status"); + throw; + } + } +} + diff --git a/src/GreenHome.VoiceCall.Avanak/GreenHome.VoiceCall.Avanak.csproj b/src/GreenHome.VoiceCall.Avanak/GreenHome.VoiceCall.Avanak.csproj new file mode 100644 index 0000000..6df2356 --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/GreenHome.VoiceCall.Avanak.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/src/GreenHome.VoiceCall.Avanak/IVoiceCallService.cs b/src/GreenHome.VoiceCall.Avanak/IVoiceCallService.cs new file mode 100644 index 0000000..c9291ae --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/IVoiceCallService.cs @@ -0,0 +1,47 @@ +namespace GreenHome.VoiceCall.Avanak; + +/// +/// Interface for Avanak Voice Call service +/// +public interface IVoiceCallService +{ + /// + /// Gets account status + /// + /// Cancellation token + /// Account status response + Task GetAccountStatusAsync(CancellationToken cancellationToken = default); + + /// + /// Generates TTS (Text-to-Speech) audio from text + /// + /// TTS generation request + /// Cancellation token + /// TTS generation response + Task GenerateTTSAsync(GenerateTTSRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends quick voice call with uploaded audio file + /// + /// Quick send request + /// Cancellation token + /// Quick send response + Task QuickSendAsync(QuickSendRequest request, CancellationToken cancellationToken = default); + + /// + /// Sends quick voice call with TTS (Text-to-Speech) + /// + /// Quick send with TTS request + /// Cancellation token + /// Quick send with TTS response + Task QuickSendWithTTSAsync(QuickSendWithTTSRequest request, CancellationToken cancellationToken = default); + + /// + /// Gets quick send status + /// + /// Get quick send request + /// Cancellation token + /// Quick send status response + Task GetQuickSendStatusAsync(GetQuickSendRequest request, CancellationToken cancellationToken = default); +} + diff --git a/src/GreenHome.VoiceCall.Avanak/Models.cs b/src/GreenHome.VoiceCall.Avanak/Models.cs new file mode 100644 index 0000000..b6997ff --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/Models.cs @@ -0,0 +1,263 @@ +using System.Text.Json.Serialization; + +namespace GreenHome.VoiceCall.Avanak; + +/// +/// Request model for AccountStatus method +/// +public sealed class AccountStatusRequest +{ + // No parameters needed for AccountStatus +} + +/// +/// Response model for AccountStatus method +/// +public sealed class AccountStatusResponse +{ + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public AccountStatusData? Data { get; set; } +} + +/// +/// Account status data +/// +public sealed class AccountStatusData +{ + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("credit")] + public decimal? Credit { get; set; } + + [JsonPropertyName("expireDate")] + public string? ExpireDate { get; set; } +} + +/// +/// Request model for GenerateTTS method +/// +public sealed class GenerateTTSRequest +{ + /// + /// Text to convert to speech + /// + [JsonPropertyName("text")] + public required string Text { get; set; } + + /// + /// Voice type (optional) + /// + [JsonPropertyName("voiceType")] + public string? VoiceType { get; set; } +} + +/// +/// Response model for GenerateTTS method +/// +public sealed class GenerateTTSResponse +{ + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public GenerateTTSData? Data { get; set; } +} + +/// +/// GenerateTTS data +/// +public sealed class GenerateTTSData +{ + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("audioUrl")] + public string? AudioUrl { get; set; } + + [JsonPropertyName("duration")] + public int? Duration { get; set; } +} + +/// +/// Request model for QuickSend method +/// +public sealed class QuickSendRequest +{ + /// + /// Recipient phone number + /// + [JsonPropertyName("recipient")] + public required string Recipient { get; set; } + + /// + /// Message ID (uploaded audio file ID) + /// + [JsonPropertyName("messageId")] + public required string MessageId { get; set; } +} + +/// +/// Response model for QuickSend method +/// +public sealed class QuickSendResponse +{ + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public QuickSendData? Data { get; set; } +} + +/// +/// QuickSend data +/// +public sealed class QuickSendData +{ + [JsonPropertyName("quickSendId")] + public string? QuickSendId { get; set; } + + [JsonPropertyName("trackingId")] + public string? TrackingId { get; set; } +} + +/// +/// Request model for QuickSendWithTTS method +/// +public sealed class QuickSendWithTTSRequest +{ + /// + /// Text to convert to speech and send (required) + /// + [JsonPropertyName("Text")] + public required string Text { get; set; } + + /// + /// Phone number (required) + /// + [JsonPropertyName("Number")] + public required string Number { get; set; } + + /// + /// Enable voting option (optional) + /// + [JsonPropertyName("Vote")] + public bool? Vote { get; set; } + + /// + /// Server ID (optional, default: 0) + /// + [JsonPropertyName("ServerID")] + public int? ServerID { get; set; } + + /// + /// Mobile number to add at the end of voice (optional) + /// + [JsonPropertyName("CallFromMobile")] + public string? CallFromMobile { get; set; } + + /// + /// Record voice (optional) + /// + [JsonPropertyName("RecordVoice")] + public bool? RecordVoice { get; set; } + + /// + /// Record voice duration (optional) + /// + [JsonPropertyName("RecordVoiceDuration")] + public short? RecordVoiceDuration { get; set; } +} + +/// +/// Response model for QuickSendWithTTS method +/// ReturnValue > 0 means success and contains the quickSendId +/// +public sealed class QuickSendWithTTSResponse +{ + /// + /// Return value: > 0 means success (contains quickSendId), negative values indicate errors + /// + [JsonPropertyName("ReturnValue")] + public int ReturnValue { get; set; } + + /// + /// QuickSendId (when ReturnValue > 0) + /// + public long QuickSendId => ReturnValue > 0 ? ReturnValue : 0; + + /// + /// Indicates if the operation was successful + /// + public bool IsSuccess => ReturnValue > 0; +} + +/// +/// Request model for GetQuickSend method +/// +public sealed class GetQuickSendRequest +{ + /// + /// QuickSend ID or Tracking ID + /// + [JsonPropertyName("quickSendId")] + public required string QuickSendId { get; set; } +} + +/// +/// Response model for GetQuickSend method +/// +public sealed class GetQuickSendResponse +{ + [JsonPropertyName("status")] + public bool Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public GetQuickSendData? Data { get; set; } +} + +/// +/// GetQuickSend data +/// +public sealed class GetQuickSendData +{ + [JsonPropertyName("quickSendId")] + public string? QuickSendId { get; set; } + + [JsonPropertyName("trackingId")] + public string? TrackingId { get; set; } + + [JsonPropertyName("recipient")] + public string? Recipient { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("deliveryStatus")] + public string? DeliveryStatus { get; set; } + + [JsonPropertyName("sentAt")] + public string? SentAt { get; set; } + + [JsonPropertyName("deliveredAt")] + public string? DeliveredAt { get; set; } + + [JsonPropertyName("duration")] + public int? Duration { get; set; } +} + diff --git a/src/GreenHome.VoiceCall.Avanak/ServiceCollectionExtensions.cs b/src/GreenHome.VoiceCall.Avanak/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9e91b63 --- /dev/null +++ b/src/GreenHome.VoiceCall.Avanak/ServiceCollectionExtensions.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace GreenHome.VoiceCall.Avanak; + +/// +/// Extension methods for registering Avanak Voice Call service +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds Avanak Voice Call service to the service collection + /// + /// The service collection + /// Configuration section for AvanakVoiceCall + /// The service collection for chaining + public static IServiceCollection AddAvanakVoiceCall( + this IServiceCollection services, + IConfiguration configuration) + { + return services.AddAvanakVoiceCall(configuration.GetSection("AvanakVoiceCall")); + } + + /// + /// Adds Avanak Voice Call service to the service collection + /// + /// The service collection + /// Configuration section for AvanakVoiceCall + /// The service collection for chaining + public static IServiceCollection AddAvanakVoiceCall( + this IServiceCollection services, + IConfigurationSection? configurationSection = null) + { + // Configure options + AvanakVoiceCallOptions? options = null; + if (configurationSection != null) + { + options = configurationSection.Get(); + } + + if (options == null) + { + throw new InvalidOperationException( + "AvanakVoiceCall configuration section is missing. " + + "Please add 'AvanakVoiceCall' section to your appsettings.json with 'Token' property."); + } + + if (string.IsNullOrWhiteSpace(options.Token)) + { + throw new InvalidOperationException( + "AvanakVoiceCall Token is required."); + } + + // Register options as singleton + services.AddSingleton(options); + + // Register HttpClient and service + services.AddHttpClient(client => + { + // Ensure BaseUrl ends with / for proper relative path handling + var baseUrl = options.BaseUrl.TrimEnd('/') + "/"; + client.BaseAddress = new Uri(baseUrl); + // Note: Content-Type will be set automatically by HttpClient based on content type (JSON or FormUrlEncoded) + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", options.Token); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + return services; + } + + /// + /// Adds Avanak Voice Call service to the service collection with explicit options + /// + /// The service collection + /// Action to configure options + /// The service collection for chaining + public static IServiceCollection AddAvanakVoiceCall( + this IServiceCollection services, + Action configureOptions) + { + var options = new AvanakVoiceCallOptions + { + Token = string.Empty // Will be set by configureOptions + }; + configureOptions(options); + + if (string.IsNullOrWhiteSpace(options.Token)) + { + throw new InvalidOperationException( + "AvanakVoiceCall Token is required."); + } + + // Register options as singleton + services.AddSingleton(options); + + // Register HttpClient and service + services.AddHttpClient(client => + { + // Ensure BaseUrl ends with / for proper relative path handling + var baseUrl = options.BaseUrl.TrimEnd('/') + "/"; + client.BaseAddress = new Uri(baseUrl); + // Note: Content-Type will be set automatically by HttpClient based on content type (JSON or FormUrlEncoded) + client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", options.Token); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + return services; + } +} + diff --git a/src/GreenHome.sln b/src/GreenHome.sln index 2abaae0..c955e8c 100644 --- a/src/GreenHome.sln +++ b/src/GreenHome.sln @@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Infrastructure", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Api", "GreenHome.Api\GreenHome.Api.csproj", "{75E498D4-5D04-4F63-A1A5-5D851FA40C74}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.Sms.Ippanel", "GreenHome.Sms.Ippanel\GreenHome.Sms.Ippanel.csproj", "{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.VoiceCall.Avanak", "GreenHome.VoiceCall.Avanak\GreenHome.VoiceCall.Avanak.csproj", "{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +37,14 @@ Global {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|Any CPU.Build.0 = Debug|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.ActiveCfg = Release|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.Build.0 = Release|Any CPU + {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.Build.0 = Release|Any CPU + {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE